Compare commits
97 Commits
bc97d1d74c
...
deploy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2bab9cceb | ||
|
|
03bb406d82 | ||
|
|
2058713e03 | ||
|
|
061e287390 | ||
|
|
f67b9c61d7 | ||
|
|
bc4fae5415 | ||
|
|
5f932f5d63 | ||
|
|
b5bf214d7f | ||
|
|
5c11b68fc0 | ||
|
|
ac5ecc9a1c | ||
|
|
dbee58cd81 | ||
|
|
1eec10ba80 | ||
|
|
dba7bb3cdc | ||
|
|
ef1803f0ab | ||
|
|
67ac16d36b | ||
|
|
ba2d144038 | ||
|
|
8513848440 | ||
|
|
57cdf847f5 | ||
|
|
c7fb3d7041 | ||
|
|
20593b7173 | ||
|
|
e45642b6f9 | ||
|
|
211403d54d | ||
|
|
0ebe540c3f | ||
|
|
ccdf9aafe8 | ||
|
|
f2406fb6d7 | ||
|
|
bd1b55fff7 | ||
|
|
fba597a6f2 | ||
|
|
3af989a83b | ||
|
|
04e9ffad28 | ||
|
|
e24f99ad38 | ||
|
|
4b6736444c | ||
|
|
629e89f380 | ||
|
|
c14c2ddaa2 | ||
|
|
ffaa1e26fd | ||
|
|
4e135bcf43 | ||
|
|
95eed45ec4 | ||
|
|
e7a3162494 | ||
|
|
8cfb149a18 | ||
|
|
55a4f7bf09 | ||
|
|
6a223892dd | ||
|
|
b7cf505ca4 | ||
|
|
7888ccb7e6 | ||
|
|
042f68e777 | ||
|
|
ed2ea23e87 | ||
|
|
cf6d071047 | ||
|
|
d31660f813 | ||
|
|
0fb8195236 | ||
|
|
dd93b78c94 | ||
|
|
bb57b842b9 | ||
|
|
75ac91eecc | ||
|
|
f1d74f922d | ||
|
|
bda5c116fe | ||
|
|
8c02b95934 | ||
|
|
496a8239a5 | ||
|
|
08af99ed52 | ||
|
|
4852f16f79 | ||
|
|
0b4ac0da29 | ||
|
|
a5bf56f5c9 | ||
|
|
8a8aefb4fb | ||
|
|
bcbfa07697 | ||
|
|
39cca96fed | ||
|
|
e0b4077552 | ||
|
|
38979b3c8b | ||
|
|
47b6cfabbc | ||
|
|
0ac69ee840 | ||
|
|
387fbf11da | ||
|
|
429f974bfc | ||
|
|
677142672f | ||
|
|
2ef6b23136 | ||
|
|
d079cfde79 | ||
|
|
1de28fa494 | ||
|
|
946283dfba | ||
|
|
1de6eac078 | ||
|
|
b7ad501704 | ||
|
|
59b3ccb0ca | ||
|
|
6a155597ce | ||
|
|
4fcbe38b56 | ||
|
|
84476e6277 | ||
|
|
04e04eb11d | ||
|
|
7bc1684566 | ||
|
|
6f3da6bb15 | ||
|
|
12066befa8 | ||
|
|
52b10c9536 | ||
|
|
1ceb8c3574 | ||
|
|
8ec9b94879 | ||
|
|
a583db7a15 | ||
|
|
f2f856628d | ||
|
|
58623bd1d3 | ||
|
|
fa5910ec43 | ||
|
|
2518119257 | ||
|
|
a44f4d6641 | ||
|
|
405b70bdfc | ||
|
|
878478e4fc | ||
|
|
a17d161c06 | ||
|
|
39a47074ae | ||
|
|
b0e3409793 | ||
|
|
dd3b132f73 |
@@ -16,6 +16,8 @@ alwaysApply: true
|
|||||||
- Keine Stub-Implementierungen, keine `TODO`-Platzhalter, keine auskommentierten Halbfertigteile in abgeschlossener Arbeit. Ein Modul ist fertig, wenn Commands, Datenbank, Jobs und (ab Phase 7) die WebUI-Seite funktionieren.
|
- Keine Stub-Implementierungen, keine `TODO`-Platzhalter, keine auskommentierten Halbfertigteile in abgeschlossener Arbeit. Ein Modul ist fertig, wenn Commands, Datenbank, Jobs und (ab Phase 7) die WebUI-Seite funktionieren.
|
||||||
- Das Moderations-Modul aus Phase 1 ist das Referenzmodul. Jedes weitere Modul übernimmt dessen Struktur (Ordnerlayout, Command-Registrierung, Settings-Zugriff, Fehlerbehandlung, Tests) exakt.
|
- Das Moderations-Modul aus Phase 1 ist das Referenzmodul. Jedes weitere Modul übernimmt dessen Struktur (Ordnerlayout, Command-Registrierung, Settings-Zugriff, Fehlerbehandlung, Tests) exakt.
|
||||||
- Bei Unsicherheit über Discord-API-Verhalten: discord.js-v14-Dokumentation prüfen statt raten. Keine erfundenen Methoden oder Events.
|
- Bei Unsicherheit über Discord-API-Verhalten: discord.js-v14-Dokumentation prüfen statt raten. Keine erfundenen Methoden oder Events.
|
||||||
|
- Fortschritt verpflichtend dokumentieren: `docs/PHASE-TRACKING.md` bei jeder relevanten Änderung aktualisieren (abgeschlossen/offen/manuelle Tests).
|
||||||
|
- Vorhandene Projekt-Assets respektieren und dokumentieren (z. B. Bot-Logo unter `docs/logo/`).
|
||||||
|
|
||||||
## Stack (nicht verhandelbar)
|
## Stack (nicht verhandelbar)
|
||||||
|
|
||||||
|
|||||||
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.turbo
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
apps/*/dist
|
||||||
|
apps/*/.next
|
||||||
|
packages/*/dist
|
||||||
|
**/*.md
|
||||||
|
.cursor
|
||||||
53
.env.example
53
.env.example
@@ -1,6 +1,13 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=production
|
||||||
|
# Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls +
|
||||||
|
# BullMQ job enqueueing for guild backups, giveaways and the scheduler).
|
||||||
BOT_TOKEN=
|
BOT_TOKEN=
|
||||||
BOT_CLIENT_ID=
|
BOT_CLIENT_ID=
|
||||||
|
BOT_CLIENT_SECRET=
|
||||||
|
# Optional: register slash commands only on this guild (instant sync for dev).
|
||||||
|
# Leave empty in production so commands are global. When set, global commands
|
||||||
|
# are cleared; when empty, leftover guild-scoped commands are cleared on startup
|
||||||
|
# so Discord does not show every command twice.
|
||||||
BOT_GUILD_ID=
|
BOT_GUILD_ID=
|
||||||
DATABASE_URL=postgresql://nexumi:nexumi@postgres:5432/nexumi
|
DATABASE_URL=postgresql://nexumi:nexumi@postgres:5432/nexumi
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
@@ -9,3 +16,47 @@ DEFAULT_LOCALE=de
|
|||||||
METRICS_TOKEN=
|
METRICS_TOKEN=
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
BACKUP_RETENTION_DAYS=14
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
BACKUP_CRON=0 3 * * *
|
||||||
|
BACKUP_DIR=/backups
|
||||||
|
HEALTH_PORT=8080
|
||||||
|
# Internal bot health/metrics base (Docker healthcheck). Captcha links use WEBUI_URL.
|
||||||
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
TWITCH_CLIENT_ID=
|
||||||
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# WebUI (apps/webui) – public URL behind Traefik (also used for captcha verification links)
|
||||||
|
WEBUI_URL=https://dashboard.nexumi.de
|
||||||
|
# Discord Developer Portal OAuth2 redirect:
|
||||||
|
# https://dashboard.nexumi.de/api/auth/callback
|
||||||
|
# Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`.
|
||||||
|
SESSION_SECRET=
|
||||||
|
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
|
||||||
|
OWNER_USER_IDS=
|
||||||
|
|
||||||
|
# Public site / bot links (Phase 8)
|
||||||
|
SUPPORT_SERVER_URL=
|
||||||
|
LEGAL_OPERATOR_NAME=HexaHost Inh. Samuel Müller
|
||||||
|
LEGAL_OPERATOR_ADDRESS=Richard-Miller-Straße 1, 94051 Hauzenberg, Deutschland
|
||||||
|
LEGAL_OPERATOR_EMAIL=info@hexahost.de
|
||||||
|
LEGAL_OPERATOR_PHONE=+49 15566 175855
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verification captcha providers (WebUI). Leave empty to disable a provider.
|
||||||
|
# MATH always works without keys. Per-guild provider is chosen in the dashboard.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Google reCAPTCHA v2 (checkbox) – https://www.google.com/recaptcha/admin
|
||||||
|
RECAPTCHA_SITE_KEY=
|
||||||
|
RECAPTCHA_SECRET_KEY=
|
||||||
|
# Google reCAPTCHA v3 (score-based). Falls back to RECAPTCHA_* if unset.
|
||||||
|
RECAPTCHA_V3_SITE_KEY=
|
||||||
|
RECAPTCHA_V3_SECRET_KEY=
|
||||||
|
RECAPTCHA_V3_MIN_SCORE=0.5
|
||||||
|
# hCaptcha – https://dashboard.hcaptcha.com
|
||||||
|
HCAPTCHA_SITE_KEY=
|
||||||
|
HCAPTCHA_SECRET_KEY=
|
||||||
|
# Cloudflare Turnstile – https://dash.cloudflare.com → Turnstile
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
# Salt for hashing client IPs used in alt-account detection (min 16 chars).
|
||||||
|
# Generate e.g. with: openssl rand -hex 16
|
||||||
|
CAPTCHA_IP_HASH_SALT=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,4 +8,3 @@ pnpm-lock.yaml
|
|||||||
apps/*/dist
|
apps/*/dist
|
||||||
apps/*/.next
|
apps/*/.next
|
||||||
packages/*/dist
|
packages/*/dist
|
||||||
prisma/migrations
|
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore"
|
||||||
|
}
|
||||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Nexumi
|
||||||
|
|
||||||
|
Nexumi is a public Discord bot with dashboard architecture, built as a TypeScript monorepo.
|
||||||
|
|
||||||
|
## Phase 1 scope
|
||||||
|
|
||||||
|
- Monorepo foundation with `apps/bot` and `packages/shared`
|
||||||
|
- PostgreSQL + Redis compose stack
|
||||||
|
- Prisma core schema (`Guild`, `GuildSettings`, `User`, `Case`) plus moderation tables (`Warning`, `ModNote`)
|
||||||
|
- Command and module framework for slash command routing
|
||||||
|
- Permission layer and i18n starter (`de` + `en`)
|
||||||
|
- BullMQ integration for persistent moderation jobs (temp-ban expiration)
|
||||||
|
- Complete moderation reference command set for Phase 1
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and fill required values.
|
||||||
|
2. Install dependencies:
|
||||||
|
- `pnpm install`
|
||||||
|
3. Generate Prisma client:
|
||||||
|
- `pnpm prisma:generate`
|
||||||
|
4. Apply database migrations:
|
||||||
|
- `pnpm prisma:migrate`
|
||||||
|
5. Start stack:
|
||||||
|
- `docker compose up -d`
|
||||||
|
6. Start bot locally:
|
||||||
|
- `pnpm --filter @nexumi/bot dev`
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
- `pnpm typecheck`
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm test`
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
See `docs/DATABASE.md` for migrations, backup and restore runbooks.
|
||||||
|
|
||||||
|
## Backup and restore note
|
||||||
|
|
||||||
|
Phase 1 includes the BullMQ foundation for scheduled jobs. Database backup rotation is configured via `BACKUP_RETENTION_DAYS` and the `backups` volume is already reserved in compose.
|
||||||
|
|
||||||
|
Restore flow (manual, PostgreSQL container):
|
||||||
|
|
||||||
|
1. Stop bot/web services.
|
||||||
|
2. Copy dump into postgres container.
|
||||||
|
3. Run `psql` restore against `nexumi` database:
|
||||||
|
- `docker compose exec -T postgres psql -U nexumi -d nexumi < /path/to/backup.sql`
|
||||||
|
4. Start services again.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
FROM node:22-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
COPY package.json pnpm-workspace.yaml turbo.json tsconfig.base.json ./
|
COPY package.json pnpm-workspace.yaml turbo.json tsconfig.base.json ./
|
||||||
@@ -9,17 +10,19 @@ COPY packages/shared/package.json packages/shared/package.json
|
|||||||
RUN pnpm install --frozen-lockfile=false
|
RUN pnpm install --frozen-lockfile=false
|
||||||
|
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
COPY . .
|
COPY apps ./apps
|
||||||
|
COPY packages ./packages
|
||||||
|
COPY eslint.config.js .eslintrc.cjs .prettierrc ./
|
||||||
|
RUN pnpm install --frozen-lockfile=false
|
||||||
RUN pnpm --filter @nexumi/shared build
|
RUN pnpm --filter @nexumi/shared build
|
||||||
RUN pnpm --filter @nexumi/bot prisma:generate
|
RUN pnpm --filter @nexumi/bot prisma:generate
|
||||||
RUN pnpm --filter @nexumi/bot build
|
RUN pnpm --filter @nexumi/bot build
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
RUN apk add --no-cache postgresql16-client
|
||||||
RUN corepack enable
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/apps/bot/dist ./apps/bot/dist
|
COPY --from=build /app/apps/bot ./apps/bot
|
||||||
COPY --from=build /app/apps/bot/prisma ./apps/bot/prisma
|
COPY --from=build /app/packages/shared ./packages/shared
|
||||||
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
|
CMD ["sh", "-c", "pnpm --filter @nexumi/bot prisma:migrate && node apps/bot/dist/index.js"]
|
||||||
COPY --from=build /app/apps/bot/package.json ./apps/bot/package.json
|
|
||||||
CMD ["node", "apps/bot/dist/index.js"]
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "welcomeComponents" JSONB;
|
||||||
|
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveType" TEXT NOT NULL DEFAULT 'TEXT';
|
||||||
|
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveComponents" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tag" ADD COLUMN IF NOT EXISTS "components" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ScheduledMessage" ADD COLUMN IF NOT EXISTS "components" JSONB;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "ComponentMessageBinding" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ComponentMessageBinding_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_guildId_createdAt_idx" ON "ComponentMessageBinding"("guildId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_messageId_idx" ON "ComponentMessageBinding"("messageId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ComponentMessageBinding" ADD CONSTRAINT "ComponentMessageBinding_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
@@ -9,12 +9,15 @@
|
|||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint src --ext .ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate --schema=prisma/schema.prisma",
|
||||||
"prisma:migrate": "prisma migrate deploy"
|
"prisma:migrate": "prisma migrate deploy --schema=prisma/schema.prisma",
|
||||||
|
"profile:banner": "tsx scripts/set-bot-banner.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.65",
|
||||||
"@nexumi/shared": "workspace:*",
|
"@nexumi/shared": "workspace:*",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@sentry/node": "^10.67.0",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"discord.js": "^14.17.3",
|
"discord.js": "^14.17.3",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
128
apps/bot/prisma/migrations/20260722123000_init/migration.sql
Normal file
128
apps/bot/prisma/migrations/20260722123000_init/migration.sql
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Guild" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GuildSettings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"locale" TEXT NOT NULL DEFAULT 'de',
|
||||||
|
"moderationEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "GuildSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Case" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"caseNumber" INTEGER NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"targetUserId" TEXT NOT NULL,
|
||||||
|
"moderatorId" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"deletedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Case_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Warning" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"moderatorId" TEXT NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"caseId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Warning_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ModNote" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"moderatorId" TEXT NOT NULL,
|
||||||
|
"note" TEXT NOT NULL,
|
||||||
|
"caseId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ModNote_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EscalationRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"warnCount" INTEGER NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"durationMs" INTEGER,
|
||||||
|
"reason" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "EscalationRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GuildSettings_guildId_key" ON "GuildSettings"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Case_guildId_targetUserId_idx" ON "Case"("guildId", "targetUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Case_guildId_caseNumber_key" ON "Case"("guildId", "caseNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Warning_guildId_userId_idx" ON "Warning"("guildId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ModNote_guildId_userId_idx" ON "ModNote"("guildId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "EscalationRule_guildId_enabled_idx" ON "EscalationRule"("guildId", "enabled");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "EscalationRule_guildId_warnCount_key" ON "EscalationRule"("guildId", "warnCount");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GuildSettings" ADD CONSTRAINT "GuildSettings_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Case" ADD CONSTRAINT "Case_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Warning" ADD CONSTRAINT "Warning_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Warning" ADD CONSTRAINT "Warning_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "Case"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EscalationRule" ADD CONSTRAINT "EscalationRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AutoModConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"antiRaidEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"antiRaidJoinThreshold" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"antiRaidWindowSeconds" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"antiRaidAction" TEXT NOT NULL DEFAULT 'LOCKDOWN',
|
||||||
|
"antiNukeEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"antiNukeBanThreshold" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"antiNukeChannelThreshold" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"antiNukeWindowSeconds" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
"lockdownActive" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AutoModConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AutoModRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"ruleType" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"action" TEXT NOT NULL DEFAULT 'DELETE',
|
||||||
|
"threshold" JSONB,
|
||||||
|
"exceptions" JSONB,
|
||||||
|
"wordList" JSONB,
|
||||||
|
"durationMs" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AutoModRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LoggingConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"ignoreBots" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"ignoredRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LoggingConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LogChannel" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"eventType" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LogChannel_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WelcomeConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"welcomeEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"leaveEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"welcomeChannelId" TEXT,
|
||||||
|
"leaveChannelId" TEXT,
|
||||||
|
"welcomeType" TEXT NOT NULL DEFAULT 'TEXT',
|
||||||
|
"welcomeContent" TEXT,
|
||||||
|
"welcomeEmbed" JSONB,
|
||||||
|
"leaveContent" TEXT,
|
||||||
|
"leaveEmbed" JSONB,
|
||||||
|
"welcomeDmEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"welcomeDmContent" TEXT,
|
||||||
|
"userAutoroleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"botAutoroleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"imageTitle" TEXT,
|
||||||
|
"imageSubtitle" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WelcomeConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VerificationConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"verifiedRoleId" TEXT,
|
||||||
|
"unverifiedRoleId" TEXT,
|
||||||
|
"mode" TEXT NOT NULL DEFAULT 'BUTTON',
|
||||||
|
"minAccountAgeDays" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failAction" TEXT NOT NULL DEFAULT 'KICK',
|
||||||
|
"panelChannelId" TEXT,
|
||||||
|
"panelMessageId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VerificationConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AutoModConfig_guildId_key" ON "AutoModConfig"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AutoModRule_guildId_enabled_idx" ON "AutoModRule"("guildId", "enabled");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AutoModRule_guildId_ruleType_key" ON "AutoModRule"("guildId", "ruleType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LoggingConfig_guildId_key" ON "LoggingConfig"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LogChannel_guildId_idx" ON "LogChannel"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LogChannel_guildId_eventType_key" ON "LogChannel"("guildId", "eventType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WelcomeConfig_guildId_key" ON "WelcomeConfig"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationConfig_guildId_key" ON "VerificationConfig"("guildId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AutoModConfig" ADD CONSTRAINT "AutoModConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AutoModRule" ADD CONSTRAINT "AutoModRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LoggingConfig" ADD CONSTRAINT "LoggingConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LogChannel" ADD CONSTRAINT "LogChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WelcomeConfig" ADD CONSTRAINT "WelcomeConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VerificationConfig" ADD CONSTRAINT "VerificationConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LevelingConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"textXpMin" INTEGER NOT NULL DEFAULT 15,
|
||||||
|
"textXpMax" INTEGER NOT NULL DEFAULT 25,
|
||||||
|
"textCooldownSeconds" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
"voiceXpPerMinute" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"noXpChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"noXpRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"roleMultipliers" JSONB,
|
||||||
|
"channelMultipliers" JSONB,
|
||||||
|
"levelUpMode" TEXT NOT NULL DEFAULT 'CHANNEL',
|
||||||
|
"levelUpChannelId" TEXT,
|
||||||
|
"levelUpMessage" TEXT,
|
||||||
|
"stackRewards" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"cardAccentColor" TEXT NOT NULL DEFAULT '#6366F1',
|
||||||
|
"cardBackgroundColor" TEXT NOT NULL DEFAULT '#111827',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "LevelingConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "MemberLevel" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"xp" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"level" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "MemberLevel_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "LevelReward" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"level" INTEGER NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "LevelReward_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "EconomyConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"currencyName" TEXT NOT NULL DEFAULT 'Coins',
|
||||||
|
"currencySymbol" TEXT NOT NULL DEFAULT '🪙',
|
||||||
|
"dailyAmount" INTEGER NOT NULL DEFAULT 200,
|
||||||
|
"weeklyAmount" INTEGER NOT NULL DEFAULT 1000,
|
||||||
|
"workMin" INTEGER NOT NULL DEFAULT 50,
|
||||||
|
"workMax" INTEGER NOT NULL DEFAULT 150,
|
||||||
|
"workCooldownSeconds" INTEGER NOT NULL DEFAULT 3600,
|
||||||
|
"startingBalance" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "EconomyConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "MemberEconomy" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"balance" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastDailyAt" TIMESTAMP(3),
|
||||||
|
"lastWeeklyAt" TIMESTAMP(3),
|
||||||
|
"lastWorkAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "MemberEconomy_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "ShopItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"price" INTEGER NOT NULL,
|
||||||
|
"roleId" TEXT,
|
||||||
|
"stock" INTEGER,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "ShopItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "InventoryItem" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"itemId" TEXT NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "InventoryItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "Poll" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"creatorId" TEXT NOT NULL,
|
||||||
|
"question" TEXT NOT NULL,
|
||||||
|
"options" JSONB NOT NULL,
|
||||||
|
"multiSelect" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"anonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"endsAt" TIMESTAMP(3),
|
||||||
|
"closedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "Poll_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "PollVote" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"pollId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"optionIndex" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PollVote_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "Reminder" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"remindAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"recurringCron" TEXT,
|
||||||
|
"jobId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "AfkStatus" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "AfkStatus_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "LevelingConfig_guildId_key" ON "LevelingConfig"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "MemberLevel_guildId_userId_key" ON "MemberLevel"("guildId", "userId");
|
||||||
|
CREATE INDEX "MemberLevel_guildId_xp_idx" ON "MemberLevel"("guildId", "xp");
|
||||||
|
CREATE UNIQUE INDEX "LevelReward_guildId_level_key" ON "LevelReward"("guildId", "level");
|
||||||
|
CREATE INDEX "LevelReward_guildId_idx" ON "LevelReward"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "EconomyConfig_guildId_key" ON "EconomyConfig"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "MemberEconomy_guildId_userId_key" ON "MemberEconomy"("guildId", "userId");
|
||||||
|
CREATE INDEX "MemberEconomy_guildId_balance_idx" ON "MemberEconomy"("guildId", "balance");
|
||||||
|
CREATE INDEX "ShopItem_guildId_enabled_idx" ON "ShopItem"("guildId", "enabled");
|
||||||
|
CREATE UNIQUE INDEX "InventoryItem_guildId_userId_itemId_key" ON "InventoryItem"("guildId", "userId", "itemId");
|
||||||
|
CREATE INDEX "InventoryItem_guildId_userId_idx" ON "InventoryItem"("guildId", "userId");
|
||||||
|
CREATE INDEX "Poll_guildId_idx" ON "Poll"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "PollVote_pollId_userId_optionIndex_key" ON "PollVote"("pollId", "userId", "optionIndex");
|
||||||
|
CREATE INDEX "PollVote_pollId_idx" ON "PollVote"("pollId");
|
||||||
|
CREATE INDEX "Reminder_userId_idx" ON "Reminder"("userId");
|
||||||
|
CREATE INDEX "Reminder_remindAt_idx" ON "Reminder"("remindAt");
|
||||||
|
CREATE UNIQUE INDEX "AfkStatus_guildId_userId_key" ON "AfkStatus"("guildId", "userId");
|
||||||
|
|
||||||
|
ALTER TABLE "LevelingConfig" ADD CONSTRAINT "LevelingConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "MemberLevel" ADD CONSTRAINT "MemberLevel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "LevelReward" ADD CONSTRAINT "LevelReward_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "EconomyConfig" ADD CONSTRAINT "EconomyConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "MemberEconomy" ADD CONSTRAINT "MemberEconomy_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ShopItem" ADD CONSTRAINT "ShopItem_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "ShopItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Poll" ADD CONSTRAINT "Poll_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "AfkStatus" ADD CONSTRAINT "AfkStatus_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
CREATE TABLE "Giveaway" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"prize" TEXT NOT NULL,
|
||||||
|
"winnerCount" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"endsAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"requiredRoleId" TEXT,
|
||||||
|
"requiredLevel" INTEGER,
|
||||||
|
"requiredMemberDays" INTEGER,
|
||||||
|
"paused" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"endedAt" TIMESTAMP(3),
|
||||||
|
"winners" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"entrants" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"hostId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "Giveaway_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "TicketConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"mode" TEXT NOT NULL DEFAULT 'CHANNEL',
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"logChannelId" TEXT,
|
||||||
|
"transcriptDm" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"inactivityHours" INTEGER NOT NULL DEFAULT 72,
|
||||||
|
"ratingEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "TicketConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "TicketCategory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"emoji" TEXT,
|
||||||
|
"supportRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"formFields" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "TicketCategory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "Ticket" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"openerId" TEXT NOT NULL,
|
||||||
|
"claimedById" TEXT,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"threadId" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
||||||
|
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
|
||||||
|
"subject" TEXT,
|
||||||
|
"formAnswers" JSONB,
|
||||||
|
"transcriptHtml" TEXT,
|
||||||
|
"rating" INTEGER,
|
||||||
|
"closedAt" TIMESTAMP(3),
|
||||||
|
"lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SelfRolePanel" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"mode" TEXT NOT NULL DEFAULT 'BUTTONS',
|
||||||
|
"behavior" TEXT NOT NULL DEFAULT 'NORMAL',
|
||||||
|
"roles" JSONB NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SelfRolePanel_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "Tag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"content" TEXT,
|
||||||
|
"embed" JSONB,
|
||||||
|
"responseType" TEXT NOT NULL DEFAULT 'TEXT',
|
||||||
|
"triggerWord" TEXT,
|
||||||
|
"allowedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"allowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"useCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "StarboardConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"emoji" TEXT NOT NULL DEFAULT E'⭐',
|
||||||
|
"threshold" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"allowSelfStar" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "StarboardConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "StarboardEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"sourceChannelId" TEXT NOT NULL,
|
||||||
|
"sourceMessageId" TEXT NOT NULL,
|
||||||
|
"starboardMessageId" TEXT NOT NULL,
|
||||||
|
"starCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "StarboardEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SuggestionConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"openChannelId" TEXT,
|
||||||
|
"approvedChannelId" TEXT,
|
||||||
|
"deniedChannelId" TEXT,
|
||||||
|
"createThread" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SuggestionConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "Suggestion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
||||||
|
"messageId" TEXT,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"threadId" TEXT,
|
||||||
|
"upvotes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"downvotes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"voterUpIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"voterDownIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"staffReason" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "BirthdayConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"roleId" TEXT,
|
||||||
|
"message" TEXT,
|
||||||
|
"timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "BirthdayConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "Birthday" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"month" INTEGER NOT NULL,
|
||||||
|
"day" INTEGER NOT NULL,
|
||||||
|
"year" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "Birthday_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "TempVoiceConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"hubChannelId" TEXT,
|
||||||
|
"categoryId" TEXT,
|
||||||
|
"defaultName" TEXT NOT NULL DEFAULT '{user}''s Channel',
|
||||||
|
"defaultLimit" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "TempVoiceConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "TempVoiceChannel" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"controlMessageId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "TempVoiceChannel_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "Giveaway_guildId_endsAt_idx" ON "Giveaway"("guildId", "endsAt");
|
||||||
|
CREATE UNIQUE INDEX "TicketConfig_guildId_key" ON "TicketConfig"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "TicketCategory_guildId_name_key" ON "TicketCategory"("guildId", "name");
|
||||||
|
CREATE INDEX "TicketCategory_guildId_idx" ON "TicketCategory"("guildId");
|
||||||
|
CREATE INDEX "Ticket_guildId_status_idx" ON "Ticket"("guildId", "status");
|
||||||
|
CREATE INDEX "Ticket_channelId_idx" ON "Ticket"("channelId");
|
||||||
|
CREATE INDEX "SelfRolePanel_guildId_idx" ON "SelfRolePanel"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "Tag_guildId_name_key" ON "Tag"("guildId", "name");
|
||||||
|
CREATE INDEX "Tag_guildId_idx" ON "Tag"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "StarboardConfig_guildId_key" ON "StarboardConfig"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "StarboardEntry_guildId_sourceMessageId_key" ON "StarboardEntry"("guildId", "sourceMessageId");
|
||||||
|
CREATE INDEX "StarboardEntry_guildId_idx" ON "StarboardEntry"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "SuggestionConfig_guildId_key" ON "SuggestionConfig"("guildId");
|
||||||
|
CREATE INDEX "Suggestion_guildId_status_idx" ON "Suggestion"("guildId", "status");
|
||||||
|
CREATE UNIQUE INDEX "BirthdayConfig_guildId_key" ON "BirthdayConfig"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "Birthday_guildId_userId_key" ON "Birthday"("guildId", "userId");
|
||||||
|
CREATE INDEX "Birthday_guildId_month_day_idx" ON "Birthday"("guildId", "month", "day");
|
||||||
|
CREATE UNIQUE INDEX "TempVoiceConfig_guildId_key" ON "TempVoiceConfig"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "TempVoiceChannel_channelId_key" ON "TempVoiceChannel"("channelId");
|
||||||
|
CREATE INDEX "TempVoiceChannel_guildId_ownerId_idx" ON "TempVoiceChannel"("guildId", "ownerId");
|
||||||
|
|
||||||
|
ALTER TABLE "Giveaway" ADD CONSTRAINT "Giveaway_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TicketConfig" ADD CONSTRAINT "TicketConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TicketCategory" ADD CONSTRAINT "TicketCategory_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TicketCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SelfRolePanel" ADD CONSTRAINT "SelfRolePanel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "StarboardConfig" ADD CONSTRAINT "StarboardConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "StarboardEntry" ADD CONSTRAINT "StarboardEntry_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SuggestionConfig" ADD CONSTRAINT "SuggestionConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "BirthdayConfig" ADD CONSTRAINT "BirthdayConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Birthday" ADD CONSTRAINT "Birthday_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TempVoiceConfig" ADD CONSTRAINT "TempVoiceConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TempVoiceChannel" ADD CONSTRAINT "TempVoiceChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
CREATE TABLE "StatsConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"membersChannelId" TEXT,
|
||||||
|
"onlineChannelId" TEXT,
|
||||||
|
"boostsChannelId" TEXT,
|
||||||
|
"membersTemplate" TEXT NOT NULL DEFAULT 'Members: {count}',
|
||||||
|
"onlineTemplate" TEXT NOT NULL DEFAULT 'Online: {count}',
|
||||||
|
"boostsTemplate" TEXT NOT NULL DEFAULT 'Boosts: {count}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "StatsConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "InviteStat" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"inviterId" TEXT NOT NULL,
|
||||||
|
"code" TEXT,
|
||||||
|
"joins" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"leaves" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"fakes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "InviteStat_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "InviteJoin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"inviterId" TEXT,
|
||||||
|
"code" TEXT,
|
||||||
|
"isFake" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"leftAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "InviteJoin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "ActivityStat" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"messageCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"voiceMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"day" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "ActivityStat_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SocialFeed" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"sourceId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"rolePingId" TEXT,
|
||||||
|
"template" TEXT,
|
||||||
|
"lastItemId" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SocialFeed_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "ScheduledMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"creatorId" TEXT NOT NULL,
|
||||||
|
"content" TEXT,
|
||||||
|
"embed" JSONB,
|
||||||
|
"rolePingId" TEXT,
|
||||||
|
"cron" TEXT,
|
||||||
|
"runAt" TIMESTAMP(3),
|
||||||
|
"jobId" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "ScheduledMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "GuildBackup" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "GuildBackup_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "StatsConfig_guildId_key" ON "StatsConfig"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "InviteStat_guildId_inviterId_key" ON "InviteStat"("guildId", "inviterId");
|
||||||
|
CREATE INDEX "InviteStat_guildId_joins_idx" ON "InviteStat"("guildId", "joins");
|
||||||
|
CREATE UNIQUE INDEX "InviteJoin_guildId_userId_key" ON "InviteJoin"("guildId", "userId");
|
||||||
|
CREATE INDEX "InviteJoin_guildId_inviterId_idx" ON "InviteJoin"("guildId", "inviterId");
|
||||||
|
CREATE UNIQUE INDEX "ActivityStat_guildId_userId_channelId_day_key" ON "ActivityStat"("guildId", "userId", "channelId", "day");
|
||||||
|
CREATE INDEX "ActivityStat_guildId_day_idx" ON "ActivityStat"("guildId", "day");
|
||||||
|
CREATE INDEX "SocialFeed_guildId_type_idx" ON "SocialFeed"("guildId", "type");
|
||||||
|
CREATE INDEX "SocialFeed_enabled_idx" ON "SocialFeed"("enabled");
|
||||||
|
CREATE INDEX "ScheduledMessage_guildId_enabled_idx" ON "ScheduledMessage"("guildId", "enabled");
|
||||||
|
CREATE INDEX "GuildBackup_guildId_createdAt_idx" ON "GuildBackup"("guildId", "createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "StatsConfig" ADD CONSTRAINT "StatsConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "InviteStat" ADD CONSTRAINT "InviteStat_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "InviteJoin" ADD CONSTRAINT "InviteJoin_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ActivityStat" ADD CONSTRAINT "ActivityStat_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SocialFeed" ADD CONSTRAINT "SocialFeed_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ScheduledMessage" ADD CONSTRAINT "ScheduledMessage_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "GuildBackup" ADD CONSTRAINT "GuildBackup_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GuildSettings" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DashboardAccessRule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"modules" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DashboardAccessRule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DashboardAuditLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT,
|
||||||
|
"actorUserId" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"path" TEXT,
|
||||||
|
"before" JSONB,
|
||||||
|
"after" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "DashboardAuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DashboardAccessRule_guildId_idx" ON "DashboardAccessRule"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DashboardAccessRule_guildId_roleId_key" ON "DashboardAccessRule"("guildId", "roleId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DashboardAuditLog_guildId_createdAt_idx" ON "DashboardAuditLog"("guildId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DashboardAuditLog_actorUserId_createdAt_idx" ON "DashboardAuditLog"("actorUserId", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DashboardAccessRule" ADD CONSTRAINT "DashboardAccessRule_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DashboardAuditLog" ADD CONSTRAINT "DashboardAuditLog_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommandOverride" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"commandName" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"allowedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"deniedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"allowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"deniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"cooldownSeconds" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CommandOverride_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OwnerTeamMember" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "OwnerTeamMember_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GlobalUserBlacklist" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GlobalUserBlacklist_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GuildBlacklist" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"reason" TEXT,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GuildBlacklist_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FeatureFlag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"rolloutPercent" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"whitelistGuildIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FeatureFlag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BotPresenceConfig" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'online',
|
||||||
|
"activityType" TEXT NOT NULL DEFAULT 'Playing',
|
||||||
|
"activityText" TEXT NOT NULL DEFAULT 'nexumi.de',
|
||||||
|
"rotatingMessages" JSONB,
|
||||||
|
"maintenanceMode" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"maintenanceMessage" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BotPresenceConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ChangelogEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"version" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ChangelogEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OwnerAuditLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"actorUserId" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"targetType" TEXT,
|
||||||
|
"targetId" TEXT,
|
||||||
|
"before" JSONB,
|
||||||
|
"after" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "OwnerAuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CommandOverride_guildId_idx" ON "CommandOverride"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CommandOverride_guildId_commandName_key" ON "CommandOverride"("guildId", "commandName");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "OwnerTeamMember_userId_key" ON "OwnerTeamMember"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GlobalUserBlacklist_userId_key" ON "GlobalUserBlacklist"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GuildBlacklist_guildId_key" ON "GuildBlacklist"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FeatureFlag_key_key" ON "FeatureFlag"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OwnerAuditLog_createdAt_idx" ON "OwnerAuditLog"("createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommandOverride" ADD CONSTRAINT "CommandOverride_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GuildSettings" ADD COLUMN "snipeEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LoggingConfig" ADD COLUMN "retentionDays" INTEGER NOT NULL DEFAULT 90;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PremiumTierConfig" (
|
||||||
|
"tier" TEXT NOT NULL,
|
||||||
|
"maxTags" INTEGER NOT NULL,
|
||||||
|
"maxFeeds" INTEGER NOT NULL,
|
||||||
|
"maxBackups" INTEGER NOT NULL,
|
||||||
|
"features" JSONB NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PremiumTierConfig_pkey" PRIMARY KEY ("tier")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GuildPremiumAssignment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"tier" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"assignedById" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GuildPremiumAssignment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserPremiumAssignment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tier" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"assignedById" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserPremiumAssignment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GdprDeletionLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT,
|
||||||
|
"summary" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GdprDeletionLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GuildPremiumAssignment_guildId_key" ON "GuildPremiumAssignment"("guildId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GuildPremiumAssignment_tier_idx" ON "GuildPremiumAssignment"("tier");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserPremiumAssignment_userId_key" ON "UserPremiumAssignment"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UserPremiumAssignment_tier_idx" ON "UserPremiumAssignment"("tier");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GdprDeletionLog_userId_createdAt_idx" ON "GdprDeletionLog"("userId", "createdAt");
|
||||||
|
|
||||||
|
-- Seed default tier configs
|
||||||
|
INSERT INTO "PremiumTierConfig" ("tier", "maxTags", "maxFeeds", "maxBackups", "features", "updatedAt")
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'FREE',
|
||||||
|
15,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
'{"tags":true,"feeds":true,"guildbackup":true}'::jsonb,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'PREMIUM',
|
||||||
|
100,
|
||||||
|
25,
|
||||||
|
25,
|
||||||
|
'{"tags":true,"feeds":true,"guildbackup":true}'::jsonb,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GuildSettings" ADD COLUMN "commandAllowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
ALTER TABLE "GuildSettings" ADD COLUMN "commandDeniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "welcomeComponents" JSONB;
|
||||||
|
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveType" TEXT NOT NULL DEFAULT 'TEXT';
|
||||||
|
ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveComponents" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tag" ADD COLUMN IF NOT EXISTS "components" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ScheduledMessage" ADD COLUMN IF NOT EXISTS "components" JSONB;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "ComponentMessageBinding" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ComponentMessageBinding_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_guildId_createdAt_idx" ON "ComponentMessageBinding"("guildId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_messageId_idx" ON "ComponentMessageBinding"("messageId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ComponentMessageBinding" ADD CONSTRAINT "ComponentMessageBinding_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "captchaProvider" TEXT NOT NULL DEFAULT 'MATH';
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altDetectionEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altBlockOnMatch" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altIpWindowDays" INTEGER NOT NULL DEFAULT 30;
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altInviteWindowHours" INTEGER NOT NULL DEFAULT 48;
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altMaxAccountsPerInvite" INTEGER NOT NULL DEFAULT 3;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "VerificationRecord" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"inviteCode" TEXT,
|
||||||
|
"inviterId" TEXT,
|
||||||
|
"ipHash" TEXT,
|
||||||
|
"userAgentHash" TEXT,
|
||||||
|
"captchaProvider" TEXT,
|
||||||
|
"flaggedAsAlt" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"matchedUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "VerificationRecord_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "VerificationRecord_guildId_userId_key" ON "VerificationRecord"("guildId", "userId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_ipHash_createdAt_idx" ON "VerificationRecord"("guildId", "ipHash", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_inviteCode_createdAt_idx" ON "VerificationRecord"("guildId", "inviteCode", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "VerificationRecord" ADD CONSTRAINT "VerificationRecord_guildId_fkey"
|
||||||
|
FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BotAboutConfig" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"messageType" TEXT NOT NULL DEFAULT 'EMBED',
|
||||||
|
"content" TEXT,
|
||||||
|
"embed" JSONB,
|
||||||
|
"components" JSONB,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BotAboutConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TicketConfig" ADD COLUMN "panelChannelId" TEXT;
|
||||||
|
ALTER TABLE "TicketConfig" ADD COLUMN "panelMessageId" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tag" ADD COLUMN "cooldownSeconds" INTEGER NOT NULL DEFAULT 15;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Warning" ADD COLUMN "warningNumber" INTEGER;
|
||||||
|
|
||||||
|
-- Backfill per-guild sequential numbers (oldest first)
|
||||||
|
WITH numbered AS (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (PARTITION BY "guildId" ORDER BY "createdAt" ASC, id ASC) AS rn
|
||||||
|
FROM "Warning"
|
||||||
|
)
|
||||||
|
UPDATE "Warning" AS w
|
||||||
|
SET "warningNumber" = numbered.rn
|
||||||
|
FROM numbered
|
||||||
|
WHERE w.id = numbered.id;
|
||||||
|
|
||||||
|
ALTER TABLE "Warning" ALTER COLUMN "warningNumber" SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "Warning_guildId_warningNumber_key" ON "Warning"("guildId", "warningNumber");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WelcomeConfig" ADD COLUMN "leaveAnnounceBan" BOOLEAN NOT NULL DEFAULT false;
|
||||||
3
apps/bot/prisma/migrations/migration_lock.toml
Normal file
3
apps/bot/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -13,16 +13,94 @@ model Guild {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
settings GuildSettings?
|
settings GuildSettings?
|
||||||
cases Case[]
|
cases Case[]
|
||||||
|
escalationRules EscalationRule[]
|
||||||
|
autoModConfig AutoModConfig?
|
||||||
|
autoModRules AutoModRule[]
|
||||||
|
loggingConfig LoggingConfig?
|
||||||
|
logChannels LogChannel[]
|
||||||
|
welcomeConfig WelcomeConfig?
|
||||||
|
verificationConfig VerificationConfig?
|
||||||
|
verificationRecords VerificationRecord[]
|
||||||
|
levelingConfig LevelingConfig?
|
||||||
|
memberLevels MemberLevel[]
|
||||||
|
levelRewards LevelReward[]
|
||||||
|
economyConfig EconomyConfig?
|
||||||
|
memberEconomies MemberEconomy[]
|
||||||
|
shopItems ShopItem[]
|
||||||
|
inventoryItems InventoryItem[]
|
||||||
|
polls Poll[]
|
||||||
|
reminders Reminder[]
|
||||||
|
afkStatuses AfkStatus[]
|
||||||
|
giveaways Giveaway[]
|
||||||
|
ticketConfig TicketConfig?
|
||||||
|
ticketCategories TicketCategory[]
|
||||||
|
tickets Ticket[]
|
||||||
|
selfRolePanels SelfRolePanel[]
|
||||||
|
tags Tag[]
|
||||||
|
starboardConfig StarboardConfig?
|
||||||
|
starboardEntries StarboardEntry[]
|
||||||
|
suggestionConfig SuggestionConfig?
|
||||||
|
suggestions Suggestion[]
|
||||||
|
birthdays Birthday[]
|
||||||
|
birthdayConfig BirthdayConfig?
|
||||||
|
tempVoiceConfig TempVoiceConfig?
|
||||||
|
tempVoiceChannels TempVoiceChannel[]
|
||||||
|
statsConfig StatsConfig?
|
||||||
|
inviteStats InviteStat[]
|
||||||
|
inviteJoins InviteJoin[]
|
||||||
|
activityStats ActivityStat[]
|
||||||
|
socialFeeds SocialFeed[]
|
||||||
|
scheduledMessages ScheduledMessage[]
|
||||||
|
guildBackups GuildBackup[]
|
||||||
|
componentMessageBindings ComponentMessageBinding[]
|
||||||
|
dashboardAccessRules DashboardAccessRule[]
|
||||||
|
dashboardAuditLogs DashboardAuditLog[]
|
||||||
|
commandOverrides CommandOverride[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model GuildSettings {
|
model GuildSettings {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
guildId String @unique
|
guildId String @unique
|
||||||
locale String @default("de")
|
locale String @default("de")
|
||||||
|
timezone String @default("Europe/Berlin")
|
||||||
moderationEnabled Boolean @default(true)
|
moderationEnabled Boolean @default(true)
|
||||||
|
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
|
||||||
|
snipeEnabled Boolean @default(false)
|
||||||
|
/// Global command channel whitelist (empty = all channels unless denied).
|
||||||
|
commandAllowedChannelIds String[] @default([])
|
||||||
|
/// Global command channel blacklist.
|
||||||
|
commandDeniedChannelIds String[] @default([])
|
||||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DashboardAccessRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
roleId String
|
||||||
|
modules Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, roleId])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DashboardAuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String?
|
||||||
|
actorUserId String
|
||||||
|
action String
|
||||||
|
path String?
|
||||||
|
before Json?
|
||||||
|
after Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, createdAt])
|
||||||
|
@@index([actorUserId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -51,17 +129,19 @@ model Case {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Warning {
|
model Warning {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
guildId String
|
warningNumber Int
|
||||||
userId String
|
guildId String
|
||||||
moderatorId String
|
userId String
|
||||||
reason String?
|
moderatorId String
|
||||||
caseId String?
|
reason String?
|
||||||
createdAt DateTime @default(now())
|
caseId String?
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
updatedAt DateTime @updatedAt
|
||||||
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([guildId, warningNumber])
|
||||||
@@index([guildId, userId])
|
@@index([guildId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,3 +159,847 @@ model ModNote {
|
|||||||
|
|
||||||
@@index([guildId, userId])
|
@@index([guildId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model EscalationRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
warnCount Int
|
||||||
|
action String
|
||||||
|
durationMs Int?
|
||||||
|
reason String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, warnCount])
|
||||||
|
@@index([guildId, enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AutoModConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
antiRaidEnabled Boolean @default(false)
|
||||||
|
antiRaidJoinThreshold Int @default(10)
|
||||||
|
antiRaidWindowSeconds Int @default(10)
|
||||||
|
antiRaidAction String @default("LOCKDOWN")
|
||||||
|
antiNukeEnabled Boolean @default(false)
|
||||||
|
antiNukeBanThreshold Int @default(5)
|
||||||
|
antiNukeChannelThreshold Int @default(3)
|
||||||
|
antiNukeWindowSeconds Int @default(60)
|
||||||
|
lockdownActive Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model AutoModRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
ruleType String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
action String @default("DELETE")
|
||||||
|
threshold Json?
|
||||||
|
exceptions Json?
|
||||||
|
wordList Json?
|
||||||
|
durationMs Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, ruleType])
|
||||||
|
@@index([guildId, enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model LoggingConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
ignoreBots Boolean @default(true)
|
||||||
|
ignoredChannelIds String[] @default([])
|
||||||
|
ignoredRoleIds String[] @default([])
|
||||||
|
/// Days to keep Case / dashboard audit rows; 0 = keep forever.
|
||||||
|
retentionDays Int @default(90)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model LogChannel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
eventType String
|
||||||
|
channelId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, eventType])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WelcomeConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
welcomeEnabled Boolean @default(false)
|
||||||
|
leaveEnabled Boolean @default(false)
|
||||||
|
welcomeChannelId String?
|
||||||
|
leaveChannelId String?
|
||||||
|
welcomeType String @default("TEXT")
|
||||||
|
welcomeContent String?
|
||||||
|
welcomeEmbed Json?
|
||||||
|
welcomeComponents Json?
|
||||||
|
leaveType String @default("TEXT")
|
||||||
|
leaveContent String?
|
||||||
|
leaveEmbed Json?
|
||||||
|
leaveComponents Json?
|
||||||
|
/// When true, leave channel also posts a notice if the member was banned.
|
||||||
|
leaveAnnounceBan Boolean @default(false)
|
||||||
|
welcomeDmEnabled Boolean @default(false)
|
||||||
|
welcomeDmContent String?
|
||||||
|
userAutoroleIds String[] @default([])
|
||||||
|
botAutoroleIds String[] @default([])
|
||||||
|
imageTitle String?
|
||||||
|
imageSubtitle String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
channelId String?
|
||||||
|
verifiedRoleId String?
|
||||||
|
unverifiedRoleId String?
|
||||||
|
mode String @default("BUTTON")
|
||||||
|
/// MATH | RECAPTCHA_V2 | RECAPTCHA_V3 | HCAPTCHA | TURNSTILE
|
||||||
|
captchaProvider String @default("MATH")
|
||||||
|
minAccountAgeDays Int @default(0)
|
||||||
|
failAction String @default("KICK")
|
||||||
|
altDetectionEnabled Boolean @default(false)
|
||||||
|
altBlockOnMatch Boolean @default(true)
|
||||||
|
altIpWindowDays Int @default(30)
|
||||||
|
altInviteWindowHours Int @default(48)
|
||||||
|
altMaxAccountsPerInvite Int @default(3)
|
||||||
|
panelChannelId String?
|
||||||
|
panelMessageId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row per successful (or blocked-as-alt) verification attempt fingerprint.
|
||||||
|
model VerificationRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
inviteCode String?
|
||||||
|
inviterId String?
|
||||||
|
ipHash String?
|
||||||
|
userAgentHash String?
|
||||||
|
captchaProvider String?
|
||||||
|
flaggedAsAlt Boolean @default(false)
|
||||||
|
matchedUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
@@index([guildId, ipHash, createdAt])
|
||||||
|
@@index([guildId, inviteCode, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model LevelingConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
textXpMin Int @default(15)
|
||||||
|
textXpMax Int @default(25)
|
||||||
|
textCooldownSeconds Int @default(60)
|
||||||
|
voiceXpPerMinute Int @default(10)
|
||||||
|
noXpChannelIds String[] @default([])
|
||||||
|
noXpRoleIds String[] @default([])
|
||||||
|
roleMultipliers Json?
|
||||||
|
channelMultipliers Json?
|
||||||
|
levelUpMode String @default("CHANNEL")
|
||||||
|
levelUpChannelId String?
|
||||||
|
levelUpMessage String?
|
||||||
|
stackRewards Boolean @default(true)
|
||||||
|
cardAccentColor String @default("#6366F1")
|
||||||
|
cardBackgroundColor String @default("#111827")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model MemberLevel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
xp Int @default(0)
|
||||||
|
level Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
@@index([guildId, xp])
|
||||||
|
}
|
||||||
|
|
||||||
|
model LevelReward {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
level Int
|
||||||
|
roleId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, level])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model EconomyConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
currencyName String @default("Coins")
|
||||||
|
currencySymbol String @default("🪙")
|
||||||
|
dailyAmount Int @default(200)
|
||||||
|
weeklyAmount Int @default(1000)
|
||||||
|
workMin Int @default(50)
|
||||||
|
workMax Int @default(150)
|
||||||
|
workCooldownSeconds Int @default(3600)
|
||||||
|
startingBalance Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model MemberEconomy {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
balance Int @default(0)
|
||||||
|
lastDailyAt DateTime?
|
||||||
|
lastWeeklyAt DateTime?
|
||||||
|
lastWorkAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
@@index([guildId, balance])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShopItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
price Int
|
||||||
|
roleId String?
|
||||||
|
stock Int?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
inventory InventoryItem[]
|
||||||
|
|
||||||
|
@@index([guildId, enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InventoryItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
itemId String
|
||||||
|
quantity Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
item ShopItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId, itemId])
|
||||||
|
@@index([guildId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Poll {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
creatorId String
|
||||||
|
question String
|
||||||
|
options Json
|
||||||
|
multiSelect Boolean @default(false)
|
||||||
|
anonymous Boolean @default(false)
|
||||||
|
endsAt DateTime?
|
||||||
|
closedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
votes PollVote[]
|
||||||
|
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PollVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String
|
||||||
|
userId String
|
||||||
|
optionIndex Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([pollId, userId, optionIndex])
|
||||||
|
@@index([pollId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Reminder {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String?
|
||||||
|
userId String
|
||||||
|
channelId String
|
||||||
|
content String
|
||||||
|
remindAt DateTime
|
||||||
|
recurringCron String?
|
||||||
|
jobId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([remindAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AfkStatus {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
reason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Giveaway {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
prize String
|
||||||
|
winnerCount Int @default(1)
|
||||||
|
endsAt DateTime
|
||||||
|
requiredRoleId String?
|
||||||
|
requiredLevel Int?
|
||||||
|
requiredMemberDays Int?
|
||||||
|
paused Boolean @default(false)
|
||||||
|
endedAt DateTime?
|
||||||
|
winners String[] @default([])
|
||||||
|
entrants String[] @default([])
|
||||||
|
hostId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, endsAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TicketConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
mode String @default("CHANNEL")
|
||||||
|
categoryId String?
|
||||||
|
logChannelId String?
|
||||||
|
panelChannelId String?
|
||||||
|
panelMessageId String?
|
||||||
|
transcriptDm Boolean @default(false)
|
||||||
|
inactivityHours Int @default(72)
|
||||||
|
ratingEnabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model TicketCategory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
emoji String?
|
||||||
|
supportRoleIds String[] @default([])
|
||||||
|
formFields Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
tickets Ticket[]
|
||||||
|
|
||||||
|
@@unique([guildId, name])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Ticket {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
categoryId String?
|
||||||
|
openerId String
|
||||||
|
claimedById String?
|
||||||
|
channelId String?
|
||||||
|
threadId String?
|
||||||
|
status String @default("OPEN")
|
||||||
|
priority String @default("NORMAL")
|
||||||
|
subject String?
|
||||||
|
formAnswers Json?
|
||||||
|
transcriptHtml String?
|
||||||
|
rating Int?
|
||||||
|
closedAt DateTime?
|
||||||
|
lastActivityAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
category TicketCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([guildId, status])
|
||||||
|
@@index([channelId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SelfRolePanel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
mode String @default("BUTTONS")
|
||||||
|
behavior String @default("NORMAL")
|
||||||
|
roles Json
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
name String
|
||||||
|
content String?
|
||||||
|
embed Json?
|
||||||
|
components Json?
|
||||||
|
responseType String @default("TEXT")
|
||||||
|
triggerWord String?
|
||||||
|
cooldownSeconds Int @default(15)
|
||||||
|
allowedRoleIds String[] @default([])
|
||||||
|
allowedChannelIds String[] @default([])
|
||||||
|
createdById String
|
||||||
|
useCount Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, name])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model StarboardConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
channelId String?
|
||||||
|
emoji String @default("⭐")
|
||||||
|
threshold Int @default(3)
|
||||||
|
allowSelfStar Boolean @default(false)
|
||||||
|
ignoredChannelIds String[] @default([])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model StarboardEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
sourceChannelId String
|
||||||
|
sourceMessageId String
|
||||||
|
starboardMessageId String
|
||||||
|
starCount Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, sourceMessageId])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SuggestionConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
openChannelId String?
|
||||||
|
approvedChannelId String?
|
||||||
|
deniedChannelId String?
|
||||||
|
createThread Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Suggestion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
authorId String
|
||||||
|
content String
|
||||||
|
status String @default("OPEN")
|
||||||
|
messageId String?
|
||||||
|
channelId String?
|
||||||
|
threadId String?
|
||||||
|
upvotes Int @default(0)
|
||||||
|
downvotes Int @default(0)
|
||||||
|
voterUpIds String[] @default([])
|
||||||
|
voterDownIds String[] @default([])
|
||||||
|
staffReason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BirthdayConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
channelId String?
|
||||||
|
roleId String?
|
||||||
|
message String?
|
||||||
|
timezone String @default("Europe/Berlin")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Birthday {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
month Int
|
||||||
|
day Int
|
||||||
|
year Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
@@index([guildId, month, day])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TempVoiceConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
hubChannelId String?
|
||||||
|
categoryId String?
|
||||||
|
defaultName String @default("{user}'s Channel")
|
||||||
|
defaultLimit Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model TempVoiceChannel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String @unique
|
||||||
|
ownerId String
|
||||||
|
controlMessageId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, ownerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model StatsConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
membersChannelId String?
|
||||||
|
onlineChannelId String?
|
||||||
|
boostsChannelId String?
|
||||||
|
membersTemplate String @default("Members: {count}")
|
||||||
|
onlineTemplate String @default("Online: {count}")
|
||||||
|
boostsTemplate String @default("Boosts: {count}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model InviteStat {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
inviterId String
|
||||||
|
code String?
|
||||||
|
joins Int @default(0)
|
||||||
|
leaves Int @default(0)
|
||||||
|
fakes Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, inviterId])
|
||||||
|
@@index([guildId, joins])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InviteJoin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
inviterId String?
|
||||||
|
code String?
|
||||||
|
isFake Boolean @default(false)
|
||||||
|
leftAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
@@index([guildId, inviterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ActivityStat {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
channelId String?
|
||||||
|
messageCount Int @default(0)
|
||||||
|
voiceMinutes Int @default(0)
|
||||||
|
day DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId, channelId, day])
|
||||||
|
@@index([guildId, day])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SocialFeed {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
type String
|
||||||
|
sourceId String
|
||||||
|
channelId String
|
||||||
|
rolePingId String?
|
||||||
|
template String?
|
||||||
|
lastItemId String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, type])
|
||||||
|
@@index([enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ScheduledMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
creatorId String
|
||||||
|
content String?
|
||||||
|
embed Json?
|
||||||
|
components Json?
|
||||||
|
rolePingId String?
|
||||||
|
cron String?
|
||||||
|
runAt DateTime?
|
||||||
|
jobId String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ComponentMessageBinding {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
messageId String?
|
||||||
|
payload Json
|
||||||
|
createdById String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, createdAt])
|
||||||
|
@@index([messageId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GuildBackup {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
name String
|
||||||
|
createdById String
|
||||||
|
payload Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CommandOverride {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
commandName String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
allowedRoleIds String[] @default([])
|
||||||
|
deniedRoleIds String[] @default([])
|
||||||
|
allowedChannelIds String[] @default([])
|
||||||
|
deniedChannelIds String[] @default([])
|
||||||
|
cooldownSeconds Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, commandName])
|
||||||
|
@@index([guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OwnerTeamMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
role String
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model GlobalUserBlacklist {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
reason String?
|
||||||
|
note String?
|
||||||
|
createdById String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model GuildBlacklist {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
reason String?
|
||||||
|
createdById String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model FeatureFlag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
rolloutPercent Int @default(0)
|
||||||
|
whitelistGuildIds String[] @default([])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model BotPresenceConfig {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
status String @default("online")
|
||||||
|
activityType String @default("Watching")
|
||||||
|
activityText String @default("nexumi.de")
|
||||||
|
rotatingMessages Json?
|
||||||
|
maintenanceMode Boolean @default(false)
|
||||||
|
maintenanceMessage String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global `/about` message (Owner panel). Singleton row id = "singleton".
|
||||||
|
model BotAboutConfig {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
/// TEXT | EMBED | COMPONENTS_V2
|
||||||
|
messageType String @default("EMBED")
|
||||||
|
content String?
|
||||||
|
embed Json?
|
||||||
|
components Json?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChangelogEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
version String
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model OwnerAuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
actorUserId String
|
||||||
|
action String
|
||||||
|
targetType String?
|
||||||
|
targetId String?
|
||||||
|
before Json?
|
||||||
|
after Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tier limits and feature gates (FREE / PREMIUM). Seeded on first use.
|
||||||
|
model PremiumTierConfig {
|
||||||
|
tier String @id
|
||||||
|
maxTags Int
|
||||||
|
maxFeeds Int
|
||||||
|
maxBackups Int
|
||||||
|
/// JSON object of feature key → boolean (tags, feeds, guildbackup, …).
|
||||||
|
features Json
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model GuildPremiumAssignment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
tier String
|
||||||
|
note String?
|
||||||
|
assignedById String
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tier])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserPremiumAssignment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
tier String
|
||||||
|
note String?
|
||||||
|
assignedById String
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tier])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GdprDeletionLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
guildId String?
|
||||||
|
summary Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
60
apps/bot/scripts/set-bot-banner.ts
Normal file
60
apps/bot/scripts/set-bot-banner.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Uploads docs/logo/nexumi-banner.png as the bot user banner (and optional bio)
|
||||||
|
* via Discord API.
|
||||||
|
*
|
||||||
|
* Usage (from repo root):
|
||||||
|
* pnpm --filter @nexumi/bot profile:banner
|
||||||
|
*
|
||||||
|
* Requires BOT_TOKEN in the environment (or root/.env / apps/bot/.env).
|
||||||
|
*/
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { config as loadEnv } from 'dotenv';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = resolve(here, '../../..');
|
||||||
|
|
||||||
|
for (const candidate of [resolve(repoRoot, '.env'), resolve(here, '../.env')]) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
loadEnv({ path: candidate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = process.env.BOT_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
console.error('BOT_TOKEN is missing.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannerPath = resolve(repoRoot, 'docs/logo/nexumi-banner.png');
|
||||||
|
if (!existsSync(bannerPath)) {
|
||||||
|
console.error(`Banner not found: ${bannerPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const png = readFileSync(bannerPath);
|
||||||
|
const bannerDataUri = `data:image/png;base64,${png.toString('base64')}`;
|
||||||
|
|
||||||
|
const bio =
|
||||||
|
'Moderation, Community & Integrationen — in einem Bot.\n' +
|
||||||
|
'Dashboard: https://dashboard.nexumi.de\n' +
|
||||||
|
'nexumi.de';
|
||||||
|
|
||||||
|
const response = await fetch('https://discord.com/api/v10/users/@me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ banner: bannerDataUri, bio })
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed (${response.status}): ${body}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Bot profile updated successfully.');
|
||||||
|
console.log(body);
|
||||||
38
apps/bot/src/command-gate-rules.ts
Normal file
38
apps/bot/src/command-gate-rules.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type CommandGateReason =
|
||||||
|
| 'disabled'
|
||||||
|
| 'channel_denied'
|
||||||
|
| 'channel_not_allowed'
|
||||||
|
| 'role_denied'
|
||||||
|
| 'role_not_allowed'
|
||||||
|
| 'cooldown';
|
||||||
|
|
||||||
|
export function isChannelAllowed(
|
||||||
|
channelId: string | null,
|
||||||
|
allowed: string[],
|
||||||
|
denied: string[]
|
||||||
|
): CommandGateReason | null {
|
||||||
|
if (!channelId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (denied.includes(channelId)) {
|
||||||
|
return 'channel_denied';
|
||||||
|
}
|
||||||
|
if (allowed.length > 0 && !allowed.includes(channelId)) {
|
||||||
|
return 'channel_not_allowed';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRoleAllowed(
|
||||||
|
roleIds: string[],
|
||||||
|
allowed: string[],
|
||||||
|
denied: string[]
|
||||||
|
): CommandGateReason | null {
|
||||||
|
if (denied.some((id) => roleIds.includes(id))) {
|
||||||
|
return 'role_denied';
|
||||||
|
}
|
||||||
|
if (allowed.length > 0 && !allowed.some((id) => roleIds.includes(id))) {
|
||||||
|
return 'role_not_allowed';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
32
apps/bot/src/command-gates.test.ts
Normal file
32
apps/bot/src/command-gates.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isChannelAllowed, isRoleAllowed } from './command-gate-rules.js';
|
||||||
|
|
||||||
|
describe('command-gates channel rules', () => {
|
||||||
|
it('allows any channel when allow and deny are empty', () => {
|
||||||
|
expect(isChannelAllowed('1', [], [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks denied channels even when allow is empty', () => {
|
||||||
|
expect(isChannelAllowed('1', [], ['1'])).toBe('channel_denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires membership when allow list is set', () => {
|
||||||
|
expect(isChannelAllowed('1', ['2'], [])).toBe('channel_not_allowed');
|
||||||
|
expect(isChannelAllowed('2', ['2'], [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers deny over allow', () => {
|
||||||
|
expect(isChannelAllowed('1', ['1'], ['1'])).toBe('channel_denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('command-gates role rules', () => {
|
||||||
|
it('blocks when any denied role is present', () => {
|
||||||
|
expect(isRoleAllowed(['a', 'b'], [], ['b'])).toBe('role_denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires an allowed role when allow list is set', () => {
|
||||||
|
expect(isRoleAllowed(['a'], ['b'], [])).toBe('role_not_allowed');
|
||||||
|
expect(isRoleAllowed(['a', 'b'], ['b'], [])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
114
apps/bot/src/command-gates.ts
Normal file
114
apps/bot/src/command-gates.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
|
||||||
|
import type { BotContext } from './types.js';
|
||||||
|
import { redis } from './redis.js';
|
||||||
|
import {
|
||||||
|
isChannelAllowed,
|
||||||
|
isRoleAllowed,
|
||||||
|
type CommandGateReason
|
||||||
|
} from './command-gate-rules.js';
|
||||||
|
|
||||||
|
export type { CommandGateReason };
|
||||||
|
export type CommandGateResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; reason: CommandGateReason; retryAfterSeconds?: number };
|
||||||
|
|
||||||
|
function memberRoleIds(member: GuildMember | null): string[] {
|
||||||
|
if (!member) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [...member.roles.cache.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies guild-wide command channel rules and per-command overrides.
|
||||||
|
* Empty allow-lists mean "no restriction"; deny-lists always block.
|
||||||
|
* Per-command allow/deny is evaluated after global channel rules.
|
||||||
|
*/
|
||||||
|
export async function evaluateCommandGate(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<CommandGateResult> {
|
||||||
|
if (!interaction.guildId) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [settings, override] = await Promise.all([
|
||||||
|
context.prisma.guildSettings.findUnique({ where: { guildId: interaction.guildId } }),
|
||||||
|
context.prisma.commandOverride.findUnique({
|
||||||
|
where: {
|
||||||
|
guildId_commandName: {
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
commandName: interaction.commandName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (override && !override.enabled) {
|
||||||
|
return { ok: false, reason: 'disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = interaction.channelId;
|
||||||
|
const globalChannelBlock = isChannelAllowed(
|
||||||
|
channelId,
|
||||||
|
settings?.commandAllowedChannelIds ?? [],
|
||||||
|
settings?.commandDeniedChannelIds ?? []
|
||||||
|
);
|
||||||
|
if (globalChannelBlock) {
|
||||||
|
return { ok: false, reason: globalChannelBlock };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (override) {
|
||||||
|
const perCommandChannelBlock = isChannelAllowed(
|
||||||
|
channelId,
|
||||||
|
override.allowedChannelIds,
|
||||||
|
override.deniedChannelIds
|
||||||
|
);
|
||||||
|
if (perCommandChannelBlock) {
|
||||||
|
return { ok: false, reason: perCommandChannelBlock };
|
||||||
|
}
|
||||||
|
|
||||||
|
const member =
|
||||||
|
interaction.member && 'roles' in interaction.member
|
||||||
|
? (interaction.member as GuildMember)
|
||||||
|
: null;
|
||||||
|
const roleIds = memberRoleIds(member);
|
||||||
|
const roleBlock = isRoleAllowed(roleIds, override.allowedRoleIds, override.deniedRoleIds);
|
||||||
|
if (roleBlock) {
|
||||||
|
return { ok: false, reason: roleBlock };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (override.cooldownSeconds && override.cooldownSeconds > 0) {
|
||||||
|
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
|
||||||
|
const existing = await redis.ttl(key);
|
||||||
|
if (existing > 0) {
|
||||||
|
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the per-command cooldown after a successful execute. */
|
||||||
|
export async function applyCommandCooldown(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<void> {
|
||||||
|
if (!interaction.guildId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const override = await context.prisma.commandOverride.findUnique({
|
||||||
|
where: {
|
||||||
|
guildId_commandName: {
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
commandName: interaction.commandName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!override?.cooldownSeconds || override.cooldownSeconds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
|
||||||
|
await redis.set(key, '1', 'EX', override.cooldownSeconds);
|
||||||
|
}
|
||||||
@@ -1,24 +1,295 @@
|
|||||||
import { REST, Routes, type ChatInputCommandInteraction } from 'discord.js';
|
import {
|
||||||
|
REST,
|
||||||
|
Routes,
|
||||||
|
type ChatInputCommandInteraction,
|
||||||
|
type MessageContextMenuCommandInteraction
|
||||||
|
} from 'discord.js';
|
||||||
|
import { t, tf, type Locale } from '@nexumi/shared';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { getGuildLocale } from './i18n.js';
|
||||||
|
import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js';
|
||||||
|
import {
|
||||||
|
isGuildBlacklisted,
|
||||||
|
isModuleEnabled,
|
||||||
|
isUserBlacklisted,
|
||||||
|
moduleForCommand
|
||||||
|
} from './module-gates.js';
|
||||||
|
import { isMaintenanceMode } from './presence.js';
|
||||||
|
import { recordCommandMetric } from './metrics.js';
|
||||||
|
import { captureException } from './sentry.js';
|
||||||
import { moderationCommands } from './modules/moderation/commands.js';
|
import { moderationCommands } from './modules/moderation/commands.js';
|
||||||
|
import { automodCommands } from './modules/automod/commands.js';
|
||||||
|
import { welcomeCommands } from './modules/welcome/commands.js';
|
||||||
|
import { verificationCommands } from './modules/verification/commands.js';
|
||||||
|
import { levelingCommands } from './modules/leveling/commands.js';
|
||||||
|
import { economyCommands } from './modules/economy/commands.js';
|
||||||
|
import { utilityCommands, utilityContextMenus } from './modules/utility/index.js';
|
||||||
|
import { funCommands } from './modules/fun/index.js';
|
||||||
|
import { giveawayCommands } from './modules/giveaways/index.js';
|
||||||
|
import { ticketsCommands } from './modules/tickets/index.js';
|
||||||
|
import { selfrolesCommands } from './modules/selfroles/index.js';
|
||||||
|
import { tagsCommands } from './modules/tags/index.js';
|
||||||
|
import { starboardCommands } from './modules/starboard/index.js';
|
||||||
|
import { suggestionsCommands } from './modules/suggestions/index.js';
|
||||||
|
import { birthdayCommands } from './modules/birthdays/index.js';
|
||||||
|
import { tempVoiceCommands } from './modules/tempvoice/index.js';
|
||||||
|
import { statsCommands } from './modules/stats/index.js';
|
||||||
|
import { feedsCommands } from './modules/feeds/index.js';
|
||||||
|
import { scheduleCommands } from './modules/scheduler/index.js';
|
||||||
|
import { guildBackupCommands } from './modules/guildbackup/index.js';
|
||||||
|
import { coreCommands } from './modules/core/index.js';
|
||||||
|
import { gdprCommands } from './modules/gdpr/index.js';
|
||||||
import type { BotContext, SlashCommand } from './types.js';
|
import type { BotContext, SlashCommand } from './types.js';
|
||||||
|
|
||||||
const commands: SlashCommand[] = [...moderationCommands];
|
const commands: SlashCommand[] = [
|
||||||
|
...coreCommands,
|
||||||
|
...gdprCommands,
|
||||||
|
...moderationCommands,
|
||||||
|
...automodCommands,
|
||||||
|
...welcomeCommands,
|
||||||
|
...verificationCommands,
|
||||||
|
...levelingCommands,
|
||||||
|
...economyCommands,
|
||||||
|
...utilityCommands,
|
||||||
|
...funCommands,
|
||||||
|
...giveawayCommands,
|
||||||
|
...ticketsCommands,
|
||||||
|
...selfrolesCommands,
|
||||||
|
...tagsCommands,
|
||||||
|
...starboardCommands,
|
||||||
|
...suggestionsCommands,
|
||||||
|
...birthdayCommands,
|
||||||
|
...tempVoiceCommands,
|
||||||
|
...statsCommands,
|
||||||
|
...feedsCommands,
|
||||||
|
...scheduleCommands,
|
||||||
|
...guildBackupCommands
|
||||||
|
];
|
||||||
const map = new Map(commands.map((c) => [c.data.name, c]));
|
const map = new Map(commands.map((c) => [c.data.name, c]));
|
||||||
|
const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c]));
|
||||||
|
|
||||||
|
function buildCommandBody(): object[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const body: object[] = [];
|
||||||
|
for (const command of [...commands, ...utilityContextMenus]) {
|
||||||
|
const json = command.data.toJSON() as { name: string; type?: number };
|
||||||
|
const key = `${json.type ?? 1}:${json.name}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
logger.warn({ name: json.name, type: json.type }, 'Skipping duplicate command definition');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
body.push(json);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guild-scoped leftovers + global commands both appear in Discord's picker.
|
||||||
|
* Always clear the unused scope so each command shows once.
|
||||||
|
*/
|
||||||
|
async function clearGuildCommandLeftovers(rest: REST, clientId: string): Promise<number> {
|
||||||
|
const guilds = (await rest.get(Routes.userGuilds())) as Array<{ id: string }>;
|
||||||
|
let cleared = 0;
|
||||||
|
for (const guild of guilds) {
|
||||||
|
const existing = (await rest.get(
|
||||||
|
Routes.applicationGuildCommands(clientId, guild.id)
|
||||||
|
)) as unknown[];
|
||||||
|
if (existing.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await rest.put(Routes.applicationGuildCommands(clientId, guild.id), { body: [] });
|
||||||
|
cleared += 1;
|
||||||
|
}
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerCommands() {
|
export async function registerCommands() {
|
||||||
const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN);
|
const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN);
|
||||||
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), {
|
const body = buildCommandBody();
|
||||||
body: commands.map((c) => c.data.toJSON())
|
|
||||||
});
|
// Guild commands sync instantly; global can take up to ~1h.
|
||||||
logger.info({ count: commands.length }, 'Registered application commands');
|
if (env.BOT_GUILD_ID) {
|
||||||
|
await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), {
|
||||||
|
body
|
||||||
|
});
|
||||||
|
// Remove global copies so the guild does not show every command twice.
|
||||||
|
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body: [] });
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
scope: 'guild',
|
||||||
|
guildId: env.BOT_GUILD_ID,
|
||||||
|
count: body.length,
|
||||||
|
clearedGlobal: true
|
||||||
|
},
|
||||||
|
'Registered guild application commands and cleared global commands'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body });
|
||||||
|
|
||||||
|
// Wipe leftover guild-scoped registrations from earlier BOT_GUILD_ID / shard runs.
|
||||||
|
let clearedGuilds = 0;
|
||||||
|
try {
|
||||||
|
clearedGuilds = await clearGuildCommandLeftovers(rest, env.BOT_CLIENT_ID);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Failed to clear leftover guild application commands');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
scope: 'global',
|
||||||
|
count: body.length,
|
||||||
|
clearedGuilds
|
||||||
|
},
|
||||||
|
'Registered global application commands and cleared guild-scoped leftovers'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSeconds?: number): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'disabled':
|
||||||
|
return t(locale, 'generic.commandDisabled');
|
||||||
|
case 'channel_denied':
|
||||||
|
case 'channel_not_allowed':
|
||||||
|
return t(locale, 'generic.commandChannelBlocked');
|
||||||
|
case 'role_denied':
|
||||||
|
case 'role_not_allowed':
|
||||||
|
return t(locale, 'generic.commandRoleBlocked');
|
||||||
|
case 'cooldown':
|
||||||
|
return tf(locale, 'generic.commandCooldown', {
|
||||||
|
seconds: retryAfterSeconds ?? 1
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return t(locale, 'generic.noPermission');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
||||||
const command = map.get(interaction.commandName);
|
const started = Date.now();
|
||||||
|
let ok = true;
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.guildBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maintenance = await isMaintenanceMode(context);
|
||||||
|
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||||
|
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = map.get(interaction.commandName);
|
||||||
|
if (!command) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleId = moduleForCommand(interaction.commandName);
|
||||||
|
if (moduleId && interaction.guildId) {
|
||||||
|
const enabled = await isModuleEnabled(
|
||||||
|
context.prisma,
|
||||||
|
context.redis,
|
||||||
|
interaction.guildId,
|
||||||
|
moduleId
|
||||||
|
);
|
||||||
|
if (!enabled) {
|
||||||
|
// Allow first-time setup while the module is still disabled (chicken-egg).
|
||||||
|
const sub = interaction.options.getSubcommand(false);
|
||||||
|
const isBootstrapSetup =
|
||||||
|
interaction.commandName === 'starboard' && sub === 'setup';
|
||||||
|
if (!isBootstrapSetup) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.moduleDisabled'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gate = await evaluateCommandGate(interaction, context);
|
||||||
|
if (!gate.ok) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: gateMessage(locale, gate.reason, gate.retryAfterSeconds),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await command.execute(interaction, context);
|
||||||
|
await applyCommandCooldown(interaction, context).catch(() => undefined);
|
||||||
|
} catch (error) {
|
||||||
|
ok = false;
|
||||||
|
captureException(error, {
|
||||||
|
command: interaction.commandName,
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
userId: interaction.user.id
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await recordCommandMetric(context.redis, {
|
||||||
|
ok,
|
||||||
|
durationMs: Date.now() - started
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function routeContextMenu(
|
||||||
|
interaction: MessageContextMenuCommandInteraction,
|
||||||
|
context: BotContext
|
||||||
|
) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
|
||||||
|
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.guildBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maintenance = await isMaintenanceMode(context);
|
||||||
|
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||||
|
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = contextMenuMap.get(interaction.commandName);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
await interaction.reply({ content: 'Unknown command.', ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await command.execute(interaction, context);
|
await command.execute(interaction, context);
|
||||||
|
|||||||
52
apps/bot/src/confirmation-store.test.ts
Normal file
52
apps/bot/src/confirmation-store.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from './confirmation-store.js';
|
||||||
|
|
||||||
|
function createMemoryRedis(): Redis {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
async set(key: string, value: string) {
|
||||||
|
store.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
},
|
||||||
|
async get(key: string) {
|
||||||
|
return store.get(key) ?? null;
|
||||||
|
},
|
||||||
|
async del(key: string) {
|
||||||
|
store.delete(key);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} as unknown as Redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('confirmation-store', () => {
|
||||||
|
it('stores and retrieves pending confirmations', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'moderation', 'tok-1', { action: 'ban', userId: '1' }, 60);
|
||||||
|
await expect(getConfirmation(redis, 'moderation', 'tok-1')).resolves.toEqual({
|
||||||
|
action: 'ban',
|
||||||
|
userId: '1'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takeConfirmation deletes the entry', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'moderation', 'tok-2', { action: 'purge' }, 60);
|
||||||
|
await expect(takeConfirmation(redis, 'moderation', 'tok-2')).resolves.toEqual({
|
||||||
|
action: 'purge'
|
||||||
|
});
|
||||||
|
await expect(getConfirmation(redis, 'moderation', 'tok-2')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteConfirmation removes pending state', async () => {
|
||||||
|
const redis = createMemoryRedis();
|
||||||
|
await setConfirmation(redis, 'guildbackup', 'tok-3', { action: 'delete' }, 60);
|
||||||
|
await deleteConfirmation(redis, 'guildbackup', 'tok-3');
|
||||||
|
await expect(getConfirmation(redis, 'guildbackup', 'tok-3')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
apps/bot/src/confirmation-store.ts
Normal file
57
apps/bot/src/confirmation-store.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
function key(namespace: string, token: string): string {
|
||||||
|
return `confirm:${namespace}:${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setConfirmation<T extends object>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string,
|
||||||
|
entry: T,
|
||||||
|
ttlSeconds: number
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.set(key(namespace, token), JSON.stringify(entry), 'EX', ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfirmation<T>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const raw = await redis.get(key(namespace, token));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConfirmation(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.del(key(namespace, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function takeConfirmation<T>(
|
||||||
|
redis: Redis,
|
||||||
|
namespace: string,
|
||||||
|
token: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const k = key(namespace, token);
|
||||||
|
const raw = await redis.get(k);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await redis.del(k);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,53 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
config();
|
config();
|
||||||
|
|
||||||
|
const emptyToUndefined = (value: unknown) =>
|
||||||
|
value === '' || value === undefined || value === null ? undefined : value;
|
||||||
|
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
BOT_TOKEN: z.string().min(1),
|
BOT_TOKEN: z.string().min(1),
|
||||||
BOT_CLIENT_ID: z.string().min(1),
|
BOT_CLIENT_ID: z.string().min(1),
|
||||||
|
BOT_GUILD_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
REDIS_URL: z.string().url(),
|
REDIS_URL: z.string().url(),
|
||||||
LOG_LEVEL: z.string().default('info'),
|
LOG_LEVEL: z.string().default('info'),
|
||||||
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'),
|
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'),
|
||||||
BACKUP_RETENTION_DAYS: z.coerce.number().int().positive().default(14),
|
BACKUP_RETENTION_DAYS: z.coerce.number().int().positive().default(14),
|
||||||
METRICS_TOKEN: z.string().optional(),
|
BACKUP_CRON: z.string().default('0 3 * * *'),
|
||||||
HEALTH_PORT: z.coerce.number().int().positive().default(8080)
|
BACKUP_DIR: z.string().default('/backups'),
|
||||||
|
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
|
SENTRY_DSN: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||||
|
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
||||||
|
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
||||||
|
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
|
TWITCH_CLIENT_SECRET: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
|
OWNER_USER_IDS: z.preprocess(
|
||||||
|
emptyToUndefined,
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) =>
|
||||||
|
value
|
||||||
|
? value
|
||||||
|
.split(',')
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter((id) => id.length > 0)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
),
|
||||||
|
WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||||
|
SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||||
|
/// Salt for hashing client IPs in verification alt-detection (shared with WebUI).
|
||||||
|
CAPTCHA_IP_HASH_SALT: z.preprocess(emptyToUndefined, z.string().min(16).optional())
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = EnvSchema.parse(process.env);
|
export const env = EnvSchema.parse(process.env);
|
||||||
|
|
||||||
|
export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
|
||||||
|
const url = new URL('https://discord.com/api/oauth2/authorize');
|
||||||
|
url.searchParams.set('client_id', clientId);
|
||||||
|
url.searchParams.set('permissions', '4786708802825463');
|
||||||
|
url.searchParams.set('scope', 'bot applications.commands');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|||||||
21
apps/bot/src/guild.ts
Normal file
21
apps/bot/src/guild.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function ensureGuild(prisma: PrismaClient, guildId: string): Promise<void> {
|
||||||
|
await prisma.guild.upsert({
|
||||||
|
where: { id: guildId },
|
||||||
|
update: {},
|
||||||
|
create: { id: guildId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureGuildSettings(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
await prisma.guildSettings.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: {},
|
||||||
|
create: { guildId }
|
||||||
|
});
|
||||||
|
}
|
||||||
44
apps/bot/src/harden.test.ts
Normal file
44
apps/bot/src/harden.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatPrometheus, type MetricsSnapshot } from './metrics.js';
|
||||||
|
|
||||||
|
describe('confirmation custom_id matchers', () => {
|
||||||
|
it('detects moderation confirm/cancel prefixes', () => {
|
||||||
|
const isMod = (id: string) => id.startsWith('mod:confirm:') || id.startsWith('mod:cancel:');
|
||||||
|
expect(isMod('mod:confirm:abc')).toBe(true);
|
||||||
|
expect(isMod('mod:cancel:abc')).toBe(true);
|
||||||
|
expect(isMod('other')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects guildbackup confirm/cancel prefixes', () => {
|
||||||
|
const isBackup = (id: string) =>
|
||||||
|
id.startsWith('gbackup:confirm:delete:') ||
|
||||||
|
id.startsWith('gbackup:confirm:restore:') ||
|
||||||
|
id.startsWith('gbackup:cancel:');
|
||||||
|
expect(isBackup('gbackup:confirm:delete:abc')).toBe(true);
|
||||||
|
expect(isBackup('gbackup:confirm:restore:abc')).toBe(true);
|
||||||
|
expect(isBackup('gbackup:cancel:abc')).toBe(true);
|
||||||
|
expect(isBackup('mod:confirm:abc')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPrometheus', () => {
|
||||||
|
it('emits core counters and shard gauges', () => {
|
||||||
|
const snapshot: MetricsSnapshot = {
|
||||||
|
up: 1,
|
||||||
|
commandsTotal: 10,
|
||||||
|
commandsErrors: 2,
|
||||||
|
commandDurationSumMs: 500,
|
||||||
|
commandDurationCount: 10,
|
||||||
|
eventsTotal: 100,
|
||||||
|
shards: [{ id: 0, ping: 42, guilds: 5, updatedAt: Date.now() }],
|
||||||
|
queueWaiting: 3
|
||||||
|
};
|
||||||
|
const body = formatPrometheus(snapshot);
|
||||||
|
expect(body).toContain('nexumi_up 1');
|
||||||
|
expect(body).toContain('nexumi_commands_total 10');
|
||||||
|
expect(body).toContain('nexumi_commands_errors_total 2');
|
||||||
|
expect(body).toContain('nexumi_guilds{shard="0"} 5');
|
||||||
|
expect(body).toContain('nexumi_shard_ping_ms{shard="0"} 42');
|
||||||
|
expect(body).toContain('nexumi_queue_waiting 3');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,196 @@
|
|||||||
import { createServer } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
|
import { redis } from './redis.js';
|
||||||
|
import {
|
||||||
|
deleteCaptchaChallenge,
|
||||||
|
getCaptchaChallenge,
|
||||||
|
hashCaptchaAnswer
|
||||||
|
} from './modules/verification/service.js';
|
||||||
|
import {
|
||||||
|
automodQueue,
|
||||||
|
backupQueue,
|
||||||
|
birthdayQueue,
|
||||||
|
feedsQueue,
|
||||||
|
presenceQueue,
|
||||||
|
retentionQueue,
|
||||||
|
ticketQueue,
|
||||||
|
verificationQueue
|
||||||
|
} from './queues.js';
|
||||||
|
import { collectMetricsSnapshot, formatPrometheus } from './metrics.js';
|
||||||
|
|
||||||
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFormBody(body: string): Record<string, string> {
|
||||||
|
return Object.fromEntries(new URLSearchParams(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaptchaPage(token: string, question: string, error?: string): string {
|
||||||
|
const errorBlock = error ? `<p style="color:#ef4444">${error}</p>` : '';
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Nexumi Verification</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Inter, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
|
||||||
|
form { background:#1f2937; padding:2rem; border-radius:12px; width:min(420px, 90vw); }
|
||||||
|
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#111827; color:#f9fafb; }
|
||||||
|
button { background:#6366f1; border:none; cursor:pointer; font-weight:600; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form method="POST" action="/verify/captcha">
|
||||||
|
<h1>Nexumi Verification</h1>
|
||||||
|
<p>Solve: <strong>${question} = ?</strong></p>
|
||||||
|
${errorBlock}
|
||||||
|
<input type="hidden" name="token" value="${token}" />
|
||||||
|
<input type="number" name="answer" required autofocus />
|
||||||
|
<button type="submit">Verify</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Missing token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const challenge = await getCaptchaChallenge(redis, token);
|
||||||
|
if (!challenge) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Captcha expired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// External providers (reCAPTCHA / hCaptcha / Turnstile) are served by the WebUI.
|
||||||
|
if (challenge.provider !== 'MATH' && env.WEBUI_URL) {
|
||||||
|
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.setHeader('Location', target);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.end(renderCaptchaPage(token, challenge.question ?? '?'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const form = parseFormBody(body);
|
||||||
|
const token = form.token;
|
||||||
|
const answer = form.answer;
|
||||||
|
if (!token || !answer) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Missing fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const challenge = await getCaptchaChallenge(redis, token);
|
||||||
|
if (!challenge) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Captcha expired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (challenge.provider !== 'MATH') {
|
||||||
|
if (env.WEBUI_URL) {
|
||||||
|
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.setHeader('Location', target);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Use the WebUI captcha URL for this provider');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.end(renderCaptchaPage(token, challenge.question ?? '?', 'Wrong answer. Try again.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteCaptchaChallenge(redis, token);
|
||||||
|
await verificationQueue.add(
|
||||||
|
'verificationComplete',
|
||||||
|
{
|
||||||
|
guildId: challenge.guildId,
|
||||||
|
userId: challenge.userId,
|
||||||
|
captchaProvider: challenge.provider
|
||||||
|
},
|
||||||
|
{ removeOnComplete: 1000, removeOnFail: 500 }
|
||||||
|
);
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approximateQueueWaiting(): Promise<number> {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
automodQueue.getWaitingCount(),
|
||||||
|
backupQueue.getWaitingCount(),
|
||||||
|
birthdayQueue.getWaitingCount(),
|
||||||
|
feedsQueue.getWaitingCount(),
|
||||||
|
presenceQueue.getWaitingCount(),
|
||||||
|
retentionQueue.getWaitingCount(),
|
||||||
|
ticketQueue.getWaitingCount()
|
||||||
|
]);
|
||||||
|
return counts.reduce((sum, n) => sum + n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function startHealthServer(): void {
|
export function startHealthServer(): void {
|
||||||
const server = createServer((req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
if (!req.url) {
|
if (!req.url) {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end('Bad Request');
|
res.end('Bad Request');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.url === '/health') {
|
const url = new URL(req.url, `http://127.0.0.1:${env.HEALTH_PORT}`);
|
||||||
|
|
||||||
|
if (url.pathname === '/health') {
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.end('ok');
|
res.end('ok');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.url === '/metrics') {
|
|
||||||
|
if (url.pathname === '/metrics') {
|
||||||
const auth = req.headers.authorization;
|
const auth = req.headers.authorization;
|
||||||
if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) {
|
if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) {
|
||||||
res.statusCode = 401;
|
res.statusCode = 401;
|
||||||
res.end('Unauthorized');
|
res.end('Unauthorized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.statusCode = 200;
|
try {
|
||||||
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
|
const snapshot = await collectMetricsSnapshot(redis);
|
||||||
|
snapshot.queueWaiting = await approximateQueueWaiting();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
||||||
|
res.end(formatPrometheus(snapshot));
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('metrics error');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/verify/captcha' && req.method === 'GET') {
|
||||||
|
await handleCaptchaGet(url, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/verify/captcha' && req.method === 'POST') {
|
||||||
|
await handleCaptchaPost(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end('Not Found');
|
res.end('Not Found');
|
||||||
});
|
});
|
||||||
|
|||||||
141
apps/bot/src/hierarchy.ts
Normal file
141
apps/bot/src/hierarchy.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
type ChatInputCommandInteraction,
|
||||||
|
type GuildMember,
|
||||||
|
type Interaction
|
||||||
|
} from 'discord.js';
|
||||||
|
import type { Locale } from '@nexumi/shared';
|
||||||
|
import { t } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export type HierarchyAction = 'ban' | 'kick' | 'timeout' | 'nick';
|
||||||
|
|
||||||
|
export type HierarchyOk = { ok: true; member: GuildMember | null };
|
||||||
|
export type HierarchyDenied = { ok: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the moderator and bot can act on the target member.
|
||||||
|
* Replies ephemerally on denial. For bans, `member` may be null if the user
|
||||||
|
* already left the guild (ban-by-id is still allowed).
|
||||||
|
*/
|
||||||
|
export async function assertModerableMember(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
targetUserId: string,
|
||||||
|
locale: Locale,
|
||||||
|
action: HierarchyAction
|
||||||
|
): Promise<HierarchyOk | HierarchyDenied> {
|
||||||
|
const guild = interaction.guild;
|
||||||
|
if (!guild) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.self'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId === guild.ownerId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.owner'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let member: GuildMember | null = null;
|
||||||
|
try {
|
||||||
|
member = await guild.members.fetch(targetUserId);
|
||||||
|
} catch {
|
||||||
|
if (action === 'ban') {
|
||||||
|
return { ok: true, member: null };
|
||||||
|
}
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.hierarchy.notMember'), ephemeral: true });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderator =
|
||||||
|
interaction.member && 'roles' in interaction.member
|
||||||
|
? (interaction.member as GuildMember)
|
||||||
|
: await guild.members.fetch(interaction.user.id);
|
||||||
|
|
||||||
|
if (guild.ownerId !== moderator.id) {
|
||||||
|
if (member.roles.highest.position >= moderator.roles.highest.position) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.hierarchy.moderator'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const botOk =
|
||||||
|
action === 'ban'
|
||||||
|
? member.bannable
|
||||||
|
: action === 'kick'
|
||||||
|
? member.kickable
|
||||||
|
: action === 'timeout'
|
||||||
|
? member.moderatable
|
||||||
|
: member.manageable;
|
||||||
|
|
||||||
|
if (!botOk) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.hierarchy.bot'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, member };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyHierarchyForButton(interaction: Interaction, locale: Locale, key: string): Promise<void> {
|
||||||
|
if (!interaction.isRepliable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = t(locale, key);
|
||||||
|
if (interaction.isButton()) {
|
||||||
|
await interaction.update({ content, components: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({ content, ephemeral: true });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertBannableForConfirm(
|
||||||
|
interaction: Interaction,
|
||||||
|
locale: Locale,
|
||||||
|
targetUserId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const guild = interaction.guild;
|
||||||
|
if (!guild) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'generic.guildOnly');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (targetUserId === interaction.user.id) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.self');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (targetUserId === guild.ownerId) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.owner');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await guild.members.fetch(targetUserId);
|
||||||
|
if (!member.bannable) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.bot');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const moderator = await guild.members.fetch(interaction.user.id);
|
||||||
|
if (
|
||||||
|
guild.ownerId !== moderator.id &&
|
||||||
|
member.roles.highest.position >= moderator.roles.highest.position
|
||||||
|
) {
|
||||||
|
await replyHierarchyForButton(interaction, locale, 'moderation.hierarchy.moderator');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Target left the guild — ban by id is still fine.
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
18
apps/bot/src/i18n.ts
Normal file
18
apps/bot/src/i18n.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { LocaleSchema, type Locale } from '@nexumi/shared';
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
export async function getGuildLocale(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string | null
|
||||||
|
): Promise<Locale> {
|
||||||
|
if (!guildId) {
|
||||||
|
return env.DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
const settings = await prisma.guildSettings.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { locale: true }
|
||||||
|
});
|
||||||
|
const parsed = LocaleSchema.safeParse(settings?.locale);
|
||||||
|
return parsed.success ? parsed.data : env.DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
@@ -10,14 +10,35 @@ import { env } from './env.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { redis } from './redis.js';
|
import { redis } from './redis.js';
|
||||||
import { registerCommands, routeCommand } from './commands.js';
|
import { registerCommands, routeCommand, routeContextMenu } from './commands.js';
|
||||||
import { startWorkers } from './jobs.js';
|
import { ensureRecurringJobs, startWorkers } from './jobs.js';
|
||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
import { startHealthServer } from './health.js';
|
import { startHealthServer } from './health.js';
|
||||||
|
import { getGuildLocale } from './i18n.js';
|
||||||
|
import { ephemeral } from './interaction-reply.js';
|
||||||
|
import { t } from '@nexumi/shared';
|
||||||
|
import { handleAutoModMessage } from './modules/automod/actions.js';
|
||||||
|
import { registerLoggingEvents } from './modules/logging/events.js';
|
||||||
|
import { registerWelcomeEvents } from './modules/welcome/events.js';
|
||||||
|
import { registerVerificationEvents } from './modules/verification/events.js';
|
||||||
|
import { registerLevelingEvents } from './modules/leveling/events.js';
|
||||||
|
import { registerUtilityEvents } from './modules/utility/index.js';
|
||||||
|
import { registerTicketEvents } from './modules/tickets/index.js';
|
||||||
|
import { registerSelfRoleEvents } from './modules/selfroles/index.js';
|
||||||
|
import { registerTagEvents } from './modules/tags/index.js';
|
||||||
|
import { registerStarboardEvents } from './modules/starboard/index.js';
|
||||||
|
import { registerTempVoiceEvents } from './modules/tempvoice/index.js';
|
||||||
|
import { registerStatsEvents } from './modules/stats/index.js';
|
||||||
|
import { routeComponentInteraction } from './interaction-registry.js';
|
||||||
|
import { isPrimaryShard, runOncePerCluster } from './shard-lifecycle.js';
|
||||||
|
import { isGuildBlacklisted, isUserBlacklisted } from './module-gates.js';
|
||||||
|
import { publishShardMetrics, recordEventMetric } from './metrics.js';
|
||||||
|
import { captureException, initSentry } from './sentry.js';
|
||||||
|
|
||||||
const isShard = process.argv.includes('--shard');
|
const isShard = process.argv.includes('--shard');
|
||||||
|
|
||||||
if (!isShard) {
|
if (!isShard) {
|
||||||
|
initSentry('bot-manager');
|
||||||
startHealthServer();
|
startHealthServer();
|
||||||
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
|
const manager = new ShardingManager(new URL('./index.js', import.meta.url).pathname, {
|
||||||
token: env.BOT_TOKEN,
|
token: env.BOT_TOKEN,
|
||||||
@@ -25,36 +46,195 @@ if (!isShard) {
|
|||||||
shardArgs: ['--shard']
|
shardArgs: ['--shard']
|
||||||
});
|
});
|
||||||
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned'));
|
manager.on('shardCreate', (shard) => logger.info({ shardId: shard.id }, 'Shard spawned'));
|
||||||
|
|
||||||
|
// Register slash commands once from the manager (not once per shard).
|
||||||
|
try {
|
||||||
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:register-commands',
|
||||||
|
300,
|
||||||
|
async () => {
|
||||||
|
await registerCommands();
|
||||||
|
},
|
||||||
|
'Command registration'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to register application commands from manager');
|
||||||
|
captureException(error, { phase: 'registerCommands' });
|
||||||
|
}
|
||||||
|
|
||||||
await manager.spawn();
|
await manager.spawn();
|
||||||
} else {
|
} else {
|
||||||
|
initSentry('bot-shard');
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.GuildMessageReactions,
|
||||||
|
GatewayIntentBits.GuildModeration,
|
||||||
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
|
GatewayIntentBits.GuildEmojisAndStickers,
|
||||||
|
GatewayIntentBits.GuildInvites,
|
||||||
GatewayIntentBits.MessageContent
|
GatewayIntentBits.MessageContent
|
||||||
],
|
],
|
||||||
partials: [Partials.Channel]
|
partials: [
|
||||||
|
Partials.Channel,
|
||||||
|
Partials.Message,
|
||||||
|
Partials.GuildMember,
|
||||||
|
Partials.Reaction
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
const context: BotContext = { client, prisma, redis };
|
const context: BotContext = { client, prisma, redis };
|
||||||
startWorkers(context);
|
startWorkers(context);
|
||||||
|
|
||||||
|
registerLoggingEvents(context);
|
||||||
|
registerWelcomeEvents(context);
|
||||||
|
registerVerificationEvents(context);
|
||||||
|
registerLevelingEvents(context);
|
||||||
|
registerUtilityEvents(context);
|
||||||
|
registerTicketEvents(context);
|
||||||
|
registerSelfRoleEvents(client, context);
|
||||||
|
registerTagEvents(client, context);
|
||||||
|
registerStarboardEvents(context);
|
||||||
|
registerTempVoiceEvents(context);
|
||||||
|
registerStatsEvents(context);
|
||||||
|
|
||||||
client.on(Events.ClientReady, async () => {
|
client.on(Events.ClientReady, async () => {
|
||||||
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
logger.info({ tag: client.user?.tag, shards: client.shard?.ids }, 'Bot ready');
|
||||||
await registerCommands();
|
|
||||||
|
if (isPrimaryShard(client)) {
|
||||||
|
try {
|
||||||
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:ensure-recurring-jobs',
|
||||||
|
120,
|
||||||
|
async () => {
|
||||||
|
await ensureRecurringJobs();
|
||||||
|
},
|
||||||
|
'Recurring job registration'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to ensure recurring jobs');
|
||||||
|
captureException(error, { phase: 'ensureRecurringJobs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:recover-overdue-giveaways',
|
||||||
|
60,
|
||||||
|
async () => {
|
||||||
|
const { recoverOverdueGiveaways } = await import('./modules/giveaways/index.js');
|
||||||
|
await recoverOverdueGiveaways(context);
|
||||||
|
},
|
||||||
|
'Overdue giveaway recovery'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to recover overdue giveaways');
|
||||||
|
captureException(error, { phase: 'recoverOverdueGiveaways' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
|
||||||
|
await applyBotPresence(context);
|
||||||
|
await publishBotStatus(context);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Failed to apply initial bot presence/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await publishShardMetrics(context.redis, client);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Failed to publish initial shard metrics');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on(Events.GuildCreate, async (guild) => {
|
||||||
|
try {
|
||||||
|
if (await isGuildBlacklisted(context.prisma, guild.id)) {
|
||||||
|
logger.info({ guildId: guild.id }, 'Leaving blacklisted guild');
|
||||||
|
await guild.leave();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, guildId: guild.id }, 'GuildCreate blacklist check failed');
|
||||||
|
captureException(error, { guildId: guild.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
publishShardMetrics(context.redis, client).catch(() => undefined);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
|
try {
|
||||||
|
await recordEventMetric(context.redis);
|
||||||
|
if (message.author.bot) return;
|
||||||
|
if (await isUserBlacklisted(context.prisma, message.author.id)) return;
|
||||||
|
if (message.guildId && (await isGuildBlacklisted(context.prisma, message.guildId))) return;
|
||||||
|
await handleAutoModMessage(context, message);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
|
||||||
|
captureException(error, { messageId: message.id });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
||||||
if (!interaction.isChatInputCommand()) return;
|
|
||||||
try {
|
try {
|
||||||
await routeCommand(interaction, context);
|
if (
|
||||||
|
interaction.user &&
|
||||||
|
(await isUserBlacklisted(context.prisma, interaction.user.id)) &&
|
||||||
|
!interaction.isChatInputCommand() &&
|
||||||
|
!interaction.isMessageContextMenuCommand()
|
||||||
|
) {
|
||||||
|
if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.userBlacklisted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isChatInputCommand()) {
|
||||||
|
await routeCommand(interaction, context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isMessageContextMenuCommand()) {
|
||||||
|
await routeContextMenu(interaction, context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
interaction.isButton() ||
|
||||||
|
interaction.isStringSelectMenu() ||
|
||||||
|
interaction.isUserSelectMenu() ||
|
||||||
|
interaction.isRoleSelectMenu() ||
|
||||||
|
interaction.isChannelSelectMenu() ||
|
||||||
|
interaction.isMentionableSelectMenu() ||
|
||||||
|
interaction.isModalSubmit()
|
||||||
|
) {
|
||||||
|
await routeComponentInteraction(interaction, context);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Command execution failed');
|
logger.error({ error }, 'Interaction handling failed');
|
||||||
if (interaction.replied || interaction.deferred) {
|
captureException(error, {
|
||||||
await interaction.followUp({ content: 'An error occurred.', ephemeral: true });
|
interactionType: interaction.type,
|
||||||
} else {
|
guildId: interaction.guildId
|
||||||
await interaction.reply({ content: 'An error occurred.', ephemeral: true });
|
});
|
||||||
|
try {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const message = t(locale, 'generic.error');
|
||||||
|
if (interaction.isRepliable()) {
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({ content: message, ...ephemeral() });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: message, ...ephemeral() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (replyError) {
|
||||||
|
logger.warn({ replyError }, 'Failed to send interaction error reply');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
216
apps/bot/src/interaction-registry.ts
Normal file
216
apps/bot/src/interaction-registry.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import type {
|
||||||
|
ButtonInteraction,
|
||||||
|
ChannelSelectMenuInteraction,
|
||||||
|
MentionableSelectMenuInteraction,
|
||||||
|
ModalSubmitInteraction,
|
||||||
|
RoleSelectMenuInteraction,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
UserSelectMenuInteraction
|
||||||
|
} from 'discord.js';
|
||||||
|
import { decodeComponentCustomId, t, type DashboardModuleId } from '@nexumi/shared';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { getGuildLocale } from './i18n.js';
|
||||||
|
import { ephemeral } from './interaction-reply.js';
|
||||||
|
import { isModuleEnabled, moduleForComponent } from './module-gates.js';
|
||||||
|
import { isMaintenanceMode } from './presence.js';
|
||||||
|
import type { BotContext } from './types.js';
|
||||||
|
import {
|
||||||
|
handleModerationConfirmation,
|
||||||
|
isModerationConfirmation
|
||||||
|
} from './modules/moderation/confirmations.js';
|
||||||
|
import { handleVerificationButton } from './modules/verification/handlers.js';
|
||||||
|
import { isVerificationButton } from './modules/verification/service.js';
|
||||||
|
import {
|
||||||
|
handleBlackjackButton,
|
||||||
|
isBlackjackButton
|
||||||
|
} from './modules/economy/blackjack-buttons.js';
|
||||||
|
import {
|
||||||
|
handleEmbedModal,
|
||||||
|
handlePollButton,
|
||||||
|
isEmbedModal,
|
||||||
|
isPollButton
|
||||||
|
} from './modules/utility/index.js';
|
||||||
|
import { handleFunButton, isFunButton } from './modules/fun/index.js';
|
||||||
|
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
|
||||||
|
import { handleTicketInteraction, isTicketInteraction } from './modules/tickets/index.js';
|
||||||
|
import {
|
||||||
|
handleSelfRoleInteraction,
|
||||||
|
isSelfRoleInteraction
|
||||||
|
} from './modules/selfroles/index.js';
|
||||||
|
import {
|
||||||
|
handleSuggestionButton,
|
||||||
|
isSuggestionButton
|
||||||
|
} from './modules/suggestions/index.js';
|
||||||
|
import {
|
||||||
|
handleTempVoiceInteraction,
|
||||||
|
isTempVoiceInteraction,
|
||||||
|
isTempVoiceModal
|
||||||
|
} from './modules/tempvoice/index.js';
|
||||||
|
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
|
||||||
|
import {
|
||||||
|
handleComponentsV2Interaction,
|
||||||
|
isComponentsV2Interaction
|
||||||
|
} from './modules/components-v2/index.js';
|
||||||
|
|
||||||
|
type AnyComponentInteraction =
|
||||||
|
| ButtonInteraction
|
||||||
|
| StringSelectMenuInteraction
|
||||||
|
| UserSelectMenuInteraction
|
||||||
|
| RoleSelectMenuInteraction
|
||||||
|
| ChannelSelectMenuInteraction
|
||||||
|
| MentionableSelectMenuInteraction
|
||||||
|
| ModalSubmitInteraction;
|
||||||
|
|
||||||
|
type InteractionHandler = {
|
||||||
|
match: (customId: string) => boolean;
|
||||||
|
handle: (interaction: AnyComponentInteraction, context: BotContext) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asButton(interaction: AnyComponentInteraction): ButtonInteraction {
|
||||||
|
return interaction as ButtonInteraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asModal(interaction: AnyComponentInteraction): ModalSubmitInteraction {
|
||||||
|
return interaction as ModalSubmitInteraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers: InteractionHandler[] = [
|
||||||
|
{
|
||||||
|
match: isModerationConfirmation,
|
||||||
|
handle: (interaction, context) => handleModerationConfirmation(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isVerificationButton,
|
||||||
|
handle: (interaction, context) => handleVerificationButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isBlackjackButton,
|
||||||
|
handle: (interaction, context) => handleBlackjackButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isPollButton,
|
||||||
|
handle: (interaction, context) => handlePollButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isFunButton,
|
||||||
|
handle: (interaction, context) => handleFunButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isGiveawayButton,
|
||||||
|
handle: (interaction, context) => handleGiveawayButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isTicketInteraction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleTicketInteraction(
|
||||||
|
interaction as Parameters<typeof handleTicketInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isSelfRoleInteraction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleSelfRoleInteraction(
|
||||||
|
interaction as Parameters<typeof handleSelfRoleInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isSuggestionButton,
|
||||||
|
handle: (interaction, context) => handleSuggestionButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: (customId) => isTempVoiceInteraction(customId) || isTempVoiceModal(customId),
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleTempVoiceInteraction(
|
||||||
|
interaction as Parameters<typeof handleTempVoiceInteraction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isGuildBackupButton,
|
||||||
|
handle: (interaction, context) => handleGuildBackupButton(asButton(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isEmbedModal,
|
||||||
|
handle: (interaction, context) => handleEmbedModal(asModal(interaction), context)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: isComponentsV2Interaction,
|
||||||
|
handle: (interaction, context) =>
|
||||||
|
handleComponentsV2Interaction(
|
||||||
|
interaction as Parameters<typeof handleComponentsV2Interaction>[0],
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function moduleForComponentsV2(customId: string): DashboardModuleId | null {
|
||||||
|
const decoded = decodeComponentCustomId(customId);
|
||||||
|
if (!decoded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switch (decoded.source) {
|
||||||
|
case 'w':
|
||||||
|
return 'welcome';
|
||||||
|
case 't':
|
||||||
|
return 'tags';
|
||||||
|
case 's':
|
||||||
|
return 'scheduler';
|
||||||
|
case 'm':
|
||||||
|
return 'messages';
|
||||||
|
default:
|
||||||
|
return 'messages';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function routeComponentInteraction(
|
||||||
|
interaction: AnyComponentInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<boolean> {
|
||||||
|
const customId = interaction.customId;
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
|
||||||
|
const maintenance = await isMaintenanceMode(context);
|
||||||
|
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||||
|
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||||
|
if (!interaction.replied && !interaction.deferred) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let moduleId = moduleForComponent(customId);
|
||||||
|
if (!moduleId && isComponentsV2Interaction(customId)) {
|
||||||
|
moduleId = moduleForComponentsV2(customId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moduleId && interaction.guildId) {
|
||||||
|
const enabled = await isModuleEnabled(
|
||||||
|
context.prisma,
|
||||||
|
context.redis,
|
||||||
|
interaction.guildId,
|
||||||
|
moduleId
|
||||||
|
);
|
||||||
|
if (!enabled) {
|
||||||
|
if (!interaction.replied && !interaction.deferred) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'generic.moduleDisabled'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
if (handler.match(customId)) {
|
||||||
|
await handler.handle(interaction, context);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
6
apps/bot/src/interaction-reply.ts
Normal file
6
apps/bot/src/interaction-reply.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { MessageFlags } from 'discord.js';
|
||||||
|
|
||||||
|
/** Discord.js v14+ prefers flags over the deprecated `ephemeral` option. */
|
||||||
|
export function ephemeral(): { flags: typeof MessageFlags.Ephemeral } {
|
||||||
|
return { flags: MessageFlags.Ephemeral };
|
||||||
|
}
|
||||||
@@ -1,11 +1,77 @@
|
|||||||
import { Queue, Worker } from 'bullmq';
|
import { Worker } from 'bullmq';
|
||||||
import { PermissionFlagsBits } from 'discord.js';
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
||||||
|
import { basename, join } from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
import { redis } from './redis.js';
|
import { redis } from './redis.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||||
|
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
|
||||||
|
import {
|
||||||
|
completeVerification,
|
||||||
|
runVerificationPanelSyncJob
|
||||||
|
} from './modules/verification/handlers.js';
|
||||||
|
import { closePoll } from './modules/utility/poll.js';
|
||||||
|
import { sendReminder } from './modules/utility/reminders.js';
|
||||||
|
import {
|
||||||
|
expireSelfRole,
|
||||||
|
runSelfRolePanelCreateJob,
|
||||||
|
runSelfRolePanelUpdateJob,
|
||||||
|
runSelfRolePanelDeleteJob
|
||||||
|
} from './modules/selfroles/index.js';
|
||||||
|
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared';
|
||||||
|
import {
|
||||||
|
endGiveaway,
|
||||||
|
runGiveawayCreateJob,
|
||||||
|
runGiveawayPauseJob,
|
||||||
|
runGiveawayDeleteJob,
|
||||||
|
GiveawayError
|
||||||
|
} from './modules/giveaways/index.js';
|
||||||
|
import { closeInactiveTickets, runTicketDashboardActionJob, runTicketPanelSyncJob } from './modules/tickets/index.js';
|
||||||
|
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||||
|
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||||
|
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||||
|
import { sendScheduledMessage } from './modules/scheduler/index.js';
|
||||||
|
import { sendDashboardMessage, type DashboardMessageSendJob } from './modules/components-v2/send.js';
|
||||||
|
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
||||||
|
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
||||||
|
import { runPresenceRefreshJob } from './presence.js';
|
||||||
|
import {
|
||||||
|
automodQueue,
|
||||||
|
automodQueueName,
|
||||||
|
backupQueue,
|
||||||
|
backupQueueName,
|
||||||
|
birthdayQueue,
|
||||||
|
birthdayQueueName,
|
||||||
|
feedsQueue,
|
||||||
|
feedsQueueName,
|
||||||
|
giveawayQueueName,
|
||||||
|
guildBackupQueueName,
|
||||||
|
messagesQueueName,
|
||||||
|
moderationQueueName,
|
||||||
|
presenceQueue,
|
||||||
|
presenceQueueName,
|
||||||
|
reminderQueueName,
|
||||||
|
retentionQueue,
|
||||||
|
retentionQueueName,
|
||||||
|
scheduleQueueName,
|
||||||
|
selfrolesQueueName,
|
||||||
|
suggestionsQueueName,
|
||||||
|
ticketQueue,
|
||||||
|
ticketQueueName,
|
||||||
|
verificationQueueName
|
||||||
|
} from './queues.js';
|
||||||
|
|
||||||
export const moderationQueueName = 'moderation';
|
export {
|
||||||
export const moderationQueue = new Queue(moderationQueueName, { connection: redis });
|
automodQueue,
|
||||||
|
backupQueue,
|
||||||
|
moderationQueue,
|
||||||
|
moderationQueueName,
|
||||||
|
reminderQueue,
|
||||||
|
verificationQueue
|
||||||
|
} from './queues.js';
|
||||||
|
|
||||||
type TempBanJob = {
|
type TempBanJob = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -13,27 +79,659 @@ type TempBanJob = {
|
|||||||
reason: string | null;
|
reason: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type VerificationCompleteJob = {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
ipHash?: string | null;
|
||||||
|
userAgentHash?: string | null;
|
||||||
|
captchaProvider?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VerificationPanelSyncJob = {
|
||||||
|
guildId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReminderSendJob = {
|
||||||
|
reminderId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PollCloseJob = {
|
||||||
|
pollId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelfRoleExpireJob = {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GiveawayEndJob = {
|
||||||
|
giveawayId: string;
|
||||||
|
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GiveawayCreateJob = {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
hostId: string;
|
||||||
|
prize: string;
|
||||||
|
winnerCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
requiredRoleId?: string | null;
|
||||||
|
requiredLevel?: number | null;
|
||||||
|
requiredMemberDays?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GiveawayPauseJob = {
|
||||||
|
giveawayId: string;
|
||||||
|
paused: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GiveawayDeleteJob = {
|
||||||
|
giveawayId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
type BirthdayRoleExpireJob = {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SuggestionStatusUpdateJob = {
|
||||||
|
suggestionId: string;
|
||||||
|
guildId: string;
|
||||||
|
status: 'OPEN' | 'APPROVED' | 'DENIED' | 'CONSIDERED' | 'IMPLEMENTED';
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelfRolePanelCreateJob = {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
mode: SelfRoleMode;
|
||||||
|
behavior: SelfRoleBehavior;
|
||||||
|
roles: SelfRoleEntry[];
|
||||||
|
temporaryDurationMs?: number;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelfRolePanelUpdateJob = {
|
||||||
|
panelId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
mode?: SelfRoleMode;
|
||||||
|
behavior?: SelfRoleBehavior;
|
||||||
|
roles?: SelfRoleEntry[];
|
||||||
|
temporaryDurationMs?: number;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelfRolePanelDeleteJob = {
|
||||||
|
panelId: string;
|
||||||
|
guildId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function startWorkers(context: BotContext): Worker[] {
|
export function startWorkers(context: BotContext): Worker[] {
|
||||||
const worker = new Worker<TempBanJob>(
|
const moderationWorker = new Worker<TempBanJob>(
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name !== 'tempBanExpire') {
|
if (job.name !== 'tempBanExpire') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guild = await context.client.guilds.fetch(job.data.guildId);
|
const { guildId, userId, reason } = job.data;
|
||||||
const me = await guild.members.fetchMe();
|
const guild = context.client.guilds.cache.get(guildId);
|
||||||
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
if (!guild) {
|
||||||
throw new Error('Missing BanMembers permission for temp ban expiration');
|
if (!context.client.shard) {
|
||||||
|
throw new Error(`Guild ${guildId} not available for temp ban expiration`);
|
||||||
|
}
|
||||||
|
const results = await context.client.shard.broadcastEval(
|
||||||
|
async (c, payload) => {
|
||||||
|
const g = c.guilds.cache.get(payload.guildId);
|
||||||
|
if (!g) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const me = await g.members.fetchMe();
|
||||||
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await g.members.unban(payload.userId, payload.reason ?? 'Temporary ban expired');
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? Number((error as { code: unknown }).code)
|
||||||
|
: 0;
|
||||||
|
// Unknown ban (10026) — already unbanned
|
||||||
|
if (code === 10026) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ context: { guildId, userId, reason } }
|
||||||
|
);
|
||||||
|
if (!results.some(Boolean)) {
|
||||||
|
throw new Error(`Guild ${guildId} not found on any shard for temp ban expiration`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const me = await guild.members.fetchMe();
|
||||||
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? Number((error as { code: unknown }).code)
|
||||||
|
: 0;
|
||||||
|
if (code !== 10026) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired');
|
|
||||||
|
const { createCase } = await import('./modules/moderation/service.js');
|
||||||
|
await createCase(context, {
|
||||||
|
guildId,
|
||||||
|
targetUserId: userId,
|
||||||
|
moderatorId: context.client.user?.id ?? 'system',
|
||||||
|
action: 'UNBAN',
|
||||||
|
reason: reason ?? 'Temporary ban expired',
|
||||||
|
source: 'job',
|
||||||
|
metadata: { tempBanExpire: true }
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ connection: redis }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
|
|
||||||
worker.on('failed', (job, error) => {
|
moderationWorker.on('failed', (job, error) => {
|
||||||
logger.error({ jobId: job?.id, error }, 'Moderation job failed');
|
logger.error({ jobId: job?.id, error }, 'Moderation job failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
return [worker];
|
const backupWorker = new Worker(
|
||||||
|
backupQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'dailyPgDump') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runPgDumpBackup();
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
backupWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Backup job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const automodWorker = new Worker(
|
||||||
|
automodQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name === 'refreshPhishingList') {
|
||||||
|
const count = await refreshPhishingDomains(redis);
|
||||||
|
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') {
|
||||||
|
const guildId = (job.data as { guildId?: string }).guildId;
|
||||||
|
if (!guildId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||||
|
if (!guild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.name === 'deactivateLockdown') {
|
||||||
|
await deactivateLockdown(guild);
|
||||||
|
logger.info({ guildId }, 'Lockdown deactivated via dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await activateLockdown(guild);
|
||||||
|
logger.info({ guildId }, 'Lockdown activated via dashboard');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
automodWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
|
||||||
|
verificationQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name === 'verificationPanelSync') {
|
||||||
|
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
|
||||||
|
}
|
||||||
|
if (job.name !== 'verificationComplete') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = job.data as VerificationCompleteJob;
|
||||||
|
await completeVerification(context, data.guildId, data.userId, {
|
||||||
|
ipHash: data.ipHash,
|
||||||
|
userAgentHash: data.userAgentHash,
|
||||||
|
captchaProvider: data.captchaProvider
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
verificationWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Verification job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const reminderWorker = new Worker<ReminderSendJob | PollCloseJob>(
|
||||||
|
reminderQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name === 'reminderSend') {
|
||||||
|
await sendReminder(context, (job.data as ReminderSendJob).reminderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.name === 'pollClose') {
|
||||||
|
await closePoll(context, (job.data as PollCloseJob).pollId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
reminderWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketWorker = new Worker(
|
||||||
|
ticketQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name === 'ticketPanelSync') {
|
||||||
|
return runTicketPanelSyncJob(context, job.data as { guildId: string });
|
||||||
|
}
|
||||||
|
if (job.name === 'ticketDashboardAction') {
|
||||||
|
return runTicketDashboardActionJob(
|
||||||
|
context,
|
||||||
|
job.data as {
|
||||||
|
ticketId: string;
|
||||||
|
guildId: string;
|
||||||
|
actorUserId: string;
|
||||||
|
action: 'close' | 'claim' | 'priority';
|
||||||
|
reason?: string;
|
||||||
|
priority?: string;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (job.name === 'selfRoleExpire') {
|
||||||
|
const data = job.data as SelfRoleExpireJob;
|
||||||
|
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.name === 'ticketAutoClose') {
|
||||||
|
await closeInactiveTickets(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
ticketWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const selfrolesWorker = new Worker<
|
||||||
|
SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob
|
||||||
|
>(
|
||||||
|
selfrolesQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name === 'selfRolePanelCreate') {
|
||||||
|
return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob);
|
||||||
|
}
|
||||||
|
if (job.name === 'selfRolePanelUpdate') {
|
||||||
|
return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob);
|
||||||
|
}
|
||||||
|
if (job.name === 'selfRolePanelDelete') {
|
||||||
|
const data = job.data as SelfRolePanelDeleteJob;
|
||||||
|
await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
selfrolesWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const giveawayWorker = new Worker<
|
||||||
|
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
|
||||||
|
>(
|
||||||
|
giveawayQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name === 'giveawayEnd') {
|
||||||
|
try {
|
||||||
|
const data = job.data as GiveawayEndJob;
|
||||||
|
if (!data.force) {
|
||||||
|
const current = await context.prisma.giveaway.findUnique({
|
||||||
|
where: { id: data.giveawayId }
|
||||||
|
});
|
||||||
|
// Timer/recover must not end a paused giveaway.
|
||||||
|
if (current?.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await endGiveaway(context, data.giveawayId);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof GiveawayError &&
|
||||||
|
(error.code === 'already_ended' || error.code === 'not_found')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.name === 'giveawayCreate') {
|
||||||
|
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
|
||||||
|
}
|
||||||
|
if (job.name === 'giveawayPause') {
|
||||||
|
const data = job.data as GiveawayPauseJob;
|
||||||
|
return runGiveawayPauseJob(context, data.giveawayId, data.paused);
|
||||||
|
}
|
||||||
|
if (job.name === 'giveawayDelete') {
|
||||||
|
await runGiveawayDeleteJob(context, (job.data as GiveawayDeleteJob).giveawayId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
giveawayWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Giveaway job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const birthdayWorker = new Worker(
|
||||||
|
birthdayQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name === 'birthdayDailyCheck') {
|
||||||
|
await runBirthdayCheck(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.name === 'birthdayRoleExpire') {
|
||||||
|
const data = job.data as BirthdayRoleExpireJob;
|
||||||
|
await expireBirthdayRole(context, data.guildId, data.userId, data.roleId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
birthdayWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Birthday job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const statsWorker = startStatsWorker(context);
|
||||||
|
|
||||||
|
const feedsWorker = new Worker(
|
||||||
|
feedsQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'pollFeeds') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pollAllFeeds(context);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
feedsWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Feeds job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleWorker = new Worker<{ scheduleId: string }>(
|
||||||
|
scheduleQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'scheduleSend') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendScheduledMessage(context, job.data.scheduleId);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
scheduleWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const messagesWorker = new Worker<DashboardMessageSendJob>(
|
||||||
|
messagesQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'dashboardMessageSend') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return sendDashboardMessage(context, job.data);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
messagesWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Dashboard message send job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
|
||||||
|
guildBackupQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'restoreGuildBackup') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runGuildBackupRestoreJob(context, job.data.backupId, job.data.guildId);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
guildBackupWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const suggestionsWorker = new Worker<SuggestionStatusUpdateJob>(
|
||||||
|
suggestionsQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'suggestionStatusUpdate') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return runSuggestionStatusUpdateJob(context, job.data);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
suggestionsWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Suggestion status update job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const presenceWorker = new Worker(
|
||||||
|
presenceQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'presenceRefresh') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runPresenceRefreshJob(context);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
presenceWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Presence refresh job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const retentionWorker = new Worker(
|
||||||
|
retentionQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'purgeExpiredLogs') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runLogRetentionJob(context);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
retentionWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Log retention job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
moderationWorker,
|
||||||
|
backupWorker,
|
||||||
|
automodWorker,
|
||||||
|
verificationWorker,
|
||||||
|
reminderWorker,
|
||||||
|
ticketWorker,
|
||||||
|
selfrolesWorker,
|
||||||
|
giveawayWorker,
|
||||||
|
birthdayWorker,
|
||||||
|
statsWorker,
|
||||||
|
feedsWorker,
|
||||||
|
scheduleWorker,
|
||||||
|
messagesWorker,
|
||||||
|
guildBackupWorker,
|
||||||
|
suggestionsWorker,
|
||||||
|
presenceWorker,
|
||||||
|
retentionWorker
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureRecurringJobs(): Promise<void> {
|
||||||
|
await backupQueue.add(
|
||||||
|
'dailyPgDump',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: env.BACKUP_CRON },
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 100
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await automodQueue.add(
|
||||||
|
'refreshPhishingList',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: '0 */6 * * *' },
|
||||||
|
removeOnComplete: 20,
|
||||||
|
removeOnFail: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await automodQueue.add('refreshPhishingList', {}, { removeOnComplete: 20, removeOnFail: 20 });
|
||||||
|
|
||||||
|
await ticketQueue.add(
|
||||||
|
'ticketAutoClose',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: '0 * * * *' },
|
||||||
|
removeOnComplete: 20,
|
||||||
|
removeOnFail: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await birthdayQueue.add(
|
||||||
|
'birthdayDailyCheck',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: '0 * * * *' },
|
||||||
|
removeOnComplete: 20,
|
||||||
|
removeOnFail: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureStatsRecurringJobs();
|
||||||
|
|
||||||
|
await feedsQueue.add(
|
||||||
|
'pollFeeds',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: '*/5 * * * *' },
|
||||||
|
removeOnComplete: 20,
|
||||||
|
removeOnFail: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await presenceQueue.add(
|
||||||
|
'presenceRefresh',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { every: 60_000 },
|
||||||
|
removeOnComplete: 5,
|
||||||
|
removeOnFail: 5
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await retentionQueue.add(
|
||||||
|
'purgeExpiredLogs',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: '15 4 * * *' },
|
||||||
|
removeOnComplete: 20,
|
||||||
|
removeOnFail: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLogRetentionJob(context: BotContext): Promise<void> {
|
||||||
|
const configs = await context.prisma.loggingConfig.findMany({
|
||||||
|
where: { retentionDays: { gt: 0 } },
|
||||||
|
select: { guildId: true, retentionDays: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const cutoff = new Date(Date.now() - config.retentionDays * 24 * 60 * 60 * 1000);
|
||||||
|
const [cases, audits] = await Promise.all([
|
||||||
|
context.prisma.case.deleteMany({
|
||||||
|
where: { guildId: config.guildId, createdAt: { lt: cutoff } }
|
||||||
|
}),
|
||||||
|
context.prisma.dashboardAuditLog.deleteMany({
|
||||||
|
where: { guildId: config.guildId, createdAt: { lt: cutoff } }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
if (cases.count > 0 || audits.count > 0) {
|
||||||
|
logger.info(
|
||||||
|
{ guildId: config.guildId, cases: cases.count, audits: audits.count, retentionDays: config.retentionDays },
|
||||||
|
'Log retention purge completed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPgDumpBackup(): Promise<void> {
|
||||||
|
await mkdir(env.BACKUP_DIR, { recursive: true });
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const filename = `nexumi-${timestamp}.sql`;
|
||||||
|
const outputPath = join(env.BACKUP_DIR, filename);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const dump = spawn('pg_dump', ['--dbname', env.DATABASE_URL, '--file', outputPath], {
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
dump.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString('utf8');
|
||||||
|
});
|
||||||
|
dump.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr || `pg_dump exited with code ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await cleanupOldBackups();
|
||||||
|
logger.info({ backupFile: basename(outputPath) }, 'Database backup finished');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupOldBackups(): Promise<void> {
|
||||||
|
const files = await readdir(env.BACKUP_DIR, { withFileTypes: true });
|
||||||
|
const cutoff = Date.now() - env.BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
await Promise.all(
|
||||||
|
files
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
|
||||||
|
.map(async (entry) => {
|
||||||
|
const fullPath = join(env.BACKUP_DIR, entry.name);
|
||||||
|
const fileStat = await stat(fullPath);
|
||||||
|
const created = fileStat.mtime.getTime();
|
||||||
|
if (Number.isNaN(created) || created >= cutoff) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await rm(fullPath, { force: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
426
apps/bot/src/lib/components-v2-payload.ts
Normal file
426
apps/bot/src/lib/components-v2-payload.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ChannelSelectMenuBuilder,
|
||||||
|
ContainerBuilder,
|
||||||
|
MediaGalleryBuilder,
|
||||||
|
MediaGalleryItemBuilder,
|
||||||
|
MentionableSelectMenuBuilder,
|
||||||
|
MessageFlags,
|
||||||
|
RoleSelectMenuBuilder,
|
||||||
|
SectionBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
ThumbnailBuilder,
|
||||||
|
UserSelectMenuBuilder,
|
||||||
|
type MessageActionRowComponentBuilder,
|
||||||
|
type MessageCreateOptions
|
||||||
|
} from 'discord.js';
|
||||||
|
import {
|
||||||
|
encodeComponentCustomId,
|
||||||
|
mapComponentsV2TextFields,
|
||||||
|
MessageComponentsV2Schema,
|
||||||
|
type ComponentButton,
|
||||||
|
type ComponentButtonStyle,
|
||||||
|
type ComponentSelect,
|
||||||
|
type ComponentV2Node,
|
||||||
|
type ComponentV2Source,
|
||||||
|
type MessageComponentsV2,
|
||||||
|
type SeparatorSpacing
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
|
||||||
|
function isHttpUrl(value: string | undefined): value is string {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTON_STYLE_MAP: Record<ComponentButtonStyle, ButtonStyle> = {
|
||||||
|
Primary: ButtonStyle.Primary,
|
||||||
|
Secondary: ButtonStyle.Secondary,
|
||||||
|
Success: ButtonStyle.Success,
|
||||||
|
Danger: ButtonStyle.Danger,
|
||||||
|
Link: ButtonStyle.Link
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPACING_MAP: Record<SeparatorSpacing, SeparatorSpacingSize> = {
|
||||||
|
Small: SeparatorSpacingSize.Small,
|
||||||
|
Large: SeparatorSpacingSize.Large
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BuildComponentsV2Options {
|
||||||
|
source: ComponentV2Source;
|
||||||
|
ref: string;
|
||||||
|
renderText?: (value: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildButton(
|
||||||
|
button: ComponentButton,
|
||||||
|
options: BuildComponentsV2Options
|
||||||
|
): ButtonBuilder | null {
|
||||||
|
const builder = new ButtonBuilder()
|
||||||
|
.setLabel(button.label.slice(0, 80))
|
||||||
|
.setDisabled(Boolean(button.disabled));
|
||||||
|
|
||||||
|
if (button.emoji) {
|
||||||
|
builder.setEmoji(button.emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.style === 'Link') {
|
||||||
|
if (!isHttpUrl(button.url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
builder.setStyle(ButtonStyle.Link).setURL(button.url);
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!button.actionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
|
.setStyle(BUTTON_STYLE_MAP[button.style] ?? ButtonStyle.Secondary)
|
||||||
|
.setCustomId(encodeComponentCustomId(options.source, options.ref, button.actionId));
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelect(
|
||||||
|
select: ComponentSelect,
|
||||||
|
options: BuildComponentsV2Options
|
||||||
|
): MessageActionRowComponentBuilder | null {
|
||||||
|
const placeholder = select.placeholder?.slice(0, 150);
|
||||||
|
const minValues = select.minValues;
|
||||||
|
const maxValues = select.maxValues;
|
||||||
|
|
||||||
|
if (select.type === 'string_select') {
|
||||||
|
const menu = new StringSelectMenuBuilder()
|
||||||
|
.setCustomId(
|
||||||
|
encodeComponentCustomId(
|
||||||
|
options.source,
|
||||||
|
options.ref,
|
||||||
|
select.actionId ?? select.id ?? 'select'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setDisabled(Boolean(select.disabled))
|
||||||
|
.addOptions(
|
||||||
|
select.options.slice(0, 25).map((option) => {
|
||||||
|
const entry: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
emoji?: string;
|
||||||
|
} = {
|
||||||
|
label: option.label.slice(0, 100),
|
||||||
|
value: option.value.slice(0, 100)
|
||||||
|
};
|
||||||
|
if (option.description) {
|
||||||
|
entry.description = option.description.slice(0, 100);
|
||||||
|
}
|
||||||
|
if (option.emoji) {
|
||||||
|
entry.emoji = option.emoji;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (placeholder) {
|
||||||
|
menu.setPlaceholder(placeholder);
|
||||||
|
}
|
||||||
|
if (minValues !== undefined) {
|
||||||
|
menu.setMinValues(minValues);
|
||||||
|
}
|
||||||
|
if (maxValues !== undefined) {
|
||||||
|
menu.setMaxValues(maxValues);
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customId = encodeComponentCustomId(options.source, options.ref, select.actionId);
|
||||||
|
let menu:
|
||||||
|
| UserSelectMenuBuilder
|
||||||
|
| RoleSelectMenuBuilder
|
||||||
|
| ChannelSelectMenuBuilder
|
||||||
|
| MentionableSelectMenuBuilder;
|
||||||
|
|
||||||
|
switch (select.type) {
|
||||||
|
case 'user_select':
|
||||||
|
menu = new UserSelectMenuBuilder().setCustomId(customId);
|
||||||
|
break;
|
||||||
|
case 'role_select':
|
||||||
|
menu = new RoleSelectMenuBuilder().setCustomId(customId);
|
||||||
|
break;
|
||||||
|
case 'channel_select':
|
||||||
|
menu = new ChannelSelectMenuBuilder().setCustomId(customId);
|
||||||
|
break;
|
||||||
|
case 'mentionable_select':
|
||||||
|
menu = new MentionableSelectMenuBuilder().setCustomId(customId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.setDisabled(Boolean(select.disabled));
|
||||||
|
if (placeholder) {
|
||||||
|
menu.setPlaceholder(placeholder);
|
||||||
|
}
|
||||||
|
if (minValues !== undefined) {
|
||||||
|
menu.setMinValues(minValues);
|
||||||
|
}
|
||||||
|
if (maxValues !== undefined) {
|
||||||
|
menu.setMaxValues(maxValues);
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThumbnail(url: string, description?: string, spoiler?: boolean): ThumbnailBuilder | null {
|
||||||
|
if (!isHttpUrl(url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const thumb = new ThumbnailBuilder().setURL(url);
|
||||||
|
if (description) {
|
||||||
|
thumb.setDescription(description.slice(0, 1024));
|
||||||
|
}
|
||||||
|
if (spoiler) {
|
||||||
|
thumb.setSpoiler(true);
|
||||||
|
}
|
||||||
|
return thumb;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopLevelBuilder =
|
||||||
|
| ContainerBuilder
|
||||||
|
| TextDisplayBuilder
|
||||||
|
| SeparatorBuilder
|
||||||
|
| MediaGalleryBuilder
|
||||||
|
| SectionBuilder
|
||||||
|
| ActionRowBuilder<MessageActionRowComponentBuilder>;
|
||||||
|
|
||||||
|
function buildNode(node: ComponentV2Node, options: BuildComponentsV2Options): TopLevelBuilder | null {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'text_display':
|
||||||
|
return new TextDisplayBuilder().setContent(node.content.slice(0, 4000));
|
||||||
|
case 'separator': {
|
||||||
|
const sep = new SeparatorBuilder();
|
||||||
|
if (node.divider !== undefined) {
|
||||||
|
sep.setDivider(node.divider);
|
||||||
|
}
|
||||||
|
if (node.spacing) {
|
||||||
|
sep.setSpacing(SPACING_MAP[node.spacing]);
|
||||||
|
}
|
||||||
|
return sep;
|
||||||
|
}
|
||||||
|
case 'media_gallery': {
|
||||||
|
const gallery = new MediaGalleryBuilder();
|
||||||
|
for (const item of node.items) {
|
||||||
|
if (!isHttpUrl(item.url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const media = new MediaGalleryItemBuilder().setURL(item.url);
|
||||||
|
if (item.description) {
|
||||||
|
media.setDescription(item.description.slice(0, 1024));
|
||||||
|
}
|
||||||
|
if (item.spoiler) {
|
||||||
|
media.setSpoiler(true);
|
||||||
|
}
|
||||||
|
gallery.addItems(media);
|
||||||
|
}
|
||||||
|
return gallery;
|
||||||
|
}
|
||||||
|
case 'section': {
|
||||||
|
const section = new SectionBuilder();
|
||||||
|
const texts = Array.isArray(node.text) ? node.text : [node.text];
|
||||||
|
for (const text of texts.slice(0, 3)) {
|
||||||
|
section.addTextDisplayComponents(new TextDisplayBuilder().setContent(text.slice(0, 4000)));
|
||||||
|
}
|
||||||
|
if (node.accessory.type === 'thumbnail') {
|
||||||
|
const thumb = buildThumbnail(
|
||||||
|
node.accessory.url,
|
||||||
|
node.accessory.description,
|
||||||
|
node.accessory.spoiler
|
||||||
|
);
|
||||||
|
if (thumb) {
|
||||||
|
section.setThumbnailAccessory(thumb);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const button = buildButton(node.accessory, options);
|
||||||
|
if (button) {
|
||||||
|
section.setButtonAccessory(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
case 'action_row': {
|
||||||
|
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>();
|
||||||
|
for (const child of node.components) {
|
||||||
|
if (child.type === 'button') {
|
||||||
|
const button = buildButton(child, options);
|
||||||
|
if (button) {
|
||||||
|
row.addComponents(button);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const select = buildSelect(child, options);
|
||||||
|
if (select) {
|
||||||
|
row.addComponents(select);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row.components.length > 0 ? row : null;
|
||||||
|
}
|
||||||
|
case 'button': {
|
||||||
|
const button = buildButton(node, options);
|
||||||
|
if (!button) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(button);
|
||||||
|
}
|
||||||
|
case 'string_select':
|
||||||
|
case 'user_select':
|
||||||
|
case 'role_select':
|
||||||
|
case 'channel_select':
|
||||||
|
case 'mentionable_select': {
|
||||||
|
const select = buildSelect(node, options);
|
||||||
|
if (!select) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(select);
|
||||||
|
}
|
||||||
|
case 'thumbnail':
|
||||||
|
// Thumbnails must be section accessories; skip top-level.
|
||||||
|
return null;
|
||||||
|
case 'container': {
|
||||||
|
const container = new ContainerBuilder();
|
||||||
|
if (node.accentColor !== undefined) {
|
||||||
|
container.setAccentColor(node.accentColor);
|
||||||
|
}
|
||||||
|
if (node.spoiler) {
|
||||||
|
container.setSpoiler(true);
|
||||||
|
}
|
||||||
|
for (const child of node.components) {
|
||||||
|
switch (child.type) {
|
||||||
|
case 'text_display':
|
||||||
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(child.content.slice(0, 4000))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'separator': {
|
||||||
|
const sep = new SeparatorBuilder();
|
||||||
|
if (child.divider !== undefined) {
|
||||||
|
sep.setDivider(child.divider);
|
||||||
|
}
|
||||||
|
if (child.spacing) {
|
||||||
|
sep.setSpacing(SPACING_MAP[child.spacing]);
|
||||||
|
}
|
||||||
|
container.addSeparatorComponents(sep);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'media_gallery': {
|
||||||
|
const gallery = new MediaGalleryBuilder();
|
||||||
|
for (const item of child.items) {
|
||||||
|
if (!isHttpUrl(item.url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const media = new MediaGalleryItemBuilder().setURL(item.url);
|
||||||
|
if (item.description) {
|
||||||
|
media.setDescription(item.description.slice(0, 1024));
|
||||||
|
}
|
||||||
|
if (item.spoiler) {
|
||||||
|
media.setSpoiler(true);
|
||||||
|
}
|
||||||
|
gallery.addItems(media);
|
||||||
|
}
|
||||||
|
container.addMediaGalleryComponents(gallery);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'section': {
|
||||||
|
const section = buildNode(child, options);
|
||||||
|
if (section instanceof SectionBuilder) {
|
||||||
|
container.addSectionComponents(section);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'action_row':
|
||||||
|
case 'button':
|
||||||
|
case 'string_select':
|
||||||
|
case 'user_select':
|
||||||
|
case 'role_select':
|
||||||
|
case 'channel_select':
|
||||||
|
case 'mentionable_select': {
|
||||||
|
const row = buildNode(child, options);
|
||||||
|
if (row instanceof ActionRowBuilder) {
|
||||||
|
container.addActionRowComponents(row);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'container':
|
||||||
|
for (const nested of child.components) {
|
||||||
|
const sibling = buildNode(nested, options);
|
||||||
|
if (sibling instanceof TextDisplayBuilder) {
|
||||||
|
container.addTextDisplayComponents(sibling);
|
||||||
|
} else if (sibling instanceof SeparatorBuilder) {
|
||||||
|
container.addSeparatorComponents(sibling);
|
||||||
|
} else if (sibling instanceof MediaGalleryBuilder) {
|
||||||
|
container.addMediaGalleryComponents(sibling);
|
||||||
|
} else if (sibling instanceof SectionBuilder) {
|
||||||
|
container.addSectionComponents(sibling);
|
||||||
|
} else if (sibling instanceof ActionRowBuilder) {
|
||||||
|
container.addActionRowComponents(sibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a discord.js Components V2 message payload from shared JSON.
|
||||||
|
* Sets MessageFlags.IsComponentsV2; never includes content/embeds.
|
||||||
|
*/
|
||||||
|
export function buildComponentsV2Payload(
|
||||||
|
raw: MessageComponentsV2 | null | undefined,
|
||||||
|
options: BuildComponentsV2Options
|
||||||
|
): MessageCreateOptions | null {
|
||||||
|
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = options.renderText
|
||||||
|
? mapComponentsV2TextFields(parsed.data, options.renderText)
|
||||||
|
: parsed.data;
|
||||||
|
|
||||||
|
const components: TopLevelBuilder[] = [];
|
||||||
|
for (const node of data.components) {
|
||||||
|
const built = buildNode(node, options);
|
||||||
|
if (built) {
|
||||||
|
components.push(built);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (components.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
components,
|
||||||
|
flags: MessageFlags.IsComponentsV2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStoredComponentsV2(raw: unknown): MessageComponentsV2 | null {
|
||||||
|
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
}
|
||||||
94
apps/bot/src/lib/embed-payload.ts
Normal file
94
apps/bot/src/lib/embed-payload.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { EmbedBuilder } from 'discord.js';
|
||||||
|
import {
|
||||||
|
embedHasContent,
|
||||||
|
mapEmbedTextFields,
|
||||||
|
type EmbedTextField,
|
||||||
|
type WelcomeEmbed
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
|
||||||
|
function isHttpUrl(value: string | undefined): value is string {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyEmbedOptions {
|
||||||
|
/**
|
||||||
|
* Called for every text/URL field before applying to the builder.
|
||||||
|
* `field` lets callers render `{user}` as a mention in the description
|
||||||
|
* but as a plain display name in title/author/footer.
|
||||||
|
*/
|
||||||
|
renderText: (value: string, field: EmbedTextField) => string;
|
||||||
|
/**
|
||||||
|
* Used when `thumbnailUrl` is unset (Welcome default: member avatar).
|
||||||
|
* Pass `null` to force no thumbnail.
|
||||||
|
*/
|
||||||
|
defaultThumbnailUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a discord.js EmbedBuilder from our shared WelcomeEmbed payload.
|
||||||
|
* Invalid/non-http URLs are skipped so bad dashboard input never crashes send.
|
||||||
|
*/
|
||||||
|
export function buildEmbedFromPayload(
|
||||||
|
raw: WelcomeEmbed | null | undefined,
|
||||||
|
options: ApplyEmbedOptions
|
||||||
|
): EmbedBuilder | null {
|
||||||
|
if (!raw || (!embedHasContent(raw) && raw.color === undefined)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = mapEmbedTextFields(raw, options.renderText);
|
||||||
|
const embed = new EmbedBuilder();
|
||||||
|
|
||||||
|
if (data.color !== undefined) {
|
||||||
|
embed.setColor(data.color);
|
||||||
|
}
|
||||||
|
if (data.title) {
|
||||||
|
embed.setTitle(data.title);
|
||||||
|
}
|
||||||
|
if (data.description) {
|
||||||
|
embed.setDescription(data.description);
|
||||||
|
}
|
||||||
|
if (isHttpUrl(data.url)) {
|
||||||
|
embed.setURL(data.url);
|
||||||
|
}
|
||||||
|
if (data.authorName) {
|
||||||
|
embed.setAuthor({
|
||||||
|
name: data.authorName,
|
||||||
|
iconURL: isHttpUrl(data.authorIconUrl) ? data.authorIconUrl : undefined,
|
||||||
|
url: isHttpUrl(data.authorUrl) ? data.authorUrl : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnail =
|
||||||
|
isHttpUrl(data.thumbnailUrl)
|
||||||
|
? data.thumbnailUrl
|
||||||
|
: options.defaultThumbnailUrl === null
|
||||||
|
? undefined
|
||||||
|
: options.defaultThumbnailUrl;
|
||||||
|
if (isHttpUrl(thumbnail)) {
|
||||||
|
embed.setThumbnail(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHttpUrl(data.imageUrl)) {
|
||||||
|
embed.setImage(data.imageUrl);
|
||||||
|
}
|
||||||
|
if (data.footerText) {
|
||||||
|
embed.setFooter({
|
||||||
|
text: data.footerText,
|
||||||
|
iconURL: isHttpUrl(data.footerIconUrl) ? data.footerIconUrl : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.timestamp) {
|
||||||
|
embed.setTimestamp(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
35
apps/bot/src/lib/safe-remove-job.ts
Normal file
35
apps/bot/src/lib/safe-remove-job.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Job } from 'bullmq';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a BullMQ job when present. Locked (active) jobs cannot be removed —
|
||||||
|
* those errors are swallowed so callers can still schedule a replacement jobId
|
||||||
|
* or rely on removeOnComplete after the worker finishes.
|
||||||
|
* Jobs that belong to a Job Scheduler must be removed via removeJobScheduler;
|
||||||
|
* direct remove is ignored here.
|
||||||
|
*/
|
||||||
|
export async function safeRemoveBullJob(
|
||||||
|
job: Job | null | undefined,
|
||||||
|
context?: { label?: string; jobId?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await job.remove();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (
|
||||||
|
message.includes('locked') ||
|
||||||
|
message.includes('could not be removed') ||
|
||||||
|
message.includes('belongs to a job scheduler')
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, jobId: context?.jobId ?? job.id, label: context?.label },
|
||||||
|
'BullMQ job could not be removed directly; skipping'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
apps/bot/src/metrics.ts
Normal file
129
apps/bot/src/metrics.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import type { Client } from 'discord.js';
|
||||||
|
|
||||||
|
const KEY_COMMANDS_TOTAL = 'nexumi:metrics:commands_total';
|
||||||
|
const KEY_COMMANDS_ERRORS = 'nexumi:metrics:commands_errors';
|
||||||
|
const KEY_COMMAND_DURATION_SUM = 'nexumi:metrics:command_duration_ms_sum';
|
||||||
|
const KEY_COMMAND_DURATION_COUNT = 'nexumi:metrics:command_duration_ms_count';
|
||||||
|
const KEY_EVENTS_TOTAL = 'nexumi:metrics:events_total';
|
||||||
|
const shardKey = (id: number) => `nexumi:metrics:shard:${id}`;
|
||||||
|
|
||||||
|
export async function recordCommandMetric(
|
||||||
|
redis: Redis,
|
||||||
|
opts: { ok: boolean; durationMs: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const multi = redis.multi();
|
||||||
|
multi.incr(KEY_COMMANDS_TOTAL);
|
||||||
|
if (!opts.ok) {
|
||||||
|
multi.incr(KEY_COMMANDS_ERRORS);
|
||||||
|
}
|
||||||
|
multi.incrbyfloat(KEY_COMMAND_DURATION_SUM, opts.durationMs);
|
||||||
|
multi.incr(KEY_COMMAND_DURATION_COUNT);
|
||||||
|
await multi.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordEventMetric(redis: Redis): Promise<void> {
|
||||||
|
await redis.incr(KEY_EVENTS_TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishShardMetrics(redis: Redis, client: Client): Promise<void> {
|
||||||
|
const ids = client.shard?.ids ?? [0];
|
||||||
|
const ping = client.ws.ping;
|
||||||
|
const guilds = client.guilds.cache.size;
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
ping,
|
||||||
|
guilds,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
await Promise.all(ids.map((id) => redis.set(shardKey(id), payload, 'EX', 120)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetricsSnapshot = {
|
||||||
|
up: 1;
|
||||||
|
commandsTotal: number;
|
||||||
|
commandsErrors: number;
|
||||||
|
commandDurationSumMs: number;
|
||||||
|
commandDurationCount: number;
|
||||||
|
eventsTotal: number;
|
||||||
|
shards: Array<{ id: number; ping: number; guilds: number; updatedAt: number }>;
|
||||||
|
queueWaiting?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function collectMetricsSnapshot(redis: Redis): Promise<MetricsSnapshot> {
|
||||||
|
const [commandsTotal, commandsErrors, durationSum, durationCount, eventsTotal, shardKeys] =
|
||||||
|
await Promise.all([
|
||||||
|
redis.get(KEY_COMMANDS_TOTAL),
|
||||||
|
redis.get(KEY_COMMANDS_ERRORS),
|
||||||
|
redis.get(KEY_COMMAND_DURATION_SUM),
|
||||||
|
redis.get(KEY_COMMAND_DURATION_COUNT),
|
||||||
|
redis.get(KEY_EVENTS_TOTAL),
|
||||||
|
redis.keys('nexumi:metrics:shard:*')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shards: MetricsSnapshot['shards'] = [];
|
||||||
|
if (shardKeys.length > 0) {
|
||||||
|
const values = await redis.mget(...shardKeys);
|
||||||
|
for (let i = 0; i < shardKeys.length; i++) {
|
||||||
|
const id = Number(shardKeys[i]!.replace('nexumi:metrics:shard:', ''));
|
||||||
|
const raw = values[i];
|
||||||
|
if (!raw || Number.isNaN(id)) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { ping: number; guilds: number; updatedAt: number };
|
||||||
|
shards.push({ id, ping: parsed.ping, guilds: parsed.guilds, updatedAt: parsed.updatedAt });
|
||||||
|
} catch {
|
||||||
|
// ignore malformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shards.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
up: 1,
|
||||||
|
commandsTotal: Number(commandsTotal ?? 0),
|
||||||
|
commandsErrors: Number(commandsErrors ?? 0),
|
||||||
|
commandDurationSumMs: Number(durationSum ?? 0),
|
||||||
|
commandDurationCount: Number(durationCount ?? 0),
|
||||||
|
eventsTotal: Number(eventsTotal ?? 0),
|
||||||
|
shards
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrometheus(snapshot: MetricsSnapshot): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
'# HELP nexumi_up 1 if the metrics endpoint is healthy',
|
||||||
|
'# TYPE nexumi_up gauge',
|
||||||
|
`nexumi_up ${snapshot.up}`,
|
||||||
|
'# HELP nexumi_commands_total Total slash commands handled',
|
||||||
|
'# TYPE nexumi_commands_total counter',
|
||||||
|
`nexumi_commands_total ${snapshot.commandsTotal}`,
|
||||||
|
'# HELP nexumi_commands_errors_total Total slash command failures',
|
||||||
|
'# TYPE nexumi_commands_errors_total counter',
|
||||||
|
`nexumi_commands_errors_total ${snapshot.commandsErrors}`,
|
||||||
|
'# HELP nexumi_command_duration_ms_sum Sum of command durations in milliseconds',
|
||||||
|
'# TYPE nexumi_command_duration_ms_sum counter',
|
||||||
|
`nexumi_command_duration_ms_sum ${snapshot.commandDurationSumMs}`,
|
||||||
|
'# HELP nexumi_command_duration_ms_count Count of timed commands',
|
||||||
|
'# TYPE nexumi_command_duration_ms_count counter',
|
||||||
|
`nexumi_command_duration_ms_count ${snapshot.commandDurationCount}`,
|
||||||
|
'# HELP nexumi_events_total Approximate event throughput counter',
|
||||||
|
'# TYPE nexumi_events_total counter',
|
||||||
|
`nexumi_events_total ${snapshot.eventsTotal}`,
|
||||||
|
'# HELP nexumi_guilds Guild count per shard',
|
||||||
|
'# TYPE nexumi_guilds gauge',
|
||||||
|
'# HELP nexumi_shard_ping_ms Websocket heartbeat latency per shard',
|
||||||
|
'# TYPE nexumi_shard_ping_ms gauge'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const shard of snapshot.shards) {
|
||||||
|
lines.push(`nexumi_guilds{shard="${shard.id}"} ${shard.guilds}`);
|
||||||
|
lines.push(`nexumi_shard_ping_ms{shard="${shard.id}"} ${shard.ping}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.queueWaiting !== undefined) {
|
||||||
|
lines.push('# HELP nexumi_queue_waiting Approximate waiting jobs across known queues');
|
||||||
|
lines.push('# TYPE nexumi_queue_waiting gauge');
|
||||||
|
lines.push(`nexumi_queue_waiting ${snapshot.queueWaiting}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
34
apps/bot/src/module-gates.test.ts
Normal file
34
apps/bot/src/module-gates.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { moduleForCommand } from './module-gates.js';
|
||||||
|
|
||||||
|
describe('moduleForCommand', () => {
|
||||||
|
it('maps moderation commands', () => {
|
||||||
|
expect(moduleForCommand('ban')).toBe('moderation');
|
||||||
|
expect(moduleForCommand('warn')).toBe('moderation');
|
||||||
|
expect(moduleForCommand('case')).toBe('moderation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps redis-only and prisma modules', () => {
|
||||||
|
expect(moduleForCommand('giveaway')).toBe('giveaways');
|
||||||
|
expect(moduleForCommand('tag')).toBe('tags');
|
||||||
|
expect(moduleForCommand('backup')).toBe('guildbackup');
|
||||||
|
expect(moduleForCommand('economy' as string)).toBeNull();
|
||||||
|
expect(moduleForCommand('balance')).toBe('economy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves core commands unmapped', () => {
|
||||||
|
expect(moduleForCommand('help')).toBeNull();
|
||||||
|
expect(moduleForCommand('info')).toBeNull();
|
||||||
|
expect(moduleForCommand('about')).toBeNull();
|
||||||
|
expect(moduleForCommand('gdpr')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('feature flag rollout hashing', () => {
|
||||||
|
it('is stable for the same guild/key pair', () => {
|
||||||
|
const a = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
|
||||||
|
const b = createHash('sha256').update('moderation:guild-1').digest().readUInt32BE(0) % 100;
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
310
apps/bot/src/module-gates.ts
Normal file
310
apps/bot/src/module-gates.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import type { DashboardModuleId } from '@nexumi/shared';
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
|
||||||
|
'giveaways',
|
||||||
|
'tags',
|
||||||
|
'selfroles',
|
||||||
|
'feeds',
|
||||||
|
'scheduler',
|
||||||
|
'guildbackup',
|
||||||
|
'messages'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Slash command name → dashboard module. Core/GDPR/utility (except gated) omit. */
|
||||||
|
const COMMAND_MODULE_MAP: Record<string, DashboardModuleId> = {
|
||||||
|
ban: 'moderation',
|
||||||
|
unban: 'moderation',
|
||||||
|
kick: 'moderation',
|
||||||
|
timeout: 'moderation',
|
||||||
|
untimeout: 'moderation',
|
||||||
|
warn: 'moderation',
|
||||||
|
purge: 'moderation',
|
||||||
|
slowmode: 'moderation',
|
||||||
|
lock: 'moderation',
|
||||||
|
unlock: 'moderation',
|
||||||
|
nick: 'moderation',
|
||||||
|
case: 'moderation',
|
||||||
|
modnote: 'moderation',
|
||||||
|
automod: 'automod',
|
||||||
|
welcome: 'welcome',
|
||||||
|
verify: 'verification',
|
||||||
|
rank: 'leveling',
|
||||||
|
leaderboard: 'leveling',
|
||||||
|
xp: 'leveling',
|
||||||
|
balance: 'economy',
|
||||||
|
daily: 'economy',
|
||||||
|
weekly: 'economy',
|
||||||
|
work: 'economy',
|
||||||
|
pay: 'economy',
|
||||||
|
gamble: 'economy',
|
||||||
|
slots: 'economy',
|
||||||
|
blackjack: 'economy',
|
||||||
|
coinflip: 'economy',
|
||||||
|
shop: 'economy',
|
||||||
|
inventory: 'economy',
|
||||||
|
eco: 'economy',
|
||||||
|
'8ball': 'fun',
|
||||||
|
dice: 'fun',
|
||||||
|
rps: 'fun',
|
||||||
|
choose: 'fun',
|
||||||
|
trivia: 'fun',
|
||||||
|
tictactoe: 'fun',
|
||||||
|
connect4: 'fun',
|
||||||
|
hangman: 'fun',
|
||||||
|
meme: 'fun',
|
||||||
|
cat: 'fun',
|
||||||
|
dog: 'fun',
|
||||||
|
giveaway: 'giveaways',
|
||||||
|
ticket: 'tickets',
|
||||||
|
selfroles: 'selfroles',
|
||||||
|
tag: 'tags',
|
||||||
|
starboard: 'starboard',
|
||||||
|
suggest: 'suggestions',
|
||||||
|
suggestion: 'suggestions',
|
||||||
|
birthday: 'birthdays',
|
||||||
|
voice: 'tempvoice',
|
||||||
|
invites: 'stats',
|
||||||
|
stats: 'stats',
|
||||||
|
feeds: 'feeds',
|
||||||
|
schedule: 'scheduler',
|
||||||
|
announce: 'scheduler',
|
||||||
|
backup: 'guildbackup',
|
||||||
|
embed: 'messages'
|
||||||
|
};
|
||||||
|
|
||||||
|
function redisModulesKey(guildId: string): string {
|
||||||
|
return `dashboard:modules:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function funConfigKey(guildId: string): string {
|
||||||
|
return `fun:config:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPercent(input: string): number {
|
||||||
|
const digest = createHash('sha256').update(input).digest();
|
||||||
|
return digest.readUInt32BE(0) % 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleForCommand(commandName: string): DashboardModuleId | null {
|
||||||
|
return COMMAND_MODULE_MAP[commandName] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Component customId → dashboard module (null = no module gate). */
|
||||||
|
export function moduleForComponent(customId: string): DashboardModuleId | null {
|
||||||
|
if (customId.startsWith('mod:confirm:') || customId.startsWith('mod:cancel:')) {
|
||||||
|
return 'moderation';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('verify:')) {
|
||||||
|
return 'verification';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('eco:bj:')) {
|
||||||
|
return 'economy';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('fun:')) {
|
||||||
|
return 'fun';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('gw:')) {
|
||||||
|
return 'giveaways';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('ticket:') || customId === 'ticket:select') {
|
||||||
|
return 'tickets';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('sr:')) {
|
||||||
|
return 'selfroles';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('sug:')) {
|
||||||
|
return 'suggestions';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('tv:')) {
|
||||||
|
return 'tempvoice';
|
||||||
|
}
|
||||||
|
if (customId.startsWith('gbackup:')) {
|
||||||
|
return 'guildbackup';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isFeatureFlagEnabled(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
key: string,
|
||||||
|
guildId: string | null
|
||||||
|
): Promise<boolean> {
|
||||||
|
const flag = await prisma.featureFlag.findUnique({ where: { key } });
|
||||||
|
if (!flag) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (guildId && flag.whitelistGuildIds.includes(guildId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!flag.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (flag.rolloutPercent >= 100) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (flag.rolloutPercent <= 0 || !guildId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hashPercent(`${flag.key}:${guildId}`) < flag.rolloutPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isRedisModuleEnabled(
|
||||||
|
redis: Redis,
|
||||||
|
guildId: string,
|
||||||
|
moduleId: DashboardModuleId
|
||||||
|
): Promise<boolean> {
|
||||||
|
const value = await redis.hget(redisModulesKey(guildId), moduleId);
|
||||||
|
if (value === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFunEnabled(redis: Redis, guildId: string): Promise<boolean> {
|
||||||
|
const raw = await redis.get(funConfigKey(guildId));
|
||||||
|
if (!raw) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { enabled?: boolean };
|
||||||
|
return parsed.enabled ?? true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isModuleEnabled(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
redis: Redis,
|
||||||
|
guildId: string,
|
||||||
|
moduleId: DashboardModuleId
|
||||||
|
): Promise<boolean> {
|
||||||
|
const flagOk = await isFeatureFlagEnabled(prisma, moduleId, guildId);
|
||||||
|
if (!flagOk) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (REDIS_ONLY_MODULES.has(moduleId)) {
|
||||||
|
return isRedisModuleEnabled(redis, guildId, moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (moduleId) {
|
||||||
|
case 'moderation': {
|
||||||
|
const settings = await prisma.guildSettings.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { moderationEnabled: true }
|
||||||
|
});
|
||||||
|
return settings?.moderationEnabled ?? true;
|
||||||
|
}
|
||||||
|
case 'automod': {
|
||||||
|
const config = await prisma.autoModConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'logging': {
|
||||||
|
const config = await prisma.loggingConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'welcome': {
|
||||||
|
const config = await prisma.welcomeConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { welcomeEnabled: true, leaveEnabled: true }
|
||||||
|
});
|
||||||
|
if (!config) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(config.welcomeEnabled || config.leaveEnabled);
|
||||||
|
}
|
||||||
|
case 'verification': {
|
||||||
|
const config = await prisma.verificationConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'leveling': {
|
||||||
|
const config = await prisma.levelingConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'economy': {
|
||||||
|
const config = await prisma.economyConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'fun':
|
||||||
|
return isFunEnabled(redis, guildId);
|
||||||
|
case 'tickets': {
|
||||||
|
const config = await prisma.ticketConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'starboard': {
|
||||||
|
const config = await prisma.starboardConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'suggestions': {
|
||||||
|
const config = await prisma.suggestionConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'birthdays': {
|
||||||
|
const config = await prisma.birthdayConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
case 'tempvoice': {
|
||||||
|
const config = await prisma.tempVoiceConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
case 'stats': {
|
||||||
|
const config = await prisma.statsConfig.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { enabled: true }
|
||||||
|
});
|
||||||
|
return config?.enabled ?? false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isUserBlacklisted(prisma: PrismaClient, userId: string): Promise<boolean> {
|
||||||
|
const row = await prisma.globalUserBlacklist.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isGuildBlacklisted(prisma: PrismaClient, guildId: string): Promise<boolean> {
|
||||||
|
const row = await prisma.guildBlacklist.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
342
apps/bot/src/modules/automod/actions.ts
Normal file
342
apps/bot/src/modules/automod/actions.ts
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
getAutoModConfig,
|
||||||
|
getEnabledAutoModRules,
|
||||||
|
isExcepted,
|
||||||
|
parseExceptions,
|
||||||
|
parseThreshold,
|
||||||
|
parseWordList
|
||||||
|
} from './service.js';
|
||||||
|
import { evaluateRule, loadPhishingDomains, type FilterResult } from './filters.js';
|
||||||
|
import type { AutoModRuleType } from '@nexumi/shared';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
async function applyAutoModAction(
|
||||||
|
context: BotContext,
|
||||||
|
message: Message,
|
||||||
|
ruleType: AutoModRuleType,
|
||||||
|
action: string,
|
||||||
|
reason: string,
|
||||||
|
durationMs?: number | null
|
||||||
|
): Promise<void> {
|
||||||
|
const guild = message.guild;
|
||||||
|
if (!guild || !message.member) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (me.permissions.has(PermissionFlagsBits.ManageMessages) && message.deletable) {
|
||||||
|
await message.delete().catch((error) => {
|
||||||
|
logger.warn({ error, messageId: message.id }, 'AutoMod failed to delete message');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderatorId = context.client.user?.id ?? guild.client.user?.id ?? '0';
|
||||||
|
const baseReason = `AutoMod (${ruleType}): ${reason}`;
|
||||||
|
|
||||||
|
if (action === 'WARN') {
|
||||||
|
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
|
guildId: guild.id,
|
||||||
|
targetUserId: message.author.id,
|
||||||
|
moderatorId,
|
||||||
|
action: 'WARN_ADD',
|
||||||
|
reason: baseReason,
|
||||||
|
channelId: message.channel.id,
|
||||||
|
source: 'automod',
|
||||||
|
metadata: { ruleType, automod: true }
|
||||||
|
});
|
||||||
|
await createWarning(context, {
|
||||||
|
guildId: guild.id,
|
||||||
|
userId: message.author.id,
|
||||||
|
moderatorId,
|
||||||
|
reason: baseReason,
|
||||||
|
caseId: modCase.id,
|
||||||
|
guildName: guild.name,
|
||||||
|
locale,
|
||||||
|
notifyUser: message.author
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'TIMEOUT' && durationMs && durationMs > 0) {
|
||||||
|
if (me.permissions.has(PermissionFlagsBits.ModerateMembers)) {
|
||||||
|
await message.member.timeout(durationMs, baseReason);
|
||||||
|
await createCase(context, {
|
||||||
|
guildId: guild.id,
|
||||||
|
targetUserId: message.author.id,
|
||||||
|
moderatorId,
|
||||||
|
action: 'TIMEOUT',
|
||||||
|
reason: baseReason,
|
||||||
|
channelId: message.channel.id,
|
||||||
|
source: 'automod',
|
||||||
|
metadata: { ruleType, automod: true, durationMs }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'KICK') {
|
||||||
|
if (me.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||||
|
await notifyModerationTargetDm({
|
||||||
|
user: message.author,
|
||||||
|
guildName: guild.name,
|
||||||
|
reason: baseReason,
|
||||||
|
locale,
|
||||||
|
action: 'kick'
|
||||||
|
});
|
||||||
|
await message.member.kick(baseReason);
|
||||||
|
await createCase(context, {
|
||||||
|
guildId: guild.id,
|
||||||
|
targetUserId: message.author.id,
|
||||||
|
moderatorId,
|
||||||
|
action: 'KICK',
|
||||||
|
reason: baseReason,
|
||||||
|
channelId: message.channel.id,
|
||||||
|
source: 'automod',
|
||||||
|
metadata: { ruleType, automod: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'BAN') {
|
||||||
|
if (me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||||
|
await notifyModerationTargetDm({
|
||||||
|
user: message.author,
|
||||||
|
guildName: guild.name,
|
||||||
|
reason: baseReason,
|
||||||
|
locale,
|
||||||
|
action: 'ban'
|
||||||
|
});
|
||||||
|
await guild.members.ban(message.author.id, { reason: baseReason });
|
||||||
|
await createCase(context, {
|
||||||
|
guildId: guild.id,
|
||||||
|
targetUserId: message.author.id,
|
||||||
|
moderatorId,
|
||||||
|
action: 'BAN',
|
||||||
|
reason: baseReason,
|
||||||
|
channelId: message.channel.id,
|
||||||
|
source: 'automod',
|
||||||
|
metadata: { ruleType, automod: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAutoModMessage(
|
||||||
|
context: BotContext,
|
||||||
|
message: Message
|
||||||
|
): Promise<FilterResult | null> {
|
||||||
|
if (!message.guild || message.author.bot || !message.content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getAutoModConfig(context.prisma, message.guild.id);
|
||||||
|
if (!config.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = message.member ?? (await message.guild.members.fetch(message.author.id));
|
||||||
|
const roleIds = member.roles.cache.map((role) => role.id);
|
||||||
|
const rules = await getEnabledAutoModRules(context.prisma, message.guild.id);
|
||||||
|
const phishingDomains = await loadPhishingDomains(context.redis);
|
||||||
|
|
||||||
|
const filterCtx = {
|
||||||
|
content: message.content,
|
||||||
|
authorId: message.author.id,
|
||||||
|
channelId: message.channel.id,
|
||||||
|
roleIds,
|
||||||
|
redis: context.redis,
|
||||||
|
phishingDomains
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const exceptions = parseExceptions(rule.exceptions);
|
||||||
|
if (isExcepted(exceptions, message.channel.id, roleIds)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = parseThreshold(rule.threshold);
|
||||||
|
const wordList = parseWordList(rule.wordList);
|
||||||
|
const result = await evaluateRule(
|
||||||
|
rule.ruleType as AutoModRuleType,
|
||||||
|
filterCtx,
|
||||||
|
threshold,
|
||||||
|
wordList
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
await applyAutoModAction(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
result.ruleType,
|
||||||
|
rule.action,
|
||||||
|
result.reason,
|
||||||
|
rule.durationMs
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateLockdown(guild: Guild): Promise<void> {
|
||||||
|
for (const channel of guild.channels.cache.values()) {
|
||||||
|
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const everyone = guild.roles.everyone;
|
||||||
|
await channel.permissionOverwrites.edit(everyone, {
|
||||||
|
SendMessages: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateLockdown(guild: Guild): Promise<void> {
|
||||||
|
for (const channel of guild.channels.cache.values()) {
|
||||||
|
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const everyone = guild.roles.everyone;
|
||||||
|
await channel.permissionOverwrites.edit(everyone, {
|
||||||
|
SendMessages: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAntiRaidJoin(
|
||||||
|
context: BotContext,
|
||||||
|
member: GuildMember
|
||||||
|
): Promise<void> {
|
||||||
|
const config = await getAutoModConfig(context.prisma, member.guild.id);
|
||||||
|
if (!config.antiRaidEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While VERIFY raid-response is active, keep gating new joins with the unverified role.
|
||||||
|
if (config.lockdownActive && config.antiRaidAction === 'VERIFY') {
|
||||||
|
await applyRaidVerifyRole(context, member);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.lockdownActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `automod:raid:${member.guild.id}`;
|
||||||
|
const count = await context.redis.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await context.redis.expire(key, config.antiRaidWindowSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count < config.antiRaidJoinThreshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.prisma.autoModConfig.update({
|
||||||
|
where: { guildId: member.guild.id },
|
||||||
|
data: { lockdownActive: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.antiRaidAction === 'LOCKDOWN') {
|
||||||
|
await activateLockdown(member.guild);
|
||||||
|
} else if (config.antiRaidAction === 'VERIFY') {
|
||||||
|
await applyRaidVerifyRole(context, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
|
||||||
|
if (alertChannel && alertChannel.isTextBased()) {
|
||||||
|
await (alertChannel as TextChannel).send(
|
||||||
|
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s (${config.antiRaidAction}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyRaidVerifyRole(context: BotContext, member: GuildMember): Promise<void> {
|
||||||
|
const verification = await context.prisma.verificationConfig.findUnique({
|
||||||
|
where: { guildId: member.guild.id }
|
||||||
|
});
|
||||||
|
const roleId = verification?.unverifiedRoleId;
|
||||||
|
if (!roleId) {
|
||||||
|
logger.warn(
|
||||||
|
{ guildId: member.guild.id },
|
||||||
|
'Anti-raid VERIFY triggered but no unverified role is configured'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = member.guild.members.me;
|
||||||
|
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const role = member.guild.roles.cache.get(roleId);
|
||||||
|
if (!role || role.position >= me.roles.highest.position) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!member.roles.cache.has(roleId)) {
|
||||||
|
await member.roles.add(roleId).catch((error) => {
|
||||||
|
logger.warn({ error, memberId: member.id }, 'Failed to apply unverified role during raid verify');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAntiNukeAction(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
executorId: string,
|
||||||
|
actionType: 'ban' | 'channel_delete'
|
||||||
|
): Promise<void> {
|
||||||
|
const config = await getAutoModConfig(context.prisma, guildId);
|
||||||
|
if (!config.antiNukeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const botId = context.client.user?.id;
|
||||||
|
if (!botId || executorId === botId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `automod:nuke:${guildId}:${executorId}:${actionType}`;
|
||||||
|
const count = await context.redis.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await context.redis.expire(key, config.antiNukeWindowSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold =
|
||||||
|
actionType === 'ban' ? config.antiNukeBanThreshold : config.antiNukeChannelThreshold;
|
||||||
|
if (count < threshold) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(guildId);
|
||||||
|
const executor = await guild.members.fetch(executorId).catch(() => null);
|
||||||
|
if (!executor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me?.permissions.has(PermissionFlagsBits.Administrator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executor.roles.set([], 'Anti-nuke: mass destructive action detected');
|
||||||
|
await createCase(context, {
|
||||||
|
guildId,
|
||||||
|
targetUserId: executorId,
|
||||||
|
moderatorId: botId,
|
||||||
|
action: 'ANTI_NUKE',
|
||||||
|
reason: `Anti-nuke triggered (${actionType}: ${count} in ${config.antiNukeWindowSeconds}s)`,
|
||||||
|
source: 'automod',
|
||||||
|
metadata: { actionType, count, antiNuke: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
12
apps/bot/src/modules/automod/command-definitions.ts
Normal file
12
apps/bot/src/modules/automod/command-definitions.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
import { applyCommandDescription } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export const automodStatusCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('automod')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('status'), 'automod.status.description')
|
||||||
|
),
|
||||||
|
'automod.description'
|
||||||
|
);
|
||||||
57
apps/bot/src/modules/automod/commands.ts
Normal file
57
apps/bot/src/modules/automod/commands.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { automodStatusCommandData } from './command-definitions.js';
|
||||||
|
import { getAutoModConfig, getEnabledAutoModRules } from './service.js';
|
||||||
|
|
||||||
|
const automodCommand: SlashCommand = {
|
||||||
|
data: automodStatusCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!interaction.inGuild()) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
if (sub !== 'status') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = interaction.guildId!;
|
||||||
|
const config = await getAutoModConfig(context.prisma, guildId);
|
||||||
|
const rules = await getEnabledAutoModRules(context.prisma, guildId);
|
||||||
|
|
||||||
|
const lines = rules.map((rule) =>
|
||||||
|
tf(locale, 'automod.status.ruleLine', {
|
||||||
|
type: rule.ruleType,
|
||||||
|
action: rule.action
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
tf(locale, 'automod.status.header', {
|
||||||
|
enabled: config.enabled ? t(locale, 'automod.status.yes') : t(locale, 'automod.status.no'),
|
||||||
|
antiRaid: config.antiRaidEnabled
|
||||||
|
? t(locale, 'automod.status.yes')
|
||||||
|
: t(locale, 'automod.status.no'),
|
||||||
|
antiNuke: config.antiNukeEnabled
|
||||||
|
? t(locale, 'automod.status.yes')
|
||||||
|
: t(locale, 'automod.status.no'),
|
||||||
|
lockdown: config.lockdownActive
|
||||||
|
? t(locale, 'automod.status.yes')
|
||||||
|
: t(locale, 'automod.status.no')
|
||||||
|
}),
|
||||||
|
lines.length > 0 ? lines.join('\n') : t(locale, 'automod.status.noRules')
|
||||||
|
].join('\n\n');
|
||||||
|
|
||||||
|
await interaction.reply({ content: body, ephemeral: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const automodCommands: SlashCommand[] = [automodCommand];
|
||||||
231
apps/bot/src/modules/automod/filters.ts
Normal file
231
apps/bot/src/modules/automod/filters.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import {
|
||||||
|
capsRatio,
|
||||||
|
contentMatchesBlockedWord,
|
||||||
|
countEmojis,
|
||||||
|
extractUrls,
|
||||||
|
hasZalgo,
|
||||||
|
isDiscordInvite,
|
||||||
|
normalizeHost,
|
||||||
|
type AutoModRuleType,
|
||||||
|
type AutoModThreshold,
|
||||||
|
type AutoModWordList
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
|
||||||
|
const PHISHING_CACHE_KEY = 'automod:phishing-domains';
|
||||||
|
|
||||||
|
export type FilterContext = {
|
||||||
|
content: string;
|
||||||
|
authorId: string;
|
||||||
|
channelId: string;
|
||||||
|
roleIds: string[];
|
||||||
|
redis: Redis;
|
||||||
|
phishingDomains: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilterResult = {
|
||||||
|
matched: boolean;
|
||||||
|
ruleType: AutoModRuleType;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadPhishingDomains(redis: Redis): Promise<Set<string>> {
|
||||||
|
const cached = await redis.smembers(PHISHING_CACHE_KEY);
|
||||||
|
if (cached.length > 0) {
|
||||||
|
return new Set(cached);
|
||||||
|
}
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshPhishingDomains(redis: Redis): Promise<number> {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://raw.githubusercontent.com/Discord-AntiScam/scam-links/main/list.txt'
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch phishing list: ${response.status}`);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
const domains = text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim().toLowerCase())
|
||||||
|
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
||||||
|
|
||||||
|
const pipeline = redis.pipeline();
|
||||||
|
pipeline.del(PHISHING_CACHE_KEY);
|
||||||
|
if (domains.length > 0) {
|
||||||
|
pipeline.sadd(PHISHING_CACHE_KEY, ...domains);
|
||||||
|
}
|
||||||
|
pipeline.expire(PHISHING_CACHE_KEY, 60 * 60 * 24);
|
||||||
|
await pipeline.exec();
|
||||||
|
return domains.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSpam(
|
||||||
|
ctx: FilterContext,
|
||||||
|
threshold: AutoModThreshold
|
||||||
|
): Promise<FilterResult | null> {
|
||||||
|
const maxMessages = threshold.maxMessages ?? 5;
|
||||||
|
const windowSeconds = threshold.windowSeconds ?? 5;
|
||||||
|
const key = `automod:spam:${ctx.authorId}:${ctx.channelId}`;
|
||||||
|
const count = await ctx.redis.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await ctx.redis.expire(key, windowSeconds);
|
||||||
|
}
|
||||||
|
if (count > maxMessages) {
|
||||||
|
return { matched: true, ruleType: 'SPAM', reason: 'Message rate exceeded' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkMassMention(content: string, threshold: AutoModThreshold): FilterResult | null {
|
||||||
|
const maxMentions = threshold.maxMentions ?? 5;
|
||||||
|
const mentions = (content.match(/<@[!&]?\d+>/g) ?? []).length;
|
||||||
|
if (mentions >= maxMentions) {
|
||||||
|
return { matched: true, ruleType: 'MASS_MENTION', reason: `${mentions} mentions` };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCaps(content: string, threshold: AutoModThreshold): FilterResult | null {
|
||||||
|
const minLength = threshold.minLength ?? 10;
|
||||||
|
const capsPercent = threshold.capsPercent ?? 70;
|
||||||
|
if (content.length < minLength) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (capsRatio(content) >= capsPercent) {
|
||||||
|
return { matched: true, ruleType: 'CAPS', reason: 'Excessive caps' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkInviteLink(content: string): FilterResult | null {
|
||||||
|
if (isDiscordInvite(content)) {
|
||||||
|
return { matched: true, ruleType: 'INVITE_LINK', reason: 'Discord invite link' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkExternalLink(content: string, threshold: AutoModThreshold): FilterResult | null {
|
||||||
|
const whitelist = (threshold.linkWhitelist ?? []).map((h) => h.toLowerCase());
|
||||||
|
const blacklist = (threshold.linkBlacklist ?? []).map((h) => h.toLowerCase());
|
||||||
|
const urls = extractUrls(content);
|
||||||
|
for (const url of urls) {
|
||||||
|
const host = normalizeHost(url);
|
||||||
|
if (!host) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (blacklist.some((entry) => host.includes(entry))) {
|
||||||
|
return { matched: true, ruleType: 'EXTERNAL_LINK', reason: `Blocked host: ${host}` };
|
||||||
|
}
|
||||||
|
if (whitelist.length > 0 && !whitelist.some((entry) => host.includes(entry))) {
|
||||||
|
return { matched: true, ruleType: 'EXTERNAL_LINK', reason: `Non-whitelisted host: ${host}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
|
||||||
|
for (const word of wordList.words) {
|
||||||
|
if (contentMatchesBlockedWord(content, word)) {
|
||||||
|
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pattern of wordList.regexPatterns) {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(pattern, 'iu');
|
||||||
|
if (regex.test(content)) {
|
||||||
|
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDuplicate(
|
||||||
|
ctx: FilterContext,
|
||||||
|
threshold: AutoModThreshold
|
||||||
|
): Promise<FilterResult | null> {
|
||||||
|
const duplicateCount = threshold.duplicateCount ?? 3;
|
||||||
|
const windowSeconds = threshold.windowSeconds ?? 30;
|
||||||
|
const normalized = ctx.content.trim().toLowerCase();
|
||||||
|
if (normalized.length < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = `automod:dup:${ctx.authorId}:${ctx.channelId}`;
|
||||||
|
const count = await ctx.redis.incr(`${key}:${normalized}`);
|
||||||
|
if (count === 1) {
|
||||||
|
await ctx.redis.expire(`${key}:${normalized}`, windowSeconds);
|
||||||
|
}
|
||||||
|
if (count >= duplicateCount) {
|
||||||
|
return { matched: true, ruleType: 'DUPLICATE', reason: 'Duplicate message' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkEmojiSpam(content: string, threshold: AutoModThreshold): FilterResult | null {
|
||||||
|
const maxEmojis = threshold.maxEmojis ?? 10;
|
||||||
|
const emojiCount = countEmojis(content);
|
||||||
|
if (emojiCount >= maxEmojis) {
|
||||||
|
return { matched: true, ruleType: 'EMOJI_SPAM', reason: `${emojiCount} emojis` };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkZalgo(content: string): FilterResult | null {
|
||||||
|
if (hasZalgo(content)) {
|
||||||
|
return { matched: true, ruleType: 'ZALGO', reason: 'Zalgo text detected' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPhishing(content: string, domains: Set<string>): FilterResult | null {
|
||||||
|
if (domains.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const url of extractUrls(content)) {
|
||||||
|
const host = normalizeHost(url);
|
||||||
|
if (!host) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const domain of domains) {
|
||||||
|
if (host === domain || host.endsWith(`.${domain}`)) {
|
||||||
|
return { matched: true, ruleType: 'PHISHING', reason: `Phishing domain: ${host}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateRule(
|
||||||
|
ruleType: AutoModRuleType,
|
||||||
|
ctx: FilterContext,
|
||||||
|
threshold: AutoModThreshold,
|
||||||
|
wordList: AutoModWordList
|
||||||
|
): Promise<FilterResult | null> {
|
||||||
|
switch (ruleType) {
|
||||||
|
case 'SPAM':
|
||||||
|
return checkSpam(ctx, threshold);
|
||||||
|
case 'MASS_MENTION':
|
||||||
|
return checkMassMention(ctx.content, threshold);
|
||||||
|
case 'CAPS':
|
||||||
|
return checkCaps(ctx.content, threshold);
|
||||||
|
case 'INVITE_LINK':
|
||||||
|
return checkInviteLink(ctx.content);
|
||||||
|
case 'EXTERNAL_LINK':
|
||||||
|
return checkExternalLink(ctx.content, threshold);
|
||||||
|
case 'WORD_FILTER':
|
||||||
|
return checkWordFilter(ctx.content, wordList);
|
||||||
|
case 'DUPLICATE':
|
||||||
|
return checkDuplicate(ctx, threshold);
|
||||||
|
case 'EMOJI_SPAM':
|
||||||
|
return checkEmojiSpam(ctx.content, threshold);
|
||||||
|
case 'ZALGO':
|
||||||
|
return checkZalgo(ctx.content);
|
||||||
|
case 'PHISHING':
|
||||||
|
return checkPhishing(ctx.content, ctx.phishingDomains);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
apps/bot/src/modules/automod/service.ts
Normal file
110
apps/bot/src/modules/automod/service.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Prisma, PrismaClient } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
DEFAULT_AUTOMOD_RULES,
|
||||||
|
defaultAutoModWordList,
|
||||||
|
shouldSeedDefaultBlockedWords,
|
||||||
|
type AutoModExceptions,
|
||||||
|
type AutoModThreshold,
|
||||||
|
type AutoModWordList
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
|
||||||
|
export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
let config = await prisma.autoModConfig.findUnique({ where: { guildId } });
|
||||||
|
if (!config) {
|
||||||
|
config = await prisma.autoModConfig.create({ data: { guildId } });
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaultWordFilterIfNeeded(prisma: PrismaClient, guildId: string) {
|
||||||
|
const rule = await prisma.autoModRule.findUnique({
|
||||||
|
where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } },
|
||||||
|
select: { id: true, wordList: true }
|
||||||
|
});
|
||||||
|
if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = parseWordList(rule.wordList);
|
||||||
|
await prisma.autoModRule.update({
|
||||||
|
where: { id: rule.id },
|
||||||
|
data: {
|
||||||
|
wordList: {
|
||||||
|
...defaultAutoModWordList(),
|
||||||
|
regexPatterns: existing.regexPatterns
|
||||||
|
} as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
const existing = await prisma.autoModRule.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
select: { ruleType: true }
|
||||||
|
});
|
||||||
|
const have = new Set(existing.map((row) => row.ruleType));
|
||||||
|
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
await prisma.autoModRule.createMany({
|
||||||
|
data: missing.map((rule) => ({
|
||||||
|
guildId,
|
||||||
|
ruleType: rule.ruleType,
|
||||||
|
action: rule.action,
|
||||||
|
threshold: rule.threshold ?? undefined,
|
||||||
|
wordList: rule.wordList ?? undefined,
|
||||||
|
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await seedDefaultWordFilterIfNeeded(prisma, guildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||||
|
await ensureDefaultAutoModRules(prisma, guildId);
|
||||||
|
return prisma.autoModRule.findMany({
|
||||||
|
where: { guildId, enabled: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseExceptions(raw: unknown): AutoModExceptions {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return { roleIds: [], channelIds: [] };
|
||||||
|
}
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
roleIds: Array.isArray(obj.roleIds) ? obj.roleIds.map(String) : [],
|
||||||
|
channelIds: Array.isArray(obj.channelIds) ? obj.channelIds.map(String) : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseThreshold(raw: unknown): AutoModThreshold {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return raw as AutoModThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWordList(raw: unknown): AutoModWordList {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return { words: [], regexPatterns: [] };
|
||||||
|
}
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
words: Array.isArray(obj.words) ? obj.words.map(String) : [],
|
||||||
|
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [],
|
||||||
|
...(obj.userCleared === true ? { userCleared: true } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExcepted(
|
||||||
|
exceptions: AutoModExceptions,
|
||||||
|
channelId: string,
|
||||||
|
roleIds: string[]
|
||||||
|
): boolean {
|
||||||
|
if (exceptions.channelIds.includes(channelId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return roleIds.some((roleId) => exceptions.roleIds.includes(roleId));
|
||||||
|
}
|
||||||
45
apps/bot/src/modules/birthdays/command-definitions.ts
Normal file
45
apps/bot/src/modules/birthdays/command-definitions.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ChannelType } from 'discord.js';
|
||||||
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
export const birthdayCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('birthday')
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('set'), 'birthdays.set.description')
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('month').setRequired(true).setMinValue(1).setMaxValue(12), 'birthdays.set.options.month')
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('day').setRequired(true).setMinValue(1).setMaxValue(31), 'birthdays.set.options.day')
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('year').setMinValue(1900).setMaxValue(new Date().getFullYear()),
|
||||||
|
'birthdays.set.options.year'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) => applyCommandDescription(s.setName('remove'), 'birthdays.remove.description'))
|
||||||
|
.addSubcommand((s) => applyCommandDescription(s.setName('next'), 'birthdays.next.description'))
|
||||||
|
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'birthdays.list.description'))
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('setup'), 'birthdays.setup.description')
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
||||||
|
'birthdays.setup.options.channel'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('role'), 'birthdays.setup.options.role')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('timezone'), 'birthdays.setup.options.timezone')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('message'), 'birthdays.setup.options.message')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'birthdays.description'
|
||||||
|
);
|
||||||
126
apps/bot/src/modules/birthdays/commands.ts
Normal file
126
apps/bot/src/modules/birthdays/commands.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { birthdayCommandData } from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
BirthdaySetSchema,
|
||||||
|
daysUntilBirthday,
|
||||||
|
getBirthdayConfig,
|
||||||
|
isValidTimezone,
|
||||||
|
listBirthdays,
|
||||||
|
removeBirthday,
|
||||||
|
setBirthday,
|
||||||
|
updateBirthdayConfig
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
|
const birthdayCommand: SlashCommand = {
|
||||||
|
data: birthdayCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!interaction.inGuild()) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildId = interaction.guildId!;
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (sub === 'setup') {
|
||||||
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = interaction.options.getChannel('channel', true);
|
||||||
|
const role = interaction.options.getRole('role');
|
||||||
|
const timezone = interaction.options.getString('timezone') ?? 'Europe/Berlin';
|
||||||
|
const message = interaction.options.getString('message');
|
||||||
|
|
||||||
|
if (!isValidTimezone(timezone)) {
|
||||||
|
await interaction.reply({ content: t(locale, 'birthdays.error.invalidTimezone'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateBirthdayConfig(context.prisma, guildId, {
|
||||||
|
enabled: true,
|
||||||
|
channelId: channel.id,
|
||||||
|
roleId: role?.id ?? null,
|
||||||
|
message: message ?? null,
|
||||||
|
timezone
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.reply({ content: t(locale, 'birthdays.setup.done'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getBirthdayConfig(context.prisma, guildId);
|
||||||
|
|
||||||
|
if (sub === 'set') {
|
||||||
|
const month = interaction.options.getInteger('month', true);
|
||||||
|
const day = interaction.options.getInteger('day', true);
|
||||||
|
const year = interaction.options.getInteger('year') ?? undefined;
|
||||||
|
const parsed = BirthdaySetSchema.safeParse({ month, day, year });
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setBirthday(context.prisma, guildId, interaction.user.id, parsed.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'invalid_date') {
|
||||||
|
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'birthdays.set.success', { month, day, year: year ?? '—' }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const removed = await removeBirthday(context.prisma, guildId, interaction.user.id);
|
||||||
|
if (!removed) {
|
||||||
|
await interaction.reply({ content: t(locale, 'birthdays.remove.none'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({ content: t(locale, 'birthdays.remove.success'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list' || sub === 'next') {
|
||||||
|
const entries = await listBirthdays(context.prisma, guildId, config.timezone);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
await interaction.reply({ content: t(locale, 'birthdays.list.empty'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slice = sub === 'next' ? entries.slice(0, 10) : entries.slice(0, 25);
|
||||||
|
const lines = slice.map((entry, index) => {
|
||||||
|
const days = daysUntilBirthday(entry.month, entry.day, config.timezone);
|
||||||
|
const dateLabel = `${String(entry.day).padStart(2, '0')}.${String(entry.month).padStart(2, '0')}`;
|
||||||
|
const yearLabel = entry.year ? ` (${entry.year})` : '';
|
||||||
|
return tf(locale, 'birthdays.list.line', {
|
||||||
|
rank: index + 1,
|
||||||
|
user: `<@${entry.userId}>`,
|
||||||
|
date: `${dateLabel}${yearLabel}`,
|
||||||
|
days
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = t(locale, sub === 'next' ? 'birthdays.next.header' : 'birthdays.list.header');
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${header}\n${lines.join('\n')}`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const birthdayCommands: SlashCommand[] = [birthdayCommand];
|
||||||
2
apps/bot/src/modules/birthdays/index.ts
Normal file
2
apps/bot/src/modules/birthdays/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { birthdayCommands } from './commands.js';
|
||||||
|
export { runBirthdayCheck, expireBirthdayRole } from './service.js';
|
||||||
304
apps/bot/src/modules/birthdays/service.ts
Normal file
304
apps/bot/src/modules/birthdays/service.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { PermissionFlagsBits, type Guild, type GuildMember, type TextChannel } from 'discord.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { applyTagPlaceholders } from '@nexumi/shared';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { birthdayQueue } from '../../queues.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
export const BirthdaySetSchema = z.object({
|
||||||
|
month: z.number().int().min(1).max(12),
|
||||||
|
day: z.number().int().min(1).max(31),
|
||||||
|
year: z.number().int().min(1900).max(new Date().getFullYear()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BirthdaySetInput = z.infer<typeof BirthdaySetSchema>;
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE = 'Happy birthday {user}! 🎂';
|
||||||
|
const ROLE_EXPIRE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function congratsRedisKey(guildId: string, userId: string, dateKey: string): string {
|
||||||
|
return `birthday:sent:${guildId}:${userId}:${dateKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidTimezone(timezone: string): boolean {
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDatePartsInTimezone(
|
||||||
|
timezone: string,
|
||||||
|
date = new Date()
|
||||||
|
): { month: number; day: number; year: number } {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric'
|
||||||
|
}).formatToParts(date);
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: Number(parts.find((p) => p.type === 'year')!.value),
|
||||||
|
month: Number(parts.find((p) => p.type === 'month')!.value),
|
||||||
|
day: Number(parts.find((p) => p.type === 'day')!.value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidCalendarDate(month: number, day: number, year?: number): boolean {
|
||||||
|
const probeYear = year ?? 2000;
|
||||||
|
const date = new Date(probeYear, month - 1, day);
|
||||||
|
return date.getFullYear() === probeYear && date.getMonth() === month - 1 && date.getDate() === day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeAge(year: number, month: number, day: number, timezone: string): number {
|
||||||
|
const today = getDatePartsInTimezone(timezone);
|
||||||
|
let age = today.year - year;
|
||||||
|
if (today.month < month || (today.month === month && today.day < day)) {
|
||||||
|
age -= 1;
|
||||||
|
}
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysUntilBirthday(month: number, day: number, timezone: string): number {
|
||||||
|
const today = getDatePartsInTimezone(timezone);
|
||||||
|
let year = today.year;
|
||||||
|
let candidate = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
const todayUtc = new Date(Date.UTC(today.year, today.month - 1, today.day));
|
||||||
|
|
||||||
|
if (candidate.getTime() < todayUtc.getTime()) {
|
||||||
|
year += 1;
|
||||||
|
candidate = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round((candidate.getTime() - todayUtc.getTime()) / (24 * 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBirthdayConfig(prisma: BotContext['prisma'], guildId: string) {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
let config = await prisma.birthdayConfig.findUnique({ where: { guildId } });
|
||||||
|
if (!config) {
|
||||||
|
config = await prisma.birthdayConfig.create({ data: { guildId } });
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBirthdayConfig(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
data: {
|
||||||
|
enabled?: boolean;
|
||||||
|
channelId?: string | null;
|
||||||
|
roleId?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
return prisma.birthdayConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: data,
|
||||||
|
create: { guildId, ...data }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setBirthday(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
input: BirthdaySetInput
|
||||||
|
) {
|
||||||
|
if (!isValidCalendarDate(input.month, input.day, input.year)) {
|
||||||
|
throw new Error('invalid_date');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.birthday.upsert({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
update: {
|
||||||
|
month: input.month,
|
||||||
|
day: input.day,
|
||||||
|
year: input.year ?? null
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
month: input.month,
|
||||||
|
day: input.day,
|
||||||
|
year: input.year ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeBirthday(prisma: BotContext['prisma'], guildId: string, userId: string) {
|
||||||
|
const existing = await prisma.birthday.findUnique({
|
||||||
|
where: { guildId_userId: { guildId, userId } }
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await prisma.birthday.delete({ where: { guildId_userId: { guildId, userId } } });
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBirthdays(prisma: BotContext['prisma'], guildId: string, timezone: string) {
|
||||||
|
const birthdays = await prisma.birthday.findMany({ where: { guildId } });
|
||||||
|
return birthdays.sort((a, b) => {
|
||||||
|
const diff = daysUntilBirthday(a.month, a.day, timezone) - daysUntilBirthday(b.month, b.day, timezone);
|
||||||
|
if (diff !== 0) {
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
|
return a.userId.localeCompare(b.userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scheduleBirthdayRoleExpire(
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
roleId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await birthdayQueue.add(
|
||||||
|
'birthdayRoleExpire',
|
||||||
|
{ guildId, userId, roleId },
|
||||||
|
{
|
||||||
|
delay: ROLE_EXPIRE_MS,
|
||||||
|
jobId: `birthday-role-${guildId}-${userId}-${Date.now()}`,
|
||||||
|
removeOnComplete: 1000,
|
||||||
|
removeOnFail: 500
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBirthdayMessage(
|
||||||
|
template: string | null | undefined,
|
||||||
|
member: GuildMember,
|
||||||
|
age?: number
|
||||||
|
): string {
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
user: `<@${member.id}>`,
|
||||||
|
'user.name': member.displayName,
|
||||||
|
'user.tag': member.user.tag
|
||||||
|
};
|
||||||
|
if (age !== undefined) {
|
||||||
|
vars.age = String(age);
|
||||||
|
}
|
||||||
|
return applyTagPlaceholders(template?.trim() || DEFAULT_MESSAGE, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendBirthdayCongrats(
|
||||||
|
context: BotContext,
|
||||||
|
guild: Guild,
|
||||||
|
config: Awaited<ReturnType<typeof getBirthdayConfig>>,
|
||||||
|
birthday: { userId: string; month: number; day: number; year: number | null },
|
||||||
|
dateKey: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!config.channelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeKey = congratsRedisKey(guild.id, birthday.userId, dateKey);
|
||||||
|
const alreadySent = await context.redis.get(dedupeKey);
|
||||||
|
if (alreadySent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(config.channelId).catch(() => null);
|
||||||
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
|
logger.warn({ guildId: guild.id, channelId: config.channelId }, 'Birthday channel invalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await guild.members.fetch(birthday.userId).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const age =
|
||||||
|
birthday.year !== null
|
||||||
|
? computeAge(birthday.year, birthday.month, birthday.day, config.timezone)
|
||||||
|
: undefined;
|
||||||
|
const content = renderBirthdayMessage(config.message, member, age);
|
||||||
|
|
||||||
|
await (channel as TextChannel).send({ content });
|
||||||
|
|
||||||
|
if (config.roleId) {
|
||||||
|
const me = guild.members.me;
|
||||||
|
const role = guild.roles.cache.get(config.roleId);
|
||||||
|
if (
|
||||||
|
me?.permissions.has(PermissionFlagsBits.ManageRoles) &&
|
||||||
|
role &&
|
||||||
|
role.position < me.roles.highest.position &&
|
||||||
|
!member.roles.cache.has(role.id)
|
||||||
|
) {
|
||||||
|
await member.roles.add(role).catch((error) => {
|
||||||
|
logger.warn({ error, guildId: guild.id, userId: birthday.userId }, 'Failed to assign birthday role');
|
||||||
|
});
|
||||||
|
await scheduleBirthdayRoleExpire(guild.id, birthday.userId, role.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.redis.set(dedupeKey, '1', 'EX', 60 * 60 * 26);
|
||||||
|
logger.info({ guildId: guild.id, userId: birthday.userId }, 'Birthday congratulations sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expireBirthdayRole(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
roleId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||||
|
if (!guild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await guild.members.fetch(userId).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = guild.members.me;
|
||||||
|
const role = guild.roles.cache.get(roleId);
|
||||||
|
if (
|
||||||
|
!me?.permissions.has(PermissionFlagsBits.ManageRoles) ||
|
||||||
|
!role ||
|
||||||
|
role.position >= me.roles.highest.position
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.roles.cache.has(roleId)) {
|
||||||
|
await member.roles.remove(roleId).catch((error) => {
|
||||||
|
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove birthday role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBirthdayCheck(context: BotContext): Promise<void> {
|
||||||
|
const configs = await context.prisma.birthdayConfig.findMany({
|
||||||
|
where: { enabled: true, channelId: { not: null } }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
try {
|
||||||
|
const guild = await context.client.guilds.fetch(config.guildId).catch(() => null);
|
||||||
|
if (!guild) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { month, day, year } = getDatePartsInTimezone(config.timezone);
|
||||||
|
const dateKey = `${year}-${month}-${day}`;
|
||||||
|
const birthdays = await context.prisma.birthday.findMany({
|
||||||
|
where: { guildId: config.guildId, month, day }
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
birthdays.map((birthday) => sendBirthdayCongrats(context, guild, config, birthday, dateKey))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, guildId: config.guildId }, 'Birthday check failed for guild');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/bot/src/modules/components-v2/index.ts
Normal file
4
apps/bot/src/modules/components-v2/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export {
|
||||||
|
handleComponentsV2Interaction,
|
||||||
|
isComponentsV2Interaction
|
||||||
|
} from './interactions.js';
|
||||||
342
apps/bot/src/modules/components-v2/interactions.ts
Normal file
342
apps/bot/src/modules/components-v2/interactions.ts
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import {
|
||||||
|
PermissionFlagsBits,
|
||||||
|
type ButtonInteraction,
|
||||||
|
type GuildMember,
|
||||||
|
type Interaction,
|
||||||
|
type MessageComponentInteraction,
|
||||||
|
type RoleSelectMenuInteraction,
|
||||||
|
type StringSelectMenuInteraction
|
||||||
|
} from 'discord.js';
|
||||||
|
import {
|
||||||
|
decodeComponentCustomId,
|
||||||
|
mapComponentsV2TextFields,
|
||||||
|
parseMessageComponentsV2,
|
||||||
|
t,
|
||||||
|
type ComponentAction,
|
||||||
|
type ComponentV2Source,
|
||||||
|
type Locale,
|
||||||
|
type MessageComponentsV2
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
export function isComponentsV2Interaction(customId: string): boolean {
|
||||||
|
return decodeComponentCustomId(customId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPayload(
|
||||||
|
context: BotContext,
|
||||||
|
source: ComponentV2Source,
|
||||||
|
ref: string,
|
||||||
|
guildId: string
|
||||||
|
): Promise<MessageComponentsV2 | null> {
|
||||||
|
switch (source) {
|
||||||
|
case 'w': {
|
||||||
|
const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } });
|
||||||
|
if (!config || config.guildId !== guildId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseMessageComponentsV2(config.welcomeComponents);
|
||||||
|
}
|
||||||
|
case 'l': {
|
||||||
|
const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } });
|
||||||
|
if (!config || config.guildId !== guildId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseMessageComponentsV2(config.leaveComponents);
|
||||||
|
}
|
||||||
|
case 't': {
|
||||||
|
const tag = await context.prisma.tag.findUnique({ where: { id: ref } });
|
||||||
|
if (!tag || tag.guildId !== guildId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseMessageComponentsV2(tag.components);
|
||||||
|
}
|
||||||
|
case 's': {
|
||||||
|
const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: ref } });
|
||||||
|
if (!schedule || schedule.guildId !== guildId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseMessageComponentsV2(schedule.components);
|
||||||
|
}
|
||||||
|
case 'm': {
|
||||||
|
const binding = await context.prisma.componentMessageBinding.findUnique({ where: { id: ref } });
|
||||||
|
if (!binding || binding.guildId !== guildId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseMessageComponentsV2(binding.payload);
|
||||||
|
}
|
||||||
|
case 'a': {
|
||||||
|
if (ref !== 'singleton') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const config = await context.prisma.botAboutConfig.findUnique({ where: { id: 'singleton' } });
|
||||||
|
return parseMessageComponentsV2(config?.components);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStringSelectAction(
|
||||||
|
payload: MessageComponentsV2,
|
||||||
|
actionId: string,
|
||||||
|
selectedValues: string[]
|
||||||
|
): ComponentAction | null {
|
||||||
|
const direct = payload.actions[actionId];
|
||||||
|
if (direct && selectedValues.length === 0) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer per-option actionId when present.
|
||||||
|
const findOptionAction = (nodes: MessageComponentsV2['components']): ComponentAction | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'string_select') {
|
||||||
|
for (const option of node.options) {
|
||||||
|
if (selectedValues.includes(option.value) && option.actionId) {
|
||||||
|
return payload.actions[option.actionId] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === 'action_row') {
|
||||||
|
const nested = findOptionAction(node.components as MessageComponentsV2['components']);
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === 'container') {
|
||||||
|
const nested = findOptionAction(node.components);
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return findOptionAction(payload.components) ?? payload.actions[actionId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRoleAssignable(member: GuildMember, roleId: string, locale: Locale): Promise<string | null> {
|
||||||
|
const me = member.guild.members.me;
|
||||||
|
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||||
|
return t(locale, 'componentsV2.error.botMissingManageRoles');
|
||||||
|
}
|
||||||
|
const role = member.guild.roles.cache.get(roleId) ?? (await member.guild.roles.fetch(roleId).catch(() => null));
|
||||||
|
if (!role) {
|
||||||
|
return t(locale, 'componentsV2.error.roleNotFound');
|
||||||
|
}
|
||||||
|
if (role.managed) {
|
||||||
|
return t(locale, 'componentsV2.error.roleManaged');
|
||||||
|
}
|
||||||
|
if (role.position >= me.roles.highest.position) {
|
||||||
|
return t(locale, 'componentsV2.error.roleHierarchy');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeAction(
|
||||||
|
interaction: MessageComponentInteraction,
|
||||||
|
action: ComponentAction,
|
||||||
|
locale: Locale,
|
||||||
|
selectedRoleIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const member = interaction.member;
|
||||||
|
if (!member || !interaction.guild || typeof member === 'string') {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guildMember =
|
||||||
|
'roles' in member && typeof (member as GuildMember).roles?.add === 'function'
|
||||||
|
? (member as GuildMember)
|
||||||
|
: await interaction.guild.members.fetch(interaction.user.id);
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case 'NONE':
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
return;
|
||||||
|
case 'EPHEMERAL_REPLY':
|
||||||
|
await interaction.reply({ content: action.content ?? '—', ephemeral: true });
|
||||||
|
return;
|
||||||
|
case 'PUBLIC_REPLY':
|
||||||
|
await interaction.reply({ content: action.content ?? '—' });
|
||||||
|
return;
|
||||||
|
case 'ASSIGN_ROLE': {
|
||||||
|
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
|
||||||
|
if (err) {
|
||||||
|
await interaction.reply({ content: err, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await guildMember.roles.add(action.roleId!);
|
||||||
|
logger.info(
|
||||||
|
{ guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'ASSIGN_ROLE' },
|
||||||
|
'Components V2 role action'
|
||||||
|
);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.role.assigned'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'REMOVE_ROLE': {
|
||||||
|
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
|
||||||
|
if (err) {
|
||||||
|
await interaction.reply({ content: err, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await guildMember.roles.remove(action.roleId!);
|
||||||
|
logger.info(
|
||||||
|
{ guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'REMOVE_ROLE' },
|
||||||
|
'Components V2 role action'
|
||||||
|
);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.role.removed'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'TOGGLE_ROLE': {
|
||||||
|
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
|
||||||
|
if (err) {
|
||||||
|
await interaction.reply({ content: err, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasRole = guildMember.roles.cache.has(action.roleId!);
|
||||||
|
if (hasRole) {
|
||||||
|
await guildMember.roles.remove(action.roleId!);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.role.removed'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await guildMember.roles.add(action.roleId!);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.role.assigned'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
roleId: action.roleId,
|
||||||
|
action: 'TOGGLE_ROLE',
|
||||||
|
removed: hasRole
|
||||||
|
},
|
||||||
|
'Components V2 role action'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ASSIGN_SELECTED_ROLES': {
|
||||||
|
if (selectedRoleIds.length === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.error.noRolesSelected'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assigned: string[] = [];
|
||||||
|
for (const roleId of selectedRoleIds) {
|
||||||
|
const err = await assertRoleAssignable(guildMember, roleId, locale);
|
||||||
|
if (err) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await guildMember.roles.add(roleId);
|
||||||
|
assigned.push(roleId);
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
{ guildId: interaction.guildId, userId: interaction.user.id, roleIds: assigned, action: 'ASSIGN_SELECTED_ROLES' },
|
||||||
|
'Components V2 role action'
|
||||||
|
);
|
||||||
|
if (assigned.length === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.error.roleNotFound'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.role.assignedSelected'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleComponentsV2Interaction(
|
||||||
|
interaction: Interaction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!interaction.isButton() &&
|
||||||
|
!interaction.isStringSelectMenu() &&
|
||||||
|
!interaction.isUserSelectMenu() &&
|
||||||
|
!interaction.isRoleSelectMenu() &&
|
||||||
|
!interaction.isChannelSelectMenu() &&
|
||||||
|
!interaction.isMentionableSelectMenu()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!interaction.inGuild() || !interaction.guildId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodeComponentCustomId(interaction.customId);
|
||||||
|
if (!decoded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await loadPayload(context, decoded.source, decoded.ref, interaction.guildId);
|
||||||
|
if (!payload) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.error.notFound'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let action: ComponentAction | null = null;
|
||||||
|
let selectedRoleIds: string[] = [];
|
||||||
|
|
||||||
|
if (interaction.isStringSelectMenu()) {
|
||||||
|
action = resolveStringSelectAction(payload, decoded.actionId, interaction.values);
|
||||||
|
} else if (interaction.isRoleSelectMenu()) {
|
||||||
|
action = payload.actions[decoded.actionId] ?? null;
|
||||||
|
selectedRoleIds = (interaction as RoleSelectMenuInteraction).values;
|
||||||
|
} else if (interaction.isButton()) {
|
||||||
|
action = payload.actions[decoded.actionId] ?? null;
|
||||||
|
} else {
|
||||||
|
action = payload.actions[decoded.actionId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'componentsV2.error.actionMissing'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply placeholders for reply content when possible.
|
||||||
|
const renderedActions = mapComponentsV2TextFields(payload, (value) => value).actions;
|
||||||
|
const rendered = renderedActions[decoded.actionId] ?? action;
|
||||||
|
|
||||||
|
await executeAction(
|
||||||
|
interaction as MessageComponentInteraction,
|
||||||
|
interaction.isStringSelectMenu() ? action : rendered,
|
||||||
|
locale,
|
||||||
|
selectedRoleIds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for typing convenience in index routing.
|
||||||
|
export type ComponentsV2ButtonInteraction = ButtonInteraction;
|
||||||
|
export type ComponentsV2StringSelectInteraction = StringSelectMenuInteraction;
|
||||||
120
apps/bot/src/modules/components-v2/send.ts
Normal file
120
apps/bot/src/modules/components-v2/send.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
PermissionFlagsBits,
|
||||||
|
type GuildTextBasedChannel,
|
||||||
|
type Message,
|
||||||
|
type TextChannel
|
||||||
|
} from 'discord.js';
|
||||||
|
import {
|
||||||
|
DashboardMessageSendSchema,
|
||||||
|
MessageComponentsV2Schema,
|
||||||
|
WelcomeEmbedSchema,
|
||||||
|
type MessageComponentsV2
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||||
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
export class MessageSendError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSendableChannel(
|
||||||
|
context: BotContext,
|
||||||
|
channelId: string
|
||||||
|
): Promise<GuildTextBasedChannel> {
|
||||||
|
const channel = await context.client.channels.fetch(channelId);
|
||||||
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
|
throw new MessageSendError('invalid_channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = channel.guild.members.me;
|
||||||
|
if (
|
||||||
|
!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) ||
|
||||||
|
!me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel)
|
||||||
|
) {
|
||||||
|
throw new MessageSendError('channel_unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardMessageSendJob = {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
createdById: string;
|
||||||
|
mode: 'EMBED' | 'COMPONENTS_V2';
|
||||||
|
embed?: unknown;
|
||||||
|
components?: unknown;
|
||||||
|
bindingId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardMessageSendResult = {
|
||||||
|
bindingId: string | null;
|
||||||
|
messageId: string | null;
|
||||||
|
channelId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendDashboardMessage(
|
||||||
|
context: BotContext,
|
||||||
|
job: DashboardMessageSendJob
|
||||||
|
): Promise<DashboardMessageSendResult> {
|
||||||
|
await ensureGuild(context.prisma, job.guildId);
|
||||||
|
const input = DashboardMessageSendSchema.parse({
|
||||||
|
channelId: job.channelId,
|
||||||
|
mode: job.mode,
|
||||||
|
embed: job.embed ?? null,
|
||||||
|
components: job.components ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = await assertSendableChannel(context, input.channelId);
|
||||||
|
|
||||||
|
if (input.mode === 'EMBED') {
|
||||||
|
const embedData = WelcomeEmbedSchema.parse(input.embed);
|
||||||
|
const embed = buildEmbedFromPayload(embedData, { renderText: (value) => value });
|
||||||
|
if (!embed) {
|
||||||
|
throw new MessageSendError('content_required');
|
||||||
|
}
|
||||||
|
const message = await (channel as TextChannel).send({ embeds: [embed] });
|
||||||
|
return { bindingId: null, messageId: message.id, channelId: channel.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = MessageComponentsV2Schema.parse(input.components) as MessageComponentsV2;
|
||||||
|
let bindingId = job.bindingId ?? null;
|
||||||
|
|
||||||
|
if (!bindingId) {
|
||||||
|
const binding = await context.prisma.componentMessageBinding.create({
|
||||||
|
data: {
|
||||||
|
guildId: job.guildId,
|
||||||
|
channelId: input.channelId,
|
||||||
|
payload: components,
|
||||||
|
createdById: job.createdById
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bindingId = binding.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildComponentsV2Payload(components, {
|
||||||
|
source: 'm',
|
||||||
|
ref: bindingId
|
||||||
|
});
|
||||||
|
if (!payload) {
|
||||||
|
throw new MessageSendError('content_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: Message = await (channel as TextChannel).send(payload);
|
||||||
|
await context.prisma.componentMessageBinding.update({
|
||||||
|
where: { id: bindingId },
|
||||||
|
data: { messageId: message.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ guildId: job.guildId, channelId: channel.id, messageId: message.id, bindingId },
|
||||||
|
'Dashboard Components V2 message sent'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { bindingId, messageId: message.id, channelId: channel.id };
|
||||||
|
}
|
||||||
98
apps/bot/src/modules/core/about.ts
Normal file
98
apps/bot/src/modules/core/about.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
ABOUT_REDIS_KEY,
|
||||||
|
BotAboutConfigSchema,
|
||||||
|
expandBracketChannelMentions,
|
||||||
|
parseMessageComponentsV2,
|
||||||
|
t,
|
||||||
|
type BotAboutConfig,
|
||||||
|
type Locale,
|
||||||
|
type WelcomeEmbed
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import type { InteractionReplyOptions, MessageCreateOptions } from 'discord.js';
|
||||||
|
import { EmbedBuilder } from 'discord.js';
|
||||||
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||||
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||||
|
import { redis } from '../../redis.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
const NEXUMI_COLOR = 0x6366f1;
|
||||||
|
|
||||||
|
function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw as WelcomeEmbed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAboutConfig(context: BotContext): Promise<BotAboutConfig> {
|
||||||
|
const cached = await redis.get(ABOUT_REDIS_KEY);
|
||||||
|
if (cached) {
|
||||||
|
const parsed = BotAboutConfigSchema.safeParse(JSON.parse(cached));
|
||||||
|
if (parsed.success) {
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await context.prisma.botAboutConfig.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
create: { id: 'singleton' },
|
||||||
|
update: {}
|
||||||
|
});
|
||||||
|
const config = BotAboutConfigSchema.parse({
|
||||||
|
enabled: row.enabled,
|
||||||
|
messageType: row.messageType,
|
||||||
|
content: row.content,
|
||||||
|
embed: parseEmbed(row.embed),
|
||||||
|
components: parseMessageComponentsV2(row.components)
|
||||||
|
});
|
||||||
|
await redis.set(ABOUT_REDIS_KEY, JSON.stringify(config));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAboutReply(locale: Locale): InteractionReplyOptions {
|
||||||
|
return {
|
||||||
|
embeds: [
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'core.about.defaultTitle'))
|
||||||
|
.setDescription(t(locale, 'core.about.defaultBody'))
|
||||||
|
.setColor(NEXUMI_COLOR)
|
||||||
|
.setFooter({ text: 'Nexumi' })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildAboutReply(
|
||||||
|
context: BotContext,
|
||||||
|
locale: Locale
|
||||||
|
): Promise<InteractionReplyOptions | MessageCreateOptions> {
|
||||||
|
const config = await readAboutConfig(context);
|
||||||
|
if (!config.enabled) {
|
||||||
|
return { content: t(locale, 'core.about.disabled'), ephemeral: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderText = (value: string) => expandBracketChannelMentions(value);
|
||||||
|
|
||||||
|
if (config.messageType === 'COMPONENTS_V2') {
|
||||||
|
const payload = buildComponentsV2Payload(config.components, {
|
||||||
|
source: 'a',
|
||||||
|
ref: 'singleton',
|
||||||
|
renderText
|
||||||
|
});
|
||||||
|
if (payload) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.messageType === 'EMBED') {
|
||||||
|
const embed = buildEmbedFromPayload(config.embed, { renderText });
|
||||||
|
if (embed) {
|
||||||
|
return { embeds: [embed] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.messageType === 'TEXT' && config.content?.trim()) {
|
||||||
|
return { content: renderText(config.content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultAboutReply(locale);
|
||||||
|
}
|
||||||
29
apps/bot/src/modules/core/command-definitions.ts
Normal file
29
apps/bot/src/modules/core/command-definitions.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export const helpCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('help'),
|
||||||
|
'core.help.description'
|
||||||
|
).addStringOption((option) =>
|
||||||
|
applyOptionDescription(option.setName('module'), 'core.help.options.module')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const infoCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('info'),
|
||||||
|
'core.info.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aboutCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('about'),
|
||||||
|
'core.about.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const inviteCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('invite'),
|
||||||
|
'core.invite.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const supportCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('support'),
|
||||||
|
'core.support.description'
|
||||||
|
);
|
||||||
170
apps/bot/src/modules/core/commands.ts
Normal file
170
apps/bot/src/modules/core/commands.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { EmbedBuilder } from 'discord.js';
|
||||||
|
import { t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { buildBotInviteUrl, env } from '../../env.js';
|
||||||
|
import {
|
||||||
|
helpCommandData,
|
||||||
|
infoCommandData,
|
||||||
|
aboutCommandData,
|
||||||
|
inviteCommandData,
|
||||||
|
supportCommandData
|
||||||
|
} from './command-definitions.js';
|
||||||
|
import { HELP_MODULES } from './help-catalog.js';
|
||||||
|
import { buildAboutReply } from './about.js';
|
||||||
|
|
||||||
|
const NEXUMI_COLOR = 0x6366f1;
|
||||||
|
const PACKAGE_VERSION = '0.1.0';
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${h}h ${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webuiBase(): string {
|
||||||
|
return env.WEBUI_URL ?? env.PUBLIC_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpCommand: SlashCommand = {
|
||||||
|
data: helpCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const filter = interaction.options.getString('module')?.trim().toLowerCase() ?? null;
|
||||||
|
const modules = filter
|
||||||
|
? HELP_MODULES.filter((module) => module.id === filter)
|
||||||
|
: HELP_MODULES;
|
||||||
|
|
||||||
|
if (modules.length === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'core.help.unknownModule', {
|
||||||
|
modules: HELP_MODULES.map((module) => module.id).join(', ')
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = modules.map((module) =>
|
||||||
|
tf(locale, 'core.help.moduleLine', {
|
||||||
|
module: t(locale, `core.module.${module.id}`),
|
||||||
|
commands: module.commands.map((name) => `\`/${name}\``).join(', ')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Discord embed description limit is 4096; split across fields if needed.
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'core.help.title'))
|
||||||
|
.setDescription(t(locale, 'core.help.subtitle'))
|
||||||
|
.setColor(NEXUMI_COLOR)
|
||||||
|
.setFooter({
|
||||||
|
text: tf(locale, 'core.help.footer', { webui: webuiBase() })
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunkSize = 8;
|
||||||
|
for (let i = 0; i < lines.length; i += chunkSize) {
|
||||||
|
const chunk = lines.slice(i, i + chunkSize);
|
||||||
|
embed.addFields({
|
||||||
|
name: '\u200b',
|
||||||
|
value: chunk.join('\n')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoCommand: SlashCommand = {
|
||||||
|
data: infoCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const shardId = context.client.shard?.ids[0] ?? 0;
|
||||||
|
const inviteUrl = buildBotInviteUrl();
|
||||||
|
const base = webuiBase();
|
||||||
|
const linkLines = [
|
||||||
|
`[${t(locale, 'core.info.link.dashboard')}](${base})`,
|
||||||
|
`[${t(locale, 'core.info.link.invite')}](${inviteUrl})`,
|
||||||
|
`[${t(locale, 'core.info.link.status')}](${base.replace(/\/$/, '')}/status)`
|
||||||
|
];
|
||||||
|
if (env.SUPPORT_SERVER_URL) {
|
||||||
|
linkLines.push(`[${t(locale, 'core.info.link.support')}](${env.SUPPORT_SERVER_URL})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'core.info.title'))
|
||||||
|
.setColor(NEXUMI_COLOR)
|
||||||
|
.addFields(
|
||||||
|
{ name: t(locale, 'core.info.version'), value: PACKAGE_VERSION, inline: true },
|
||||||
|
{
|
||||||
|
name: t(locale, 'core.info.uptime'),
|
||||||
|
value: formatUptime(process.uptime()),
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{ name: t(locale, 'core.info.shard'), value: String(shardId), inline: true },
|
||||||
|
{
|
||||||
|
name: t(locale, 'core.info.guilds'),
|
||||||
|
value: String(context.client.guilds.cache.size),
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(locale, 'core.info.latency'),
|
||||||
|
value: `${context.client.ws.ping} ms`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{ name: t(locale, 'core.info.links'), value: linkLines.join('\n') }
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'Nexumi' });
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const aboutCommand: SlashCommand = {
|
||||||
|
data: aboutCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const reply = await buildAboutReply(context, locale);
|
||||||
|
await interaction.reply(reply as import('discord.js').InteractionReplyOptions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inviteCommand: SlashCommand = {
|
||||||
|
data: inviteCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const inviteUrl = buildBotInviteUrl();
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'core.invite.title'))
|
||||||
|
.setDescription(`${t(locale, 'core.invite.body')}\n${inviteUrl}`)
|
||||||
|
.setColor(NEXUMI_COLOR);
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const supportCommand: SlashCommand = {
|
||||||
|
data: supportCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!env.SUPPORT_SERVER_URL) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'core.support.missing'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'core.support.title'))
|
||||||
|
.setDescription(`${t(locale, 'core.support.body')}\n${env.SUPPORT_SERVER_URL}`)
|
||||||
|
.setColor(NEXUMI_COLOR);
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const coreCommands: SlashCommand[] = [
|
||||||
|
helpCommand,
|
||||||
|
infoCommand,
|
||||||
|
aboutCommand,
|
||||||
|
inviteCommand,
|
||||||
|
supportCommand
|
||||||
|
];
|
||||||
93
apps/bot/src/modules/core/help-catalog.ts
Normal file
93
apps/bot/src/modules/core/help-catalog.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/** Module id → top-level slash command names for `/help`. */
|
||||||
|
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
|
||||||
|
{ id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] },
|
||||||
|
{
|
||||||
|
id: 'moderation',
|
||||||
|
commands: [
|
||||||
|
'ban',
|
||||||
|
'unban',
|
||||||
|
'kick',
|
||||||
|
'timeout',
|
||||||
|
'untimeout',
|
||||||
|
'warn',
|
||||||
|
'purge',
|
||||||
|
'slowmode',
|
||||||
|
'lock',
|
||||||
|
'unlock',
|
||||||
|
'nick',
|
||||||
|
'case',
|
||||||
|
'modnote'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'automod', commands: ['automod'] },
|
||||||
|
{ id: 'welcome', commands: ['welcome'] },
|
||||||
|
{ id: 'verification', commands: ['verify'] },
|
||||||
|
{ id: 'leveling', commands: ['rank', 'leaderboard', 'xp'] },
|
||||||
|
{
|
||||||
|
id: 'economy',
|
||||||
|
commands: [
|
||||||
|
'balance',
|
||||||
|
'daily',
|
||||||
|
'weekly',
|
||||||
|
'work',
|
||||||
|
'pay',
|
||||||
|
'gamble',
|
||||||
|
'slots',
|
||||||
|
'blackjack',
|
||||||
|
'coinflip',
|
||||||
|
'shop',
|
||||||
|
'inventory',
|
||||||
|
'eco'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'utility',
|
||||||
|
commands: [
|
||||||
|
'userinfo',
|
||||||
|
'serverinfo',
|
||||||
|
'roleinfo',
|
||||||
|
'channelinfo',
|
||||||
|
'avatar',
|
||||||
|
'banner',
|
||||||
|
'poll',
|
||||||
|
'remindme',
|
||||||
|
'reminders',
|
||||||
|
'afk',
|
||||||
|
'emoji',
|
||||||
|
'sticker',
|
||||||
|
'timestamp',
|
||||||
|
'snipe',
|
||||||
|
'editsnipe',
|
||||||
|
'embed'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fun',
|
||||||
|
commands: [
|
||||||
|
'8ball',
|
||||||
|
'dice',
|
||||||
|
'flip',
|
||||||
|
'rps',
|
||||||
|
'choose',
|
||||||
|
'trivia',
|
||||||
|
'tictactoe',
|
||||||
|
'connect4',
|
||||||
|
'hangman',
|
||||||
|
'meme',
|
||||||
|
'cat',
|
||||||
|
'dog'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ id: 'giveaways', commands: ['giveaway'] },
|
||||||
|
{ id: 'tickets', commands: ['ticket'] },
|
||||||
|
{ id: 'selfroles', commands: ['selfroles'] },
|
||||||
|
{ id: 'tags', commands: ['tag'] },
|
||||||
|
{ id: 'starboard', commands: ['starboard'] },
|
||||||
|
{ id: 'suggestions', commands: ['suggest', 'suggestion'] },
|
||||||
|
{ id: 'birthdays', commands: ['birthday'] },
|
||||||
|
{ id: 'tempvoice', commands: ['voice'] },
|
||||||
|
{ id: 'stats', commands: ['stats', 'invites'] },
|
||||||
|
{ id: 'feeds', commands: ['feeds'] },
|
||||||
|
{ id: 'scheduler', commands: ['schedule', 'announce'] },
|
||||||
|
{ id: 'guildbackup', commands: ['backup'] }
|
||||||
|
];
|
||||||
2
apps/bot/src/modules/core/index.ts
Normal file
2
apps/bot/src/modules/core/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { coreCommands } from './commands.js';
|
||||||
|
export { HELP_MODULES } from './help-catalog.js';
|
||||||
50
apps/bot/src/modules/economy/blackjack-buttons.ts
Normal file
50
apps/bot/src/modules/economy/blackjack-buttons.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ButtonInteraction } from 'discord.js';
|
||||||
|
import { t } from '@nexumi/shared';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
BLACKJACK_BUTTON_PREFIX,
|
||||||
|
hitBlackjack,
|
||||||
|
parseBlackjackButtonCustomId,
|
||||||
|
standBlackjack
|
||||||
|
} from './games.js';
|
||||||
|
import { EconomyError } from './service.js';
|
||||||
|
|
||||||
|
export function isBlackjackButton(customId: string): boolean {
|
||||||
|
return customId.startsWith(BLACKJACK_BUTTON_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleBlackjackButton(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<void> {
|
||||||
|
const parsed = parseBlackjackButtonCustomId(interaction.customId);
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
|
||||||
|
if (!parsed || !interaction.guildId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'economy.blackjack.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.user.id !== parsed.userId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'economy.blackjack.notOwner'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (parsed.action === 'hit') {
|
||||||
|
const result = await hitBlackjack(context, parsed.guildId, parsed.userId, locale);
|
||||||
|
await interaction.update({ embeds: [result.embed], components: result.components });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await standBlackjack(context, parsed.guildId, parsed.userId, locale);
|
||||||
|
await interaction.update({ embeds: [result.embed], components: [] });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof EconomyError && error.code === 'not_found') {
|
||||||
|
await interaction.reply({ content: t(locale, 'economy.blackjack.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/bot/src/modules/economy/command-definitions.ts
Normal file
121
apps/bot/src/modules/economy/command-definitions.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
type SlashCommandIntegerOption,
|
||||||
|
type SlashCommandStringOption,
|
||||||
|
type SlashCommandUserOption
|
||||||
|
} from 'discord.js';
|
||||||
|
import {
|
||||||
|
applyCommandDescription,
|
||||||
|
applyOptionDescription,
|
||||||
|
localizedChoice,
|
||||||
|
type CommandLocaleKey
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
|
||||||
|
function amountOpt(o: SlashCommandIntegerOption, key: CommandLocaleKey = 'economy.common.options.amount') {
|
||||||
|
return applyOptionDescription(o.setName('amount').setRequired(true).setMinValue(1), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userOpt(o: SlashCommandUserOption, key: CommandLocaleKey = 'economy.common.options.user') {
|
||||||
|
return applyOptionDescription(o.setName('user').setRequired(true), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalUserOpt(o: SlashCommandUserOption) {
|
||||||
|
return applyOptionDescription(o.setName('user'), 'economy.common.options.userOptional');
|
||||||
|
}
|
||||||
|
|
||||||
|
function choiceOpt(o: SlashCommandStringOption) {
|
||||||
|
return applyOptionDescription(o.setName('choice').setRequired(true), 'economy.coinflip.options.choice').addChoices(
|
||||||
|
localizedChoice('economy.choice.heads', 'heads'),
|
||||||
|
localizedChoice('economy.choice.tails', 'tails')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const balanceCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('balance'),
|
||||||
|
'economy.balance.description'
|
||||||
|
).addUserOption((o) => optionalUserOpt(o));
|
||||||
|
|
||||||
|
export const dailyCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('daily'),
|
||||||
|
'economy.daily.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const weeklyCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('weekly'),
|
||||||
|
'economy.weekly.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const workCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('work'),
|
||||||
|
'economy.work.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const payCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('pay'),
|
||||||
|
'economy.pay.description'
|
||||||
|
)
|
||||||
|
.addUserOption((o) => userOpt(o))
|
||||||
|
.addIntegerOption((o) => amountOpt(o));
|
||||||
|
|
||||||
|
export const gambleCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('gamble'),
|
||||||
|
'economy.gamble.description'
|
||||||
|
).addIntegerOption((o) => amountOpt(o));
|
||||||
|
|
||||||
|
export const slotsCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('slots'),
|
||||||
|
'economy.slots.description'
|
||||||
|
).addIntegerOption((o) => amountOpt(o));
|
||||||
|
|
||||||
|
export const blackjackCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('blackjack'),
|
||||||
|
'economy.blackjack.description'
|
||||||
|
).addIntegerOption((o) => amountOpt(o));
|
||||||
|
|
||||||
|
export const coinflipCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('coinflip'),
|
||||||
|
'economy.coinflip.description'
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) => amountOpt(o))
|
||||||
|
.addStringOption((o) => choiceOpt(o));
|
||||||
|
|
||||||
|
export const shopCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('shop'),
|
||||||
|
'economy.shop.description'
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('view'), 'economy.shop.view.description')
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('buy'), 'economy.shop.buy.description').addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('item_id').setRequired(true), 'economy.shop.options.item_id')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const inventoryCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('inventory'),
|
||||||
|
'economy.inventory.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ecoCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('eco'),
|
||||||
|
'economy.eco.description'
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('leaderboard'), 'economy.eco.leaderboard.description')
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('give'), 'economy.eco.give.description')
|
||||||
|
.addUserOption((o) => userOpt(o, 'economy.common.options.target'))
|
||||||
|
.addIntegerOption((o) => amountOpt(o))
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('remove'), 'economy.eco.remove.description')
|
||||||
|
.addUserOption((o) => userOpt(o, 'economy.common.options.target'))
|
||||||
|
.addIntegerOption((o) => amountOpt(o))
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('reset'), 'economy.eco.reset.description').addUserOption((o) =>
|
||||||
|
userOpt(o, 'economy.common.options.target')
|
||||||
|
)
|
||||||
|
);
|
||||||
497
apps/bot/src/modules/economy/commands.ts
Normal file
497
apps/bot/src/modules/economy/commands.ts
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||||
|
import { formatCurrency, randomInt, t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import {
|
||||||
|
balanceCommandData,
|
||||||
|
blackjackCommandData,
|
||||||
|
coinflipCommandData,
|
||||||
|
dailyCommandData,
|
||||||
|
ecoCommandData,
|
||||||
|
gambleCommandData,
|
||||||
|
inventoryCommandData,
|
||||||
|
payCommandData,
|
||||||
|
shopCommandData,
|
||||||
|
slotsCommandData,
|
||||||
|
weeklyCommandData,
|
||||||
|
workCommandData
|
||||||
|
} from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
adminGiveBalance,
|
||||||
|
adminRemoveBalance,
|
||||||
|
adminResetEconomy,
|
||||||
|
assertEconomyEnabled,
|
||||||
|
buyShopItem,
|
||||||
|
claimDaily,
|
||||||
|
claimWeekly,
|
||||||
|
claimWork,
|
||||||
|
EconomyError,
|
||||||
|
formatCooldownSeconds,
|
||||||
|
getBalance,
|
||||||
|
getDailyCooldownRemaining,
|
||||||
|
getEconomyConfig,
|
||||||
|
getInventory,
|
||||||
|
getLeaderboard,
|
||||||
|
getWeeklyCooldownRemaining,
|
||||||
|
getWorkCooldownRemaining,
|
||||||
|
listShopItems,
|
||||||
|
transferBalance
|
||||||
|
} from './service.js';
|
||||||
|
import { playCoinflipBet, playGamble, playSlots, startBlackjack, type CoinflipChoice } from './games.js';
|
||||||
|
|
||||||
|
async function ensureGuildCommand(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!interaction.inGuild()) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return interaction.guildId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEconomyError(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
error: unknown,
|
||||||
|
extras?: Record<string, string | number>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(error instanceof EconomyError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `economy.error.${error.code}` as const;
|
||||||
|
const content =
|
||||||
|
error.code === 'cooldown' && extras?.seconds !== undefined
|
||||||
|
? tf(locale, 'economy.error.cooldown', { seconds: extras.seconds })
|
||||||
|
: t(locale, key);
|
||||||
|
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(locale: 'de' | 'en', amount: number, symbol: string, name: string): string {
|
||||||
|
return formatCurrency(amount, symbol, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceCommand: SlashCommand = {
|
||||||
|
data: balanceCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await assertEconomyEnabled(context.prisma, guildId);
|
||||||
|
const target = interaction.options.getUser('user') ?? interaction.user;
|
||||||
|
const balance = await getBalance(context.prisma, guildId, target.id);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.balance.result', {
|
||||||
|
user: `<@${target.id}>`,
|
||||||
|
amount: formatMoney(locale, balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dailyCommand: SlashCommand = {
|
||||||
|
data: dailyCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const result = await claimDaily(context.prisma, guildId, interaction.user.id);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.daily.success', {
|
||||||
|
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof EconomyError && error.code === 'cooldown') {
|
||||||
|
const seconds = formatCooldownSeconds(
|
||||||
|
await getDailyCooldownRemaining(context.prisma, guildId, interaction.user.id)
|
||||||
|
);
|
||||||
|
await handleEconomyError(interaction, locale, error, { seconds });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const weeklyCommand: SlashCommand = {
|
||||||
|
data: weeklyCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const result = await claimWeekly(context.prisma, guildId, interaction.user.id);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.weekly.success', {
|
||||||
|
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof EconomyError && error.code === 'cooldown') {
|
||||||
|
const seconds = formatCooldownSeconds(
|
||||||
|
await getWeeklyCooldownRemaining(context.prisma, guildId, interaction.user.id)
|
||||||
|
);
|
||||||
|
await handleEconomyError(interaction, locale, error, { seconds });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const workCommand: SlashCommand = {
|
||||||
|
data: workCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const earned = randomInt(config.workMin, config.workMax);
|
||||||
|
const result = await claimWork(context.prisma, guildId, interaction.user.id, earned);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.work.success', {
|
||||||
|
amount: formatMoney(locale, result.amount, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof EconomyError && error.code === 'cooldown') {
|
||||||
|
const seconds = formatCooldownSeconds(
|
||||||
|
await getWorkCooldownRemaining(context.prisma, guildId, interaction.user.id)
|
||||||
|
);
|
||||||
|
await handleEconomyError(interaction, locale, error, { seconds });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const payCommand: SlashCommand = {
|
||||||
|
data: payCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const target = interaction.options.getUser('user', true);
|
||||||
|
const amount = interaction.options.getInteger('amount', true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertEconomyEnabled(context.prisma, guildId);
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const { sender } = await transferBalance(
|
||||||
|
context.prisma,
|
||||||
|
guildId,
|
||||||
|
interaction.user.id,
|
||||||
|
target.id,
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.pay.success', {
|
||||||
|
user: `<@${target.id}>`,
|
||||||
|
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, sender.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gambleCommand: SlashCommand = {
|
||||||
|
data: gambleCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const amount = interaction.options.getInteger('amount', true);
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const result = await playGamble(context, guildId, interaction.user.id, amount);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, result.won ? 'economy.gamble.win' : 'economy.gamble.lose', {
|
||||||
|
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotsCommand: SlashCommand = {
|
||||||
|
data: slotsCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const amount = interaction.options.getInteger('amount', true);
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const result = await playSlots(context, guildId, interaction.user.id, amount);
|
||||||
|
const key = result.payout > 0 ? 'economy.slots.win' : 'economy.slots.lose';
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, key, {
|
||||||
|
reels: result.reels.join(' | '),
|
||||||
|
payout: formatMoney(locale, result.payout, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const blackjackCommand: SlashCommand = {
|
||||||
|
data: blackjackCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const amount = interaction.options.getInteger('amount', true);
|
||||||
|
try {
|
||||||
|
const { embed, components } = await startBlackjack(
|
||||||
|
context,
|
||||||
|
guildId,
|
||||||
|
interaction.user.id,
|
||||||
|
amount,
|
||||||
|
locale
|
||||||
|
);
|
||||||
|
await interaction.reply({ embeds: [embed], components });
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const coinflipCommand: SlashCommand = {
|
||||||
|
data: coinflipCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const amount = interaction.options.getInteger('amount', true);
|
||||||
|
const choice = interaction.options.getString('choice', true) as CoinflipChoice;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const result = await playCoinflipBet(context, guildId, interaction.user.id, amount, choice);
|
||||||
|
const choiceLabel = t(locale, `economy.choice.${choice}`);
|
||||||
|
const resultLabel = t(locale, `economy.choice.${result.result}`);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, result.won ? 'economy.coinflip.win' : 'economy.coinflip.lose', {
|
||||||
|
choice: choiceLabel,
|
||||||
|
result: resultLabel,
|
||||||
|
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, result.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shopCommand: SlashCommand = {
|
||||||
|
data: shopCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
|
||||||
|
if (sub === 'view') {
|
||||||
|
const items = await listShopItems(context.prisma, guildId);
|
||||||
|
const content =
|
||||||
|
items.length === 0
|
||||||
|
? t(locale, 'economy.shop.empty')
|
||||||
|
: items
|
||||||
|
.map((item) =>
|
||||||
|
tf(locale, 'economy.shop.itemLine', {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
price: formatMoney(locale, item.price, config.currencySymbol, config.currencyName),
|
||||||
|
stock: item.stock === null ? '∞' : String(item.stock),
|
||||||
|
description: item.description ?? t(locale, 'economy.shop.noDescription')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = interaction.options.getString('item_id', true);
|
||||||
|
const purchase = await buyShopItem(context, guildId, interaction.user.id, itemId);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.shop.purchased', {
|
||||||
|
name: purchase.item.name,
|
||||||
|
balance: formatMoney(locale, purchase.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inventoryCommand: SlashCommand = {
|
||||||
|
data: inventoryCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await getInventory(context.prisma, guildId, interaction.user.id);
|
||||||
|
const content =
|
||||||
|
entries.length === 0
|
||||||
|
? t(locale, 'economy.inventory.empty')
|
||||||
|
: entries
|
||||||
|
.map((entry) =>
|
||||||
|
tf(locale, 'economy.inventory.itemLine', {
|
||||||
|
name: entry.item.name,
|
||||||
|
quantity: entry.quantity
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ecoCommand: SlashCommand = {
|
||||||
|
data: ecoCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuildCommand(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (sub === 'leaderboard') {
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const rows = await getLeaderboard(context.prisma, guildId);
|
||||||
|
const content =
|
||||||
|
rows.length === 0
|
||||||
|
? t(locale, 'economy.leaderboard.empty')
|
||||||
|
: rows
|
||||||
|
.map((row, index) =>
|
||||||
|
tf(locale, 'economy.leaderboard.line', {
|
||||||
|
rank: index + 1,
|
||||||
|
user: `<@${row.userId}>`,
|
||||||
|
amount: formatMoney(locale, row.balance, config.currencySymbol, config.currencyName)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = interaction.options.getUser('user', true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
|
||||||
|
if (sub === 'give') {
|
||||||
|
const amount = interaction.options.getInteger('amount', true);
|
||||||
|
const updated = await adminGiveBalance(context.prisma, guildId, target.id, amount);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.admin.give', {
|
||||||
|
user: `<@${target.id}>`,
|
||||||
|
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, updated.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const amount = interaction.options.getInteger('amount', true);
|
||||||
|
const updated = await adminRemoveBalance(context.prisma, guildId, target.id, amount);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.admin.remove', {
|
||||||
|
user: `<@${target.id}>`,
|
||||||
|
amount: formatMoney(locale, amount, config.currencySymbol, config.currencyName),
|
||||||
|
balance: formatMoney(locale, updated.balance, config.currencySymbol, config.currencyName)
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminResetEconomy(context.prisma, guildId, target.id);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'economy.admin.reset', {
|
||||||
|
user: `<@${target.id}>`
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await handleEconomyError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const economyCommands: SlashCommand[] = [
|
||||||
|
balanceCommand,
|
||||||
|
dailyCommand,
|
||||||
|
weeklyCommand,
|
||||||
|
workCommand,
|
||||||
|
payCommand,
|
||||||
|
gambleCommand,
|
||||||
|
slotsCommand,
|
||||||
|
blackjackCommand,
|
||||||
|
coinflipCommand,
|
||||||
|
shopCommand,
|
||||||
|
inventoryCommand,
|
||||||
|
ecoCommand
|
||||||
|
];
|
||||||
420
apps/bot/src/modules/economy/games.ts
Normal file
420
apps/bot/src/modules/economy/games.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type APIEmbed } from 'discord.js';
|
||||||
|
import { formatCurrency, randomInt } from '@nexumi/shared';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import type { Locale } from '@nexumi/shared';
|
||||||
|
import { tf } from '@nexumi/shared';
|
||||||
|
import {
|
||||||
|
addBalance,
|
||||||
|
assertEconomyEnabled,
|
||||||
|
deductBalance,
|
||||||
|
EconomyError,
|
||||||
|
getEconomyConfig
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
|
const BLACKJACK_TTL_SECONDS = 300;
|
||||||
|
const BLACKJACK_KEY_PREFIX = 'eco:bj:';
|
||||||
|
export const BLACKJACK_BUTTON_PREFIX = 'eco:bj:';
|
||||||
|
|
||||||
|
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] as const;
|
||||||
|
const SUITS = ['H', 'D', 'C', 'S'] as const;
|
||||||
|
|
||||||
|
type Card = `${(typeof RANKS)[number]}${(typeof SUITS)[number]}`;
|
||||||
|
|
||||||
|
export type BlackjackState = {
|
||||||
|
userId: string;
|
||||||
|
guildId: string;
|
||||||
|
bet: number;
|
||||||
|
deck: Card[];
|
||||||
|
playerHand: Card[];
|
||||||
|
dealerHand: Card[];
|
||||||
|
finished: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLOT_SYMBOLS = ['🍒', '🍋', '🔔', '⭐', '💎', '7️⃣'] as const;
|
||||||
|
|
||||||
|
function buildDeck(): Card[] {
|
||||||
|
const deck: Card[] = [];
|
||||||
|
for (const suit of SUITS) {
|
||||||
|
for (const rank of RANKS) {
|
||||||
|
deck.push(`${rank}${suit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = deck.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = randomInt(0, i);
|
||||||
|
[deck[i], deck[j]] = [deck[j]!, deck[i]!];
|
||||||
|
}
|
||||||
|
return deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardValue(card: Card): number {
|
||||||
|
const rank = card.slice(0, card.length - 1);
|
||||||
|
if (rank === 'A') {
|
||||||
|
return 11;
|
||||||
|
}
|
||||||
|
if (rank === 'K' || rank === 'Q' || rank === 'J') {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
return Number(rank);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handValue(hand: Card[]): number {
|
||||||
|
let total = hand.reduce((sum, card) => sum + cardValue(card), 0);
|
||||||
|
let aces = hand.filter((card) => card.startsWith('A')).length;
|
||||||
|
while (total > 21 && aces > 0) {
|
||||||
|
total -= 10;
|
||||||
|
aces -= 1;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlackjack(hand: Card[]): boolean {
|
||||||
|
return hand.length === 2 && handValue(hand) === 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHand(hand: Card[], hideFirst = false): string {
|
||||||
|
if (hideFirst && hand.length > 0) {
|
||||||
|
return `🂠 ${hand.slice(1).join(' ')}`;
|
||||||
|
}
|
||||||
|
return hand.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function blackjackKey(guildId: string, userId: string): string {
|
||||||
|
return `${BLACKJACK_KEY_PREFIX}${guildId}:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBlackjackState(context: BotContext, state: BlackjackState): Promise<void> {
|
||||||
|
await context.redis.set(
|
||||||
|
blackjackKey(state.guildId, state.userId),
|
||||||
|
JSON.stringify(state),
|
||||||
|
'EX',
|
||||||
|
BLACKJACK_TTL_SECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBlackjackState(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<BlackjackState | null> {
|
||||||
|
const raw = await context.redis.get(blackjackKey(guildId, userId));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(raw) as BlackjackState;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBlackjackState(context: BotContext, guildId: string, userId: string): Promise<void> {
|
||||||
|
await context.redis.del(blackjackKey(guildId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCard(state: BlackjackState): Card {
|
||||||
|
const card = state.deck.pop();
|
||||||
|
if (!card) {
|
||||||
|
throw new Error('Blackjack deck empty');
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBlackjackButtons(locale: Locale, guildId: string, userId: string): ActionRowBuilder<ButtonBuilder> {
|
||||||
|
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${BLACKJACK_BUTTON_PREFIX}hit:${guildId}:${userId}`)
|
||||||
|
.setLabel(tf(locale, 'economy.blackjack.button.hit', {}))
|
||||||
|
.setStyle(ButtonStyle.Primary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${BLACKJACK_BUTTON_PREFIX}stand:${guildId}:${userId}`)
|
||||||
|
.setLabel(tf(locale, 'economy.blackjack.button.stand', {}))
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBlackjackEmbed(
|
||||||
|
locale: Locale,
|
||||||
|
state: BlackjackState,
|
||||||
|
config: { currencySymbol: string; currencyName: string },
|
||||||
|
revealDealer: boolean,
|
||||||
|
outcome?: 'win' | 'lose' | 'push' | 'blackjack'
|
||||||
|
): APIEmbed {
|
||||||
|
const playerTotal = handValue(state.playerHand);
|
||||||
|
const dealerTotal = revealDealer ? handValue(state.dealerHand) : handValue(state.dealerHand.slice(1));
|
||||||
|
|
||||||
|
let description = tf(locale, 'economy.blackjack.hand', {
|
||||||
|
playerHand: formatHand(state.playerHand),
|
||||||
|
playerTotal: String(playerTotal),
|
||||||
|
dealerHand: formatHand(state.dealerHand, !revealDealer),
|
||||||
|
dealerTotal: revealDealer ? String(dealerTotal) : '?',
|
||||||
|
bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outcome) {
|
||||||
|
const outcomeKey = `economy.blackjack.outcome.${outcome}` as const;
|
||||||
|
description += `\n\n${tf(locale, outcomeKey, {
|
||||||
|
bet: formatCurrency(state.bet, config.currencySymbol, config.currencyName)
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: tf(locale, 'economy.blackjack.title', {}),
|
||||||
|
description,
|
||||||
|
color: outcome === 'win' || outcome === 'blackjack' ? 0x22c55e : outcome === 'push' ? 0xeab308 : 0x6366f1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishBlackjack(
|
||||||
|
context: BotContext,
|
||||||
|
state: BlackjackState
|
||||||
|
): Promise<{ state: BlackjackState; outcome: 'win' | 'lose' | 'push' | 'blackjack'; payout: number }> {
|
||||||
|
const playerTotal = handValue(state.playerHand);
|
||||||
|
const dealerTotal = handValue(state.dealerHand);
|
||||||
|
|
||||||
|
let outcome: 'win' | 'lose' | 'push' | 'blackjack';
|
||||||
|
let payout = 0;
|
||||||
|
|
||||||
|
if (playerTotal > 21) {
|
||||||
|
outcome = 'lose';
|
||||||
|
} else if (isBlackjack(state.playerHand) && !isBlackjack(state.dealerHand)) {
|
||||||
|
outcome = 'blackjack';
|
||||||
|
payout = Math.floor(state.bet * 2.5);
|
||||||
|
} else if (dealerTotal > 21 || playerTotal > dealerTotal) {
|
||||||
|
outcome = 'win';
|
||||||
|
payout = state.bet * 2;
|
||||||
|
} else if (playerTotal === dealerTotal) {
|
||||||
|
outcome = 'push';
|
||||||
|
payout = state.bet;
|
||||||
|
} else {
|
||||||
|
outcome = 'lose';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payout > 0) {
|
||||||
|
await addBalance(context.prisma, state.guildId, state.userId, payout);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.finished = true;
|
||||||
|
await deleteBlackjackState(context, state.guildId, state.userId);
|
||||||
|
|
||||||
|
return { state, outcome, payout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBlackjack(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
bet: number,
|
||||||
|
locale: Locale
|
||||||
|
): Promise<{ embed: APIEmbed; components: ActionRowBuilder<ButtonBuilder>[] }> {
|
||||||
|
await assertEconomyEnabled(context.prisma, guildId);
|
||||||
|
if (!Number.isInteger(bet) || bet <= 0) {
|
||||||
|
throw new EconomyError('invalid_amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await loadBlackjackState(context, guildId, userId);
|
||||||
|
if (existing && !existing.finished) {
|
||||||
|
throw new EconomyError('game_in_progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deductBalance(context.prisma, guildId, userId, bet);
|
||||||
|
|
||||||
|
const state: BlackjackState = {
|
||||||
|
userId,
|
||||||
|
guildId,
|
||||||
|
bet,
|
||||||
|
deck: buildDeck(),
|
||||||
|
playerHand: [],
|
||||||
|
dealerHand: [],
|
||||||
|
finished: false
|
||||||
|
};
|
||||||
|
|
||||||
|
state.playerHand.push(drawCard(state), drawCard(state));
|
||||||
|
state.dealerHand.push(drawCard(state), drawCard(state));
|
||||||
|
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
|
||||||
|
if (isBlackjack(state.playerHand) || isBlackjack(state.dealerHand)) {
|
||||||
|
const { state: finished, outcome } = await finishBlackjack(context, state);
|
||||||
|
return {
|
||||||
|
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
|
||||||
|
components: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveBlackjackState(context, state);
|
||||||
|
return {
|
||||||
|
embed: buildBlackjackEmbed(locale, state, config, false),
|
||||||
|
components: [buildBlackjackButtons(locale, guildId, userId)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hitBlackjack(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
locale: Locale
|
||||||
|
): Promise<{ embed: APIEmbed; components: ActionRowBuilder<ButtonBuilder>[]; finished: boolean }> {
|
||||||
|
const state = await loadBlackjackState(context, guildId, userId);
|
||||||
|
if (!state || state.finished) {
|
||||||
|
throw new EconomyError('not_found');
|
||||||
|
}
|
||||||
|
if (state.userId !== userId) {
|
||||||
|
throw new EconomyError('not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.playerHand.push(drawCard(state));
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
|
||||||
|
if (handValue(state.playerHand) > 21) {
|
||||||
|
const { state: finished, outcome } = await finishBlackjack(context, state);
|
||||||
|
return {
|
||||||
|
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
|
||||||
|
components: [],
|
||||||
|
finished: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveBlackjackState(context, state);
|
||||||
|
return {
|
||||||
|
embed: buildBlackjackEmbed(locale, state, config, false),
|
||||||
|
components: [buildBlackjackButtons(locale, guildId, userId)],
|
||||||
|
finished: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function standBlackjack(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
locale: Locale
|
||||||
|
): Promise<{ embed: APIEmbed; finished: true }> {
|
||||||
|
const state = await loadBlackjackState(context, guildId, userId);
|
||||||
|
if (!state || state.finished) {
|
||||||
|
throw new EconomyError('not_found');
|
||||||
|
}
|
||||||
|
if (state.userId !== userId) {
|
||||||
|
throw new EconomyError('not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
while (handValue(state.dealerHand) < 17) {
|
||||||
|
state.dealerHand.push(drawCard(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getEconomyConfig(context.prisma, guildId);
|
||||||
|
const { state: finished, outcome } = await finishBlackjack(context, state);
|
||||||
|
return {
|
||||||
|
embed: buildBlackjackEmbed(locale, finished, config, true, outcome),
|
||||||
|
finished: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playGamble(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<{ won: boolean; balance: number }> {
|
||||||
|
await assertEconomyEnabled(context.prisma, guildId);
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
|
throw new EconomyError('invalid_amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deductBalance(context.prisma, guildId, userId, amount);
|
||||||
|
const won = Math.random() < 0.5;
|
||||||
|
if (won) {
|
||||||
|
const updated = await addBalance(context.prisma, guildId, userId, amount * 2);
|
||||||
|
return { won: true, balance: updated.balance };
|
||||||
|
}
|
||||||
|
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
|
||||||
|
where: { guildId_userId: { guildId, userId } }
|
||||||
|
});
|
||||||
|
return { won: false, balance: member.balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playSlots(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<{ reels: string[]; multiplier: number; payout: number; balance: number }> {
|
||||||
|
await assertEconomyEnabled(context.prisma, guildId);
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
|
throw new EconomyError('invalid_amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deductBalance(context.prisma, guildId, userId, amount);
|
||||||
|
|
||||||
|
const reels = [
|
||||||
|
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!,
|
||||||
|
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!,
|
||||||
|
SLOT_SYMBOLS[randomInt(0, SLOT_SYMBOLS.length - 1)]!
|
||||||
|
];
|
||||||
|
|
||||||
|
let multiplier = 0;
|
||||||
|
if (reels[0] === reels[1] && reels[1] === reels[2]) {
|
||||||
|
multiplier = reels[0] === '7️⃣' ? 10 : reels[0] === '💎' ? 5 : 3;
|
||||||
|
} else if (reels[0] === reels[1] || reels[1] === reels[2] || reels[0] === reels[2]) {
|
||||||
|
multiplier = 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payout = Math.floor(amount * multiplier);
|
||||||
|
let balance: number;
|
||||||
|
if (payout > 0) {
|
||||||
|
const updated = await addBalance(context.prisma, guildId, userId, payout);
|
||||||
|
balance = updated.balance;
|
||||||
|
} else {
|
||||||
|
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
|
||||||
|
where: { guildId_userId: { guildId, userId } }
|
||||||
|
});
|
||||||
|
balance = member.balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reels, multiplier, payout, balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CoinflipChoice = 'heads' | 'tails';
|
||||||
|
|
||||||
|
export async function playCoinflipBet(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
choice: CoinflipChoice
|
||||||
|
): Promise<{ result: CoinflipChoice; won: boolean; balance: number }> {
|
||||||
|
await assertEconomyEnabled(context.prisma, guildId);
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
|
throw new EconomyError('invalid_amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deductBalance(context.prisma, guildId, userId, amount);
|
||||||
|
const result: CoinflipChoice = Math.random() < 0.5 ? 'heads' : 'tails';
|
||||||
|
const won = result === choice;
|
||||||
|
|
||||||
|
if (won) {
|
||||||
|
const updated = await addBalance(context.prisma, guildId, userId, amount * 2);
|
||||||
|
return { result, won: true, balance: updated.balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await context.prisma.memberEconomy.findUniqueOrThrow({
|
||||||
|
where: { guildId_userId: { guildId, userId } }
|
||||||
|
});
|
||||||
|
return { result, won: false, balance: member.balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlackjackButtonCustomId(customId: string): {
|
||||||
|
action: 'hit' | 'stand';
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
} | null {
|
||||||
|
if (!customId.startsWith(BLACKJACK_BUTTON_PREFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parts = customId.slice(BLACKJACK_BUTTON_PREFIX.length).split(':');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [action, guildId, userId] = parts;
|
||||||
|
if (action !== 'hit' && action !== 'stand') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!guildId || !userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { action, guildId, userId };
|
||||||
|
}
|
||||||
2
apps/bot/src/modules/economy/index.ts
Normal file
2
apps/bot/src/modules/economy/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { economyCommands } from './commands.js';
|
||||||
|
export { isBlackjackButton, handleBlackjackButton } from './blackjack-buttons.js';
|
||||||
380
apps/bot/src/modules/economy/service.ts
Normal file
380
apps/bot/src/modules/economy/service.ts
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import type { EconomyConfig, MemberEconomy, Prisma, PrismaClient, ShopItem } from '@prisma/client';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
type DbClient = PrismaClient | Prisma.TransactionClient;
|
||||||
|
|
||||||
|
export type EconomyErrorCode =
|
||||||
|
| 'disabled'
|
||||||
|
| 'insufficient'
|
||||||
|
| 'cooldown'
|
||||||
|
| 'invalid_amount'
|
||||||
|
| 'self_transfer'
|
||||||
|
| 'not_found'
|
||||||
|
| 'out_of_stock'
|
||||||
|
| 'role_assign_failed'
|
||||||
|
| 'game_in_progress';
|
||||||
|
|
||||||
|
export class EconomyError extends Error {
|
||||||
|
constructor(public readonly code: EconomyErrorCode) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'EconomyError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAILY_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const WEEKLY_COOLDOWN_MS = 7 * DAILY_COOLDOWN_MS;
|
||||||
|
|
||||||
|
export async function getEconomyConfig(
|
||||||
|
prisma: DbClient,
|
||||||
|
guildId: string
|
||||||
|
): Promise<EconomyConfig> {
|
||||||
|
await ensureGuild(prisma as PrismaClient, guildId);
|
||||||
|
return prisma.economyConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: {},
|
||||||
|
create: { guildId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertEconomyEnabled(prisma: PrismaClient, guildId: string): Promise<EconomyConfig> {
|
||||||
|
const config = await getEconomyConfig(prisma, guildId);
|
||||||
|
if (!config.enabled) {
|
||||||
|
throw new EconomyError('disabled');
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureMemberEconomy(
|
||||||
|
prisma: DbClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<MemberEconomy> {
|
||||||
|
const config = await getEconomyConfig(prisma, guildId);
|
||||||
|
return prisma.memberEconomy.upsert({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
balance: config.startingBalance
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalance(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const member = await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
return member.balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPositiveAmount(amount: number): void {
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
|
throw new EconomyError('invalid_amount');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addBalance(
|
||||||
|
prisma: DbClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<MemberEconomy> {
|
||||||
|
await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
return prisma.memberEconomy.update({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
data: { balance: { increment: amount } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deductBalance(
|
||||||
|
prisma: DbClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<MemberEconomy> {
|
||||||
|
assertPositiveAmount(amount);
|
||||||
|
const member = await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
if (member.balance < amount) {
|
||||||
|
throw new EconomyError('insufficient');
|
||||||
|
}
|
||||||
|
return prisma.memberEconomy.update({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
data: { balance: { decrement: amount } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferBalance(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
fromUserId: string,
|
||||||
|
toUserId: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<{ sender: MemberEconomy; receiver: MemberEconomy }> {
|
||||||
|
assertPositiveAmount(amount);
|
||||||
|
if (fromUserId === toUserId) {
|
||||||
|
throw new EconomyError('self_transfer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const sender = await ensureMemberEconomy(tx, guildId, fromUserId);
|
||||||
|
if (sender.balance < amount) {
|
||||||
|
throw new EconomyError('insufficient');
|
||||||
|
}
|
||||||
|
await ensureMemberEconomy(tx, guildId, toUserId);
|
||||||
|
const updatedSender = await tx.memberEconomy.update({
|
||||||
|
where: { guildId_userId: { guildId, userId: fromUserId } },
|
||||||
|
data: { balance: { decrement: amount } }
|
||||||
|
});
|
||||||
|
const updatedReceiver = await tx.memberEconomy.update({
|
||||||
|
where: { guildId_userId: { guildId, userId: toUserId } },
|
||||||
|
data: { balance: { increment: amount } }
|
||||||
|
});
|
||||||
|
return { sender: updatedSender, receiver: updatedReceiver };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cooldownRemainingMs(lastAt: Date | null, cooldownMs: number): number {
|
||||||
|
if (!lastAt) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsed = Date.now() - lastAt.getTime();
|
||||||
|
return Math.max(0, cooldownMs - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimDaily(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<{ amount: number; balance: number }> {
|
||||||
|
const config = await assertEconomyEnabled(prisma, guildId);
|
||||||
|
const member = await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
const remaining = cooldownRemainingMs(member.lastDailyAt, DAILY_COOLDOWN_MS);
|
||||||
|
if (remaining > 0) {
|
||||||
|
throw new EconomyError('cooldown');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.memberEconomy.update({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
data: {
|
||||||
|
balance: { increment: config.dailyAmount },
|
||||||
|
lastDailyAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { amount: config.dailyAmount, balance: updated.balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimWeekly(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<{ amount: number; balance: number }> {
|
||||||
|
const config = await assertEconomyEnabled(prisma, guildId);
|
||||||
|
const member = await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
const remaining = cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS);
|
||||||
|
if (remaining > 0) {
|
||||||
|
throw new EconomyError('cooldown');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.memberEconomy.update({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
data: {
|
||||||
|
balance: { increment: config.weeklyAmount },
|
||||||
|
lastWeeklyAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { amount: config.weeklyAmount, balance: updated.balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimWork(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
earned: number
|
||||||
|
): Promise<{ amount: number; balance: number }> {
|
||||||
|
await assertEconomyEnabled(prisma, guildId);
|
||||||
|
const config = await getEconomyConfig(prisma, guildId);
|
||||||
|
const member = await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
const cooldownMs = config.workCooldownSeconds * 1000;
|
||||||
|
const remaining = cooldownRemainingMs(member.lastWorkAt, cooldownMs);
|
||||||
|
if (remaining > 0) {
|
||||||
|
throw new EconomyError('cooldown');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.memberEconomy.update({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
data: {
|
||||||
|
balance: { increment: earned },
|
||||||
|
lastWorkAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { amount: earned, balance: updated.balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkCooldownRemaining(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const config = await getEconomyConfig(prisma, guildId);
|
||||||
|
const member = await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
return cooldownRemainingMs(member.lastWorkAt, config.workCooldownSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDailyCooldownRemaining(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const member = await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
return cooldownRemainingMs(member.lastDailyAt, DAILY_COOLDOWN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWeeklyCooldownRemaining(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const member = await ensureMemberEconomy(prisma, guildId, userId);
|
||||||
|
return cooldownRemainingMs(member.lastWeeklyAt, WEEKLY_COOLDOWN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listShopItems(prisma: PrismaClient, guildId: string): Promise<ShopItem[]> {
|
||||||
|
await assertEconomyEnabled(prisma, guildId);
|
||||||
|
return prisma.shopItem.findMany({
|
||||||
|
where: { guildId, enabled: true },
|
||||||
|
orderBy: { price: 'asc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buyShopItem(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
itemId: string
|
||||||
|
): Promise<{ item: ShopItem; balance: number }> {
|
||||||
|
await assertEconomyEnabled(context.prisma, guildId);
|
||||||
|
|
||||||
|
return context.prisma.$transaction(async (tx) => {
|
||||||
|
const item = await tx.shopItem.findFirst({
|
||||||
|
where: { id: itemId, guildId, enabled: true }
|
||||||
|
});
|
||||||
|
if (!item) {
|
||||||
|
throw new EconomyError('not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.stock !== null && item.stock <= 0) {
|
||||||
|
throw new EconomyError('out_of_stock');
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await ensureMemberEconomy(tx, guildId, userId);
|
||||||
|
if (member.balance < item.price) {
|
||||||
|
throw new EconomyError('insufficient');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMember = await tx.memberEconomy.update({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
data: { balance: { decrement: item.price } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.inventoryItem.upsert({
|
||||||
|
where: { guildId_userId_itemId: { guildId, userId, itemId: item.id } },
|
||||||
|
update: { quantity: { increment: 1 } },
|
||||||
|
create: { guildId, userId, itemId: item.id, quantity: 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item.stock !== null) {
|
||||||
|
await tx.shopItem.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: { stock: { decrement: 1 } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.roleId) {
|
||||||
|
const guild = context.client.guilds.cache.get(guildId) ?? (await context.client.guilds.fetch(guildId));
|
||||||
|
const memberObj = await guild.members.fetch(userId);
|
||||||
|
try {
|
||||||
|
await memberObj.roles.add(item.roleId, 'Economy shop purchase');
|
||||||
|
} catch {
|
||||||
|
throw new EconomyError('role_assign_failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item, balance: updatedMember.balance };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInventory(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
await assertEconomyEnabled(prisma, guildId);
|
||||||
|
return prisma.inventoryItem.findMany({
|
||||||
|
where: { guildId, userId },
|
||||||
|
include: { item: true },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLeaderboard(prisma: PrismaClient, guildId: string, limit = 10) {
|
||||||
|
await assertEconomyEnabled(prisma, guildId);
|
||||||
|
return prisma.memberEconomy.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { balance: 'desc' },
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminGiveBalance(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<MemberEconomy> {
|
||||||
|
assertPositiveAmount(amount);
|
||||||
|
await assertEconomyEnabled(prisma, guildId);
|
||||||
|
return addBalance(prisma, guildId, userId, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminRemoveBalance(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<MemberEconomy> {
|
||||||
|
return deductBalance(prisma, guildId, userId, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminResetEconomy(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await assertEconomyEnabled(prisma, guildId);
|
||||||
|
const config = await getEconomyConfig(prisma, guildId);
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.memberEconomy.upsert({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
update: {
|
||||||
|
balance: config.startingBalance,
|
||||||
|
lastDailyAt: null,
|
||||||
|
lastWeeklyAt: null,
|
||||||
|
lastWorkAt: null
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
balance: config.startingBalance
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.inventoryItem.deleteMany({ where: { guildId, userId } })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCooldownSeconds(ms: number): number {
|
||||||
|
return Math.ceil(ms / 1000);
|
||||||
|
}
|
||||||
54
apps/bot/src/modules/feeds/command-definitions.ts
Normal file
54
apps/bot/src/modules/feeds/command-definitions.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChannelType } from 'discord.js';
|
||||||
|
import {
|
||||||
|
applyCommandDescription,
|
||||||
|
applyOptionDescription,
|
||||||
|
localizedChoice
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
const feedTypeChoices = [
|
||||||
|
localizedChoice('feeds.choice.type.twitch', 'TWITCH'),
|
||||||
|
localizedChoice('feeds.choice.type.youtube', 'YOUTUBE'),
|
||||||
|
localizedChoice('feeds.choice.type.rss', 'RSS'),
|
||||||
|
localizedChoice('feeds.choice.type.reddit', 'REDDIT')
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const feedsCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('feeds')
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('add'), 'feeds.add.description')
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('type').setRequired(true), 'feeds.add.options.type').addChoices(
|
||||||
|
...feedTypeChoices
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('source').setRequired(true), 'feeds.add.options.source')
|
||||||
|
)
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
||||||
|
'feeds.add.options.channel'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('role'), 'feeds.add.options.role')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('template'), 'feeds.add.options.template')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('remove'), 'feeds.remove.description').addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'feeds.remove.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) => applyCommandDescription(sub.setName('list'), 'feeds.list.description'))
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('test'), 'feeds.test.description').addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'feeds.test.options.id')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'feeds.description'
|
||||||
|
);
|
||||||
141
apps/bot/src/modules/feeds/commands.ts
Normal file
141
apps/bot/src/modules/feeds/commands.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { SocialFeedTypeSchema, t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { feedsCommandData } from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
addFeed,
|
||||||
|
FeedAddSchema,
|
||||||
|
FeedError,
|
||||||
|
getFeedById,
|
||||||
|
listFeeds,
|
||||||
|
removeFeed,
|
||||||
|
testFeed
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
|
const feedsCommand: SlashCommand = {
|
||||||
|
data: feedsCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!interaction.inGuild()) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand(true);
|
||||||
|
const guildId = interaction.guildId!;
|
||||||
|
|
||||||
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sub === 'add') {
|
||||||
|
const input = FeedAddSchema.parse({
|
||||||
|
type: SocialFeedTypeSchema.parse(interaction.options.getString('type', true)),
|
||||||
|
source: interaction.options.getString('source', true),
|
||||||
|
channelId: interaction.options.getChannel('channel', true).id,
|
||||||
|
rolePingId: interaction.options.getRole('role')?.id,
|
||||||
|
template: interaction.options.getString('template') ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const feed = await addFeed(context.prisma, guildId, input);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'feeds.added', { id: feed.id, type: feed.type }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const feedId = interaction.options.getString('id', true);
|
||||||
|
const removed = await removeFeed(context.prisma, guildId, feedId);
|
||||||
|
if (!removed) {
|
||||||
|
await interaction.reply({ content: t(locale, 'feeds.error.not_found'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'feeds.removed', { id: removed.id }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const feeds = await listFeeds(context.prisma, guildId);
|
||||||
|
if (feeds.length === 0) {
|
||||||
|
await interaction.reply({ content: t(locale, 'feeds.list.empty'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = feeds.map((feed) =>
|
||||||
|
tf(locale, 'feeds.list.line', {
|
||||||
|
id: feed.id,
|
||||||
|
type: feed.type,
|
||||||
|
source: feed.sourceId,
|
||||||
|
channel: `<#${feed.channelId}>`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'feeds.list.header')}\n${lines.join('\n')}`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'test') {
|
||||||
|
const feedId = interaction.options.getString('id', true);
|
||||||
|
const feed = await getFeedById(context.prisma, guildId, feedId);
|
||||||
|
if (!feed) {
|
||||||
|
await interaction.reply({ content: t(locale, 'feeds.error.not_found'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await testFeed(context, feed);
|
||||||
|
if (result.skipped) {
|
||||||
|
const skipKey =
|
||||||
|
result.message === 'twitch_credentials_missing'
|
||||||
|
? 'feeds.test.twitchSkipped'
|
||||||
|
: 'feeds.test.skipped';
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, skipKey),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.message && result.posted === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'feeds.test.failed', { reason: result.message }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.posted === 0) {
|
||||||
|
await interaction.reply({ content: t(locale, 'feeds.test.noItems'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'feeds.test.posted', { count: result.posted }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FeedError) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, `feeds.error.${error.code}`),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const feedsCommands: SlashCommand[] = [feedsCommand];
|
||||||
277
apps/bot/src/modules/feeds/fetchers.ts
Normal file
277
apps/bot/src/modules/feeds/fetchers.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { env } from '../../env.js';
|
||||||
|
|
||||||
|
export type FeedItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchResult =
|
||||||
|
| { ok: true; items: FeedItem[] }
|
||||||
|
| { ok: false; skipped?: boolean; message: string };
|
||||||
|
|
||||||
|
const USER_AGENT = 'NexumiBot/1.0';
|
||||||
|
const YOUTUBE_CHANNEL_ID_RE = /^UC[\w-]{10,}$/i;
|
||||||
|
|
||||||
|
function decodeXmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCdata(value: string): string {
|
||||||
|
return value.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/i, '$1').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTag(block: string, tagNames: string[]): string | null {
|
||||||
|
for (const tag of tagNames) {
|
||||||
|
const match = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'));
|
||||||
|
if (match?.[1]) {
|
||||||
|
return decodeXmlEntities(stripCdata(match[1].trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLink(block: string): string | null {
|
||||||
|
const atomLink = block.match(/<link[^>]*href=["']([^"']+)["'][^>]*\/?>/i);
|
||||||
|
if (atomLink?.[1]) {
|
||||||
|
return atomLink[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rssLink = extractTag(block, ['link']);
|
||||||
|
return rssLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGuid(block: string, link: string | null): string | null {
|
||||||
|
const guid = extractTag(block, ['guid', 'id']);
|
||||||
|
if (guid) {
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRssXml(xml: string): FeedItem[] {
|
||||||
|
const items: FeedItem[] = [];
|
||||||
|
const blockRegex = /<(?:item|entry)\b[\s\S]*?<\/(?:item|entry)>/gi;
|
||||||
|
const blocks = xml.match(blockRegex) ?? [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const title = extractTag(block, ['title']);
|
||||||
|
const link = extractLink(block);
|
||||||
|
const id = extractGuid(block, link);
|
||||||
|
if (!title || !link || !id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({ id, title, url: link });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url: string): Promise<string> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRssFeed(sourceUrl: string): Promise<FetchResult> {
|
||||||
|
try {
|
||||||
|
const xml = await fetchText(sourceUrl);
|
||||||
|
const items = parseRssXml(xml);
|
||||||
|
return { ok: true, items };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : 'fetch_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchYouTubeFeed(source: string): Promise<FetchResult> {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
const channelIdMatch = trimmed.match(/channel_id=([\w-]+)/i);
|
||||||
|
const channelId = YOUTUBE_CHANNEL_ID_RE.test(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: (channelIdMatch?.[1] ?? null);
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
return fetchRssFeed(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return fetchRssFeed(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message: 'invalid_youtube_source' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRedditFeed(subreddit: string): Promise<FetchResult> {
|
||||||
|
const name = subreddit.replace(/^r\//i, '').trim();
|
||||||
|
if (!name) {
|
||||||
|
return { ok: false, message: 'invalid_subreddit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://www.reddit.com/r/${encodeURIComponent(name)}/new.json?limit=5`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, message: `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
data?: { children?: Array<{ data?: { id?: string; title?: string; permalink?: string } }> };
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: FeedItem[] = [];
|
||||||
|
for (const child of data.data?.children ?? []) {
|
||||||
|
const post = child.data;
|
||||||
|
if (!post?.id || !post.title || !post.permalink) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
url: post.permalink.startsWith('http')
|
||||||
|
? post.permalink
|
||||||
|
: `https://www.reddit.com${post.permalink}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, items };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : 'fetch_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let twitchAppToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
async function getTwitchAppToken(): Promise<string | null> {
|
||||||
|
const clientId = env.TWITCH_CLIENT_ID;
|
||||||
|
const clientSecret = env.TWITCH_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (twitchAppToken && twitchAppToken.expiresAt > Date.now() + 60_000) {
|
||||||
|
return twitchAppToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: 'client_credentials'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`https://id.twitch.tv/oauth2/token?${params.toString()}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`twitch_token_http_${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as { access_token?: string; expires_in?: number };
|
||||||
|
if (!body.access_token) {
|
||||||
|
throw new Error('twitch_token_missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
twitchAppToken = {
|
||||||
|
token: body.access_token,
|
||||||
|
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000
|
||||||
|
};
|
||||||
|
return twitchAppToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTwitchFeed(login: string): Promise<FetchResult> {
|
||||||
|
const userLogin = login.trim().toLowerCase();
|
||||||
|
if (!userLogin) {
|
||||||
|
return { ok: false, message: 'invalid_twitch_login' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = env.TWITCH_CLIENT_ID;
|
||||||
|
const clientSecret = env.TWITCH_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: true,
|
||||||
|
message: 'twitch_credentials_missing'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getTwitchAppToken();
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: true,
|
||||||
|
message: 'twitch_credentials_missing'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.twitch.tv/helix/streams?user_login=${encodeURIComponent(userLogin)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Client-ID': clientId,
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, message: `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
data?: Array<{ id?: string; title?: string; user_login?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = body.data?.[0];
|
||||||
|
if (!stream?.id || !stream.title) {
|
||||||
|
return { ok: true, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamLogin = stream.user_login ?? userLogin;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: stream.id,
|
||||||
|
title: stream.title,
|
||||||
|
url: `https://twitch.tv/${streamLogin}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : 'fetch_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFeedItems(type: string, sourceId: string): Promise<FetchResult> {
|
||||||
|
switch (type) {
|
||||||
|
case 'RSS':
|
||||||
|
return fetchRssFeed(sourceId);
|
||||||
|
case 'YOUTUBE':
|
||||||
|
return fetchYouTubeFeed(sourceId);
|
||||||
|
case 'REDDIT':
|
||||||
|
return fetchRedditFeed(sourceId);
|
||||||
|
case 'TWITCH':
|
||||||
|
return fetchTwitchFeed(sourceId);
|
||||||
|
default:
|
||||||
|
return { ok: false, message: 'unsupported_type' };
|
||||||
|
}
|
||||||
|
}
|
||||||
2
apps/bot/src/modules/feeds/index.ts
Normal file
2
apps/bot/src/modules/feeds/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { feedsCommands } from './commands.js';
|
||||||
|
export { pollAllFeeds } from './service.js';
|
||||||
239
apps/bot/src/modules/feeds/service.ts
Normal file
239
apps/bot/src/modules/feeds/service.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { PermissionFlagsBits, type TextChannel } from 'discord.js';
|
||||||
|
import type { SocialFeed } from '@prisma/client';
|
||||||
|
import { applyFeedTemplate, SocialFeedTypeSchema } from '@nexumi/shared';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { fetchFeedItems, type FeedItem, type FetchResult } from './fetchers.js';
|
||||||
|
|
||||||
|
export const FeedAddSchema = z.object({
|
||||||
|
type: SocialFeedTypeSchema,
|
||||||
|
source: z.string().min(1).max(512),
|
||||||
|
channelId: z.string().min(1),
|
||||||
|
rolePingId: z.string().min(1).optional(),
|
||||||
|
template: z.string().min(1).max(2000).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FeedAddInput = z.infer<typeof FeedAddSchema>;
|
||||||
|
|
||||||
|
export class FeedError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'FeedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE = '{title}\n{url}';
|
||||||
|
|
||||||
|
export function normalizeFeedSource(type: z.infer<typeof SocialFeedTypeSchema>, source: string): string {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
switch (type) {
|
||||||
|
case 'TWITCH':
|
||||||
|
return trimmed.replace(/^@/, '').toLowerCase();
|
||||||
|
case 'REDDIT':
|
||||||
|
return trimmed.replace(/^r\//i, '').toLowerCase();
|
||||||
|
case 'YOUTUBE':
|
||||||
|
case 'RSS':
|
||||||
|
return trimmed;
|
||||||
|
default:
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFeed(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
input: FeedAddInput
|
||||||
|
): Promise<SocialFeed> {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
const sourceId = normalizeFeedSource(input.type, input.source);
|
||||||
|
|
||||||
|
const currentCount = await prisma.socialFeed.count({ where: { guildId } });
|
||||||
|
try {
|
||||||
|
await assertPremiumLimit(prisma, guildId, 'feeds', currentCount);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PremiumLimitError) {
|
||||||
|
throw new FeedError(error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.socialFeed.create({
|
||||||
|
data: {
|
||||||
|
guildId,
|
||||||
|
type: input.type,
|
||||||
|
sourceId,
|
||||||
|
channelId: input.channelId,
|
||||||
|
rolePingId: input.rolePingId ?? null,
|
||||||
|
template: input.template ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFeed(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
feedId: string
|
||||||
|
): Promise<SocialFeed | null> {
|
||||||
|
const existing = await prisma.socialFeed.findFirst({
|
||||||
|
where: { id: feedId, guildId }
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await prisma.socialFeed.delete({ where: { id: feedId } });
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFeeds(prisma: BotContext['prisma'], guildId: string): Promise<SocialFeed[]> {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
return prisma.socialFeed.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeedById(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
feedId: string
|
||||||
|
): Promise<SocialFeed | null> {
|
||||||
|
return prisma.socialFeed.findFirst({
|
||||||
|
where: { id: feedId, guildId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeedMessage(feed: SocialFeed, item: FeedItem): string {
|
||||||
|
const body = applyFeedTemplate(feed.template?.trim() || DEFAULT_TEMPLATE, {
|
||||||
|
title: item.title,
|
||||||
|
url: item.url
|
||||||
|
});
|
||||||
|
if (feed.rolePingId) {
|
||||||
|
return `<@&${feed.rolePingId}>\n${body}`;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTextChannel(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
channelId: string
|
||||||
|
): Promise<TextChannel | null> {
|
||||||
|
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||||
|
if (!guild) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel as TextChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postFeedItem(
|
||||||
|
context: BotContext,
|
||||||
|
feed: SocialFeed,
|
||||||
|
item: FeedItem
|
||||||
|
): Promise<boolean> {
|
||||||
|
const channel = await resolveTextChannel(context, feed.guildId, feed.channelId);
|
||||||
|
if (!channel) {
|
||||||
|
logger.warn({ feedId: feed.id, channelId: feed.channelId }, 'Feed channel invalid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.send({ content: buildFeedMessage(feed, item) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNewItems(items: FeedItem[], lastItemId: string | null): FeedItem[] {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastItemId) {
|
||||||
|
return [items[0]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIndex = items.findIndex((item) => item.id === lastItemId);
|
||||||
|
if (lastIndex === -1) {
|
||||||
|
return [items[0]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, lastIndex).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processFeedFetch(
|
||||||
|
context: BotContext,
|
||||||
|
feed: SocialFeed,
|
||||||
|
result: FetchResult,
|
||||||
|
options: { forceLatest?: boolean } = {}
|
||||||
|
): Promise<{ posted: number; skipped?: boolean; message?: string }> {
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
posted: 0,
|
||||||
|
skipped: result.skipped,
|
||||||
|
message: result.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = result.items;
|
||||||
|
if (items.length === 0) {
|
||||||
|
return { posted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = options.forceLatest ? [items[0]!] : selectNewItems(items, feed.lastItemId);
|
||||||
|
let posted = 0;
|
||||||
|
|
||||||
|
for (const item of candidates) {
|
||||||
|
const sent = await postFeedItem(context, feed, item);
|
||||||
|
if (sent) {
|
||||||
|
posted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestId = items[0]!.id;
|
||||||
|
if (posted > 0 || feed.lastItemId !== newestId) {
|
||||||
|
await context.prisma.socialFeed.update({
|
||||||
|
where: { id: feed.id },
|
||||||
|
data: { lastItemId: newestId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { posted };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollFeed(context: BotContext, feed: SocialFeed): Promise<void> {
|
||||||
|
const result = await fetchFeedItems(feed.type, feed.sourceId);
|
||||||
|
await processFeedFetch(context, feed, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testFeed(
|
||||||
|
context: BotContext,
|
||||||
|
feed: SocialFeed
|
||||||
|
): Promise<{ posted: number; skipped?: boolean; message?: string }> {
|
||||||
|
const result = await fetchFeedItems(feed.type, feed.sourceId);
|
||||||
|
return processFeedFetch(context, feed, result, { forceLatest: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollAllFeeds(context: BotContext): Promise<void> {
|
||||||
|
const feeds = await context.prisma.socialFeed.findMany({
|
||||||
|
where: { enabled: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
try {
|
||||||
|
await pollFeed(context, feed);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, feedId: feed.id, guildId: feed.guildId }, 'Feed poll failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
234
apps/bot/src/modules/fun/buttons.ts
Normal file
234
apps/bot/src/modules/fun/buttons.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import type { ButtonInteraction } from 'discord.js';
|
||||||
|
import { t } from '@nexumi/shared';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
applyConnect4Move,
|
||||||
|
applyHangmanGuess,
|
||||||
|
applyTicTacToeMove,
|
||||||
|
buildConnect4Components,
|
||||||
|
buildConnect4Embed,
|
||||||
|
buildHangmanComponents,
|
||||||
|
buildHangmanEmbed,
|
||||||
|
buildTicTacToeComponents,
|
||||||
|
buildTicTacToeEmbed,
|
||||||
|
currentConnect4PlayerId,
|
||||||
|
currentTicTacToePlayerId,
|
||||||
|
deleteConnect4State,
|
||||||
|
deleteHangmanState,
|
||||||
|
deleteTicTacToeState,
|
||||||
|
deleteTriviaSession,
|
||||||
|
FUN_BUTTON_PREFIX,
|
||||||
|
loadConnect4State,
|
||||||
|
loadHangmanState,
|
||||||
|
loadTicTacToeState,
|
||||||
|
loadTriviaSession,
|
||||||
|
parseConnect4Button,
|
||||||
|
parseHangmanButton,
|
||||||
|
parseTicTacToeButton,
|
||||||
|
parseTriviaButton,
|
||||||
|
saveConnect4State,
|
||||||
|
saveHangmanState,
|
||||||
|
saveTicTacToeState
|
||||||
|
} from './games.js';
|
||||||
|
|
||||||
|
export function isFunButton(customId: string): boolean {
|
||||||
|
return customId.startsWith(FUN_BUTTON_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyNotParticipant(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise<void> {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.common.notParticipant'), ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyExpired(interaction: ButtonInteraction, locale: 'de' | 'en'): Promise<void> {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.common.expired'), ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTriviaButton(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<void> {
|
||||||
|
const parsed = parseTriviaButton(interaction.customId);
|
||||||
|
if (!parsed) {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.user.id !== parsed.userId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.trivia.notOwner'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await loadTriviaSession(context, parsed.userId);
|
||||||
|
if (!session) {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const correct = parsed.answerIndex === parsed.correctIndex;
|
||||||
|
await deleteTriviaSession(context, parsed.userId);
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
embeds: interaction.message.embeds,
|
||||||
|
components: [],
|
||||||
|
content: correct ? t(locale, 'fun.trivia.correct') : t(locale, 'fun.trivia.incorrect')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTicTacToeButton(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<void> {
|
||||||
|
const parsed = parseTicTacToeButton(interaction.customId);
|
||||||
|
if (!parsed) {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadTicTacToeState(context, parsed.gameId);
|
||||||
|
if (!state || state.status !== 'active') {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.user.id !== currentTicTacToePlayerId(state)) {
|
||||||
|
await replyNotParticipant(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = applyTicTacToeMove(state, parsed.cell);
|
||||||
|
if (next.board[parsed.cell] === state.board[parsed.cell]) {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.tictactoe.invalidMove'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.status !== 'active') {
|
||||||
|
await deleteTicTacToeState(context, parsed.gameId);
|
||||||
|
} else {
|
||||||
|
await saveTicTacToeState(context, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
embeds: [buildTicTacToeEmbed(locale, next)],
|
||||||
|
components: buildTicTacToeComponents(next)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConnect4Button(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<void> {
|
||||||
|
const parsed = parseConnect4Button(interaction.customId);
|
||||||
|
if (!parsed) {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadConnect4State(context, parsed.gameId);
|
||||||
|
if (!state || state.status !== 'active') {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.user.id !== currentConnect4PlayerId(state)) {
|
||||||
|
await replyNotParticipant(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = applyConnect4Move(state, parsed.col);
|
||||||
|
const changed = next.board.some((row, rowIndex) =>
|
||||||
|
row.some((cell, colIndex) => cell !== state.board[rowIndex]![colIndex])
|
||||||
|
);
|
||||||
|
if (!changed) {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.connect4.invalidMove'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.status !== 'active') {
|
||||||
|
await deleteConnect4State(context, parsed.gameId);
|
||||||
|
} else {
|
||||||
|
await saveConnect4State(context, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
embeds: [buildConnect4Embed(locale, next)],
|
||||||
|
components: buildConnect4Components(next)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHangmanButton(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<void> {
|
||||||
|
const parsed = parseHangmanButton(interaction.customId);
|
||||||
|
if (!parsed) {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadHangmanState(context, parsed.gameId);
|
||||||
|
if (!state) {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.user.id !== state.userId) {
|
||||||
|
await replyNotParticipant(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.page !== undefined) {
|
||||||
|
const next = { ...state, letterPage: parsed.page };
|
||||||
|
await saveHangmanState(context, next);
|
||||||
|
await interaction.update({
|
||||||
|
embeds: [buildHangmanEmbed(locale, next)],
|
||||||
|
components: buildHangmanComponents(next)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.letter || state.status !== 'active') {
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = applyHangmanGuess(state, parsed.letter);
|
||||||
|
if (next.status !== 'active') {
|
||||||
|
await deleteHangmanState(context, parsed.gameId);
|
||||||
|
} else {
|
||||||
|
await saveHangmanState(context, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
embeds: [buildHangmanEmbed(locale, next)],
|
||||||
|
components: buildHangmanComponents(next)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleFunButton(interaction: ButtonInteraction, context: BotContext): Promise<void> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const parts = interaction.customId.split(':');
|
||||||
|
|
||||||
|
if (parts[1] === 'trivia') {
|
||||||
|
await handleTriviaButton(interaction, context, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parts[1] === 'ttt') {
|
||||||
|
await handleTicTacToeButton(interaction, context, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parts[1] === 'c4') {
|
||||||
|
await handleConnect4Button(interaction, context, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parts[1] === 'hm') {
|
||||||
|
await handleHangmanButton(interaction, context, locale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await replyExpired(interaction, locale);
|
||||||
|
}
|
||||||
112
apps/bot/src/modules/fun/command-definitions.ts
Normal file
112
apps/bot/src/modules/fun/command-definitions.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
type SlashCommandIntegerOption,
|
||||||
|
type SlashCommandStringOption,
|
||||||
|
type SlashCommandUserOption
|
||||||
|
} from 'discord.js';
|
||||||
|
import {
|
||||||
|
applyCommandDescription,
|
||||||
|
applyOptionDescription,
|
||||||
|
localizedChoice,
|
||||||
|
type CommandLocaleKey
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
|
||||||
|
function questionOpt(o: SlashCommandStringOption) {
|
||||||
|
return applyOptionDescription(o.setName('question').setRequired(true), 'fun.8ball.options.question');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidesOpt(o: SlashCommandIntegerOption) {
|
||||||
|
return applyOptionDescription(
|
||||||
|
o.setName('sides').setMinValue(2).setMaxValue(100),
|
||||||
|
'fun.dice.options.sides'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countOpt(o: SlashCommandIntegerOption) {
|
||||||
|
return applyOptionDescription(
|
||||||
|
o.setName('count').setMinValue(1).setMaxValue(10),
|
||||||
|
'fun.dice.options.count'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpsChoiceOpt(o: SlashCommandStringOption) {
|
||||||
|
return applyOptionDescription(o.setName('choice').setRequired(true), 'fun.rps.options.choice').addChoices(
|
||||||
|
localizedChoice('fun.rps.choice.rock', 'rock'),
|
||||||
|
localizedChoice('fun.rps.choice.paper', 'paper'),
|
||||||
|
localizedChoice('fun.rps.choice.scissors', 'scissors')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionsOpt(o: SlashCommandStringOption) {
|
||||||
|
return applyOptionDescription(o.setName('options').setRequired(true), 'fun.choose.options.options');
|
||||||
|
}
|
||||||
|
|
||||||
|
function opponentOpt(o: SlashCommandUserOption) {
|
||||||
|
return applyOptionDescription(o.setName('user').setRequired(true), 'fun.common.options.opponent');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eightBallCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('8ball'),
|
||||||
|
'fun.8ball.description'
|
||||||
|
).addStringOption((o) => questionOpt(o));
|
||||||
|
|
||||||
|
export const diceCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('dice'),
|
||||||
|
'fun.dice.description'
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) => sidesOpt(o))
|
||||||
|
.addIntegerOption((o) => countOpt(o));
|
||||||
|
|
||||||
|
export const flipCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('flip'),
|
||||||
|
'fun.flip.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const rpsCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('rps'),
|
||||||
|
'fun.rps.description'
|
||||||
|
).addStringOption((o) => rpsChoiceOpt(o));
|
||||||
|
|
||||||
|
export const chooseCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('choose'),
|
||||||
|
'fun.choose.description'
|
||||||
|
).addStringOption((o) => optionsOpt(o));
|
||||||
|
|
||||||
|
export const triviaCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('trivia'),
|
||||||
|
'fun.trivia.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tictactoeCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('tictactoe'),
|
||||||
|
'fun.tictactoe.description'
|
||||||
|
).addUserOption((o) => opponentOpt(o));
|
||||||
|
|
||||||
|
export const connect4CommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('connect4'),
|
||||||
|
'fun.connect4.description'
|
||||||
|
).addUserOption((o) => opponentOpt(o));
|
||||||
|
|
||||||
|
export const hangmanCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('hangman'),
|
||||||
|
'fun.hangman.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const memeCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('meme'),
|
||||||
|
'fun.meme.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const catCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('cat'),
|
||||||
|
'fun.cat.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dogCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('dog'),
|
||||||
|
'fun.dog.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export type RpsChoice = 'rock' | 'paper' | 'scissors';
|
||||||
|
|
||||||
|
export type FunCommandLocaleKey = CommandLocaleKey;
|
||||||
420
apps/bot/src/modules/fun/commands.ts
Normal file
420
apps/bot/src/modules/fun/commands.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { EmbedBuilder, type ChatInputCommandInteraction } from 'discord.js';
|
||||||
|
import { EIGHT_BALL_ANSWERS, randomInt, TRIVIA_QUESTIONS, t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import type { RpsChoice } from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
catCommandData,
|
||||||
|
chooseCommandData,
|
||||||
|
connect4CommandData,
|
||||||
|
diceCommandData,
|
||||||
|
dogCommandData,
|
||||||
|
eightBallCommandData,
|
||||||
|
flipCommandData,
|
||||||
|
hangmanCommandData,
|
||||||
|
memeCommandData,
|
||||||
|
rpsCommandData,
|
||||||
|
tictactoeCommandData,
|
||||||
|
triviaCommandData
|
||||||
|
} from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
buildConnect4Components,
|
||||||
|
buildConnect4Embed,
|
||||||
|
buildHangmanComponents,
|
||||||
|
buildHangmanEmbed,
|
||||||
|
buildTicTacToeComponents,
|
||||||
|
buildTicTacToeEmbed,
|
||||||
|
buildTriviaComponents,
|
||||||
|
buildTriviaEmbed,
|
||||||
|
createConnect4Board,
|
||||||
|
createGameId,
|
||||||
|
pickHangmanWord,
|
||||||
|
saveConnect4State,
|
||||||
|
saveHangmanState,
|
||||||
|
saveTicTacToeState,
|
||||||
|
saveTriviaSession
|
||||||
|
} from './games.js';
|
||||||
|
import {
|
||||||
|
assertMediaEnabled,
|
||||||
|
fetchCatImage,
|
||||||
|
fetchDogImage,
|
||||||
|
fetchMeme,
|
||||||
|
FunMediaError
|
||||||
|
} from './media.js';
|
||||||
|
|
||||||
|
const RPS_CHOICES: RpsChoice[] = ['rock', 'paper', 'scissors'];
|
||||||
|
|
||||||
|
function rpsWinner(a: RpsChoice, b: RpsChoice): 'player' | 'bot' | 'draw' {
|
||||||
|
if (a === b) {
|
||||||
|
return 'draw';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(a === 'rock' && b === 'scissors') ||
|
||||||
|
(a === 'paper' && b === 'rock') ||
|
||||||
|
(a === 'scissors' && b === 'paper')
|
||||||
|
) {
|
||||||
|
return 'player';
|
||||||
|
}
|
||||||
|
return 'bot';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGuild(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!interaction.inGuild() || !interaction.guildId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return interaction.guildId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChooseOptions(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eightBallCommand: SlashCommand = {
|
||||||
|
data: eightBallCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const question = interaction.options.getString('question', true);
|
||||||
|
const answers = EIGHT_BALL_ANSWERS[locale];
|
||||||
|
const answer = answers[randomInt(0, answers.length - 1)]!;
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'fun.8ball.result', { question, answer }),
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const diceCommand: SlashCommand = {
|
||||||
|
data: diceCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const sides = interaction.options.getInteger('sides') ?? 6;
|
||||||
|
const count = interaction.options.getInteger('count') ?? 1;
|
||||||
|
const rolls = Array.from({ length: count }, () => randomInt(1, sides));
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'fun.dice.result', {
|
||||||
|
rolls: rolls.join(', '),
|
||||||
|
sides: String(sides)
|
||||||
|
}),
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flipCommand: SlashCommand = {
|
||||||
|
data: flipCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const result = Math.random() < 0.5 ? 'heads' : 'tails';
|
||||||
|
const label = t(locale, `economy.choice.${result}`);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'fun.flip.result', { result: label }),
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rpsCommand: SlashCommand = {
|
||||||
|
data: rpsCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const playerChoice = interaction.options.getString('choice', true) as RpsChoice;
|
||||||
|
const botChoice = RPS_CHOICES[randomInt(0, RPS_CHOICES.length - 1)]!;
|
||||||
|
const outcome = rpsWinner(playerChoice, botChoice);
|
||||||
|
|
||||||
|
const key =
|
||||||
|
outcome === 'draw'
|
||||||
|
? 'fun.rps.draw'
|
||||||
|
: outcome === 'player'
|
||||||
|
? 'fun.rps.win'
|
||||||
|
: 'fun.rps.lose';
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, key, {
|
||||||
|
player: t(locale, `fun.rps.choice.${playerChoice}`),
|
||||||
|
bot: t(locale, `fun.rps.choice.${botChoice}`)
|
||||||
|
}),
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseCommand: SlashCommand = {
|
||||||
|
data: chooseCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const raw = interaction.options.getString('options', true);
|
||||||
|
const options = parseChooseOptions(raw);
|
||||||
|
if (options.length < 2) {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.choose.tooFew'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = options[randomInt(0, options.length - 1)]!;
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'fun.choose.result', { choice: picked }),
|
||||||
|
ephemeral: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triviaCommand: SlashCommand = {
|
||||||
|
data: triviaCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const questionIndex = randomInt(0, TRIVIA_QUESTIONS.length - 1);
|
||||||
|
const question = TRIVIA_QUESTIONS[questionIndex]!;
|
||||||
|
const correctIndex = question.answer;
|
||||||
|
|
||||||
|
await saveTriviaSession(context, {
|
||||||
|
userId: interaction.user.id,
|
||||||
|
guildId,
|
||||||
|
questionIndex,
|
||||||
|
correctIndex
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [buildTriviaEmbed(locale, question, interaction.user.id)],
|
||||||
|
components: buildTriviaComponents(interaction.user.id, correctIndex, locale, question)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tictactoeCommand: SlashCommand = {
|
||||||
|
data: tictactoeCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId || !interaction.channel) return;
|
||||||
|
|
||||||
|
const opponent = interaction.options.getUser('user', true);
|
||||||
|
if (opponent.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.common.selfChallenge'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opponent.bot) {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.common.botChallenge'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameId = createGameId();
|
||||||
|
const state = {
|
||||||
|
gameId,
|
||||||
|
guildId,
|
||||||
|
channelId: interaction.channel.id,
|
||||||
|
playerX: interaction.user.id,
|
||||||
|
playerO: opponent.id,
|
||||||
|
board: Array.from({ length: 9 }, () => null) as Array<'X' | 'O' | null>,
|
||||||
|
current: 'X' as const,
|
||||||
|
status: 'active' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveTicTacToeState(context, state);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'fun.tictactoe.challenge', {
|
||||||
|
challenger: `<@${interaction.user.id}>`,
|
||||||
|
opponent: `<@${opponent.id}>`
|
||||||
|
}),
|
||||||
|
embeds: [buildTicTacToeEmbed(locale, state)],
|
||||||
|
components: buildTicTacToeComponents(state)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect4Command: SlashCommand = {
|
||||||
|
data: connect4CommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId || !interaction.channel) return;
|
||||||
|
|
||||||
|
const opponent = interaction.options.getUser('user', true);
|
||||||
|
if (opponent.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.common.selfChallenge'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opponent.bot) {
|
||||||
|
await interaction.reply({ content: t(locale, 'fun.common.botChallenge'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameId = createGameId();
|
||||||
|
const state = {
|
||||||
|
gameId,
|
||||||
|
guildId,
|
||||||
|
channelId: interaction.channel.id,
|
||||||
|
playerRed: interaction.user.id,
|
||||||
|
playerYellow: opponent.id,
|
||||||
|
board: createConnect4Board(),
|
||||||
|
current: 'R' as const,
|
||||||
|
status: 'active' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveConnect4State(context, state);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'fun.connect4.challenge', {
|
||||||
|
challenger: `<@${interaction.user.id}>`,
|
||||||
|
opponent: `<@${opponent.id}>`
|
||||||
|
}),
|
||||||
|
embeds: [buildConnect4Embed(locale, state)],
|
||||||
|
components: buildConnect4Components(state)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hangmanCommand: SlashCommand = {
|
||||||
|
data: hangmanCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
const gameId = createGameId();
|
||||||
|
const state = {
|
||||||
|
gameId,
|
||||||
|
guildId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
word: pickHangmanWord(locale),
|
||||||
|
guessed: [] as string[],
|
||||||
|
wrongGuesses: 0,
|
||||||
|
maxWrong: 6,
|
||||||
|
status: 'active' as const,
|
||||||
|
letterPage: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveHangmanState(context, state);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
embeds: [buildHangmanEmbed(locale, state)],
|
||||||
|
components: buildHangmanComponents(state)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleMediaError(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
error: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
if (error instanceof FunMediaError) {
|
||||||
|
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
|
||||||
|
const content = t(locale, key);
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.editReply({ content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memeCommand: SlashCommand = {
|
||||||
|
data: memeCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
|
const meme = await fetchMeme();
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(meme.title)
|
||||||
|
.setImage(meme.url)
|
||||||
|
.setColor(0x6366f1);
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
} catch (error) {
|
||||||
|
await handleMediaError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const catCommand: SlashCommand = {
|
||||||
|
data: catCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
|
const url = await fetchCatImage();
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'fun.cat.title'))
|
||||||
|
.setImage(url)
|
||||||
|
.setColor(0x6366f1);
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
} catch (error) {
|
||||||
|
await handleMediaError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dogCommand: SlashCommand = {
|
||||||
|
data: dogCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const guildId = await ensureGuild(interaction, locale);
|
||||||
|
if (!guildId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
|
const url = await fetchDogImage();
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'fun.dog.title'))
|
||||||
|
.setImage(url)
|
||||||
|
.setColor(0x6366f1);
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
} catch (error) {
|
||||||
|
await handleMediaError(interaction, locale, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const funCommands: SlashCommand[] = [
|
||||||
|
eightBallCommand,
|
||||||
|
diceCommand,
|
||||||
|
flipCommand,
|
||||||
|
rpsCommand,
|
||||||
|
chooseCommand,
|
||||||
|
triviaCommand,
|
||||||
|
tictactoeCommand,
|
||||||
|
connect4Command,
|
||||||
|
hangmanCommand,
|
||||||
|
memeCommand,
|
||||||
|
catCommand,
|
||||||
|
dogCommand
|
||||||
|
];
|
||||||
26
apps/bot/src/modules/fun/config.ts
Normal file
26
apps/bot/src/modules/fun/config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const FUN_CONFIG_PREFIX = 'fun:config:';
|
||||||
|
|
||||||
|
export const FunConfigSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
mediaEnabled: z.boolean().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FunConfig = z.infer<typeof FunConfigSchema>;
|
||||||
|
|
||||||
|
const DEFAULT_FUN_CONFIG: FunConfig = { enabled: true, mediaEnabled: true };
|
||||||
|
|
||||||
|
export async function getFunConfig(redis: Redis, guildId: string): Promise<FunConfig> {
|
||||||
|
const raw = await redis.get(`${FUN_CONFIG_PREFIX}${guildId}`);
|
||||||
|
if (!raw) {
|
||||||
|
return { ...DEFAULT_FUN_CONFIG };
|
||||||
|
}
|
||||||
|
const parsed = FunConfigSchema.safeParse(JSON.parse(raw));
|
||||||
|
return parsed.success ? parsed.data : { ...DEFAULT_FUN_CONFIG };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFunConfig(redis: Redis, guildId: string, config: FunConfig): Promise<void> {
|
||||||
|
await redis.set(`${FUN_CONFIG_PREFIX}${guildId}`, JSON.stringify(config));
|
||||||
|
}
|
||||||
574
apps/bot/src/modules/fun/games.ts
Normal file
574
apps/bot/src/modules/fun/games.ts
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
type APIEmbed
|
||||||
|
} from 'discord.js';
|
||||||
|
import { HANGMAN_WORDS, randomInt, TRIVIA_QUESTIONS, type Locale, tf } from '@nexumi/shared';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
export const FUN_BUTTON_PREFIX = 'fun:';
|
||||||
|
export const TRIVIA_TTL_SECONDS = 120;
|
||||||
|
export const GAME_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
|
const TRIVIA_KEY_PREFIX = 'fun:trivia:';
|
||||||
|
const TTT_KEY_PREFIX = 'fun:ttt:';
|
||||||
|
const C4_KEY_PREFIX = 'fun:c4:';
|
||||||
|
const HM_KEY_PREFIX = 'fun:hm:';
|
||||||
|
|
||||||
|
export type TicTacToeMark = 'X' | 'O';
|
||||||
|
export type TicTacToeCell = TicTacToeMark | null;
|
||||||
|
|
||||||
|
export type TicTacToeState = {
|
||||||
|
gameId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
playerX: string;
|
||||||
|
playerO: string;
|
||||||
|
board: TicTacToeCell[];
|
||||||
|
current: TicTacToeMark;
|
||||||
|
status: 'active' | 'draw' | 'won';
|
||||||
|
winner?: TicTacToeMark;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Connect4Mark = 'R' | 'Y';
|
||||||
|
export type Connect4Cell = Connect4Mark | null;
|
||||||
|
|
||||||
|
export type Connect4State = {
|
||||||
|
gameId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
playerRed: string;
|
||||||
|
playerYellow: string;
|
||||||
|
board: Connect4Cell[][];
|
||||||
|
current: Connect4Mark;
|
||||||
|
status: 'active' | 'draw' | 'won';
|
||||||
|
winner?: Connect4Mark;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HangmanState = {
|
||||||
|
gameId: string;
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
word: string;
|
||||||
|
guessed: string[];
|
||||||
|
wrongGuesses: number;
|
||||||
|
maxWrong: number;
|
||||||
|
status: 'active' | 'won' | 'lost';
|
||||||
|
letterPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TriviaSession = {
|
||||||
|
userId: string;
|
||||||
|
guildId: string;
|
||||||
|
questionIndex: number;
|
||||||
|
correctIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TTT_LINES = [
|
||||||
|
[0, 1, 2],
|
||||||
|
[3, 4, 5],
|
||||||
|
[6, 7, 8],
|
||||||
|
[0, 3, 6],
|
||||||
|
[1, 4, 7],
|
||||||
|
[2, 5, 8],
|
||||||
|
[0, 4, 8],
|
||||||
|
[2, 4, 6]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
|
|
||||||
|
export function createGameId(): string {
|
||||||
|
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkTicTacToeWinner(board: TicTacToeCell[]): TicTacToeMark | null {
|
||||||
|
for (const [a, b, c] of TTT_LINES) {
|
||||||
|
const mark = board[a];
|
||||||
|
if (mark && mark === board[b] && mark === board[c]) {
|
||||||
|
return mark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentTicTacToePlayerId(state: TicTacToeState): string {
|
||||||
|
return state.current === 'X' ? state.playerX : state.playerO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTicTacToeMove(state: TicTacToeState, cell: number): TicTacToeState {
|
||||||
|
if (state.status !== 'active' || state.board[cell] !== null) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const board = [...state.board];
|
||||||
|
board[cell] = state.current;
|
||||||
|
const winner = checkTicTacToeWinner(board);
|
||||||
|
if (winner) {
|
||||||
|
return { ...state, board, status: 'won', winner };
|
||||||
|
}
|
||||||
|
if (board.every((value) => value !== null)) {
|
||||||
|
return { ...state, board, status: 'draw' };
|
||||||
|
}
|
||||||
|
return { ...state, board, current: state.current === 'X' ? 'O' : 'X' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnect4Board(): Connect4Cell[][] {
|
||||||
|
return Array.from({ length: 6 }, () => Array.from({ length: 7 }, () => null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect4DropRow(board: Connect4Cell[][], col: number): number {
|
||||||
|
for (let row = board.length - 1; row >= 0; row -= 1) {
|
||||||
|
if (board[row]![col] === null) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect4ColumnFull(board: Connect4Cell[][], col: number): boolean {
|
||||||
|
return board[0]![col] !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyConnect4Move(state: Connect4State, col: number): Connect4State {
|
||||||
|
if (state.status !== 'active' || col < 0 || col >= 7 || connect4ColumnFull(state.board, col)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const row = connect4DropRow(state.board, col);
|
||||||
|
if (row < 0) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const board = state.board.map((line) => [...line]);
|
||||||
|
board[row]![col] = state.current;
|
||||||
|
const winner = checkConnect4Winner(board);
|
||||||
|
if (winner) {
|
||||||
|
return { ...state, board, status: 'won', winner };
|
||||||
|
}
|
||||||
|
if (board.every((line) => line.every((cell) => cell !== null))) {
|
||||||
|
return { ...state, board, status: 'draw' };
|
||||||
|
}
|
||||||
|
return { ...state, board, current: state.current === 'R' ? 'Y' : 'R' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkConnect4Winner(board: Connect4Cell[][]): Connect4Mark | null {
|
||||||
|
const directions = [
|
||||||
|
[0, 1],
|
||||||
|
[1, 0],
|
||||||
|
[1, 1],
|
||||||
|
[1, -1]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (let row = 0; row < 6; row += 1) {
|
||||||
|
for (let col = 0; col < 7; col += 1) {
|
||||||
|
const start = board[row]![col];
|
||||||
|
if (!start) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [dr, dc] of directions) {
|
||||||
|
let matches = 1;
|
||||||
|
for (let step = 1; step < 4; step += 1) {
|
||||||
|
const next = board[row + dr * step]?.[col + dc * step];
|
||||||
|
if (next !== start) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
matches += 1;
|
||||||
|
}
|
||||||
|
if (matches >= 4) {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentConnect4PlayerId(state: Connect4State): string {
|
||||||
|
return state.current === 'R' ? state.playerRed : state.playerYellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickHangmanWord(locale: Locale): string {
|
||||||
|
const words = HANGMAN_WORDS[locale];
|
||||||
|
return words[randomInt(0, words.length - 1)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderHangmanWord(word: string, guessed: string[]): string {
|
||||||
|
return word
|
||||||
|
.split('')
|
||||||
|
.map((letter) => (guessed.includes(letter) ? letter : '⬜'))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHangmanGuess(state: HangmanState, letter: string): HangmanState {
|
||||||
|
const normalized = letter.toUpperCase();
|
||||||
|
if (
|
||||||
|
state.status !== 'active' ||
|
||||||
|
normalized.length !== 1 ||
|
||||||
|
!/^[A-Z]$/.test(normalized) ||
|
||||||
|
state.guessed.includes(normalized)
|
||||||
|
) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guessed = [...state.guessed, normalized];
|
||||||
|
const inWord = state.word.includes(normalized);
|
||||||
|
const wrongGuesses = inWord ? state.wrongGuesses : state.wrongGuesses + 1;
|
||||||
|
const revealed = state.word.split('').every((char) => guessed.includes(char));
|
||||||
|
if (revealed) {
|
||||||
|
return { ...state, guessed, wrongGuesses, status: 'won' };
|
||||||
|
}
|
||||||
|
if (wrongGuesses >= state.maxWrong) {
|
||||||
|
return { ...state, guessed, wrongGuesses, status: 'lost' };
|
||||||
|
}
|
||||||
|
return { ...state, guessed, wrongGuesses };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJson(redis: BotContext['redis'], key: string, value: unknown, ttl: number): Promise<void> {
|
||||||
|
await redis.set(key, JSON.stringify(value), 'EX', ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJson<T>(redis: BotContext['redis'], key: string): Promise<T | null> {
|
||||||
|
const raw = await redis.get(key);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTriviaSession(context: BotContext, session: TriviaSession): Promise<void> {
|
||||||
|
await saveJson(context.redis, `${TRIVIA_KEY_PREFIX}${session.userId}`, session, TRIVIA_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTriviaSession(context: BotContext, userId: string): Promise<TriviaSession | null> {
|
||||||
|
return loadJson<TriviaSession>(context.redis, `${TRIVIA_KEY_PREFIX}${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTriviaSession(context: BotContext, userId: string): Promise<void> {
|
||||||
|
await context.redis.del(`${TRIVIA_KEY_PREFIX}${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTicTacToeState(context: BotContext, state: TicTacToeState): Promise<void> {
|
||||||
|
await saveJson(context.redis, `${TTT_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTicTacToeState(context: BotContext, gameId: string): Promise<TicTacToeState | null> {
|
||||||
|
return loadJson<TicTacToeState>(context.redis, `${TTT_KEY_PREFIX}${gameId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTicTacToeState(context: BotContext, gameId: string): Promise<void> {
|
||||||
|
await context.redis.del(`${TTT_KEY_PREFIX}${gameId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConnect4State(context: BotContext, state: Connect4State): Promise<void> {
|
||||||
|
await saveJson(context.redis, `${C4_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConnect4State(context: BotContext, gameId: string): Promise<Connect4State | null> {
|
||||||
|
return loadJson<Connect4State>(context.redis, `${C4_KEY_PREFIX}${gameId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConnect4State(context: BotContext, gameId: string): Promise<void> {
|
||||||
|
await context.redis.del(`${C4_KEY_PREFIX}${gameId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHangmanState(context: BotContext, state: HangmanState): Promise<void> {
|
||||||
|
await saveJson(context.redis, `${HM_KEY_PREFIX}${state.gameId}`, state, GAME_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadHangmanState(context: BotContext, gameId: string): Promise<HangmanState | null> {
|
||||||
|
return loadJson<HangmanState>(context.redis, `${HM_KEY_PREFIX}${gameId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHangmanState(context: BotContext, gameId: string): Promise<void> {
|
||||||
|
await context.redis.del(`${HM_KEY_PREFIX}${gameId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tttCellLabel(cell: TicTacToeCell): string {
|
||||||
|
if (cell === 'X') return '❌';
|
||||||
|
if (cell === 'O') return '⭕';
|
||||||
|
return '⬜';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTicTacToeEmbed(locale: Locale, state: TicTacToeState): APIEmbed {
|
||||||
|
const boardLines = [0, 1, 2]
|
||||||
|
.map((row) => state.board.slice(row * 3, row * 3 + 3).map(tttCellLabel).join(' '))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
let description = tf(locale, 'fun.tictactoe.board', {
|
||||||
|
x: `<@${state.playerX}>`,
|
||||||
|
o: `<@${state.playerO}>`,
|
||||||
|
board: boardLines
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.status === 'won' && state.winner) {
|
||||||
|
const winnerId = state.winner === 'X' ? state.playerX : state.playerO;
|
||||||
|
description += `\n\n${tf(locale, 'fun.tictactoe.won', { user: `<@${winnerId}>` })}`;
|
||||||
|
} else if (state.status === 'draw') {
|
||||||
|
description += `\n\n${tf(locale, 'fun.tictactoe.draw', {})}`;
|
||||||
|
} else {
|
||||||
|
description += `\n\n${tf(locale, 'fun.tictactoe.turn', {
|
||||||
|
user: `<@${currentTicTacToePlayerId(state)}>`
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title: tf(locale, 'fun.tictactoe.title', {}), description, color: 0x6366f1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTicTacToeComponents(state: TicTacToeState): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
if (state.status !== 'active') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
for (let row = 0; row < 3; row += 1) {
|
||||||
|
const builder = new ActionRowBuilder<ButtonBuilder>();
|
||||||
|
for (let col = 0; col < 3; col += 1) {
|
||||||
|
const cell = row * 3 + col;
|
||||||
|
const mark = state.board[cell];
|
||||||
|
builder.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${FUN_BUTTON_PREFIX}ttt:${state.gameId}:${cell}`)
|
||||||
|
.setLabel(mark ?? String(cell + 1))
|
||||||
|
.setStyle(mark ? ButtonStyle.Secondary : ButtonStyle.Primary)
|
||||||
|
.setDisabled(mark !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows.push(builder);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function c4CellLabel(cell: Connect4Cell): string {
|
||||||
|
if (cell === 'R') return '🔴';
|
||||||
|
if (cell === 'Y') return '🟡';
|
||||||
|
return '⚪';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConnect4Embed(locale: Locale, state: Connect4State): APIEmbed {
|
||||||
|
const boardText = [...state.board]
|
||||||
|
.reverse()
|
||||||
|
.map((row) => row.map(c4CellLabel).join(''))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
let description = tf(locale, 'fun.connect4.board', {
|
||||||
|
red: `<@${state.playerRed}>`,
|
||||||
|
yellow: `<@${state.playerYellow}>`,
|
||||||
|
board: boardText
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.status === 'won' && state.winner) {
|
||||||
|
const winnerId = state.winner === 'R' ? state.playerRed : state.playerYellow;
|
||||||
|
description += `\n\n${tf(locale, 'fun.connect4.won', { user: `<@${winnerId}>` })}`;
|
||||||
|
} else if (state.status === 'draw') {
|
||||||
|
description += `\n\n${tf(locale, 'fun.connect4.draw', {})}`;
|
||||||
|
} else {
|
||||||
|
description += `\n\n${tf(locale, 'fun.connect4.turn', {
|
||||||
|
user: `<@${currentConnect4PlayerId(state)}>`
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title: tf(locale, 'fun.connect4.title', {}), description, color: 0x6366f1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConnect4Components(state: Connect4State): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
if (state.status !== 'active') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||||
|
for (let col = 0; col < 7; col += 1) {
|
||||||
|
row.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${FUN_BUTTON_PREFIX}c4:${state.gameId}:${col}`)
|
||||||
|
.setLabel(String(col + 1))
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setDisabled(connect4ColumnFull(state.board, col))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [row];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHangmanEmbed(locale: Locale, state: HangmanState): APIEmbed {
|
||||||
|
let description = tf(locale, 'fun.hangman.progress', {
|
||||||
|
word: renderHangmanWord(state.word, state.guessed),
|
||||||
|
wrong: state.wrongGuesses,
|
||||||
|
max: state.maxWrong,
|
||||||
|
guessed: state.guessed.length > 0 ? state.guessed.join(', ') : '—'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.status === 'won') {
|
||||||
|
description += `\n\n${tf(locale, 'fun.hangman.won', { word: state.word })}`;
|
||||||
|
} else if (state.status === 'lost') {
|
||||||
|
description += `\n\n${tf(locale, 'fun.hangman.lost', { word: state.word })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title: tf(locale, 'fun.hangman.title', {}), description, color: 0x6366f1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hangmanLettersForPage(state: HangmanState): { letters: string[]; page: number; totalPages: number } {
|
||||||
|
const remaining = ALPHABET.filter((letter) => !state.guessed.includes(letter));
|
||||||
|
const lettersPerPage = 24;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(remaining.length / lettersPerPage));
|
||||||
|
const page = Math.min(state.letterPage, totalPages - 1);
|
||||||
|
const start = page * lettersPerPage;
|
||||||
|
return {
|
||||||
|
letters: remaining.slice(start, start + lettersPerPage),
|
||||||
|
page,
|
||||||
|
totalPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHangmanComponents(state: HangmanState): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
if (state.status !== 'active') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { letters, page, totalPages } = hangmanLettersForPage(state);
|
||||||
|
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < letters.length; i += 5) {
|
||||||
|
const chunk = letters.slice(i, i + 5);
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||||
|
for (const letter of chunk) {
|
||||||
|
row.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:${letter}`)
|
||||||
|
.setLabel(letter)
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
if (rows.length >= 4) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navRow = new ActionRowBuilder<ButtonBuilder>();
|
||||||
|
if (totalPages > 1) {
|
||||||
|
navRow.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.max(0, page - 1)}`)
|
||||||
|
.setLabel('◀')
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(page <= 0),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${FUN_BUTTON_PREFIX}hm:${state.gameId}:page:${Math.min(totalPages - 1, page + 1)}`)
|
||||||
|
.setLabel('▶')
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(page >= totalPages - 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (navRow.components.length > 0) {
|
||||||
|
rows.push(navRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickTriviaQuestion(): (typeof TRIVIA_QUESTIONS)[number] {
|
||||||
|
return TRIVIA_QUESTIONS[randomInt(0, TRIVIA_QUESTIONS.length - 1)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTriviaEmbed(
|
||||||
|
locale: Locale,
|
||||||
|
question: (typeof TRIVIA_QUESTIONS)[number],
|
||||||
|
userId: string
|
||||||
|
): APIEmbed {
|
||||||
|
const options = question.options[locale]
|
||||||
|
.map((option, index) => `${String.fromCharCode(65 + index)}. ${option}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: tf(locale, 'fun.trivia.title', {}),
|
||||||
|
description: tf(locale, 'fun.trivia.question', {
|
||||||
|
question: question.q[locale],
|
||||||
|
options
|
||||||
|
}),
|
||||||
|
footer: { text: tf(locale, 'fun.trivia.footer', { user: `<@${userId}>` }) },
|
||||||
|
color: 0x6366f1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTriviaComponents(
|
||||||
|
userId: string,
|
||||||
|
correctIndex: number,
|
||||||
|
locale: Locale,
|
||||||
|
question: (typeof TRIVIA_QUESTIONS)[number]
|
||||||
|
): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
const labels = ['A', 'B', 'C', 'D'];
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||||
|
for (let index = 0; index < question.options[locale].length; index += 1) {
|
||||||
|
row.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${FUN_BUTTON_PREFIX}trivia:${userId}:${index}:${correctIndex}`)
|
||||||
|
.setLabel(`${labels[index]!}. ${question.options[locale][index]!.slice(0, 20)}`)
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [row];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTriviaButton(customId: string): {
|
||||||
|
userId: string;
|
||||||
|
answerIndex: number;
|
||||||
|
correctIndex: number;
|
||||||
|
} | null {
|
||||||
|
const parts = customId.split(':');
|
||||||
|
if (parts.length !== 5 || parts[0] !== 'fun' || parts[1] !== 'trivia') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const userId = parts[2];
|
||||||
|
const answerIndex = Number(parts[3]);
|
||||||
|
const correctIndex = Number(parts[4]);
|
||||||
|
if (!userId || Number.isNaN(answerIndex) || Number.isNaN(correctIndex)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { userId, answerIndex, correctIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTicTacToeButton(customId: string): { gameId: string; cell: number } | null {
|
||||||
|
const parts = customId.split(':');
|
||||||
|
if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'ttt') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const gameId = parts[2];
|
||||||
|
const cell = Number(parts[3]);
|
||||||
|
if (!gameId || Number.isNaN(cell) || cell < 0 || cell > 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { gameId, cell };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConnect4Button(customId: string): { gameId: string; col: number } | null {
|
||||||
|
const parts = customId.split(':');
|
||||||
|
if (parts.length !== 4 || parts[0] !== 'fun' || parts[1] !== 'c4') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const gameId = parts[2];
|
||||||
|
const col = Number(parts[3]);
|
||||||
|
if (!gameId || Number.isNaN(col) || col < 0 || col > 6) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { gameId, col };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHangmanButton(
|
||||||
|
customId: string
|
||||||
|
): { gameId: string; letter?: string; page?: number } | null {
|
||||||
|
const parts = customId.split(':');
|
||||||
|
if (parts.length < 4 || parts[0] !== 'fun' || parts[1] !== 'hm') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const gameId = parts[2];
|
||||||
|
if (!gameId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parts[3] === 'page') {
|
||||||
|
const page = Number(parts[4]);
|
||||||
|
if (Number.isNaN(page) || page < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { gameId, page };
|
||||||
|
}
|
||||||
|
const letter = parts[3]?.toUpperCase();
|
||||||
|
if (!letter || !/^[A-Z]$/.test(letter)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { gameId, letter };
|
||||||
|
}
|
||||||
2
apps/bot/src/modules/fun/index.ts
Normal file
2
apps/bot/src/modules/fun/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { funCommands } from './commands.js';
|
||||||
|
export { isFunButton, handleFunButton } from './buttons.js';
|
||||||
65
apps/bot/src/modules/fun/media.ts
Normal file
65
apps/bot/src/modules/fun/media.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import { getFunConfig } from './config.js';
|
||||||
|
|
||||||
|
export class FunMediaError extends Error {
|
||||||
|
constructor(public readonly code: 'disabled' | 'fetch_failed') {
|
||||||
|
super(code);
|
||||||
|
this.name = 'FunMediaError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemeApiResponse = {
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CatApiResponse = Array<{ url?: string }>;
|
||||||
|
|
||||||
|
type DogApiResponse = {
|
||||||
|
message?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function assertMediaEnabled(redis: Redis, guildId: string): Promise<void> {
|
||||||
|
const config = await getFunConfig(redis, guildId);
|
||||||
|
if (!config.mediaEnabled) {
|
||||||
|
throw new FunMediaError('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMeme(): Promise<{ url: string; title: string }> {
|
||||||
|
const response = await fetch('https://meme-api.com/gimme');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new FunMediaError('fetch_failed');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as MemeApiResponse;
|
||||||
|
if (!data.url) {
|
||||||
|
throw new FunMediaError('fetch_failed');
|
||||||
|
}
|
||||||
|
return { url: data.url, title: data.title ?? 'Meme' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCatImage(): Promise<string> {
|
||||||
|
const response = await fetch('https://api.thecatapi.com/v1/images/search');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new FunMediaError('fetch_failed');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as CatApiResponse;
|
||||||
|
const url = data[0]?.url;
|
||||||
|
if (!url) {
|
||||||
|
throw new FunMediaError('fetch_failed');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDogImage(): Promise<string> {
|
||||||
|
const response = await fetch('https://dog.ceo/api/breeds/image/random');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new FunMediaError('fetch_failed');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as DogApiResponse;
|
||||||
|
if (data.status !== 'success' || !data.message) {
|
||||||
|
throw new FunMediaError('fetch_failed');
|
||||||
|
}
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
22
apps/bot/src/modules/gdpr/command-definitions.ts
Normal file
22
apps/bot/src/modules/gdpr/command-definitions.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
applyCommandDescription,
|
||||||
|
applyOptionDescription
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
export const privacyCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('privacy'),
|
||||||
|
'gdpr.privacy.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const gdprCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('gdpr'),
|
||||||
|
'gdpr.command.description'
|
||||||
|
).addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('delete'), 'gdpr.delete.description').addBooleanOption((option) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
option.setName('confirm').setRequired(true),
|
||||||
|
'gdpr.delete.options.confirm'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
77
apps/bot/src/modules/gdpr/commands.ts
Normal file
77
apps/bot/src/modules/gdpr/commands.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { EmbedBuilder } from 'discord.js';
|
||||||
|
import { t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { env } from '../../env.js';
|
||||||
|
import { privacyCommandData, gdprCommandData } from './command-definitions.js';
|
||||||
|
import { deleteUserPersonalData } from './service.js';
|
||||||
|
|
||||||
|
const NEXUMI_COLOR = 0x6366f1;
|
||||||
|
|
||||||
|
function privacyUrl(): string {
|
||||||
|
const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, '');
|
||||||
|
return `${base}/privacy`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privacyCommand: SlashCommand = {
|
||||||
|
data: privacyCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'gdpr.privacy.title'))
|
||||||
|
.setColor(NEXUMI_COLOR)
|
||||||
|
.setDescription(
|
||||||
|
[
|
||||||
|
t(locale, 'gdpr.privacy.body'),
|
||||||
|
'',
|
||||||
|
tf(locale, 'gdpr.privacy.link', { url: privacyUrl() }),
|
||||||
|
t(locale, 'gdpr.privacy.deleteHint')
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gdprCommand: SlashCommand = {
|
||||||
|
data: gdprCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const sub = interaction.options.getSubcommand(true);
|
||||||
|
if (sub !== 'delete') {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = interaction.options.getBoolean('confirm', true);
|
||||||
|
if (!confirmed) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'gdpr.delete.needConfirm'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const summary = await deleteUserPersonalData(
|
||||||
|
context.prisma,
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.guildId
|
||||||
|
);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'gdpr.delete.doneTitle'))
|
||||||
|
.setColor(NEXUMI_COLOR)
|
||||||
|
.setDescription(
|
||||||
|
tf(locale, 'gdpr.delete.doneBody', {
|
||||||
|
warnings: summary.warnings,
|
||||||
|
levels: summary.memberLevels,
|
||||||
|
economy: summary.memberEconomies,
|
||||||
|
cases: summary.casesAnonymized
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gdprCommands: SlashCommand[] = [privacyCommand, gdprCommand];
|
||||||
2
apps/bot/src/modules/gdpr/index.ts
Normal file
2
apps/bot/src/modules/gdpr/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { gdprCommands } from './commands.js';
|
||||||
|
export { deleteUserPersonalData } from './service.js';
|
||||||
96
apps/bot/src/modules/gdpr/service.ts
Normal file
96
apps/bot/src/modules/gdpr/service.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export type GdprDeletionSummary = {
|
||||||
|
warnings: number;
|
||||||
|
modNotes: number;
|
||||||
|
memberLevels: number;
|
||||||
|
memberEconomies: number;
|
||||||
|
inventoryItems: number;
|
||||||
|
birthdays: number;
|
||||||
|
afkStatuses: number;
|
||||||
|
reminders: number;
|
||||||
|
inviteStats: number;
|
||||||
|
casesAnonymized: number;
|
||||||
|
userPremium: number;
|
||||||
|
userRecord: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes or anonymizes personal data for a Discord user across guilds.
|
||||||
|
* Moderation case history is anonymized (targetUserId cleared to a placeholder)
|
||||||
|
* so server audit integrity is preserved.
|
||||||
|
*/
|
||||||
|
export async function deleteUserPersonalData(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
guildId: string | null
|
||||||
|
): Promise<GdprDeletionSummary> {
|
||||||
|
const scope = guildId ? { guildId, userId } : { userId };
|
||||||
|
const caseScope = guildId
|
||||||
|
? { guildId, targetUserId: userId }
|
||||||
|
: { targetUserId: userId };
|
||||||
|
|
||||||
|
const [
|
||||||
|
warnings,
|
||||||
|
modNotes,
|
||||||
|
memberLevels,
|
||||||
|
memberEconomies,
|
||||||
|
inventoryItems,
|
||||||
|
birthdays,
|
||||||
|
afkStatuses,
|
||||||
|
reminders,
|
||||||
|
inviteStats,
|
||||||
|
inviteJoins,
|
||||||
|
casesAnonymized,
|
||||||
|
userPremium
|
||||||
|
] = await prisma.$transaction([
|
||||||
|
prisma.warning.deleteMany({ where: scope }),
|
||||||
|
prisma.modNote.deleteMany({ where: scope }),
|
||||||
|
prisma.memberLevel.deleteMany({ where: scope }),
|
||||||
|
prisma.memberEconomy.deleteMany({ where: scope }),
|
||||||
|
prisma.inventoryItem.deleteMany({ where: scope }),
|
||||||
|
prisma.birthday.deleteMany({ where: scope }),
|
||||||
|
prisma.afkStatus.deleteMany({ where: scope }),
|
||||||
|
prisma.reminder.deleteMany({ where: { userId, ...(guildId ? { guildId } : {}) } }),
|
||||||
|
prisma.inviteStat.deleteMany({
|
||||||
|
where: guildId ? { guildId, inviterId: userId } : { inviterId: userId }
|
||||||
|
}),
|
||||||
|
prisma.inviteJoin.deleteMany({ where: scope }),
|
||||||
|
prisma.case.updateMany({
|
||||||
|
where: caseScope,
|
||||||
|
data: { targetUserId: '0', reason: '[redacted – GDPR deletion]' }
|
||||||
|
}),
|
||||||
|
prisma.userPremiumAssignment.deleteMany({ where: { userId } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
let userRecord = 0;
|
||||||
|
if (!guildId) {
|
||||||
|
const deleted = await prisma.user.deleteMany({ where: { id: userId } });
|
||||||
|
userRecord = deleted.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: GdprDeletionSummary = {
|
||||||
|
warnings: warnings.count,
|
||||||
|
modNotes: modNotes.count,
|
||||||
|
memberLevels: memberLevels.count,
|
||||||
|
memberEconomies: memberEconomies.count,
|
||||||
|
inventoryItems: inventoryItems.count,
|
||||||
|
birthdays: birthdays.count,
|
||||||
|
afkStatuses: afkStatuses.count,
|
||||||
|
reminders: reminders.count,
|
||||||
|
inviteStats: inviteStats.count + inviteJoins.count,
|
||||||
|
casesAnonymized: casesAnonymized.count,
|
||||||
|
userPremium: userPremium.count,
|
||||||
|
userRecord
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.gdprDeletionLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
guildId,
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
42
apps/bot/src/modules/giveaways/buttons.ts
Normal file
42
apps/bot/src/modules/giveaways/buttons.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ButtonInteraction } from 'discord.js';
|
||||||
|
import { t } from '@nexumi/shared';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { GiveawayError, joinGiveaway } from './service.js';
|
||||||
|
|
||||||
|
export const GIVEAWAY_JOIN_PREFIX = 'gw:join:';
|
||||||
|
|
||||||
|
export function joinButtonId(giveawayId: string): string {
|
||||||
|
return `${GIVEAWAY_JOIN_PREFIX}${giveawayId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGiveawayButton(customId: string): boolean {
|
||||||
|
return customId.startsWith(GIVEAWAY_JOIN_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGiveawayButton(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<void> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
const giveawayId = interaction.customId.slice(GIVEAWAY_JOIN_PREFIX.length);
|
||||||
|
|
||||||
|
if (!giveawayId || !interaction.guildId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'giveaway.error.not_found'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await joinGiveaway(context, giveawayId, interaction.user.id, locale);
|
||||||
|
await interaction.reply({ content: t(locale, 'giveaway.joined'), ephemeral: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GiveawayError) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, `giveaway.error.${error.code}`),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/bot/src/modules/giveaways/command-definitions.ts
Normal file
62
apps/bot/src/modules/giveaways/command-definitions.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
export const giveawayCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('giveaway')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('duration').setRequired(true), 'giveaway.start.options.duration')
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('winners').setRequired(true).setMinValue(1).setMaxValue(25),
|
||||||
|
'giveaway.start.options.winners'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('prize').setRequired(true), 'giveaway.start.options.prize')
|
||||||
|
)
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('role'), 'giveaway.start.options.role')
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('level').setMinValue(0).setMaxValue(500),
|
||||||
|
'giveaway.start.options.level'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('member_days').setMinValue(0).setMaxValue(3650),
|
||||||
|
'giveaway.start.options.member_days'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('end'), 'giveaway.end.description').addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('reroll'), 'giveaway.reroll.description').addStringOption(
|
||||||
|
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('list'), 'giveaway.list.description')
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('delete'), 'giveaway.delete.description').addStringOption(
|
||||||
|
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('pause'), 'giveaway.pause.description').addStringOption(
|
||||||
|
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'giveaway.description'
|
||||||
|
);
|
||||||
194
apps/bot/src/modules/giveaways/commands.ts
Normal file
194
apps/bot/src/modules/giveaways/commands.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { parseDuration } from '../moderation/duration.js';
|
||||||
|
import { giveawayCommandData } from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
cancelGiveawayJob,
|
||||||
|
deleteGiveaway,
|
||||||
|
endGiveaway,
|
||||||
|
GiveawayError,
|
||||||
|
listActiveGiveaways,
|
||||||
|
pauseGiveaway,
|
||||||
|
rerollGiveaway,
|
||||||
|
startGiveaway
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
|
async function ensureManageGuild(
|
||||||
|
interaction: Parameters<SlashCommand['execute']>[0],
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!interaction.inGuild()) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyEphemeral(
|
||||||
|
interaction: Parameters<SlashCommand['execute']>[0],
|
||||||
|
content: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.editReply({ content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const giveawayCommand: SlashCommand = {
|
||||||
|
data: giveawayCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!interaction.inGuild()) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (sub === 'start') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationInput = interaction.options.getString('duration', true);
|
||||||
|
let durationMs: number;
|
||||||
|
try {
|
||||||
|
durationMs = parseDuration(durationInput);
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'giveaway.error.invalid_duration'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationMs < 60_000) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'giveaway.error.duration_too_short'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = interaction.channel;
|
||||||
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'giveaway.error.invalid_channel'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const giveaway = await startGiveaway(
|
||||||
|
context,
|
||||||
|
{
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
channelId: channel.id,
|
||||||
|
hostId: interaction.user.id,
|
||||||
|
prize: interaction.options.getString('prize', true),
|
||||||
|
winnerCount: interaction.options.getInteger('winners', true),
|
||||||
|
durationMs,
|
||||||
|
requiredRoleId: interaction.options.getRole('role')?.id,
|
||||||
|
requiredLevel: interaction.options.getInteger('level'),
|
||||||
|
requiredMemberDays: interaction.options.getInteger('member_days')
|
||||||
|
},
|
||||||
|
locale
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: tf(locale, 'giveaway.started', { id: giveaway.id })
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sub === 'end') {
|
||||||
|
const id = interaction.options.getString('id', true).trim();
|
||||||
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||||
|
if (!giveaway || giveaway.guildId !== interaction.guildId) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
await cancelGiveawayJob(id);
|
||||||
|
await endGiveaway(context, id);
|
||||||
|
await interaction.editReply({ content: t(locale, 'giveaway.ended') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'reroll') {
|
||||||
|
const id = interaction.options.getString('id', true).trim();
|
||||||
|
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.guildId !== interaction.guildId) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
const updated = await rerollGiveaway(context, id, locale);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: tf(locale, 'giveaway.rerolled', {
|
||||||
|
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const active = await listActiveGiveaways(context, interaction.guildId!);
|
||||||
|
if (active.length === 0) {
|
||||||
|
await interaction.reply({ content: t(locale, 'giveaway.list.empty'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = active.map(
|
||||||
|
(g) =>
|
||||||
|
`\`${g.id}\` — **${g.prize}** (${g.entrants.length} ${t(locale, 'giveaway.list.entrants')}, <t:${Math.floor(g.endsAt.getTime() / 1000)}:R>)`
|
||||||
|
);
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'giveaway.list.header')}\n${lines.join('\n')}`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'delete') {
|
||||||
|
const id = interaction.options.getString('id', true).trim();
|
||||||
|
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.guildId !== interaction.guildId) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
await deleteGiveaway(context, id);
|
||||||
|
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'pause') {
|
||||||
|
const id = interaction.options.getString('id', true).trim();
|
||||||
|
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.guildId !== interaction.guildId) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
const updated = await pauseGiveaway(context, id, locale);
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GiveawayError) {
|
||||||
|
await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const giveawayCommands = [giveawayCommand];
|
||||||
11
apps/bot/src/modules/giveaways/index.ts
Normal file
11
apps/bot/src/modules/giveaways/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { giveawayCommands } from './commands.js';
|
||||||
|
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||||
|
export {
|
||||||
|
endGiveaway,
|
||||||
|
scheduleGiveawayEnd,
|
||||||
|
runGiveawayCreateJob,
|
||||||
|
runGiveawayPauseJob,
|
||||||
|
runGiveawayDeleteJob,
|
||||||
|
recoverOverdueGiveaways,
|
||||||
|
GiveawayError
|
||||||
|
} from './service.js';
|
||||||
565
apps/bot/src/modules/giveaways/service.ts
Normal file
565
apps/bot/src/modules/giveaways/service.ts
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
EmbedBuilder,
|
||||||
|
type Guild,
|
||||||
|
type GuildMember,
|
||||||
|
type TextChannel
|
||||||
|
} from 'discord.js';
|
||||||
|
import { pickRandomWinners, t, tf } from '@nexumi/shared';
|
||||||
|
import type { Giveaway } from '@prisma/client';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { giveawayQueue } from '../../queues.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { joinButtonId } from './buttons.js';
|
||||||
|
|
||||||
|
export class GiveawayError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function giveawayEndJobId(giveawayId: string): string {
|
||||||
|
return `giveaway-end-${giveawayId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a scheduled/failed end job if present. Locked (active) jobs cannot be
|
||||||
|
* removed — callers must not invoke this from inside the giveawayEnd worker.
|
||||||
|
*/
|
||||||
|
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
||||||
|
const job = await giveawayQueue.getJob(giveawayEndJobId(giveawayId));
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await job.remove();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('locked') || message.includes('could not be removed')) {
|
||||||
|
logger.warn({ giveawayId, error }, 'Giveaway end job is locked; skipping remove');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
|
||||||
|
await cancelGiveawayJob(giveawayId);
|
||||||
|
const delay = Math.max(0, endsAt.getTime() - Date.now());
|
||||||
|
await giveawayQueue.add(
|
||||||
|
'giveawayEnd',
|
||||||
|
{ giveawayId },
|
||||||
|
{
|
||||||
|
delay,
|
||||||
|
jobId: giveawayEndJobId(giveawayId),
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 50
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (giveaway.requiredRoleId) {
|
||||||
|
lines.push(tf(locale, 'giveaway.requirement.role', { role: `<@&${giveaway.requiredRoleId}>` }));
|
||||||
|
}
|
||||||
|
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
|
||||||
|
lines.push(tf(locale, 'giveaway.requirement.level', { level: giveaway.requiredLevel }));
|
||||||
|
}
|
||||||
|
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
|
||||||
|
lines.push(
|
||||||
|
tf(locale, 'giveaway.requirement.memberDays', { days: giveaway.requiredMemberDays })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
|
||||||
|
const ended = giveaway.endedAt !== null;
|
||||||
|
const endsAtUnix = Math.floor(giveaway.endsAt.getTime() / 1000);
|
||||||
|
// Discord does not parse <t:…> timestamps in embed footers — only in
|
||||||
|
// description/fields/content. Keep relative + absolute for clarity.
|
||||||
|
const endsAtText = `<t:${endsAtUnix}:R> (<t:${endsAtUnix}:f>)`;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'giveaway.embed.title'))
|
||||||
|
.setColor(ended ? 0x6b7280 : 0x6366f1)
|
||||||
|
.addFields(
|
||||||
|
{ name: t(locale, 'giveaway.embed.prize'), value: giveaway.prize, inline: true },
|
||||||
|
{
|
||||||
|
name: t(locale, 'giveaway.embed.winners'),
|
||||||
|
value: String(giveaway.winnerCount),
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t(locale, 'giveaway.embed.entrants'),
|
||||||
|
value: String(giveaway.entrants.length),
|
||||||
|
inline: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ended) {
|
||||||
|
embed.setDescription(
|
||||||
|
giveaway.winners.length > 0
|
||||||
|
? tf(locale, 'giveaway.embed.endedWinners', {
|
||||||
|
winners: giveaway.winners.map((id) => `<@${id}>`).join(', ')
|
||||||
|
})
|
||||||
|
: t(locale, 'giveaway.embed.endedNoWinners')
|
||||||
|
);
|
||||||
|
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
|
||||||
|
} else if (giveaway.paused) {
|
||||||
|
embed.setDescription(
|
||||||
|
`${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
|
||||||
|
time: endsAtText
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
embed.setDescription(
|
||||||
|
`${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
|
||||||
|
time: endsAtText
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirements = requirementLines(locale, giveaway);
|
||||||
|
if (requirements.length > 0) {
|
||||||
|
embed.addFields({
|
||||||
|
name: t(locale, 'giveaway.embed.requirements'),
|
||||||
|
value: requirements.join('\n')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: t(locale, 'giveaway.embed.host'),
|
||||||
|
value: `<@${giveaway.hostId}>`,
|
||||||
|
inline: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGiveawayComponents(
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
giveaway: Giveaway
|
||||||
|
): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
if (giveaway.endedAt) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(joinButtonId(giveaway.id))
|
||||||
|
.setLabel(t(locale, 'giveaway.button.join'))
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setDisabled(giveaway.paused)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGiveawayMessage(
|
||||||
|
context: BotContext,
|
||||||
|
giveaway: Giveaway,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<void> {
|
||||||
|
if (!giveaway.messageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
|
||||||
|
const message = await channel.messages.fetch(giveaway.messageId);
|
||||||
|
await message.edit({
|
||||||
|
embeds: [buildGiveawayEmbed(locale, giveaway)],
|
||||||
|
components: buildGiveawayComponents(locale, giveaway)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, giveawayId: giveaway.id }, 'Failed to update giveaway message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkGiveawayRequirements(
|
||||||
|
context: BotContext,
|
||||||
|
guild: Guild,
|
||||||
|
member: GuildMember,
|
||||||
|
giveaway: Giveaway
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (giveaway.requiredRoleId && !member.roles.cache.has(giveaway.requiredRoleId)) {
|
||||||
|
return 'missing_role';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
|
||||||
|
const levelRow = await context.prisma.memberLevel.findUnique({
|
||||||
|
where: { guildId_userId: { guildId: guild.id, userId: member.id } }
|
||||||
|
});
|
||||||
|
const level = levelRow?.level ?? 0;
|
||||||
|
if (level < giveaway.requiredLevel) {
|
||||||
|
return 'insufficient_level';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
|
||||||
|
const joinedAt = member.joinedAt;
|
||||||
|
const days = joinedAt
|
||||||
|
? Math.floor((Date.now() - joinedAt.getTime()) / 86_400_000)
|
||||||
|
: 0;
|
||||||
|
if (days < giveaway.requiredMemberDays) {
|
||||||
|
return 'insufficient_member_days';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startGiveaway(
|
||||||
|
context: BotContext,
|
||||||
|
params: {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
hostId: string;
|
||||||
|
prize: string;
|
||||||
|
winnerCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
requiredRoleId?: string | null;
|
||||||
|
requiredLevel?: number | null;
|
||||||
|
requiredMemberDays?: number | null;
|
||||||
|
},
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<Giveaway> {
|
||||||
|
await ensureGuild(context.prisma, params.guildId);
|
||||||
|
const endsAt = new Date(Date.now() + params.durationMs);
|
||||||
|
|
||||||
|
const giveaway = await context.prisma.giveaway.create({
|
||||||
|
data: {
|
||||||
|
guildId: params.guildId,
|
||||||
|
channelId: params.channelId,
|
||||||
|
prize: params.prize,
|
||||||
|
winnerCount: params.winnerCount,
|
||||||
|
endsAt,
|
||||||
|
requiredRoleId: params.requiredRoleId ?? null,
|
||||||
|
requiredLevel: params.requiredLevel ?? null,
|
||||||
|
requiredMemberDays: params.requiredMemberDays ?? null,
|
||||||
|
hostId: params.hostId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
|
||||||
|
const message = await channel.send({
|
||||||
|
embeds: [buildGiveawayEmbed(locale, giveaway)],
|
||||||
|
components: buildGiveawayComponents(locale, giveaway)
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await context.prisma.giveaway.update({
|
||||||
|
where: { id: giveaway.id },
|
||||||
|
data: { messageId: message.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
await scheduleGiveawayEnd(updated.id, updated.endsAt);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinGiveaway(
|
||||||
|
context: BotContext,
|
||||||
|
giveawayId: string,
|
||||||
|
userId: string,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<Giveaway> {
|
||||||
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
|
if (!giveaway || giveaway.endedAt) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
if (giveaway.paused) {
|
||||||
|
throw new GiveawayError('paused');
|
||||||
|
}
|
||||||
|
if (giveaway.entrants.includes(userId)) {
|
||||||
|
throw new GiveawayError('already_joined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||||
|
const member = await guild.members.fetch(userId).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
throw new GiveawayError('member_not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirementError = await checkGiveawayRequirements(context, guild, member, giveaway);
|
||||||
|
if (requirementError) {
|
||||||
|
throw new GiveawayError(requirementError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await context.prisma.giveaway.update({
|
||||||
|
where: { id: giveawayId },
|
||||||
|
data: { entrants: { push: userId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateGiveawayMessage(context, updated, locale);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterEligibleEntrants(
|
||||||
|
context: BotContext,
|
||||||
|
guild: Guild,
|
||||||
|
giveaway: Giveaway
|
||||||
|
): Promise<string[]> {
|
||||||
|
const eligible: string[] = [];
|
||||||
|
for (const userId of giveaway.entrants) {
|
||||||
|
const member = await guild.members.fetch(userId).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const error = await checkGiveawayRequirements(context, guild, member, giveaway);
|
||||||
|
if (!error) {
|
||||||
|
eligible.push(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eligible;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dmWinners(
|
||||||
|
context: BotContext,
|
||||||
|
giveaway: Giveaway,
|
||||||
|
winnerIds: string[],
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<void> {
|
||||||
|
for (const userId of winnerIds) {
|
||||||
|
try {
|
||||||
|
const user = await context.client.users.fetch(userId);
|
||||||
|
await user.send(
|
||||||
|
tf(locale, 'giveaway.dm.won', {
|
||||||
|
prize: giveaway.prize,
|
||||||
|
guild: (await context.client.guilds.fetch(giveaway.guildId)).name
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, userId, giveawayId: giveaway.id }, 'Failed to DM giveaway winner');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws winners and marks the giveaway ended. Does **not** cancel BullMQ jobs —
|
||||||
|
* the slash-command / dashboard paths cancel the delayed job first; the
|
||||||
|
* `giveawayEnd` worker must never remove its own locked job (that threw and
|
||||||
|
* left giveaways stuck with the join button still visible).
|
||||||
|
*
|
||||||
|
* Uses a conditional update so concurrent ends (timer + slash + recover)
|
||||||
|
* cannot each write different winners.
|
||||||
|
*/
|
||||||
|
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
|
||||||
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
|
if (!giveaway) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
if (giveaway.endedAt) {
|
||||||
|
throw new GiveawayError('already_ended');
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||||
|
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||||
|
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
||||||
|
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
|
||||||
|
const endedAt = new Date();
|
||||||
|
|
||||||
|
const claimed = await context.prisma.giveaway.updateMany({
|
||||||
|
where: { id: giveawayId, endedAt: null },
|
||||||
|
data: {
|
||||||
|
endedAt,
|
||||||
|
winners,
|
||||||
|
paused: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (claimed.count === 0) {
|
||||||
|
const current = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
|
if (!current) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
throw new GiveawayError('already_ended');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await context.prisma.giveaway.findUniqueOrThrow({ where: { id: giveawayId } });
|
||||||
|
|
||||||
|
await updateGiveawayMessage(context, updated, locale);
|
||||||
|
if (winners.length > 0) {
|
||||||
|
await dmWinners(context, updated, winners, locale);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends giveaways whose `endsAt` has passed but `endedAt` is still null
|
||||||
|
* (e.g. after a failed/locked BullMQ job). Skips paused giveaways.
|
||||||
|
*/
|
||||||
|
export async function recoverOverdueGiveaways(context: BotContext): Promise<number> {
|
||||||
|
const overdue = await context.prisma.giveaway.findMany({
|
||||||
|
where: {
|
||||||
|
endedAt: null,
|
||||||
|
paused: false,
|
||||||
|
endsAt: { lte: new Date() }
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
orderBy: { endsAt: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
let recovered = 0;
|
||||||
|
for (const giveaway of overdue) {
|
||||||
|
try {
|
||||||
|
await cancelGiveawayJob(giveaway.id);
|
||||||
|
await endGiveaway(context, giveaway.id);
|
||||||
|
recovered += 1;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GiveawayError && error.code === 'already_ended') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logger.error({ error, giveawayId: giveaway.id }, 'Failed to recover overdue giveaway');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recovered > 0) {
|
||||||
|
logger.info({ recovered }, 'Recovered overdue giveaways');
|
||||||
|
}
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rerollGiveaway(
|
||||||
|
context: BotContext,
|
||||||
|
giveawayId: string,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<Giveaway> {
|
||||||
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
|
if (!giveaway || !giveaway.endedAt) {
|
||||||
|
throw new GiveawayError('not_ended');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||||
|
const eligible = (await filterEligibleEntrants(context, guild, giveaway)).filter(
|
||||||
|
(id) => !giveaway.winners.includes(id)
|
||||||
|
);
|
||||||
|
const newWinners = pickRandomWinners(eligible, giveaway.winnerCount);
|
||||||
|
|
||||||
|
const updated = await context.prisma.giveaway.update({
|
||||||
|
where: { id: giveawayId },
|
||||||
|
data: { winners: newWinners }
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateGiveawayMessage(context, updated, locale);
|
||||||
|
if (newWinners.length > 0) {
|
||||||
|
await dmWinners(context, updated, newWinners, locale);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pauseGiveaway(
|
||||||
|
context: BotContext,
|
||||||
|
giveawayId: string,
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
forcePaused?: boolean
|
||||||
|
): Promise<Giveaway> {
|
||||||
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
|
if (!giveaway || giveaway.endedAt) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPaused = forcePaused ?? !giveaway.paused;
|
||||||
|
if (nextPaused === giveaway.paused) {
|
||||||
|
await updateGiveawayMessage(context, giveaway, locale);
|
||||||
|
return giveaway;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPaused) {
|
||||||
|
// Stop the end timer while paused; remaining wall-clock is preserved via endsAt.
|
||||||
|
await cancelGiveawayJob(giveawayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await context.prisma.giveaway.update({
|
||||||
|
where: { id: giveawayId },
|
||||||
|
data: { paused: nextPaused }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!nextPaused) {
|
||||||
|
// Resume: re-schedule with delay based on remaining time until endsAt
|
||||||
|
// (delay 0 if endsAt already passed while paused → ends immediately).
|
||||||
|
await scheduleGiveawayEnd(updated.id, updated.endsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateGiveawayMessage(context, updated, locale);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard entry points — same Discord side-effects as slash commands.
|
||||||
|
*/
|
||||||
|
export async function runGiveawayPauseJob(
|
||||||
|
context: BotContext,
|
||||||
|
giveawayId: string,
|
||||||
|
paused: boolean
|
||||||
|
): Promise<{ id: string; paused: boolean }> {
|
||||||
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
|
if (!giveaway) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||||
|
const updated = await pauseGiveaway(context, giveawayId, locale, paused);
|
||||||
|
return { id: updated.id, paused: updated.paused };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runGiveawayDeleteJob(context: BotContext, giveawayId: string): Promise<void> {
|
||||||
|
await deleteGiveaway(context, giveawayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
||||||
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
|
if (!giveaway) {
|
||||||
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await cancelGiveawayJob(giveawayId);
|
||||||
|
|
||||||
|
if (giveaway.messageId) {
|
||||||
|
try {
|
||||||
|
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
|
||||||
|
const message = await channel.messages.fetch(giveaway.messageId);
|
||||||
|
await message.delete();
|
||||||
|
} catch {
|
||||||
|
// Message may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.prisma.giveaway.delete({ where: { id: giveawayId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for the `giveawayCreate` BullMQ job, enqueued by the WebUI
|
||||||
|
* dashboard (which has no discord.js Client to post the giveaway message
|
||||||
|
* itself). Resolves the guild's locale and delegates to {@link startGiveaway}
|
||||||
|
* so dashboard-created giveaways behave identically to `/giveaway start`.
|
||||||
|
*/
|
||||||
|
export async function runGiveawayCreateJob(
|
||||||
|
context: BotContext,
|
||||||
|
params: {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
hostId: string;
|
||||||
|
prize: string;
|
||||||
|
winnerCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
requiredRoleId?: string | null;
|
||||||
|
requiredLevel?: number | null;
|
||||||
|
requiredMemberDays?: number | null;
|
||||||
|
}
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, params.guildId);
|
||||||
|
const giveaway = await startGiveaway(context, params, locale);
|
||||||
|
return { id: giveaway.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActiveGiveaways(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string
|
||||||
|
): Promise<Giveaway[]> {
|
||||||
|
return context.prisma.giveaway.findMany({
|
||||||
|
where: { guildId, endedAt: null },
|
||||||
|
orderBy: { endsAt: 'asc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
33
apps/bot/src/modules/guildbackup/command-definitions.ts
Normal file
33
apps/bot/src/modules/guildbackup/command-definitions.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
export const backupCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('backup')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
|
||||||
|
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'guildbackup.list.description'))
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('info'), 'guildbackup.info.description').addStringOption(
|
||||||
|
(o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('delete'), 'guildbackup.delete.description').addStringOption(
|
||||||
|
(o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('restore'), 'guildbackup.restore.description').addStringOption(
|
||||||
|
(o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'guildbackup.description'
|
||||||
|
);
|
||||||
209
apps/bot/src/modules/guildbackup/commands.ts
Normal file
209
apps/bot/src/modules/guildbackup/commands.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { backupCommandData } from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
createGuildBackup,
|
||||||
|
ensureBotCanRestore,
|
||||||
|
getGuildBackup,
|
||||||
|
GuildBackupError,
|
||||||
|
listGuildBackups,
|
||||||
|
parseBackupPayload
|
||||||
|
} from './service.js';
|
||||||
|
import { promptDeleteConfirmation, promptRestoreConfirmation } from './confirmations.js';
|
||||||
|
|
||||||
|
async function ensureManageGuild(
|
||||||
|
interaction: Parameters<SlashCommand['execute']>[0],
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!interaction.inGuild()) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultBackupName(): string {
|
||||||
|
return `backup-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupCommand: SlashCommand = {
|
||||||
|
data: backupCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!interaction.inGuild()) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
const guild = interaction.guild!;
|
||||||
|
|
||||||
|
if (sub === 'create') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = interaction.options.getString('name')?.trim() || defaultBackupName();
|
||||||
|
if (name.length < 1 || name.length > 100) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.invalid_name'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GuildBackupError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: t(locale, `guildbackup.error.${error.code}`)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const backups = await listGuildBackups(context, guild.id);
|
||||||
|
if (backups.length === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.list.empty'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = backups.map((backup) =>
|
||||||
|
tf(locale, 'guildbackup.list.line', {
|
||||||
|
id: backup.id,
|
||||||
|
name: backup.name,
|
||||||
|
date: backup.createdAt.toISOString().slice(0, 10)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'guildbackup.list.header')}\n${lines.join('\n')}`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'info') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const backupId = interaction.options.getString('id', true);
|
||||||
|
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||||
|
if (!backup) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let roleCount = 0;
|
||||||
|
let channelCount = 0;
|
||||||
|
try {
|
||||||
|
const payload = parseBackupPayload(backup.payload);
|
||||||
|
roleCount = payload.roles.length;
|
||||||
|
channelCount = payload.channels.length;
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.invalid_payload'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: [
|
||||||
|
tf(locale, 'guildbackup.info.id', { id: backup.id }),
|
||||||
|
tf(locale, 'guildbackup.info.name', { name: backup.name }),
|
||||||
|
tf(locale, 'guildbackup.info.created', { date: backup.createdAt.toISOString() }),
|
||||||
|
tf(locale, 'guildbackup.info.createdBy', { userId: backup.createdById }),
|
||||||
|
tf(locale, 'guildbackup.info.roles', { count: roleCount }),
|
||||||
|
tf(locale, 'guildbackup.info.channels', { count: channelCount }),
|
||||||
|
t(locale, 'guildbackup.info.limitations')
|
||||||
|
].join('\n'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'delete') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupId = interaction.options.getString('id', true);
|
||||||
|
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||||
|
if (!backup) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await promptDeleteConfirmation(interaction, context, locale, backupId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'restore') {
|
||||||
|
if (guild.ownerId !== interaction.user.id) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.owner_only'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBotCanRestore(guild)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.bot_missing_permissions'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupId = interaction.options.getString('id', true);
|
||||||
|
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||||
|
if (!backup) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseBackupPayload(backup.payload);
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.invalid_payload'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await promptRestoreConfirmation(interaction, context, locale, backupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guildBackupCommands = [backupCommand];
|
||||||
296
apps/bot/src/modules/guildbackup/confirmations.ts
Normal file
296
apps/bot/src/modules/guildbackup/confirmations.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
type ButtonInteraction,
|
||||||
|
type ChatInputCommandInteraction
|
||||||
|
} from 'discord.js';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from '../../confirmation-store.js';
|
||||||
|
import {
|
||||||
|
deleteGuildBackup,
|
||||||
|
enqueueGuildBackupRestore,
|
||||||
|
getGuildBackup,
|
||||||
|
GuildBackupError,
|
||||||
|
parseBackupPayload,
|
||||||
|
restoreGuildBackup,
|
||||||
|
shouldQueueRestore
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
|
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
||||||
|
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
||||||
|
const CANCEL_PREFIX = 'gbackup:cancel:';
|
||||||
|
const NAMESPACE = 'guildbackup';
|
||||||
|
const TTL_SECONDS = 120;
|
||||||
|
|
||||||
|
type PendingDelete = {
|
||||||
|
action: 'delete';
|
||||||
|
backupId: string;
|
||||||
|
userId: string;
|
||||||
|
guildId: string;
|
||||||
|
locale: Locale;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRestore = {
|
||||||
|
action: 'restore';
|
||||||
|
step: 1 | 2;
|
||||||
|
backupId: string;
|
||||||
|
userId: string;
|
||||||
|
guildId: string;
|
||||||
|
locale: Locale;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingEntry = PendingDelete | PendingRestore;
|
||||||
|
|
||||||
|
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${confirmPrefix}${token}`)
|
||||||
|
.setLabel(t(locale, 'guildbackup.confirm.button.confirm'))
|
||||||
|
.setStyle(ButtonStyle.Danger),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${CANCEL_PREFIX}${token}`)
|
||||||
|
.setLabel(t(locale, 'guildbackup.confirm.button.cancel'))
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptDeleteConfirmation(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
locale: Locale,
|
||||||
|
backupId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const token = randomUUID();
|
||||||
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
action: 'delete',
|
||||||
|
backupId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
locale
|
||||||
|
} satisfies PendingDelete,
|
||||||
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
||||||
|
components: buildRows(token, locale, DELETE_CONFIRM_PREFIX),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptRestoreConfirmation(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
locale: Locale,
|
||||||
|
backupId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const token = randomUUID();
|
||||||
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
action: 'restore',
|
||||||
|
step: 1,
|
||||||
|
backupId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
locale
|
||||||
|
} satisfies PendingRestore,
|
||||||
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
||||||
|
components: buildRows(token, locale, RESTORE_CONFIRM_PREFIX),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGuildBackupButton(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<void> {
|
||||||
|
const customId = interaction.customId;
|
||||||
|
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
||||||
|
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
||||||
|
const isCancel = customId.startsWith(CANCEL_PREFIX);
|
||||||
|
|
||||||
|
if (!isDeleteConfirm && !isRestoreConfirm && !isCancel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = customId.slice(
|
||||||
|
isDeleteConfirm
|
||||||
|
? DELETE_CONFIRM_PREFIX.length
|
||||||
|
: isRestoreConfirm
|
||||||
|
? RESTORE_CONFIRM_PREFIX.length
|
||||||
|
: CANCEL_PREFIX.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
|
if (!peeked) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.user.id !== peeked.userId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancel) {
|
||||||
|
await deleteConfirmation(context.redis, NAMESPACE, token);
|
||||||
|
await interaction.update({
|
||||||
|
content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peeked.action === 'delete') {
|
||||||
|
const entry = await takeConfirmation<PendingDelete>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await executeDelete(interaction, context, entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peeked.step === 1) {
|
||||||
|
const next: PendingRestore = { ...peeked, step: 2 };
|
||||||
|
await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
|
||||||
|
await interaction.update({
|
||||||
|
content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
|
||||||
|
components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await takeConfirmation<PendingRestore>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await executeRestore(interaction, context, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDelete(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
entry: PendingDelete
|
||||||
|
): Promise<void> {
|
||||||
|
const deleted = await deleteGuildBackup(context, entry.guildId, entry.backupId);
|
||||||
|
if (!deleted) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'guildbackup.error.not_found'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
content: tf(entry.locale, 'guildbackup.deleted', { id: entry.backupId }),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRestore(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
entry: PendingRestore
|
||||||
|
): Promise<void> {
|
||||||
|
const guild = interaction.guild;
|
||||||
|
if (!guild) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'generic.guildOnly'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = await getGuildBackup(context, entry.guildId, entry.backupId);
|
||||||
|
if (!backup) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'guildbackup.error.not_found'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = parseBackupPayload(backup.payload);
|
||||||
|
} catch {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'guildbackup.error.invalid_payload'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldQueueRestore(payload)) {
|
||||||
|
await enqueueGuildBackupRestore(entry.backupId, entry.guildId, entry.userId);
|
||||||
|
await interaction.update({
|
||||||
|
content: tf(entry.locale, 'guildbackup.restore.queued', { id: entry.backupId }),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await restoreGuildBackup(guild, payload);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: tf(entry.locale, 'guildbackup.restore.done', {
|
||||||
|
id: entry.backupId,
|
||||||
|
rolesCreated: summary.rolesCreated,
|
||||||
|
rolesSkipped: summary.rolesSkipped,
|
||||||
|
channelsCreated: summary.channelsCreated,
|
||||||
|
channelsUpdated: summary.channelsUpdated,
|
||||||
|
overwritesApplied: summary.overwritesApplied
|
||||||
|
}),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GuildBackupError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: t(entry.locale, `guildbackup.error.${error.code}`),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGuildBackupButton(customId: string): boolean {
|
||||||
|
return (
|
||||||
|
customId.startsWith(DELETE_CONFIRM_PREFIX) ||
|
||||||
|
customId.startsWith(RESTORE_CONFIRM_PREFIX) ||
|
||||||
|
customId.startsWith(CANCEL_PREFIX)
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/bot/src/modules/guildbackup/index.ts
Normal file
11
apps/bot/src/modules/guildbackup/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { guildBackupCommands } from './commands.js';
|
||||||
|
export { isGuildBackupButton, handleGuildBackupButton } from './confirmations.js';
|
||||||
|
export {
|
||||||
|
createGuildBackup,
|
||||||
|
deleteGuildBackup,
|
||||||
|
getGuildBackup,
|
||||||
|
listGuildBackups,
|
||||||
|
restoreGuildBackup,
|
||||||
|
runGuildBackupRestoreJob,
|
||||||
|
shouldQueueRestore
|
||||||
|
} from './service.js';
|
||||||
645
apps/bot/src/modules/guildbackup/service.ts
Normal file
645
apps/bot/src/modules/guildbackup/service.ts
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
import {
|
||||||
|
ChannelType,
|
||||||
|
OverwriteType,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
type Guild,
|
||||||
|
type GuildChannel,
|
||||||
|
type NonThreadGuildBasedChannel,
|
||||||
|
type OverwriteResolvable
|
||||||
|
} from 'discord.js';
|
||||||
|
import { GuildBackupPayloadSchema, type GuildBackupPayload } from '@nexumi/shared';
|
||||||
|
import type { GuildBackup } from '@prisma/client';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
|
||||||
|
import { guildBackupQueue } from '../../queues.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
export class GuildBackupError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LARGE_RESTORE_THRESHOLD = 50;
|
||||||
|
const RESTORE_REASON = 'Nexumi guild backup restore';
|
||||||
|
|
||||||
|
export type RestoreSummary = {
|
||||||
|
rolesCreated: number;
|
||||||
|
rolesSkipped: number;
|
||||||
|
channelsCreated: number;
|
||||||
|
channelsUpdated: number;
|
||||||
|
overwritesApplied: number;
|
||||||
|
settingsApplied: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createGuildBackup(
|
||||||
|
context: BotContext,
|
||||||
|
guild: Guild,
|
||||||
|
name: string,
|
||||||
|
createdById: string
|
||||||
|
): Promise<GuildBackup> {
|
||||||
|
await guild.roles.fetch();
|
||||||
|
await guild.channels.fetch();
|
||||||
|
|
||||||
|
const payload = buildPayloadFromGuild(guild);
|
||||||
|
GuildBackupPayloadSchema.parse(payload);
|
||||||
|
|
||||||
|
await ensureGuild(context.prisma, guild.id);
|
||||||
|
|
||||||
|
const currentCount = await context.prisma.guildBackup.count({ where: { guildId: guild.id } });
|
||||||
|
try {
|
||||||
|
await assertPremiumLimit(context.prisma, guild.id, 'backups', currentCount);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PremiumLimitError) {
|
||||||
|
throw new GuildBackupError(
|
||||||
|
error.code === 'feature_disabled' ? 'premium_feature' : 'premium_limit'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.prisma.guildBackup.create({
|
||||||
|
data: {
|
||||||
|
guildId: guild.id,
|
||||||
|
name,
|
||||||
|
createdById,
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPayloadFromGuild(guild: Guild): GuildBackupPayload {
|
||||||
|
const roles = [...guild.roles.cache.values()]
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((role) => ({
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
color: role.color,
|
||||||
|
hoist: role.hoist,
|
||||||
|
mentionable: role.mentionable,
|
||||||
|
permissions: role.permissions.bitfield.toString(),
|
||||||
|
position: role.position
|
||||||
|
}));
|
||||||
|
|
||||||
|
const channels = [...guild.channels.cache.values()]
|
||||||
|
.filter(
|
||||||
|
(channel): channel is NonThreadGuildBasedChannel =>
|
||||||
|
!channel.isThread() && 'permissionOverwrites' in channel
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.parentId !== b.parentId) {
|
||||||
|
return (a.parentId ?? '').localeCompare(b.parentId ?? '');
|
||||||
|
}
|
||||||
|
return a.rawPosition - b.rawPosition;
|
||||||
|
})
|
||||||
|
.map((channel) => {
|
||||||
|
const base = {
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name,
|
||||||
|
type: channel.type,
|
||||||
|
parentId: channel.parentId,
|
||||||
|
position: channel.rawPosition,
|
||||||
|
permissionOverwrites: channel.permissionOverwrites.cache.map((overwrite) => ({
|
||||||
|
id: overwrite.id,
|
||||||
|
type: overwrite.type,
|
||||||
|
allow: overwrite.allow.bitfield.toString(),
|
||||||
|
deny: overwrite.deny.bitfield.toString()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (channel.type === ChannelType.GuildText || channel.type === ChannelType.GuildAnnouncement) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
topic: channel.topic ?? undefined,
|
||||||
|
nsfw: channel.nsfw,
|
||||||
|
rateLimitPerUser: channel.rateLimitPerUser ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
bitrate: channel.bitrate ?? undefined,
|
||||||
|
userLimit: channel.userLimit ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.type === ChannelType.GuildForum) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
topic: channel.topic ?? undefined,
|
||||||
|
nsfw: channel.nsfw,
|
||||||
|
rateLimitPerUser: channel.rateLimitPerUser ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
guildName: guild.name,
|
||||||
|
roles,
|
||||||
|
channels,
|
||||||
|
settings: {
|
||||||
|
verificationLevel: guild.verificationLevel,
|
||||||
|
explicitContentFilter: guild.explicitContentFilter,
|
||||||
|
defaultMessageNotifications: guild.defaultMessageNotifications,
|
||||||
|
afkTimeout: guild.afkTimeout
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGuildBackups(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
limit = 15
|
||||||
|
): Promise<GuildBackup[]> {
|
||||||
|
return context.prisma.guildBackup.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuildBackup(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
backupId: string
|
||||||
|
): Promise<GuildBackup | null> {
|
||||||
|
return context.prisma.guildBackup.findFirst({
|
||||||
|
where: { id: backupId, guildId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGuildBackup(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
backupId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await context.prisma.guildBackup.deleteMany({
|
||||||
|
where: { id: backupId, guildId }
|
||||||
|
});
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupPayload(raw: unknown): GuildBackupPayload {
|
||||||
|
const parsed = GuildBackupPayloadSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new GuildBackupError('invalid_payload');
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldQueueRestore(payload: GuildBackupPayload): boolean {
|
||||||
|
return payload.roles.length + payload.channels.length >= LARGE_RESTORE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueGuildBackupRestore(
|
||||||
|
backupId: string,
|
||||||
|
guildId: string,
|
||||||
|
requestedById: string
|
||||||
|
): Promise<void> {
|
||||||
|
await guildBackupQueue.add(
|
||||||
|
'restoreGuildBackup',
|
||||||
|
{ backupId, guildId, requestedById },
|
||||||
|
{
|
||||||
|
jobId: `guild-backup-restore-${backupId}`,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 25
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runGuildBackupRestoreJob(
|
||||||
|
context: BotContext,
|
||||||
|
backupId: string,
|
||||||
|
guildId: string
|
||||||
|
): Promise<RestoreSummary> {
|
||||||
|
const backup = await getGuildBackup(context, guildId, backupId);
|
||||||
|
if (!backup) {
|
||||||
|
throw new GuildBackupError('not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(guildId);
|
||||||
|
const payload = parseBackupPayload(backup.payload);
|
||||||
|
return restoreGuildBackup(guild, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreGuildBackup(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload
|
||||||
|
): Promise<RestoreSummary> {
|
||||||
|
await guild.roles.fetch();
|
||||||
|
await guild.channels.fetch();
|
||||||
|
|
||||||
|
const summary: RestoreSummary = {
|
||||||
|
rolesCreated: 0,
|
||||||
|
rolesSkipped: 0,
|
||||||
|
channelsCreated: 0,
|
||||||
|
channelsUpdated: 0,
|
||||||
|
overwritesApplied: 0,
|
||||||
|
settingsApplied: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleIdMap = buildRoleIdMap(guild, payload);
|
||||||
|
await createMissingRoles(guild, payload, roleIdMap, summary);
|
||||||
|
await applyRolePositions(guild, payload, roleIdMap);
|
||||||
|
|
||||||
|
const channelIdMap = new Map<string, string>();
|
||||||
|
await restoreCategories(guild, payload, roleIdMap, channelIdMap, summary);
|
||||||
|
await restoreOtherChannels(guild, payload, roleIdMap, channelIdMap, summary);
|
||||||
|
await applyGuildSettings(guild, payload, summary);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
guildId: guild.id,
|
||||||
|
backupGuildName: payload.guildName,
|
||||||
|
...summary
|
||||||
|
},
|
||||||
|
'Guild backup restore completed'
|
||||||
|
);
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoleIdMap(guild: Guild, payload: GuildBackupPayload): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
map.set(guild.id, guild.id);
|
||||||
|
|
||||||
|
for (const roleData of payload.roles) {
|
||||||
|
if (roleData.id === guild.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = guild.roles.cache.find(
|
||||||
|
(role) => role.name.toLowerCase() === roleData.name.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
map.set(roleData.id, existing.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMissingRoles(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
roleIdMap: Map<string, string>,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
const sortedRoles = [...payload.roles].sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
for (const roleData of sortedRoles) {
|
||||||
|
if (roleData.id === guild.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleIdMap.has(roleData.id)) {
|
||||||
|
summary.rolesSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingByName = guild.roles.cache.find(
|
||||||
|
(role) => role.name.toLowerCase() === roleData.name.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existingByName) {
|
||||||
|
roleIdMap.set(roleData.id, existingByName.id);
|
||||||
|
summary.rolesSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await guild.roles.create({
|
||||||
|
name: roleData.name,
|
||||||
|
color: roleData.color,
|
||||||
|
hoist: roleData.hoist,
|
||||||
|
mentionable: roleData.mentionable,
|
||||||
|
permissions: BigInt(roleData.permissions),
|
||||||
|
reason: RESTORE_REASON
|
||||||
|
});
|
||||||
|
roleIdMap.set(roleData.id, created.id);
|
||||||
|
summary.rolesCreated += 1;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, roleName: roleData.name, guildId: guild.id }, 'Failed to create role');
|
||||||
|
summary.rolesSkipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyRolePositions(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
roleIdMap: Map<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const positions = payload.roles
|
||||||
|
.map((roleData) => {
|
||||||
|
const mappedId = roleIdMap.get(roleData.id);
|
||||||
|
if (!mappedId || mappedId === guild.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { role: mappedId, position: roleData.position };
|
||||||
|
})
|
||||||
|
.filter((entry): entry is { role: string; position: number } => entry !== null);
|
||||||
|
|
||||||
|
if (positions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await guild.roles.setPositions(positions);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to apply role positions during restore');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParentId(
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
channelData: GuildBackupPayload['channels'][number],
|
||||||
|
channelIdMap: Map<string, string>,
|
||||||
|
guild: Guild
|
||||||
|
): string | undefined {
|
||||||
|
if (!channelData.parentId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedParentId = channelIdMap.get(channelData.parentId);
|
||||||
|
if (mappedParentId) {
|
||||||
|
return mappedParentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentData = payload.channels.find((channel) => channel.id === channelData.parentId);
|
||||||
|
if (!parentData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingParent = findChannelByName(guild, parentData.name, undefined);
|
||||||
|
return existingParent?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChannelByName(
|
||||||
|
guild: Guild,
|
||||||
|
name: string,
|
||||||
|
parentId: string | undefined
|
||||||
|
): GuildChannel | undefined {
|
||||||
|
return guild.channels.cache.find((channel) => {
|
||||||
|
if (!('name' in channel) || channel.name.toLowerCase() !== name.toLowerCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parentId === undefined) {
|
||||||
|
return channel.parentId === null;
|
||||||
|
}
|
||||||
|
return channel.parentId === parentId;
|
||||||
|
}) as GuildChannel | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOverwrites(
|
||||||
|
payloadOverwrites: GuildBackupPayload['channels'][number]['permissionOverwrites'],
|
||||||
|
roleIdMap: Map<string, string>
|
||||||
|
): OverwriteResolvable[] {
|
||||||
|
const mapped: OverwriteResolvable[] = [];
|
||||||
|
|
||||||
|
for (const overwrite of payloadOverwrites) {
|
||||||
|
let targetId = overwrite.id;
|
||||||
|
if (overwrite.type === OverwriteType.Role) {
|
||||||
|
const mapped = roleIdMap.get(overwrite.id);
|
||||||
|
if (!mapped) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targetId = mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped.push({
|
||||||
|
id: targetId,
|
||||||
|
type: overwrite.type,
|
||||||
|
allow: BigInt(overwrite.allow),
|
||||||
|
deny: BigInt(overwrite.deny)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCategories(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
roleIdMap: Map<string, string>,
|
||||||
|
channelIdMap: Map<string, string>,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
const categories = payload.channels
|
||||||
|
.filter((channel) => channel.type === ChannelType.GuildCategory)
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
for (const categoryData of categories) {
|
||||||
|
const existing = findChannelByName(guild, categoryData.name, undefined);
|
||||||
|
if (existing) {
|
||||||
|
channelIdMap.set(categoryData.id, existing.id);
|
||||||
|
await applyChannelOverwrites(existing, categoryData, roleIdMap, summary);
|
||||||
|
summary.channelsUpdated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await guild.channels.create({
|
||||||
|
name: categoryData.name,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites: mapOverwrites(categoryData.permissionOverwrites, roleIdMap),
|
||||||
|
reason: RESTORE_REASON
|
||||||
|
});
|
||||||
|
channelIdMap.set(categoryData.id, created.id);
|
||||||
|
summary.channelsCreated += 1;
|
||||||
|
summary.overwritesApplied += categoryData.permissionOverwrites.length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, channelName: categoryData.name, guildId: guild.id },
|
||||||
|
'Failed to create category'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreOtherChannels(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
roleIdMap: Map<string, string>,
|
||||||
|
channelIdMap: Map<string, string>,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
const channels = payload.channels
|
||||||
|
.filter((channel) => channel.type !== ChannelType.GuildCategory)
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
for (const channelData of channels) {
|
||||||
|
const parentId = resolveParentId(payload, channelData, channelIdMap, guild);
|
||||||
|
const existing = findChannelByName(guild, channelData.name, parentId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
channelIdMap.set(channelData.id, existing.id);
|
||||||
|
await applyChannelOverwrites(existing, channelData, roleIdMap, summary);
|
||||||
|
summary.channelsUpdated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createChannelFromBackup(guild, channelData, parentId, roleIdMap);
|
||||||
|
if (created) {
|
||||||
|
channelIdMap.set(channelData.id, created.id);
|
||||||
|
summary.channelsCreated += 1;
|
||||||
|
summary.overwritesApplied += channelData.permissionOverwrites.length;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, channelName: channelData.name, guildId: guild.id },
|
||||||
|
'Failed to create channel'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChannelFromBackup(
|
||||||
|
guild: Guild,
|
||||||
|
channelData: GuildBackupPayload['channels'][number],
|
||||||
|
parentId: string | undefined,
|
||||||
|
roleIdMap: Map<string, string>
|
||||||
|
): Promise<NonThreadGuildBasedChannel | null> {
|
||||||
|
const overwrites = mapOverwrites(channelData.permissionOverwrites, roleIdMap);
|
||||||
|
const base = {
|
||||||
|
name: channelData.name,
|
||||||
|
parent: parentId,
|
||||||
|
permissionOverwrites: overwrites,
|
||||||
|
reason: RESTORE_REASON
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (channelData.type) {
|
||||||
|
case ChannelType.GuildText:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
topic: channelData.topic ?? undefined,
|
||||||
|
nsfw: channelData.nsfw ?? false,
|
||||||
|
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
|
||||||
|
});
|
||||||
|
case ChannelType.GuildAnnouncement:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildAnnouncement,
|
||||||
|
topic: channelData.topic ?? undefined,
|
||||||
|
nsfw: channelData.nsfw ?? false,
|
||||||
|
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
|
||||||
|
});
|
||||||
|
case ChannelType.GuildVoice:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
bitrate: channelData.bitrate ?? 64_000,
|
||||||
|
userLimit: channelData.userLimit ?? 0
|
||||||
|
});
|
||||||
|
case ChannelType.GuildStageVoice:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildStageVoice,
|
||||||
|
bitrate: channelData.bitrate ?? 64_000,
|
||||||
|
userLimit: channelData.userLimit ?? 0
|
||||||
|
});
|
||||||
|
case ChannelType.GuildForum:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
topic: channelData.topic ?? undefined,
|
||||||
|
nsfw: channelData.nsfw ?? false,
|
||||||
|
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
logger.warn(
|
||||||
|
{ channelType: channelData.type, channelName: channelData.name },
|
||||||
|
'Unsupported channel type in backup restore'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChannelOverwrites(
|
||||||
|
channel: GuildChannel,
|
||||||
|
channelData: GuildBackupPayload['channels'][number],
|
||||||
|
roleIdMap: Map<string, string>,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
if (!('permissionOverwrites' in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overwrites = mapOverwrites(channelData.permissionOverwrites, roleIdMap);
|
||||||
|
if (overwrites.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await channel.permissionOverwrites.set(overwrites, RESTORE_REASON);
|
||||||
|
summary.overwritesApplied += overwrites.length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, channelId: channel.id, guildId: channel.guild.id },
|
||||||
|
'Failed to apply channel overwrites'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyGuildSettings(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
const settings = payload.settings;
|
||||||
|
if (!settings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.verificationLevel !== undefined) {
|
||||||
|
try {
|
||||||
|
await guild.setVerificationLevel(settings.verificationLevel, RESTORE_REASON);
|
||||||
|
summary.settingsApplied.push('verificationLevel');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to restore verification level');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.explicitContentFilter !== undefined) {
|
||||||
|
try {
|
||||||
|
await guild.setExplicitContentFilter(settings.explicitContentFilter, RESTORE_REASON);
|
||||||
|
summary.settingsApplied.push('explicitContentFilter');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to restore explicit content filter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.defaultMessageNotifications !== undefined) {
|
||||||
|
try {
|
||||||
|
await guild.setDefaultMessageNotifications(
|
||||||
|
settings.defaultMessageNotifications,
|
||||||
|
RESTORE_REASON
|
||||||
|
);
|
||||||
|
summary.settingsApplied.push('defaultMessageNotifications');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to restore default notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.afkTimeout !== undefined) {
|
||||||
|
try {
|
||||||
|
await guild.setAFKTimeout(settings.afkTimeout, RESTORE_REASON);
|
||||||
|
summary.settingsApplied.push('afkTimeout');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to restore AFK timeout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureBotCanRestore(guild: Guild): boolean {
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return me.permissions.has(PermissionFlagsBits.ManageRoles | PermissionFlagsBits.ManageChannels);
|
||||||
|
}
|
||||||
49
apps/bot/src/modules/leveling/command-definitions.ts
Normal file
49
apps/bot/src/modules/leveling/command-definitions.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export const rankCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('rank'),
|
||||||
|
'leveling.rank.description'
|
||||||
|
).addUserOption((option) =>
|
||||||
|
applyOptionDescription(option.setName('user'), 'leveling.rank.options.user')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const leaderboardCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('leaderboard'),
|
||||||
|
'leveling.leaderboard.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const xpCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('xp'),
|
||||||
|
'leveling.xp.description'
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('give'), 'leveling.xp.give.description')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000),
|
||||||
|
'leveling.xp.options.amount'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('remove'), 'leveling.xp.remove.description')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000),
|
||||||
|
'leveling.xp.options.amount'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('reset'), 'leveling.xp.reset.description').addUserOption(
|
||||||
|
(option) =>
|
||||||
|
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
|
||||||
|
)
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user