Compare commits
56 Commits
08af99ed52
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ef76f5beb | |||
|
|
e2bab9cceb | ||
|
|
03bb406d82 | ||
|
|
2058713e03 | ||
|
|
061e287390 | ||
|
|
f67b9c61d7 | ||
|
|
bc4fae5415 | ||
|
|
5f932f5d63 | ||
|
|
b5bf214d7f | ||
|
|
5c11b68fc0 | ||
|
|
ac5ecc9a1c | ||
|
|
dbee58cd81 | ||
|
|
1eec10ba80 | ||
|
|
dba7bb3cdc | ||
|
|
ef1803f0ab | ||
|
|
67ac16d36b | ||
|
|
ba2d144038 | ||
|
|
8513848440 | ||
|
|
57cdf847f5 | ||
|
|
c7fb3d7041 | ||
|
|
20593b7173 | ||
|
|
e45642b6f9 | ||
|
|
211403d54d | ||
|
|
0ebe540c3f | ||
|
|
ccdf9aafe8 | ||
| 2f01335e61 | |||
|
|
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 |
42
.env.example
42
.env.example
@@ -1,9 +1,13 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=production
|
||||||
# Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls +
|
# Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls +
|
||||||
# BullMQ job enqueueing for guild backups, giveaways and the scheduler).
|
# BullMQ job enqueueing for guild backups, giveaways and the scheduler).
|
||||||
BOT_TOKEN=
|
BOT_TOKEN=
|
||||||
BOT_CLIENT_ID=
|
BOT_CLIENT_ID=
|
||||||
BOT_CLIENT_SECRET=
|
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
|
||||||
@@ -15,12 +19,15 @@ BACKUP_RETENTION_DAYS=14
|
|||||||
BACKUP_CRON=0 3 * * *
|
BACKUP_CRON=0 3 * * *
|
||||||
BACKUP_DIR=/backups
|
BACKUP_DIR=/backups
|
||||||
HEALTH_PORT=8080
|
HEALTH_PORT=8080
|
||||||
|
# Internal bot health/metrics base (Docker healthcheck). Captcha links use WEBUI_URL.
|
||||||
PUBLIC_BASE_URL=http://localhost:8080
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
TWITCH_CLIENT_ID=
|
TWITCH_CLIENT_ID=
|
||||||
TWITCH_CLIENT_SECRET=
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|
||||||
# WebUI (apps/webui)
|
# WebUI (apps/webui) – public URL behind Traefik (also used for captcha verification links)
|
||||||
WEBUI_URL=http://10.111.0.65:3000
|
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`.
|
# Must be at least 32 characters long. Generate e.g. with `openssl rand -hex 32`.
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
|
# Comma-separated Discord user IDs treated as global bot owners (owner panel, Phase 7 Batch C).
|
||||||
@@ -28,7 +35,28 @@ OWNER_USER_IDS=
|
|||||||
|
|
||||||
# Public site / bot links (Phase 8)
|
# Public site / bot links (Phase 8)
|
||||||
SUPPORT_SERVER_URL=
|
SUPPORT_SERVER_URL=
|
||||||
LEGAL_OPERATOR_NAME=
|
LEGAL_OPERATOR_NAME=HexaHost Inh. Samuel Müller
|
||||||
LEGAL_OPERATOR_ADDRESS=
|
LEGAL_OPERATOR_ADDRESS=Richard-Miller-Straße 1, 94051 Hauzenberg, Deutschland
|
||||||
LEGAL_OPERATOR_EMAIL=
|
LEGAL_OPERATOR_EMAIL=info@hexahost.de
|
||||||
LEGAL_OPERATOR_PHONE=
|
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=
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Nexumi
|
# Nexumi
|
||||||
|
|
||||||
Nexumi is a self-hosted public Discord bot with dashboard architecture, built as a TypeScript monorepo.
|
Nexumi is a public Discord bot with dashboard architecture, built as a TypeScript monorepo.
|
||||||
|
|
||||||
## Phase 1 scope
|
## Phase 1 scope
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +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": {
|
||||||
"@nexumi/shared": "workspace:*",
|
|
||||||
"@napi-rs/canvas": "^0.1.65",
|
"@napi-rs/canvas": "^0.1.65",
|
||||||
|
"@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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -20,6 +20,7 @@ model Guild {
|
|||||||
logChannels LogChannel[]
|
logChannels LogChannel[]
|
||||||
welcomeConfig WelcomeConfig?
|
welcomeConfig WelcomeConfig?
|
||||||
verificationConfig VerificationConfig?
|
verificationConfig VerificationConfig?
|
||||||
|
verificationRecords VerificationRecord[]
|
||||||
levelingConfig LevelingConfig?
|
levelingConfig LevelingConfig?
|
||||||
memberLevels MemberLevel[]
|
memberLevels MemberLevel[]
|
||||||
levelRewards LevelReward[]
|
levelRewards LevelReward[]
|
||||||
@@ -51,6 +52,7 @@ model Guild {
|
|||||||
socialFeeds SocialFeed[]
|
socialFeeds SocialFeed[]
|
||||||
scheduledMessages ScheduledMessage[]
|
scheduledMessages ScheduledMessage[]
|
||||||
guildBackups GuildBackup[]
|
guildBackups GuildBackup[]
|
||||||
|
componentMessageBindings ComponentMessageBinding[]
|
||||||
dashboardAccessRules DashboardAccessRule[]
|
dashboardAccessRules DashboardAccessRule[]
|
||||||
dashboardAuditLogs DashboardAuditLog[]
|
dashboardAuditLogs DashboardAuditLog[]
|
||||||
commandOverrides CommandOverride[]
|
commandOverrides CommandOverride[]
|
||||||
@@ -64,6 +66,10 @@ model GuildSettings {
|
|||||||
moderationEnabled Boolean @default(true)
|
moderationEnabled Boolean @default(true)
|
||||||
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
|
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
|
||||||
snipeEnabled Boolean @default(false)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +130,7 @@ model Case {
|
|||||||
|
|
||||||
model Warning {
|
model Warning {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
warningNumber Int
|
||||||
guildId String
|
guildId String
|
||||||
userId String
|
userId String
|
||||||
moderatorId String
|
moderatorId String
|
||||||
@@ -134,6 +141,7 @@ model Warning {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
|
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([guildId, warningNumber])
|
||||||
@@index([guildId, userId])
|
@@index([guildId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +249,13 @@ model WelcomeConfig {
|
|||||||
welcomeType String @default("TEXT")
|
welcomeType String @default("TEXT")
|
||||||
welcomeContent String?
|
welcomeContent String?
|
||||||
welcomeEmbed Json?
|
welcomeEmbed Json?
|
||||||
|
welcomeComponents Json?
|
||||||
|
leaveType String @default("TEXT")
|
||||||
leaveContent String?
|
leaveContent String?
|
||||||
leaveEmbed Json?
|
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)
|
welcomeDmEnabled Boolean @default(false)
|
||||||
welcomeDmContent String?
|
welcomeDmContent String?
|
||||||
userAutoroleIds String[] @default([])
|
userAutoroleIds String[] @default([])
|
||||||
@@ -262,8 +275,15 @@ model VerificationConfig {
|
|||||||
verifiedRoleId String?
|
verifiedRoleId String?
|
||||||
unverifiedRoleId String?
|
unverifiedRoleId String?
|
||||||
mode String @default("BUTTON")
|
mode String @default("BUTTON")
|
||||||
|
/// MATH | RECAPTCHA_V2 | RECAPTCHA_V3 | HCAPTCHA | TURNSTILE
|
||||||
|
captchaProvider String @default("MATH")
|
||||||
minAccountAgeDays Int @default(0)
|
minAccountAgeDays Int @default(0)
|
||||||
failAction String @default("KICK")
|
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?
|
panelChannelId String?
|
||||||
panelMessageId String?
|
panelMessageId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -271,6 +291,26 @@ model VerificationConfig {
|
|||||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
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 {
|
model LevelingConfig {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
guildId String @unique
|
guildId String @unique
|
||||||
@@ -477,6 +517,8 @@ model TicketConfig {
|
|||||||
mode String @default("CHANNEL")
|
mode String @default("CHANNEL")
|
||||||
categoryId String?
|
categoryId String?
|
||||||
logChannelId String?
|
logChannelId String?
|
||||||
|
panelChannelId String?
|
||||||
|
panelMessageId String?
|
||||||
transcriptDm Boolean @default(false)
|
transcriptDm Boolean @default(false)
|
||||||
inactivityHours Int @default(72)
|
inactivityHours Int @default(72)
|
||||||
ratingEnabled Boolean @default(true)
|
ratingEnabled Boolean @default(true)
|
||||||
@@ -551,8 +593,10 @@ model Tag {
|
|||||||
name String
|
name String
|
||||||
content String?
|
content String?
|
||||||
embed Json?
|
embed Json?
|
||||||
|
components Json?
|
||||||
responseType String @default("TEXT")
|
responseType String @default("TEXT")
|
||||||
triggerWord String?
|
triggerWord String?
|
||||||
|
cooldownSeconds Int @default(15)
|
||||||
allowedRoleIds String[] @default([])
|
allowedRoleIds String[] @default([])
|
||||||
allowedChannelIds String[] @default([])
|
allowedChannelIds String[] @default([])
|
||||||
createdById String
|
createdById String
|
||||||
@@ -770,6 +814,7 @@ model ScheduledMessage {
|
|||||||
creatorId String
|
creatorId String
|
||||||
content String?
|
content String?
|
||||||
embed Json?
|
embed Json?
|
||||||
|
components Json?
|
||||||
rolePingId String?
|
rolePingId String?
|
||||||
cron String?
|
cron String?
|
||||||
runAt DateTime?
|
runAt DateTime?
|
||||||
@@ -782,6 +827,20 @@ model ScheduledMessage {
|
|||||||
@@index([guildId, enabled])
|
@@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 {
|
model GuildBackup {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
guildId String
|
guildId String
|
||||||
@@ -854,7 +913,7 @@ model FeatureFlag {
|
|||||||
model BotPresenceConfig {
|
model BotPresenceConfig {
|
||||||
id String @id @default("singleton")
|
id String @id @default("singleton")
|
||||||
status String @default("online")
|
status String @default("online")
|
||||||
activityType String @default("Playing")
|
activityType String @default("Watching")
|
||||||
activityText String @default("nexumi.de")
|
activityText String @default("nexumi.de")
|
||||||
rotatingMessages Json?
|
rotatingMessages Json?
|
||||||
maintenanceMode Boolean @default(false)
|
maintenanceMode Boolean @default(false)
|
||||||
@@ -862,6 +921,18 @@ model BotPresenceConfig {
|
|||||||
updatedAt DateTime @updatedAt
|
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 {
|
model ChangelogEntry {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
version String
|
version String
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -4,11 +4,20 @@ import {
|
|||||||
type ChatInputCommandInteraction,
|
type ChatInputCommandInteraction,
|
||||||
type MessageContextMenuCommandInteraction
|
type MessageContextMenuCommandInteraction
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t } from '@nexumi/shared';
|
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 { 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 { 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 { automodCommands } from './modules/automod/commands.js';
|
||||||
import { welcomeCommands } from './modules/welcome/commands.js';
|
import { welcomeCommands } from './modules/welcome/commands.js';
|
||||||
@@ -60,43 +69,126 @@ const commands: SlashCommand[] = [
|
|||||||
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]));
|
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);
|
||||||
const body = [
|
const body = buildCommandBody();
|
||||||
...commands.map((c) => c.data.toJSON()),
|
|
||||||
...utilityContextMenus.map((c) => c.data.toJSON())
|
|
||||||
];
|
|
||||||
|
|
||||||
// Guild commands sync instantly; global can take up to ~1h.
|
// Guild commands sync instantly; global can take up to ~1h.
|
||||||
if (env.BOT_GUILD_ID) {
|
if (env.BOT_GUILD_ID) {
|
||||||
await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), {
|
await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), {
|
||||||
body
|
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(
|
logger.info(
|
||||||
{
|
{
|
||||||
scope: 'guild',
|
scope: 'guild',
|
||||||
guildId: env.BOT_GUILD_ID,
|
guildId: env.BOT_GUILD_ID,
|
||||||
count: commands.length,
|
count: body.length,
|
||||||
contextMenus: utilityContextMenus.length
|
clearedGlobal: true
|
||||||
},
|
},
|
||||||
'Registered guild application commands'
|
'Registered guild application commands and cleared global commands'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body });
|
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(
|
logger.info(
|
||||||
{
|
{
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
count: commands.length,
|
count: body.length,
|
||||||
contextMenus: utilityContextMenus.length
|
clearedGuilds
|
||||||
},
|
},
|
||||||
'Registered global application commands'
|
'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 started = Date.now();
|
||||||
|
let ok = true;
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
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 maintenance = await isMaintenanceMode(context);
|
||||||
const ownerIds = env.OWNER_USER_IDS ?? [];
|
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||||
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||||
@@ -112,15 +204,90 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
|||||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
return;
|
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 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(
|
export async function routeContextMenu(
|
||||||
interaction: MessageContextMenuCommandInteraction,
|
interaction: MessageContextMenuCommandInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
) {
|
) {
|
||||||
const command = contextMenuMap.get(interaction.commandName);
|
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
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: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const EnvSchema = z.object({
|
|||||||
BACKUP_CRON: z.string().default('0 3 * * *'),
|
BACKUP_CRON: z.string().default('0 3 * * *'),
|
||||||
BACKUP_DIR: z.string().default('/backups'),
|
BACKUP_DIR: z.string().default('/backups'),
|
||||||
METRICS_TOKEN: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
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),
|
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
||||||
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
||||||
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
TWITCH_CLIENT_ID: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
@@ -38,7 +39,9 @@ const EnvSchema = z.object({
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||||
SUPPORT_SERVER_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);
|
||||||
@@ -46,7 +49,7 @@ export const env = EnvSchema.parse(process.env);
|
|||||||
export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
|
export function buildBotInviteUrl(clientId = env.BOT_CLIENT_ID): string {
|
||||||
const url = new URL('https://discord.com/api/oauth2/authorize');
|
const url = new URL('https://discord.com/api/oauth2/authorize');
|
||||||
url.searchParams.set('client_id', clientId);
|
url.searchParams.set('client_id', clientId);
|
||||||
url.searchParams.set('permissions', '8');
|
url.searchParams.set('permissions', '4786708802825463');
|
||||||
url.searchParams.set('scope', 'bot applications.commands');
|
url.searchParams.set('scope', 'bot applications.commands');
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,17 @@ import {
|
|||||||
getCaptchaChallenge,
|
getCaptchaChallenge,
|
||||||
hashCaptchaAnswer
|
hashCaptchaAnswer
|
||||||
} from './modules/verification/service.js';
|
} from './modules/verification/service.js';
|
||||||
import { verificationQueue } from './queues.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> {
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -61,9 +71,17 @@ async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
|
|||||||
res.end('Captcha expired');
|
res.end('Captcha expired');
|
||||||
return;
|
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.statusCode = 200;
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.end(renderCaptchaPage(token, challenge.question));
|
res.end(renderCaptchaPage(token, challenge.question ?? '?'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
@@ -82,16 +100,32 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro
|
|||||||
res.end('Captcha expired');
|
res.end('Captcha expired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
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.statusCode = 200;
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.end(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
|
res.end(renderCaptchaPage(token, challenge.question ?? '?', 'Wrong answer. Try again.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deleteCaptchaChallenge(redis, token);
|
await deleteCaptchaChallenge(redis, token);
|
||||||
await verificationQueue.add(
|
await verificationQueue.add(
|
||||||
'verificationComplete',
|
'verificationComplete',
|
||||||
{ guildId: challenge.guildId, userId: challenge.userId },
|
{
|
||||||
|
guildId: challenge.guildId,
|
||||||
|
userId: challenge.userId,
|
||||||
|
captchaProvider: challenge.provider
|
||||||
|
},
|
||||||
{ removeOnComplete: 1000, removeOnFail: 500 }
|
{ removeOnComplete: 1000, removeOnFail: 500 }
|
||||||
);
|
);
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
@@ -99,6 +133,19 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro
|
|||||||
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>');
|
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(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
if (!req.url) {
|
if (!req.url) {
|
||||||
@@ -121,8 +168,16 @@ export function startHealthServer(): void {
|
|||||||
res.end('Unauthorized');
|
res.end('Unauthorized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const snapshot = await collectMetricsSnapshot(redis);
|
||||||
|
snapshot.queueWaiting = await approximateQueueWaiting();
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.end('# Prometheus metrics scaffold\nnexumi_up 1\n');
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -14,60 +14,31 @@ import { registerCommands, routeCommand, routeContextMenu } from './commands.js'
|
|||||||
import { ensureRecurringJobs, 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 {
|
|
||||||
handleModerationConfirmation,
|
|
||||||
isModerationConfirmation
|
|
||||||
} from './modules/moderation/confirmations.js';
|
|
||||||
import { getGuildLocale } from './i18n.js';
|
import { getGuildLocale } from './i18n.js';
|
||||||
|
import { ephemeral } from './interaction-reply.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import { handleAutoModMessage } from './modules/automod/actions.js';
|
import { handleAutoModMessage } from './modules/automod/actions.js';
|
||||||
import { registerLoggingEvents } from './modules/logging/events.js';
|
import { registerLoggingEvents } from './modules/logging/events.js';
|
||||||
import { registerWelcomeEvents } from './modules/welcome/events.js';
|
import { registerWelcomeEvents } from './modules/welcome/events.js';
|
||||||
import { registerVerificationEvents } from './modules/verification/events.js';
|
import { registerVerificationEvents } from './modules/verification/events.js';
|
||||||
import { handleVerificationButton } from './modules/verification/handlers.js';
|
|
||||||
import { isVerificationButton } from './modules/verification/service.js';
|
|
||||||
import { registerLevelingEvents } from './modules/leveling/events.js';
|
import { registerLevelingEvents } from './modules/leveling/events.js';
|
||||||
import {
|
import { registerUtilityEvents } from './modules/utility/index.js';
|
||||||
handleBlackjackButton,
|
import { registerTicketEvents } from './modules/tickets/index.js';
|
||||||
isBlackjackButton
|
import { registerSelfRoleEvents } from './modules/selfroles/index.js';
|
||||||
} from './modules/economy/blackjack-buttons.js';
|
|
||||||
import {
|
|
||||||
handleEmbedModal,
|
|
||||||
handlePollButton,
|
|
||||||
isEmbedModal,
|
|
||||||
isPollButton,
|
|
||||||
registerUtilityEvents
|
|
||||||
} from './modules/utility/index.js';
|
|
||||||
import { handleFunButton, isFunButton } from './modules/fun/index.js';
|
|
||||||
import { handleGiveawayButton, isGiveawayButton } from './modules/giveaways/index.js';
|
|
||||||
import {
|
|
||||||
handleTicketInteraction,
|
|
||||||
isTicketInteraction,
|
|
||||||
registerTicketEvents
|
|
||||||
} from './modules/tickets/index.js';
|
|
||||||
import {
|
|
||||||
handleSelfRoleInteraction,
|
|
||||||
isSelfRoleInteraction,
|
|
||||||
registerSelfRoleEvents
|
|
||||||
} from './modules/selfroles/index.js';
|
|
||||||
import { registerTagEvents } from './modules/tags/index.js';
|
import { registerTagEvents } from './modules/tags/index.js';
|
||||||
import { registerStarboardEvents } from './modules/starboard/index.js';
|
import { registerStarboardEvents } from './modules/starboard/index.js';
|
||||||
import {
|
import { registerTempVoiceEvents } from './modules/tempvoice/index.js';
|
||||||
handleSuggestionButton,
|
|
||||||
isSuggestionButton
|
|
||||||
} from './modules/suggestions/index.js';
|
|
||||||
import {
|
|
||||||
handleTempVoiceInteraction,
|
|
||||||
isTempVoiceInteraction,
|
|
||||||
isTempVoiceModal,
|
|
||||||
registerTempVoiceEvents
|
|
||||||
} from './modules/tempvoice/index.js';
|
|
||||||
import { registerStatsEvents } from './modules/stats/index.js';
|
import { registerStatsEvents } from './modules/stats/index.js';
|
||||||
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/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,
|
||||||
@@ -75,8 +46,25 @@ 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,
|
||||||
@@ -99,7 +87,7 @@ if (!isShard) {
|
|||||||
|
|
||||||
const context: BotContext = { client, prisma, redis };
|
const context: BotContext = { client, prisma, redis };
|
||||||
startWorkers(context);
|
startWorkers(context);
|
||||||
await ensureRecurringJobs();
|
|
||||||
registerLoggingEvents(context);
|
registerLoggingEvents(context);
|
||||||
registerWelcomeEvents(context);
|
registerWelcomeEvents(context);
|
||||||
registerVerificationEvents(context);
|
registerVerificationEvents(context);
|
||||||
@@ -113,12 +101,39 @@ if (!isShard) {
|
|||||||
registerStatsEvents(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');
|
||||||
|
|
||||||
|
if (isPrimaryShard(client)) {
|
||||||
try {
|
try {
|
||||||
await registerCommands();
|
await runOncePerCluster(
|
||||||
|
'nexumi:lock:ensure-recurring-jobs',
|
||||||
|
120,
|
||||||
|
async () => {
|
||||||
|
await ensureRecurringJobs();
|
||||||
|
},
|
||||||
|
'Recurring job registration'
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Failed to register application commands');
|
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 {
|
try {
|
||||||
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
|
const { applyBotPresence, publishBotStatus } = await import('./presence.js');
|
||||||
await applyBotPresence(context);
|
await applyBotPresence(context);
|
||||||
@@ -126,18 +141,61 @@ if (!isShard) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ error }, 'Failed to apply initial bot presence/status');
|
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) => {
|
client.on(Events.MessageCreate, async (message) => {
|
||||||
try {
|
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);
|
await handleAutoModMessage(context, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, messageId: message.id }, 'AutoMod message handler failed');
|
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) => {
|
||||||
try {
|
try {
|
||||||
|
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()) {
|
if (interaction.isChatInputCommand()) {
|
||||||
await routeCommand(interaction, context);
|
await routeCommand(interaction, context);
|
||||||
return;
|
return;
|
||||||
@@ -148,89 +206,36 @@ if (!isShard) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.isButton() && isModerationConfirmation(interaction.customId)) {
|
|
||||||
await handleModerationConfirmation(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isVerificationButton(interaction.customId)) {
|
|
||||||
await handleVerificationButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isBlackjackButton(interaction.customId)) {
|
|
||||||
await handleBlackjackButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isPollButton(interaction.customId)) {
|
|
||||||
await handlePollButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isFunButton(interaction.customId)) {
|
|
||||||
await handleFunButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isGiveawayButton(interaction.customId)) {
|
|
||||||
await handleGiveawayButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(interaction.isButton() ||
|
interaction.isButton() ||
|
||||||
interaction.isStringSelectMenu() ||
|
interaction.isStringSelectMenu() ||
|
||||||
interaction.isModalSubmit()) &&
|
interaction.isUserSelectMenu() ||
|
||||||
isTicketInteraction(interaction.customId)
|
interaction.isRoleSelectMenu() ||
|
||||||
|
interaction.isChannelSelectMenu() ||
|
||||||
|
interaction.isMentionableSelectMenu() ||
|
||||||
|
interaction.isModalSubmit()
|
||||||
) {
|
) {
|
||||||
await handleTicketInteraction(interaction, context);
|
await routeComponentInteraction(interaction, context);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(interaction.isButton() || interaction.isStringSelectMenu()) &&
|
|
||||||
isSelfRoleInteraction(interaction.customId)
|
|
||||||
) {
|
|
||||||
await handleSelfRoleInteraction(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isSuggestionButton(interaction.customId)) {
|
|
||||||
await handleSuggestionButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isTempVoiceInteraction(interaction.customId)) {
|
|
||||||
await handleTempVoiceInteraction(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isModalSubmit() && isTempVoiceModal(interaction.customId)) {
|
|
||||||
await handleTempVoiceInteraction(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && isGuildBackupButton(interaction.customId)) {
|
|
||||||
await handleGuildBackupButton(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) {
|
|
||||||
await handleEmbedModal(interaction, context);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Interaction handling failed');
|
logger.error({ error }, 'Interaction handling failed');
|
||||||
|
captureException(error, {
|
||||||
|
interactionType: interaction.type,
|
||||||
|
guildId: interaction.guildId
|
||||||
|
});
|
||||||
|
try {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
const message = t(locale, 'generic.error');
|
const message = t(locale, 'generic.error');
|
||||||
if (interaction.isRepliable()) {
|
if (interaction.isRepliable()) {
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
await interaction.followUp({ content: message, ephemeral: true });
|
await interaction.followUp({ content: message, ...ephemeral() });
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply({ content: message, ephemeral: true });
|
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 };
|
||||||
|
}
|
||||||
@@ -8,16 +8,33 @@ import { logger } from './logger.js';
|
|||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||||
import { completeVerification } from './modules/verification/handlers.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 { closePoll } from './modules/utility/poll.js';
|
||||||
import { sendReminder } from './modules/utility/reminders.js';
|
import { sendReminder } from './modules/utility/reminders.js';
|
||||||
import { expireSelfRole } from './modules/selfroles/service.js';
|
import {
|
||||||
import { endGiveaway, runGiveawayCreateJob } from './modules/giveaways/index.js';
|
expireSelfRole,
|
||||||
import { closeInactiveTickets } from './modules/tickets/index.js';
|
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 { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||||
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||||
import { pollAllFeeds } from './modules/feeds/index.js';
|
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||||
import { sendScheduledMessage } from './modules/scheduler/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 { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
||||||
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
||||||
import { runPresenceRefreshJob } from './presence.js';
|
import { runPresenceRefreshJob } from './presence.js';
|
||||||
@@ -32,6 +49,7 @@ import {
|
|||||||
feedsQueueName,
|
feedsQueueName,
|
||||||
giveawayQueueName,
|
giveawayQueueName,
|
||||||
guildBackupQueueName,
|
guildBackupQueueName,
|
||||||
|
messagesQueueName,
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
presenceQueue,
|
presenceQueue,
|
||||||
presenceQueueName,
|
presenceQueueName,
|
||||||
@@ -39,6 +57,7 @@ import {
|
|||||||
retentionQueue,
|
retentionQueue,
|
||||||
retentionQueueName,
|
retentionQueueName,
|
||||||
scheduleQueueName,
|
scheduleQueueName,
|
||||||
|
selfrolesQueueName,
|
||||||
suggestionsQueueName,
|
suggestionsQueueName,
|
||||||
ticketQueue,
|
ticketQueue,
|
||||||
ticketQueueName,
|
ticketQueueName,
|
||||||
@@ -63,6 +82,13 @@ type TempBanJob = {
|
|||||||
type VerificationCompleteJob = {
|
type VerificationCompleteJob = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
ipHash?: string | null;
|
||||||
|
userAgentHash?: string | null;
|
||||||
|
captchaProvider?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VerificationPanelSyncJob = {
|
||||||
|
guildId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReminderSendJob = {
|
type ReminderSendJob = {
|
||||||
@@ -81,6 +107,8 @@ type SelfRoleExpireJob = {
|
|||||||
|
|
||||||
type GiveawayEndJob = {
|
type GiveawayEndJob = {
|
||||||
giveawayId: string;
|
giveawayId: string;
|
||||||
|
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
|
||||||
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GiveawayCreateJob = {
|
type GiveawayCreateJob = {
|
||||||
@@ -95,6 +123,15 @@ type GiveawayCreateJob = {
|
|||||||
requiredMemberDays?: number | null;
|
requiredMemberDays?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GiveawayPauseJob = {
|
||||||
|
giveawayId: string;
|
||||||
|
paused: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GiveawayDeleteJob = {
|
||||||
|
giveawayId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
type BirthdayRoleExpireJob = {
|
type BirthdayRoleExpireJob = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -109,6 +146,36 @@ type SuggestionStatusUpdateJob = {
|
|||||||
reason: string;
|
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 moderationWorker = new Worker<TempBanJob>(
|
const moderationWorker = new Worker<TempBanJob>(
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
@@ -117,12 +184,70 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guild = await context.client.guilds.fetch(job.data.guildId);
|
const { guildId, userId, reason } = job.data;
|
||||||
|
const guild = context.client.guilds.cache.get(guildId);
|
||||||
|
if (!guild) {
|
||||||
|
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();
|
const me = await guild.members.fetchMe();
|
||||||
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
if (!me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
throw new Error('Missing BanMembers permission for temp ban expiration');
|
throw new Error('Missing BanMembers permission for temp ban expiration');
|
||||||
}
|
}
|
||||||
await guild.members.unban(job.data.userId, job.data.reason ?? 'Temporary ban expired');
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
);
|
);
|
||||||
@@ -149,11 +274,28 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
const automodWorker = new Worker(
|
const automodWorker = new Worker(
|
||||||
automodQueueName,
|
automodQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name !== 'refreshPhishingList') {
|
if (job.name === 'refreshPhishingList') {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const count = await refreshPhishingDomains(redis);
|
const count = await refreshPhishingDomains(redis);
|
||||||
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
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 }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
@@ -162,13 +304,21 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
|
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
const verificationWorker = new Worker<VerificationCompleteJob>(
|
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
|
||||||
verificationQueueName,
|
verificationQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
|
if (job.name === 'verificationPanelSync') {
|
||||||
|
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
|
||||||
|
}
|
||||||
if (job.name !== 'verificationComplete') {
|
if (job.name !== 'verificationComplete') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await completeVerification(context, job.data.guildId, job.data.userId);
|
const data = job.data as VerificationCompleteJob;
|
||||||
|
await completeVerification(context, data.guildId, data.userId, {
|
||||||
|
ipHash: data.ipHash,
|
||||||
|
userAgentHash: data.userAgentHash,
|
||||||
|
captchaProvider: data.captchaProvider
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ connection: redis }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
@@ -198,6 +348,22 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
const ticketWorker = new Worker(
|
const ticketWorker = new Worker(
|
||||||
ticketQueueName,
|
ticketQueueName,
|
||||||
async (job) => {
|
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') {
|
if (job.name === 'selfRoleExpire') {
|
||||||
const data = job.data as SelfRoleExpireJob;
|
const data = job.data as SelfRoleExpireJob;
|
||||||
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
|
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
|
||||||
@@ -214,16 +380,68 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
const giveawayWorker = new Worker<GiveawayEndJob | GiveawayCreateJob>(
|
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,
|
giveawayQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name === 'giveawayEnd') {
|
if (job.name === 'giveawayEnd') {
|
||||||
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (job.name === 'giveawayCreate') {
|
if (job.name === 'giveawayCreate') {
|
||||||
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
|
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 }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
@@ -281,6 +499,20 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
|
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 }>(
|
const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
|
||||||
guildBackupQueueName,
|
guildBackupQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@@ -344,11 +576,13 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
verificationWorker,
|
verificationWorker,
|
||||||
reminderWorker,
|
reminderWorker,
|
||||||
ticketWorker,
|
ticketWorker,
|
||||||
|
selfrolesWorker,
|
||||||
giveawayWorker,
|
giveawayWorker,
|
||||||
birthdayWorker,
|
birthdayWorker,
|
||||||
statsWorker,
|
statsWorker,
|
||||||
feedsWorker,
|
feedsWorker,
|
||||||
scheduleWorker,
|
scheduleWorker,
|
||||||
|
messagesWorker,
|
||||||
guildBackupWorker,
|
guildBackupWorker,
|
||||||
suggestionsWorker,
|
suggestionsWorker,
|
||||||
presenceWorker,
|
presenceWorker,
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
|
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { createCase } from '../moderation/service.js';
|
import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import {
|
import {
|
||||||
getAutoModConfig,
|
getAutoModConfig,
|
||||||
getEnabledAutoModRules,
|
getEnabledAutoModRules,
|
||||||
@@ -41,15 +42,8 @@ async function applyAutoModAction(
|
|||||||
const baseReason = `AutoMod (${ruleType}): ${reason}`;
|
const baseReason = `AutoMod (${ruleType}): ${reason}`;
|
||||||
|
|
||||||
if (action === 'WARN') {
|
if (action === 'WARN') {
|
||||||
await context.prisma.warning.create({
|
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||||
data: {
|
const modCase = await createCase(context, {
|
||||||
guildId: guild.id,
|
|
||||||
userId: message.author.id,
|
|
||||||
moderatorId,
|
|
||||||
reason: baseReason
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await createCase(context, {
|
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
targetUserId: message.author.id,
|
targetUserId: message.author.id,
|
||||||
moderatorId,
|
moderatorId,
|
||||||
@@ -59,6 +53,16 @@ async function applyAutoModAction(
|
|||||||
source: 'automod',
|
source: 'automod',
|
||||||
metadata: { ruleType, automod: true }
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +85,14 @@ async function applyAutoModAction(
|
|||||||
|
|
||||||
if (action === 'KICK') {
|
if (action === 'KICK') {
|
||||||
if (me.permissions.has(PermissionFlagsBits.KickMembers)) {
|
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 message.member.kick(baseReason);
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
@@ -98,6 +110,14 @@ async function applyAutoModAction(
|
|||||||
|
|
||||||
if (action === 'BAN') {
|
if (action === 'BAN') {
|
||||||
if (me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
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 guild.members.ban(message.author.id, { reason: baseReason });
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
@@ -204,6 +224,16 @@ export async function handleAntiRaidJoin(
|
|||||||
return;
|
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 key = `automod:raid:${member.guild.id}`;
|
||||||
const count = await context.redis.incr(key);
|
const count = await context.redis.incr(key);
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
@@ -214,10 +244,6 @@ export async function handleAntiRaidJoin(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.lockdownActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.prisma.autoModConfig.update({
|
await context.prisma.autoModConfig.update({
|
||||||
where: { guildId: member.guild.id },
|
where: { guildId: member.guild.id },
|
||||||
data: { lockdownActive: true }
|
data: { lockdownActive: true }
|
||||||
@@ -225,16 +251,45 @@ export async function handleAntiRaidJoin(
|
|||||||
|
|
||||||
if (config.antiRaidAction === 'LOCKDOWN') {
|
if (config.antiRaidAction === 'LOCKDOWN') {
|
||||||
await activateLockdown(member.guild);
|
await activateLockdown(member.guild);
|
||||||
|
} else if (config.antiRaidAction === 'VERIFY') {
|
||||||
|
await applyRaidVerifyRole(context, member);
|
||||||
}
|
}
|
||||||
|
|
||||||
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
|
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
|
||||||
if (alertChannel && alertChannel.isTextBased()) {
|
if (alertChannel && alertChannel.isTextBased()) {
|
||||||
await (alertChannel as TextChannel).send(
|
await (alertChannel as TextChannel).send(
|
||||||
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s.`
|
`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(
|
export async function handleAntiNukeAction(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
import { applyCommandDescription } from '@nexumi/shared';
|
import { applyCommandDescription } from '@nexumi/shared';
|
||||||
|
|
||||||
export const automodStatusCommandData = applyCommandDescription(
|
export const automodStatusCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('automod').addSubcommand((s) =>
|
new SlashCommandBuilder()
|
||||||
|
.setName('automod')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('status'), 'automod.status.description')
|
applyCommandDescription(s.setName('status'), 'automod.status.description')
|
||||||
),
|
),
|
||||||
'automod.description'
|
'automod.description'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Redis } from 'ioredis';
|
import type { Redis } from 'ioredis';
|
||||||
import {
|
import {
|
||||||
capsRatio,
|
capsRatio,
|
||||||
|
contentMatchesBlockedWord,
|
||||||
countEmojis,
|
countEmojis,
|
||||||
extractUrls,
|
extractUrls,
|
||||||
hasZalgo,
|
hasZalgo,
|
||||||
@@ -124,15 +125,14 @@ function checkExternalLink(content: string, threshold: AutoModThreshold): Filter
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
|
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
|
||||||
const lower = content.toLowerCase();
|
|
||||||
for (const word of wordList.words) {
|
for (const word of wordList.words) {
|
||||||
if (word.length > 0 && lower.includes(word.toLowerCase())) {
|
if (contentMatchesBlockedWord(content, word)) {
|
||||||
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
|
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const pattern of wordList.regexPatterns) {
|
for (const pattern of wordList.regexPatterns) {
|
||||||
try {
|
try {
|
||||||
const regex = new RegExp(pattern, 'i');
|
const regex = new RegExp(pattern, 'iu');
|
||||||
if (regex.test(content)) {
|
if (regex.test(content)) {
|
||||||
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
|
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { PrismaClient } from '@prisma/client';
|
import type { Prisma, PrismaClient } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
DEFAULT_AUTOMOD_RULES,
|
DEFAULT_AUTOMOD_RULES,
|
||||||
|
defaultAutoModWordList,
|
||||||
|
shouldSeedDefaultBlockedWords,
|
||||||
type AutoModExceptions,
|
type AutoModExceptions,
|
||||||
type AutoModThreshold,
|
type AutoModThreshold,
|
||||||
type AutoModWordList
|
type AutoModWordList
|
||||||
@@ -16,14 +18,37 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
|
async function seedDefaultWordFilterIfNeeded(prisma: PrismaClient, guildId: string) {
|
||||||
await ensureGuild(prisma, guildId);
|
const rule = await prisma.autoModRule.findUnique({
|
||||||
const existing = await prisma.autoModRule.count({ where: { guildId } });
|
where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } },
|
||||||
if (existing > 0) {
|
select: { id: true, wordList: true }
|
||||||
|
});
|
||||||
|
if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) {
|
||||||
return;
|
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({
|
await prisma.autoModRule.createMany({
|
||||||
data: DEFAULT_AUTOMOD_RULES.map((rule) => ({
|
data: missing.map((rule) => ({
|
||||||
guildId,
|
guildId,
|
||||||
ruleType: rule.ruleType,
|
ruleType: rule.ruleType,
|
||||||
action: rule.action,
|
action: rule.action,
|
||||||
@@ -32,6 +57,8 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s
|
|||||||
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
|
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
await seedDefaultWordFilterIfNeeded(prisma, guildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
|
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||||
@@ -66,7 +93,8 @@ export function parseWordList(raw: unknown): AutoModWordList {
|
|||||||
const obj = raw as Record<string, unknown>;
|
const obj = raw as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
words: Array.isArray(obj.words) ? obj.words.map(String) : [],
|
words: Array.isArray(obj.words) ? obj.words.map(String) : [],
|
||||||
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : []
|
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [],
|
||||||
|
...(obj.userCleared === true ? { userCleared: true } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ export const infoCommandData = applyCommandDescription(
|
|||||||
'core.info.description'
|
'core.info.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const aboutCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder().setName('about'),
|
||||||
|
'core.about.description'
|
||||||
|
);
|
||||||
|
|
||||||
export const inviteCommandData = applyCommandDescription(
|
export const inviteCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('invite'),
|
new SlashCommandBuilder().setName('invite'),
|
||||||
'core.invite.description'
|
'core.invite.description'
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { buildBotInviteUrl, env } from '../../env.js';
|
|||||||
import {
|
import {
|
||||||
helpCommandData,
|
helpCommandData,
|
||||||
infoCommandData,
|
infoCommandData,
|
||||||
|
aboutCommandData,
|
||||||
inviteCommandData,
|
inviteCommandData,
|
||||||
supportCommandData
|
supportCommandData
|
||||||
} from './command-definitions.js';
|
} from './command-definitions.js';
|
||||||
import { HELP_MODULES } from './help-catalog.js';
|
import { HELP_MODULES } from './help-catalog.js';
|
||||||
|
import { buildAboutReply } from './about.js';
|
||||||
|
|
||||||
const NEXUMI_COLOR = 0x6366f1;
|
const NEXUMI_COLOR = 0x6366f1;
|
||||||
const PACKAGE_VERSION = '0.1.0';
|
const PACKAGE_VERSION = '0.1.0';
|
||||||
@@ -118,6 +120,15 @@ const infoCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
const inviteCommand: SlashCommand = {
|
||||||
data: inviteCommandData,
|
data: inviteCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
@@ -153,6 +164,7 @@ const supportCommand: SlashCommand = {
|
|||||||
export const coreCommands: SlashCommand[] = [
|
export const coreCommands: SlashCommand[] = [
|
||||||
helpCommand,
|
helpCommand,
|
||||||
infoCommand,
|
infoCommand,
|
||||||
|
aboutCommand,
|
||||||
inviteCommand,
|
inviteCommand,
|
||||||
supportCommand
|
supportCommand
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Module id → top-level slash command names for `/help`. */
|
/** Module id → top-level slash command names for `/help`. */
|
||||||
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
|
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
|
||||||
{ id: 'core', commands: ['help', 'info', 'invite', 'support', 'privacy', 'gdpr'] },
|
{ id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] },
|
||||||
{
|
{
|
||||||
id: 'moderation',
|
id: 'moderation',
|
||||||
commands: [
|
commands: [
|
||||||
|
|||||||
@@ -327,7 +327,12 @@ async function handleMediaError(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (error instanceof FunMediaError) {
|
if (error instanceof FunMediaError) {
|
||||||
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
|
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
|
||||||
await interaction.reply({ content: t(locale, key), ephemeral: true });
|
const content = t(locale, key);
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.editReply({ content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -342,12 +347,13 @@ const memeCommand: SlashCommand = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await assertMediaEnabled(context.redis, guildId);
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
const meme = await fetchMeme();
|
const meme = await fetchMeme();
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(meme.title)
|
.setTitle(meme.title)
|
||||||
.setImage(meme.url)
|
.setImage(meme.url)
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await handleMediaError(interaction, locale, error);
|
await handleMediaError(interaction, locale, error);
|
||||||
}
|
}
|
||||||
@@ -363,12 +369,13 @@ const catCommand: SlashCommand = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await assertMediaEnabled(context.redis, guildId);
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
const url = await fetchCatImage();
|
const url = await fetchCatImage();
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(t(locale, 'fun.cat.title'))
|
.setTitle(t(locale, 'fun.cat.title'))
|
||||||
.setImage(url)
|
.setImage(url)
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await handleMediaError(interaction, locale, error);
|
await handleMediaError(interaction, locale, error);
|
||||||
}
|
}
|
||||||
@@ -384,12 +391,13 @@ const dogCommand: SlashCommand = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await assertMediaEnabled(context.redis, guildId);
|
await assertMediaEnabled(context.redis, guildId);
|
||||||
|
await interaction.deferReply();
|
||||||
const url = await fetchDogImage();
|
const url = await fetchDogImage();
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(t(locale, 'fun.dog.title'))
|
.setTitle(t(locale, 'fun.dog.title'))
|
||||||
.setImage(url)
|
.setImage(url)
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await handleMediaError(interaction, locale, error);
|
await handleMediaError(interaction, locale, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const giveawayCommandData = applyCommandDescription(
|
export const giveawayCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('giveaway')
|
.setName('giveaway')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { requirePermission } from '../../permissions.js';
|
|||||||
import { parseDuration } from '../moderation/duration.js';
|
import { parseDuration } from '../moderation/duration.js';
|
||||||
import { giveawayCommandData } from './command-definitions.js';
|
import { giveawayCommandData } from './command-definitions.js';
|
||||||
import {
|
import {
|
||||||
|
cancelGiveawayJob,
|
||||||
deleteGiveaway,
|
deleteGiveaway,
|
||||||
endGiveaway,
|
endGiveaway,
|
||||||
GiveawayError,
|
GiveawayError,
|
||||||
@@ -26,6 +27,17 @@ async function ensureManageGuild(
|
|||||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
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 = {
|
const giveawayCommand: SlashCommand = {
|
||||||
data: giveawayCommandData,
|
data: giveawayCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
@@ -71,6 +83,8 @@ const giveawayCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const giveaway = await startGiveaway(
|
const giveaway = await startGiveaway(
|
||||||
context,
|
context,
|
||||||
{
|
{
|
||||||
@@ -87,9 +101,8 @@ const giveawayCommand: SlashCommand = {
|
|||||||
locale
|
locale
|
||||||
);
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
|
content: tf(locale, 'giveaway.started', { id: giveaway.id })
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,20 +113,30 @@ const giveawayCommand: SlashCommand = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (sub === 'end') {
|
if (sub === 'end') {
|
||||||
const id = interaction.options.getString('id', true);
|
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 endGiveaway(context, id);
|
||||||
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
|
await interaction.editReply({ content: t(locale, 'giveaway.ended') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'reroll') {
|
if (sub === 'reroll') {
|
||||||
const id = interaction.options.getString('id', true);
|
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);
|
const updated = await rerollGiveaway(context, id, locale);
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: tf(locale, 'giveaway.rerolled', {
|
content: tf(locale, 'giveaway.rerolled', {
|
||||||
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
||||||
}),
|
})
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -136,14 +159,22 @@ const giveawayCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'delete') {
|
if (sub === 'delete') {
|
||||||
const id = interaction.options.getString('id', true);
|
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 deleteGiveaway(context, id);
|
||||||
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'pause') {
|
if (sub === 'pause') {
|
||||||
const id = interaction.options.getString('id', true);
|
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);
|
const updated = await pauseGiveaway(context, id, locale);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
||||||
@@ -152,10 +183,7 @@ const giveawayCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GiveawayError) {
|
if (error instanceof GiveawayError) {
|
||||||
await interaction.reply({
|
await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`));
|
||||||
content: t(locale, `giveaway.error.${error.code}`),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
export { giveawayCommands } from './commands.js';
|
export { giveawayCommands } from './commands.js';
|
||||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||||
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob } from './service.js';
|
export {
|
||||||
|
endGiveaway,
|
||||||
|
scheduleGiveawayEnd,
|
||||||
|
runGiveawayCreateJob,
|
||||||
|
runGiveawayPauseJob,
|
||||||
|
runGiveawayDeleteJob,
|
||||||
|
recoverOverdueGiveaways,
|
||||||
|
GiveawayError
|
||||||
|
} from './service.js';
|
||||||
|
|||||||
@@ -22,25 +22,46 @@ export class GiveawayError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
|
||||||
|
await cancelGiveawayJob(giveawayId);
|
||||||
const delay = Math.max(0, endsAt.getTime() - Date.now());
|
const delay = Math.max(0, endsAt.getTime() - Date.now());
|
||||||
await giveawayQueue.add(
|
await giveawayQueue.add(
|
||||||
'giveawayEnd',
|
'giveawayEnd',
|
||||||
{ giveawayId },
|
{ giveawayId },
|
||||||
{
|
{
|
||||||
delay,
|
delay,
|
||||||
jobId: `giveaway-end-${giveawayId}`,
|
jobId: giveawayEndJobId(giveawayId),
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
removeOnFail: 50
|
removeOnFail: 50
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
|
||||||
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
|
|
||||||
await job?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (giveaway.requiredRoleId) {
|
if (giveaway.requiredRoleId) {
|
||||||
@@ -59,6 +80,11 @@ function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
|||||||
|
|
||||||
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
|
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
|
||||||
const ended = giveaway.endedAt !== null;
|
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()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(t(locale, 'giveaway.embed.title'))
|
.setTitle(t(locale, 'giveaway.embed.title'))
|
||||||
.setColor(ended ? 0x6b7280 : 0x6366f1)
|
.setColor(ended ? 0x6b7280 : 0x6366f1)
|
||||||
@@ -86,19 +112,17 @@ export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): Emb
|
|||||||
);
|
);
|
||||||
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
|
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
|
||||||
} else if (giveaway.paused) {
|
} else if (giveaway.paused) {
|
||||||
embed.setDescription(t(locale, 'giveaway.embed.paused'));
|
embed.setDescription(
|
||||||
embed.setFooter({
|
`${t(locale, 'giveaway.embed.paused')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
|
||||||
text: tf(locale, 'giveaway.embed.endsAt', {
|
time: endsAtText
|
||||||
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
|
})}`
|
||||||
})
|
);
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
embed.setDescription(t(locale, 'giveaway.embed.active'));
|
embed.setDescription(
|
||||||
embed.setFooter({
|
`${t(locale, 'giveaway.embed.active')}\n\n${tf(locale, 'giveaway.embed.endsAt', {
|
||||||
text: tf(locale, 'giveaway.embed.endsAt', {
|
time: endsAtText
|
||||||
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
|
})}`
|
||||||
})
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requirements = requirementLines(locale, giveaway);
|
const requirements = requirementLines(locale, giveaway);
|
||||||
@@ -313,31 +337,89 @@ async function dmWinners(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
/**
|
||||||
|
* 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 } });
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
if (!giveaway || giveaway.endedAt) {
|
if (!giveaway) {
|
||||||
return;
|
throw new GiveawayError('not_found');
|
||||||
|
}
|
||||||
|
if (giveaway.endedAt) {
|
||||||
|
throw new GiveawayError('already_ended');
|
||||||
}
|
}
|
||||||
|
|
||||||
await cancelGiveawayJob(giveawayId);
|
|
||||||
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||||
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||||
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
||||||
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
|
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
|
||||||
|
const endedAt = new Date();
|
||||||
|
|
||||||
const updated = await context.prisma.giveaway.update({
|
const claimed = await context.prisma.giveaway.updateMany({
|
||||||
where: { id: giveawayId },
|
where: { id: giveawayId, endedAt: null },
|
||||||
data: {
|
data: {
|
||||||
endedAt: new Date(),
|
endedAt,
|
||||||
winners,
|
winners,
|
||||||
paused: false
|
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);
|
await updateGiveawayMessage(context, updated, locale);
|
||||||
if (winners.length > 0) {
|
if (winners.length > 0) {
|
||||||
await dmWinners(context, updated, winners, locale);
|
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(
|
export async function rerollGiveaway(
|
||||||
@@ -371,22 +453,61 @@ export async function rerollGiveaway(
|
|||||||
export async function pauseGiveaway(
|
export async function pauseGiveaway(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
giveawayId: string,
|
giveawayId: string,
|
||||||
locale: 'de' | 'en'
|
locale: 'de' | 'en',
|
||||||
|
forcePaused?: boolean
|
||||||
): Promise<Giveaway> {
|
): Promise<Giveaway> {
|
||||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
if (!giveaway || giveaway.endedAt) {
|
if (!giveaway || giveaway.endedAt) {
|
||||||
throw new GiveawayError('not_found');
|
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({
|
const updated = await context.prisma.giveaway.update({
|
||||||
where: { id: giveawayId },
|
where: { id: giveawayId },
|
||||||
data: { paused: !giveaway.paused }
|
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);
|
await updateGiveawayMessage(context, updated, locale);
|
||||||
return updated;
|
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> {
|
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
||||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||||
if (!giveaway) {
|
if (!giveaway) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const backupCommandData = applyCommandDescription(
|
export const backupCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('backup')
|
.setName('backup')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
|
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
|
||||||
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
|
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ const backupCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
|
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }),
|
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name })
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GuildBackupError) {
|
if (error instanceof GuildBackupError) {
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: t(locale, `guildbackup.error.${error.code}`),
|
content: t(locale, `guildbackup.error.${error.code}`)
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,9 @@ const backupCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'list') {
|
if (sub === 'list') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const backups = await listGuildBackups(context, guild.id);
|
const backups = await listGuildBackups(context, guild.id);
|
||||||
if (backups.length === 0) {
|
if (backups.length === 0) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
@@ -100,6 +103,9 @@ const backupCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'info') {
|
if (sub === 'info') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const backupId = interaction.options.getString('id', true);
|
const backupId = interaction.options.getString('id', true);
|
||||||
const backup = await getGuildBackup(context, guild.id, backupId);
|
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||||
if (!backup) {
|
if (!backup) {
|
||||||
@@ -154,7 +160,7 @@ const backupCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptDeleteConfirmation(interaction, locale, backupId);
|
await promptDeleteConfirmation(interaction, context, locale, backupId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +201,7 @@ const backupCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptRestoreConfirmation(interaction, locale, backupId);
|
await promptRestoreConfirmation(interaction, context, locale, backupId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { type Locale, t, tf } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from '../../confirmation-store.js';
|
||||||
import {
|
import {
|
||||||
deleteGuildBackup,
|
deleteGuildBackup,
|
||||||
enqueueGuildBackupRestore,
|
enqueueGuildBackupRestore,
|
||||||
@@ -22,7 +28,8 @@ import {
|
|||||||
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
||||||
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
||||||
const CANCEL_PREFIX = 'gbackup:cancel:';
|
const CANCEL_PREFIX = 'gbackup:cancel:';
|
||||||
const TTL_MS = 120_000;
|
const NAMESPACE = 'guildbackup';
|
||||||
|
const TTL_SECONDS = 120;
|
||||||
|
|
||||||
type PendingDelete = {
|
type PendingDelete = {
|
||||||
action: 'delete';
|
action: 'delete';
|
||||||
@@ -30,7 +37,6 @@ type PendingDelete = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingRestore = {
|
type PendingRestore = {
|
||||||
@@ -40,22 +46,10 @@ type PendingRestore = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingEntry = PendingDelete | PendingRestore;
|
type PendingEntry = PendingDelete | PendingRestore;
|
||||||
|
|
||||||
const pending = new Map<string, PendingEntry>();
|
|
||||||
|
|
||||||
function pruneExpired(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [token, entry] of pending) {
|
|
||||||
if (entry.expiresAt <= now) {
|
|
||||||
pending.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
return [
|
return [
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
@@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action
|
|||||||
|
|
||||||
export async function promptDeleteConfirmation(
|
export async function promptDeleteConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
backupId: string
|
backupId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
action: 'delete',
|
action: 'delete',
|
||||||
backupId,
|
backupId,
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingDelete,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
||||||
@@ -96,20 +95,25 @@ export async function promptDeleteConfirmation(
|
|||||||
|
|
||||||
export async function promptRestoreConfirmation(
|
export async function promptRestoreConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
backupId: string
|
backupId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
action: 'restore',
|
action: 'restore',
|
||||||
step: 1,
|
step: 1,
|
||||||
backupId,
|
backupId,
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingRestore,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
||||||
@@ -122,8 +126,6 @@ export async function handleGuildBackupButton(
|
|||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
|
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
||||||
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
||||||
@@ -141,48 +143,57 @@ export async function handleGuildBackupButton(
|
|||||||
: CANCEL_PREFIX.length
|
: CANCEL_PREFIX.length
|
||||||
);
|
);
|
||||||
|
|
||||||
const entry = pending.get(token);
|
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
if (!entry) {
|
if (!peeked) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.user.id !== entry.userId) {
|
if (interaction.user.id !== peeked.userId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(entry.locale, 'guildbackup.confirm.unauthorized'),
|
content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCancel) {
|
if (isCancel) {
|
||||||
pending.delete(token);
|
await deleteConfirmation(context.redis, NAMESPACE, token);
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(entry.locale, 'guildbackup.confirm.cancelled'),
|
content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.action === 'delete') {
|
if (peeked.action === 'delete') {
|
||||||
pending.delete(token);
|
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);
|
await executeDelete(interaction, context, entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.step === 1) {
|
if (peeked.step === 1) {
|
||||||
entry.step = 2;
|
const next: PendingRestore = { ...peeked, step: 2 };
|
||||||
entry.expiresAt = Date.now() + TTL_MS;
|
await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
|
||||||
pending.set(token, entry);
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }),
|
content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
|
||||||
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX)
|
components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.delete(token);
|
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);
|
await executeRestore(interaction, context, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export async function applyLevelRewards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const reward = rewards.find((entry) => entry.level === newLevel);
|
const reward = [...rewards].reverse().find((entry) => entry.level <= newLevel);
|
||||||
const removable = rewards
|
const removable = rewards
|
||||||
.map((entry) => entry.roleId)
|
.map((entry) => entry.roleId)
|
||||||
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));
|
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import type { LogEventType } from '@nexumi/shared';
|
|||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
/** Audit-log entries can lag a moment behind the gateway event. */
|
||||||
|
const AUDIT_LOG_MAX_AGE_MS = 15_000;
|
||||||
|
|
||||||
export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) {
|
export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
let config = await prisma.loggingConfig.findUnique({ where: { guildId } });
|
let config = await prisma.loggingConfig.findUnique({ where: { guildId } });
|
||||||
@@ -19,6 +22,36 @@ export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: st
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord does not send ban/kick reasons over the gateway.
|
||||||
|
* Reasons must be read from the audit log (requires View Audit Log).
|
||||||
|
*/
|
||||||
|
async function fetchRecentAuditEntry(
|
||||||
|
guild: Guild,
|
||||||
|
type: AuditLogEvent,
|
||||||
|
targetId: string
|
||||||
|
): Promise<{ reason: string | null; executorId: string | null } | null> {
|
||||||
|
try {
|
||||||
|
const auditLogs = await guild.fetchAuditLogs({ type, limit: 5 });
|
||||||
|
const entry = auditLogs.entries.find((item) => {
|
||||||
|
if (item.targetId !== targetId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Date.now() - item.createdTimestamp <= AUDIT_LOG_MAX_AGE_MS;
|
||||||
|
});
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reason: entry.reason ?? null,
|
||||||
|
executorId: entry.executorId ?? entry.executor?.id ?? null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id, type }, 'Failed to fetch audit log for logging');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLogChannelId(
|
export async function getLogChannelId(
|
||||||
prisma: BotContext['prisma'],
|
prisma: BotContext['prisma'],
|
||||||
guildId: string,
|
guildId: string,
|
||||||
@@ -124,6 +157,45 @@ export async function logModAction(
|
|||||||
await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444);
|
await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logVerificationFail(
|
||||||
|
context: BotContext,
|
||||||
|
input: {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
userTag?: string;
|
||||||
|
reason: string;
|
||||||
|
detail?: string;
|
||||||
|
failAction?: string;
|
||||||
|
matchedUserId?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const guild = await context.client.guilds.fetch(input.guildId).catch(() => null);
|
||||||
|
if (!guild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = [
|
||||||
|
`**User:** ${input.userTag ? `${input.userTag} ` : ''}(<@${input.userId}>) (\`${input.userId}\`)`,
|
||||||
|
`**Reason:** ${input.reason}`
|
||||||
|
];
|
||||||
|
if (input.detail) {
|
||||||
|
lines.push(`**Detail:** ${input.detail}`);
|
||||||
|
}
|
||||||
|
if (input.matchedUserId) {
|
||||||
|
lines.push(`**Matched user:** <@${input.matchedUserId}> (\`${input.matchedUserId}\`)`);
|
||||||
|
}
|
||||||
|
if (input.failAction) {
|
||||||
|
lines.push(`**Fail action:** ${input.failAction}`);
|
||||||
|
}
|
||||||
|
await sendLogEmbed(
|
||||||
|
context,
|
||||||
|
guild,
|
||||||
|
'VERIFICATION_FAIL',
|
||||||
|
'Verification Failed',
|
||||||
|
lines.join('\n'),
|
||||||
|
0xf97316
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleChannelDeleteForAntiNuke(
|
export async function handleChannelDeleteForAntiNuke(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
guild: Guild,
|
guild: Guild,
|
||||||
@@ -253,6 +325,29 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
|
|||||||
if (memberIgnored(config, member as GuildMember, member.user.bot)) {
|
if (memberIgnored(config, member as GuildMember, member.user.bot)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kickEntry = await fetchRecentAuditEntry(
|
||||||
|
member.guild,
|
||||||
|
AuditLogEvent.MemberKick,
|
||||||
|
member.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (kickEntry) {
|
||||||
|
await sendLogEmbed(
|
||||||
|
context,
|
||||||
|
member.guild,
|
||||||
|
'MEMBER_LEAVE',
|
||||||
|
'Member Kicked',
|
||||||
|
[
|
||||||
|
`**User:** ${member.user.tag} (<@${member.id}>)`,
|
||||||
|
`**Moderator:** ${kickEntry.executorId ? `<@${kickEntry.executorId}>` : '—'}`,
|
||||||
|
`**Reason:** ${kickEntry.reason ?? '—'}`
|
||||||
|
].join('\n'),
|
||||||
|
0xf59e0b
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await sendLogEmbed(
|
await sendLogEmbed(
|
||||||
context,
|
context,
|
||||||
member.guild,
|
member.guild,
|
||||||
@@ -265,12 +360,19 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
|
|||||||
client.on('guildBanAdd', async (ban) => {
|
client.on('guildBanAdd', async (ban) => {
|
||||||
const guild = ban.guild;
|
const guild = ban.guild;
|
||||||
await handleBanForAntiNuke(context, guild);
|
await handleBanForAntiNuke(context, guild);
|
||||||
|
|
||||||
|
const audit = await fetchRecentAuditEntry(guild, AuditLogEvent.MemberBanAdd, ban.user.id);
|
||||||
|
const reason = audit?.reason ?? ban.reason ?? '—';
|
||||||
|
const executorLine = audit?.executorId
|
||||||
|
? `\n**Moderator:** <@${audit.executorId}>`
|
||||||
|
: '';
|
||||||
|
|
||||||
await sendLogEmbed(
|
await sendLogEmbed(
|
||||||
context,
|
context,
|
||||||
guild,
|
guild,
|
||||||
'MEMBER_BAN',
|
'MEMBER_BAN',
|
||||||
'Member Banned',
|
'Member Banned',
|
||||||
`**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}`
|
`**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${reason}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TextChannel, type ButtonInteraction } from 'discord.js';
|
import { TextChannel, type ButtonInteraction } from 'discord.js';
|
||||||
import { type Locale, t } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { createCase, scheduleTempBanExpire } from './service.js';
|
import { assertBannableForConfirm } from '../../hierarchy.js';
|
||||||
import { parseDuration } from './duration.js';
|
import { createCase, notifyModerationTargetDm, scheduleTempBanExpire } from './service.js';
|
||||||
|
import { tryParseDuration } from './duration.js';
|
||||||
|
|
||||||
type BanPayload = {
|
type BanPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -36,8 +37,38 @@ export async function executeBan(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await assertBannableForConfirm(interaction, locale, payload.userId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let durationMs: number | null = null;
|
||||||
|
if (payload.duration) {
|
||||||
|
durationMs = tryParseDuration(payload.duration);
|
||||||
|
if (durationMs === null || durationMs <= 0) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
|
const targetUser = await context.client.users.fetch(payload.userId).catch(() => null);
|
||||||
|
if (targetUser) {
|
||||||
|
await notifyModerationTargetDm({
|
||||||
|
user: targetUser,
|
||||||
|
guildName: guild.name,
|
||||||
|
reason: payload.reason,
|
||||||
|
locale,
|
||||||
|
action: 'ban',
|
||||||
|
duration: payload.duration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await guild.members.ban(payload.userId, { reason: payload.reason });
|
await guild.members.ban(payload.userId, { reason: payload.reason });
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
targetUserId: payload.userId,
|
targetUserId: payload.userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -47,21 +78,20 @@ export async function executeBan(
|
|||||||
source: 'button_confirmation',
|
source: 'button_confirmation',
|
||||||
metadata: {
|
metadata: {
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
duration: payload.duration
|
duration: payload.duration,
|
||||||
|
durationMs
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (payload.duration) {
|
if (durationMs !== null) {
|
||||||
await scheduleTempBanExpire(
|
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
|
||||||
guild.id,
|
|
||||||
payload.userId,
|
|
||||||
parseDuration(payload.duration),
|
|
||||||
payload.reason
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.editReply({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.success.caseWithReason', {
|
||||||
|
caseNumber: modCase.caseNumber,
|
||||||
|
reason: payload.reason
|
||||||
|
}),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -78,6 +108,8 @@ export async function executePurge(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
const channel = await guild.channels.fetch(payload.channelId);
|
const channel = await guild.channels.fetch(payload.channelId);
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
const regex = payload.regex ? new RegExp(payload.regex, 'i') : null;
|
const regex = payload.regex ? new RegExp(payload.regex, 'i') : null;
|
||||||
@@ -97,7 +129,7 @@ export async function executePurge(
|
|||||||
deletedCount = deleted.size;
|
deletedCount = deleted.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: guild.id,
|
guildId: guild.id,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -121,8 +153,11 @@ export async function executePurge(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.editReply({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.purge.done', {
|
||||||
|
deletedCount,
|
||||||
|
caseNumber: modCase.caseNumber
|
||||||
|
}),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -143,7 +178,7 @@ export async function executeCaseDelete(
|
|||||||
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
|
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing || existing.deletedAt) {
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.case.notFound'),
|
content: t(locale, 'moderation.case.notFound'),
|
||||||
components: []
|
components: []
|
||||||
@@ -156,7 +191,7 @@ export async function executeCaseDelete(
|
|||||||
data: { deletedAt: new Date() }
|
data: { deletedAt: new Date() }
|
||||||
});
|
});
|
||||||
|
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId,
|
guildId,
|
||||||
targetUserId: existing.targetUserId,
|
targetUserId: existing.targetUserId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -172,7 +207,7 @@ export async function executeCaseDelete(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(locale, 'moderation.success'),
|
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
PermissionFlagsBits,
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
type SlashCommandStringOption,
|
type SlashCommandStringOption,
|
||||||
type SlashCommandUserOption
|
type SlashCommandUserOption
|
||||||
@@ -26,7 +27,9 @@ function reasonOpt(o: SlashCommandStringOption, required = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const banCommandData = applyCommandDescription(
|
export const banCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('ban'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('ban')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
|
||||||
'moderation.ban.description'
|
'moderation.ban.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
@@ -36,7 +39,9 @@ export const banCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const unbanCommandData = applyCommandDescription(
|
export const unbanCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('unban'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('unban')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
|
||||||
'moderation.unban.description'
|
'moderation.unban.description'
|
||||||
)
|
)
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
@@ -45,14 +50,18 @@ export const unbanCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const kickCommandData = applyCommandDescription(
|
export const kickCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('kick'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('kick')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
|
||||||
'moderation.kick.description'
|
'moderation.kick.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const timeoutCommandData = applyCommandDescription(
|
export const timeoutCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('timeout'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('timeout')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.timeout.description'
|
'moderation.timeout.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
@@ -62,14 +71,18 @@ export const timeoutCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const untimeoutCommandData = applyCommandDescription(
|
export const untimeoutCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('untimeout'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('untimeout')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.untimeout.description'
|
'moderation.untimeout.description'
|
||||||
)
|
)
|
||||||
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
|
||||||
.addStringOption((o) => reasonOpt(o));
|
.addStringOption((o) => reasonOpt(o));
|
||||||
|
|
||||||
export const warnCommandData = applyCommandDescription(
|
export const warnCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('warn'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('warn')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.warn.description'
|
'moderation.warn.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -83,9 +96,12 @@ export const warnCommandData = applyCommandDescription(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addStringOption(
|
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addIntegerOption(
|
||||||
(o) =>
|
(o) =>
|
||||||
applyOptionDescription(o.setName('warning_id').setRequired(true), 'moderation.warn.options.warning_id')
|
applyOptionDescription(
|
||||||
|
o.setName('id').setRequired(true).setMinValue(1),
|
||||||
|
'moderation.warn.options.warning_id'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -123,7 +139,9 @@ export const warnCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const purgeCommandData = applyCommandDescription(
|
export const purgeCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('purge'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('purge')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
|
||||||
'moderation.purge.description'
|
'moderation.purge.description'
|
||||||
)
|
)
|
||||||
.addIntegerOption((o) =>
|
.addIntegerOption((o) =>
|
||||||
@@ -138,24 +156,38 @@ export const purgeCommandData = applyCommandDescription(
|
|||||||
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
|
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
|
||||||
|
|
||||||
export const slowmodeCommandData = applyCommandDescription(
|
export const slowmodeCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('slowmode'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('slowmode')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||||
'moderation.slowmode.description'
|
'moderation.slowmode.description'
|
||||||
).addIntegerOption((o) =>
|
).addIntegerOption((o) =>
|
||||||
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
|
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
|
||||||
);
|
);
|
||||||
|
|
||||||
export const lockCommandData = applyCommandDescription(
|
export const lockCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('lock'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('lock')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('server'), 'moderation.lock.options.server')
|
||||||
|
),
|
||||||
'moderation.lock.description'
|
'moderation.lock.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const unlockCommandData = applyCommandDescription(
|
export const unlockCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('unlock'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('unlock')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('server'), 'moderation.unlock.options.server')
|
||||||
|
),
|
||||||
'moderation.unlock.description'
|
'moderation.unlock.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const nickCommandData = applyCommandDescription(
|
export const nickCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('nick'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('nick')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames),
|
||||||
'moderation.nick.description'
|
'moderation.nick.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -172,7 +204,9 @@ export const nickCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const caseCommandData = applyCommandDescription(
|
export const caseCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('case'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('case')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.case.description'
|
'moderation.case.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
@@ -196,7 +230,9 @@ export const caseCommandData = applyCommandDescription(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const modNoteCommandData = applyCommandDescription(
|
export const modNoteCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('modnote'),
|
new SlashCommandBuilder()
|
||||||
|
.setName('modnote')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
'moderation.modnote.description'
|
'moderation.modnote.description'
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import {
|
|||||||
ChannelType,
|
ChannelType,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
TextChannel,
|
TextChannel,
|
||||||
type ChatInputCommandInteraction,
|
type ChatInputCommandInteraction
|
||||||
type GuildMember
|
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t, tf } from '@nexumi/shared';
|
import { t, tf } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
import { applyWarnEscalation, createCase } from './service.js';
|
import { assertModerableMember } from '../../hierarchy.js';
|
||||||
import { parseDuration } from './duration.js';
|
import { applyWarnEscalation, createCase, createWarning, notifyModerationTargetDm } from './service.js';
|
||||||
|
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { promptDestructiveConfirmation } from './confirmations.js';
|
import { promptDestructiveConfirmation } from './confirmations.js';
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +48,26 @@ async function ensureMod(
|
|||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return requirePermission(interaction, permission, locale);
|
return requirePermission(interaction, permission, locale, { botPermissions: permission });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replySuccessCase(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
caseNumber: number,
|
||||||
|
reason?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const content = reason
|
||||||
|
? tf(locale, 'moderation.success.caseWithReason', { caseNumber, reason })
|
||||||
|
: tf(locale, 'moderation.success.case', { caseNumber });
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.editReply({ content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({
|
||||||
|
content,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const banCommand: SlashCommand = {
|
const banCommand: SlashCommand = {
|
||||||
@@ -59,7 +78,22 @@ const banCommand: SlashCommand = {
|
|||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const duration = interaction.options.getString('duration');
|
const duration = interaction.options.getString('duration');
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
|
||||||
|
if (duration) {
|
||||||
|
const durationMs = tryParseDuration(duration);
|
||||||
|
if (durationMs === null || durationMs <= 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'ban');
|
||||||
|
if (!hierarchy.ok) return;
|
||||||
|
|
||||||
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'ban',
|
action: 'ban',
|
||||||
payload: {
|
payload: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -78,7 +112,7 @@ const unbanCommand: SlashCommand = {
|
|||||||
const userId = interaction.options.getString('user_id', true);
|
const userId = interaction.options.getString('user_id', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
await interaction.guild!.members.unban(userId, reason);
|
await interaction.guild!.members.unban(userId, reason);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -86,7 +120,7 @@ const unbanCommand: SlashCommand = {
|
|||||||
action: 'UNBAN',
|
action: 'UNBAN',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,9 +131,17 @@ const kickCommand: SlashCommand = {
|
|||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember;
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
|
||||||
await member.kick(reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await notifyModerationTargetDm({
|
||||||
|
user,
|
||||||
|
guildName: interaction.guild!.name,
|
||||||
|
reason,
|
||||||
|
locale,
|
||||||
|
action: 'kick'
|
||||||
|
});
|
||||||
|
await hierarchy.member.kick(reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -107,7 +149,7 @@ const kickCommand: SlashCommand = {
|
|||||||
action: 'KICK',
|
action: 'KICK',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,11 +159,21 @@ const timeoutCommand: SlashCommand = {
|
|||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const duration = parseDuration(interaction.options.getString('duration', true));
|
let duration: number;
|
||||||
|
try {
|
||||||
|
duration = parseTimeoutDuration(interaction.options.getString('duration', true));
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalidTimeout'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
|
||||||
await member.timeout(duration, reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await hierarchy.member.timeout(duration, reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -130,7 +182,7 @@ const timeoutCommand: SlashCommand = {
|
|||||||
reason,
|
reason,
|
||||||
metadata: { durationMs: duration }
|
metadata: { durationMs: duration }
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,9 +193,10 @@ const untimeoutCommand: SlashCommand = {
|
|||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const reason = reasonFrom(interaction, locale);
|
const reason = reasonFrom(interaction, locale);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
|
||||||
await member.timeout(null, reason);
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
await createCase(context, {
|
await hierarchy.member.timeout(null, reason);
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -151,7 +204,7 @@ const untimeoutCommand: SlashCommand = {
|
|||||||
action: 'UN_TIMEOUT',
|
action: 'UN_TIMEOUT',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,7 +219,19 @@ const warnCommand: SlashCommand = {
|
|||||||
const action = interaction.options.getString('action', true);
|
const action = interaction.options.getString('action', true);
|
||||||
const durationInput = interaction.options.getString('duration');
|
const durationInput = interaction.options.getString('duration');
|
||||||
const reason = interaction.options.getString('reason');
|
const reason = interaction.options.getString('reason');
|
||||||
const durationMs = durationInput ? parseDuration(durationInput) : null;
|
let durationMs: number | null = null;
|
||||||
|
if (durationInput) {
|
||||||
|
try {
|
||||||
|
durationMs =
|
||||||
|
action === 'TIMEOUT' ? parseTimeoutDuration(durationInput) : parseDuration(durationInput);
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.duration.invalid'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (action === 'TIMEOUT' && !durationMs) {
|
if (action === 'TIMEOUT' && !durationMs) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, 'moderation.escalation.durationRequired'),
|
content: t(locale, 'moderation.escalation.durationRequired'),
|
||||||
@@ -249,30 +314,27 @@ const warnCommand: SlashCommand = {
|
|||||||
action: 'WARN_ADD',
|
action: 'WARN_ADD',
|
||||||
reason
|
reason
|
||||||
});
|
});
|
||||||
await context.prisma.user.upsert({
|
const { warning } = await createWarning(context, {
|
||||||
where: { id: user.id },
|
|
||||||
update: {},
|
|
||||||
create: { id: user.id }
|
|
||||||
});
|
|
||||||
const warning = await context.prisma.warning.create({
|
|
||||||
data: {
|
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
reason,
|
reason,
|
||||||
caseId: modCase.id
|
caseId: modCase.id,
|
||||||
}
|
guildName: interaction.guild!.name,
|
||||||
|
locale,
|
||||||
|
notifyUser: user
|
||||||
});
|
});
|
||||||
const escalationResult = await applyWarnEscalation(
|
const escalationResult = await applyWarnEscalation(
|
||||||
interaction,
|
interaction,
|
||||||
context,
|
context,
|
||||||
user.id,
|
user.id,
|
||||||
interaction.user.id
|
interaction.user.id,
|
||||||
|
locale
|
||||||
);
|
);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: escalationResult
|
content: escalationResult
|
||||||
? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}`
|
? `${tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber })}\n${escalationResult}`
|
||||||
: tf(locale, 'moderation.warning.created', { warningId: warning.id }),
|
: tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber }),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -290,8 +352,8 @@ const warnCommand: SlashCommand = {
|
|||||||
? t(locale, 'moderation.warning.none')
|
? t(locale, 'moderation.warning.none')
|
||||||
: warnings
|
: warnings
|
||||||
.map(
|
.map(
|
||||||
(w: { id: string; reason: string | null }) =>
|
(w: { warningNumber: number; reason: string | null }) =>
|
||||||
`- ${w.id}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
|
`- #${w.warningNumber}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
|
||||||
)
|
)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
await interaction.reply({ content, ephemeral: true });
|
await interaction.reply({ content, ephemeral: true });
|
||||||
@@ -299,23 +361,35 @@ const warnCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'remove') {
|
if (sub === 'remove') {
|
||||||
const warningId = interaction.options.getString('warning_id', true);
|
const warningNumber = interaction.options.getInteger('id', true);
|
||||||
const warning = await context.prisma.warning.delete({ where: { id: warningId } });
|
const warning = await context.prisma.warning.findFirst({
|
||||||
await createCase(context, {
|
where: { warningNumber, guildId: interaction.guildId! }
|
||||||
|
});
|
||||||
|
if (!warning) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.warning.notFound'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await context.prisma.warning.delete({ where: { id: warning.id } });
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: warning.userId,
|
targetUserId: warning.userId,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
...auditFrom(interaction),
|
...auditFrom(interaction),
|
||||||
action: 'WARN_REMOVE',
|
action: 'WARN_REMOVE',
|
||||||
reason: `Warning ${warningId} removed`
|
reason: `Warning #${warningNumber} removed`
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } });
|
await context.prisma.warning.deleteMany({
|
||||||
await createCase(context, {
|
where: { guildId: interaction.guildId!, userId: user.id }
|
||||||
|
});
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -323,7 +397,7 @@ const warnCommand: SlashCommand = {
|
|||||||
action: 'WARN_CLEAR',
|
action: 'WARN_CLEAR',
|
||||||
reason: 'Warnings cleared'
|
reason: 'Warnings cleared'
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,7 +426,7 @@ const purgeCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'purge',
|
action: 'purge',
|
||||||
payload: {
|
payload: {
|
||||||
channelId: interaction.channel.id,
|
channelId: interaction.channel.id,
|
||||||
@@ -376,7 +450,7 @@ const slowmodeCommand: SlashCommand = {
|
|||||||
if (interaction.channel instanceof TextChannel) {
|
if (interaction.channel instanceof TextChannel) {
|
||||||
await interaction.channel.setRateLimitPerUser(seconds);
|
await interaction.channel.setRateLimitPerUser(seconds);
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -384,7 +458,7 @@ const slowmodeCommand: SlashCommand = {
|
|||||||
action: 'SLOWMODE',
|
action: 'SLOWMODE',
|
||||||
reason: `Set slowmode to ${seconds}s`
|
reason: `Set slowmode to ${seconds}s`
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -393,20 +467,34 @@ const lockCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
||||||
if (interaction.channel?.type === ChannelType.GuildText) {
|
const serverWide = interaction.options.getBoolean('server') ?? false;
|
||||||
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
|
const guild = interaction.guild!;
|
||||||
|
|
||||||
|
if (serverWide) {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
for (const channel of guild.channels.cache.values()) {
|
||||||
|
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||||
SendMessages: false
|
SendMessages: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
} else if (interaction.channel?.type === ChannelType.GuildText) {
|
||||||
|
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||||
|
SendMessages: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
...auditFrom(interaction),
|
...auditFrom(interaction),
|
||||||
action: 'LOCK',
|
action: 'LOCK',
|
||||||
reason: 'Channel locked'
|
reason: serverWide ? 'Server locked' : 'Channel locked'
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -415,20 +503,34 @@ const unlockCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
||||||
if (interaction.channel?.type === ChannelType.GuildText) {
|
const serverWide = interaction.options.getBoolean('server') ?? false;
|
||||||
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
|
const guild = interaction.guild!;
|
||||||
|
|
||||||
|
if (serverWide) {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
for (const channel of guild.channels.cache.values()) {
|
||||||
|
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||||
SendMessages: null
|
SendMessages: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createCase(context, {
|
} else if (interaction.channel?.type === ChannelType.GuildText) {
|
||||||
|
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||||
|
SendMessages: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
...auditFrom(interaction),
|
...auditFrom(interaction),
|
||||||
action: 'UNLOCK',
|
action: 'UNLOCK',
|
||||||
reason: 'Channel unlocked'
|
reason: serverWide ? 'Server unlocked' : 'Channel unlocked'
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -439,11 +541,13 @@ const nickCommand: SlashCommand = {
|
|||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
|
||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const member = await interaction.guild!.members.fetch(user.id);
|
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'nick');
|
||||||
|
if (!hierarchy.ok || !hierarchy.member) return;
|
||||||
|
const member = hierarchy.member;
|
||||||
if (sub === 'set') {
|
if (sub === 'set') {
|
||||||
const nickname = interaction.options.getString('nickname', true);
|
const nickname = interaction.options.getString('nickname', true);
|
||||||
await member.setNickname(nickname);
|
await member.setNickname(nickname);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -451,9 +555,10 @@ const nickCommand: SlashCommand = {
|
|||||||
action: 'NICK_SET',
|
action: 'NICK_SET',
|
||||||
reason: nickname
|
reason: nickname
|
||||||
});
|
});
|
||||||
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
} else {
|
} else {
|
||||||
await member.setNickname(null);
|
await member.setNickname(null);
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -461,8 +566,8 @@ const nickCommand: SlashCommand = {
|
|||||||
action: 'NICK_RESET',
|
action: 'NICK_RESET',
|
||||||
reason: 'Nickname reset'
|
reason: 'Nickname reset'
|
||||||
});
|
});
|
||||||
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -474,8 +579,12 @@ const caseCommand: SlashCommand = {
|
|||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
const caseNumber = interaction.options.getInteger('id', true);
|
const caseNumber = interaction.options.getInteger('id', true);
|
||||||
if (sub === 'view') {
|
if (sub === 'view') {
|
||||||
const found = await context.prisma.case.findUnique({
|
const found = await context.prisma.case.findFirst({
|
||||||
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }
|
where: {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
caseNumber,
|
||||||
|
deletedAt: null
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
|
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
|
||||||
@@ -485,14 +594,31 @@ const caseCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
if (sub === 'edit') {
|
if (sub === 'edit') {
|
||||||
const reason = interaction.options.getString('reason', true);
|
const reason = interaction.options.getString('reason', true);
|
||||||
await context.prisma.case.update({
|
const existing = await context.prisma.case.findFirst({
|
||||||
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } },
|
where: {
|
||||||
data: { reason }
|
guildId: interaction.guildId!,
|
||||||
|
caseNumber,
|
||||||
|
deletedAt: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'moderation.case.notFound'),
|
||||||
|
ephemeral: true
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await promptDestructiveConfirmation(interaction, locale, {
|
await context.prisma.case.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { reason }
|
||||||
|
});
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'moderation.success.case', { caseNumber }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await promptDestructiveConfirmation(interaction, context, locale, {
|
||||||
action: 'case_delete',
|
action: 'case_delete',
|
||||||
payload: { caseNumber }
|
payload: { caseNumber }
|
||||||
});
|
});
|
||||||
@@ -517,7 +643,7 @@ const modNoteCommand: SlashCommand = {
|
|||||||
note
|
note
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
@@ -525,7 +651,7 @@ const modNoteCommand: SlashCommand = {
|
|||||||
action: 'MODNOTE_ADD',
|
action: 'MODNOTE_ADD',
|
||||||
reason: note
|
reason: note
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const notes = await context.prisma.modNote.findMany({
|
const notes = await context.prisma.modNote.findMany({
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { type Locale, t, tf } from '@nexumi/shared';
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
deleteConfirmation,
|
||||||
|
getConfirmation,
|
||||||
|
setConfirmation,
|
||||||
|
takeConfirmation
|
||||||
|
} from '../../confirmation-store.js';
|
||||||
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
|
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
|
||||||
|
|
||||||
const CONFIRM_PREFIX = 'mod:confirm:';
|
const CONFIRM_PREFIX = 'mod:confirm:';
|
||||||
const CANCEL_PREFIX = 'mod:cancel:';
|
const CANCEL_PREFIX = 'mod:cancel:';
|
||||||
const TTL_MS = 60_000;
|
const NAMESPACE = 'moderation';
|
||||||
|
const TTL_SECONDS = 60;
|
||||||
|
|
||||||
type BanPayload = {
|
type BanPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & {
|
|||||||
moderatorId: string;
|
moderatorId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
expiresAt: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pending = new Map<string, PendingEntry>();
|
|
||||||
|
|
||||||
function pruneExpired(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [token, entry] of pending) {
|
|
||||||
if (entry.expiresAt <= now) {
|
|
||||||
pending.delete(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
|
function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
|
||||||
if (entry.action === 'ban') {
|
if (entry.action === 'ban') {
|
||||||
return tf(locale, 'moderation.confirm.ban', {
|
return tf(locale, 'moderation.confirm.ban', {
|
||||||
@@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder<ButtonBuilde
|
|||||||
|
|
||||||
export async function promptDestructiveConfirmation(
|
export async function promptDestructiveConfirmation(
|
||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
|
context: BotContext,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
entry: PendingConfirmation
|
entry: PendingConfirmation
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
pending.set(token, {
|
await setConfirmation(
|
||||||
|
context.redis,
|
||||||
|
NAMESPACE,
|
||||||
|
token,
|
||||||
|
{
|
||||||
...entry,
|
...entry,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
locale,
|
locale
|
||||||
expiresAt: Date.now() + TTL_MS
|
} satisfies PendingEntry,
|
||||||
});
|
TTL_SECONDS
|
||||||
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: confirmationMessage(locale, entry),
|
content: confirmationMessage(locale, entry),
|
||||||
@@ -113,8 +113,6 @@ export async function handleModerationConfirmation(
|
|||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
pruneExpired();
|
|
||||||
|
|
||||||
const customId = interaction.customId;
|
const customId = interaction.customId;
|
||||||
const isConfirm = customId.startsWith(CONFIRM_PREFIX);
|
const isConfirm = customId.startsWith(CONFIRM_PREFIX);
|
||||||
const isCancel = customId.startsWith(CANCEL_PREFIX);
|
const isCancel = customId.startsWith(CANCEL_PREFIX);
|
||||||
@@ -123,31 +121,37 @@ export async function handleModerationConfirmation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
|
const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
|
||||||
const entry = pending.get(token);
|
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
if (!entry) {
|
if (!peeked) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.user.id !== entry.moderatorId) {
|
if (interaction.user.id !== peeked.moderatorId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(entry.locale, 'moderation.confirm.unauthorized'),
|
content: t(peeked.locale, 'moderation.confirm.unauthorized'),
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.delete(token);
|
|
||||||
|
|
||||||
if (isCancel) {
|
if (isCancel) {
|
||||||
|
await deleteConfirmation(context.redis, NAMESPACE, token);
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
content: t(entry.locale, 'moderation.confirm.cancelled'),
|
content: t(peeked.locale, 'moderation.confirm.cancelled'),
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entry = await takeConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.action === 'ban') {
|
if (entry.action === 'ban') {
|
||||||
await executeBan(interaction, context, entry.locale, entry.payload);
|
await executeBan(interaction, context, entry.locale, entry.payload);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseDuration } from './duration.js';
|
import {
|
||||||
|
MAX_TIMEOUT_MS,
|
||||||
|
parseDuration,
|
||||||
|
parseTimeoutDuration,
|
||||||
|
tryParseDuration
|
||||||
|
} from './duration.js';
|
||||||
|
|
||||||
describe('parseDuration', () => {
|
describe('parseDuration', () => {
|
||||||
it('parses minutes', () => {
|
it('parses minutes', () => {
|
||||||
@@ -14,3 +19,27 @@ describe('parseDuration', () => {
|
|||||||
expect(() => parseDuration('tomorrow')).toThrow();
|
expect(() => parseDuration('tomorrow')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseTimeoutDuration', () => {
|
||||||
|
it('accepts durations within Discord limit', () => {
|
||||||
|
expect(parseTimeoutDuration('1d')).toBe(86_400_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects durations over 28 days', () => {
|
||||||
|
expect(() => parseTimeoutDuration('30d')).toThrow(/28/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes MAX_TIMEOUT_MS as 28 days', () => {
|
||||||
|
expect(MAX_TIMEOUT_MS).toBe(28 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tryParseDuration', () => {
|
||||||
|
it('returns null for invalid input', () => {
|
||||||
|
expect(tryParseDuration('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ms for valid input', () => {
|
||||||
|
expect(tryParseDuration('10s')).toBe(10_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
|
export const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export class DurationParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DurationParseError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDuration(input: string): number {
|
export function parseDuration(input: string): number {
|
||||||
const m = input.match(/^(\d+)([smhd])$/i);
|
const m = input.match(/^(\d+)([smhd])$/i);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
throw new Error('Duration must be like 30m, 12h, 7d');
|
throw new DurationParseError('Duration must be like 30m, 12h, 7d');
|
||||||
}
|
}
|
||||||
const amount = Number(m[1]);
|
const amount = Number(m[1]);
|
||||||
const unit = m[2].toLowerCase();
|
const unit = m[2]!.toLowerCase();
|
||||||
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
|
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
|
||||||
return amount * factor;
|
return amount * factor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a duration and enforce Discord's 28-day timeout maximum. */
|
||||||
|
export function parseTimeoutDuration(input: string): number {
|
||||||
|
const ms = parseDuration(input);
|
||||||
|
if (ms <= 0) {
|
||||||
|
throw new DurationParseError('Duration must be positive');
|
||||||
|
}
|
||||||
|
if (ms > MAX_TIMEOUT_MS) {
|
||||||
|
throw new DurationParseError('Timeout duration cannot exceed 28 days');
|
||||||
|
}
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryParseDuration(input: string): number | null {
|
||||||
|
try {
|
||||||
|
return parseDuration(input);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import {
|
||||||
|
PermissionFlagsBits,
|
||||||
|
type ChatInputCommandInteraction,
|
||||||
|
type User
|
||||||
|
} from 'discord.js';
|
||||||
|
import type { Prisma, Warning } from '@prisma/client';
|
||||||
|
import { buildCaseMetadata, type CaseAuditSource, t, tf, type Locale } from '@nexumi/shared';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||||
import { moderationQueue } from '../../queues.js';
|
import { moderationQueue } from '../../queues.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
import { MAX_TIMEOUT_MS } from './duration.js';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared';
|
|
||||||
|
|
||||||
type CreateCaseInput = {
|
type CreateCaseInput = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -15,6 +22,152 @@ type CreateCaseInput = {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CreateWarningInput = {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
moderatorId: string;
|
||||||
|
reason?: string;
|
||||||
|
caseId?: string;
|
||||||
|
guildName: string;
|
||||||
|
locale: Locale;
|
||||||
|
/** Discord user to DM; when omitted the user is fetched by id. */
|
||||||
|
notifyUser?: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatedWarning = {
|
||||||
|
warning: Warning;
|
||||||
|
warningCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function notifyWarnedUser(params: {
|
||||||
|
user: User;
|
||||||
|
guildName: string;
|
||||||
|
reason: string;
|
||||||
|
warningCount: number;
|
||||||
|
warningNumber: number;
|
||||||
|
locale: Locale;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await params.user.send(
|
||||||
|
tf(params.locale, 'moderation.warning.dm', {
|
||||||
|
guild: params.guildName,
|
||||||
|
reason: params.reason,
|
||||||
|
count: params.warningCount,
|
||||||
|
warningNumber: params.warningNumber
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, userId: params.user.id, warningNumber: params.warningNumber },
|
||||||
|
'Failed to DM warned user'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyModerationTargetDm(params: {
|
||||||
|
user: User;
|
||||||
|
guildName: string;
|
||||||
|
reason: string;
|
||||||
|
locale: Locale;
|
||||||
|
action: 'ban' | 'kick';
|
||||||
|
duration?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const reason = params.reason || t(params.locale, 'moderation.reason.none');
|
||||||
|
const key =
|
||||||
|
params.action === 'kick'
|
||||||
|
? 'moderation.kick.dm'
|
||||||
|
: params.duration
|
||||||
|
? 'moderation.ban.dm.temporary'
|
||||||
|
: 'moderation.ban.dm';
|
||||||
|
try {
|
||||||
|
await params.user.send(
|
||||||
|
tf(params.locale, key, {
|
||||||
|
guild: params.guildName,
|
||||||
|
reason,
|
||||||
|
...(params.duration ? { duration: params.duration } : {})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, userId: params.user.id, action: params.action },
|
||||||
|
'Failed to DM moderation target'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWarning(
|
||||||
|
context: BotContext,
|
||||||
|
input: CreateWarningInput
|
||||||
|
): Promise<CreatedWarning> {
|
||||||
|
await context.prisma.user.upsert({
|
||||||
|
where: { id: input.userId },
|
||||||
|
update: {},
|
||||||
|
create: { id: input.userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
let warning: Warning | null = null;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
warning = await context.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const last = await tx.warning.findFirst({
|
||||||
|
where: { guildId: input.guildId },
|
||||||
|
orderBy: { warningNumber: 'desc' },
|
||||||
|
select: { warningNumber: true }
|
||||||
|
});
|
||||||
|
return tx.warning.create({
|
||||||
|
data: {
|
||||||
|
guildId: input.guildId,
|
||||||
|
warningNumber: (last?.warningNumber ?? 0) + 1,
|
||||||
|
userId: input.userId,
|
||||||
|
moderatorId: input.moderatorId,
|
||||||
|
reason: input.reason,
|
||||||
|
caseId: input.caseId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: 'Serializable' }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? String((error as { code: unknown }).code)
|
||||||
|
: '';
|
||||||
|
if (code !== 'P2002' && attempt === 4) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (attempt === 4) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!warning) {
|
||||||
|
throw new Error('Failed to create warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningCount = await context.prisma.warning.count({
|
||||||
|
where: { guildId: input.guildId, userId: input.userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
const reason = input.reason ?? t(input.locale, 'moderation.warning.defaultReason');
|
||||||
|
const notifyUser =
|
||||||
|
input.notifyUser ?? (await context.client.users.fetch(input.userId).catch(() => null));
|
||||||
|
if (notifyUser) {
|
||||||
|
await notifyWarnedUser({
|
||||||
|
user: notifyUser,
|
||||||
|
guildName: input.guildName,
|
||||||
|
reason,
|
||||||
|
warningCount,
|
||||||
|
warningNumber: warning.warningNumber,
|
||||||
|
locale: input.locale
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { warning, warningCount };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createCase(context: BotContext, input: CreateCaseInput) {
|
export async function createCase(context: BotContext, input: CreateCaseInput) {
|
||||||
await context.prisma.guild.upsert({
|
await context.prisma.guild.upsert({
|
||||||
where: { id: input.guildId },
|
where: { id: input.guildId },
|
||||||
@@ -22,18 +175,23 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
create: { id: input.guildId }
|
create: { id: input.guildId }
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastCase = await context.prisma.case.findFirst({
|
|
||||||
where: { guildId: input.guildId },
|
|
||||||
orderBy: { caseNumber: 'desc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const metadata = buildCaseMetadata({
|
const metadata = buildCaseMetadata({
|
||||||
source: input.source ?? 'slash_command',
|
source: input.source ?? 'slash_command',
|
||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
extras: input.metadata
|
extras: input.metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
return context.prisma.case.create({
|
let created = null;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
created = await context.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const lastCase = await tx.case.findFirst({
|
||||||
|
where: { guildId: input.guildId },
|
||||||
|
orderBy: { caseNumber: 'desc' },
|
||||||
|
select: { caseNumber: true }
|
||||||
|
});
|
||||||
|
return tx.case.create({
|
||||||
data: {
|
data: {
|
||||||
guildId: input.guildId,
|
guildId: input.guildId,
|
||||||
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
|
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
|
||||||
@@ -43,7 +201,29 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
metadata: metadata as Prisma.InputJsonValue
|
metadata: metadata as Prisma.InputJsonValue
|
||||||
}
|
}
|
||||||
}).then(async (created) => {
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: 'Serializable' }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
typeof error === 'object' && error && 'code' in error
|
||||||
|
? String((error as { code: unknown }).code)
|
||||||
|
: '';
|
||||||
|
if (code !== 'P2002' && attempt === 4) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (attempt === 4) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
throw new Error('Failed to create case');
|
||||||
|
}
|
||||||
|
|
||||||
const { logModAction } = await import('../logging/events.js');
|
const { logModAction } = await import('../logging/events.js');
|
||||||
await logModAction(context, {
|
await logModAction(context, {
|
||||||
guildId: input.guildId,
|
guildId: input.guildId,
|
||||||
@@ -54,7 +234,10 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
|
|||||||
reason: input.reason
|
reason: input.reason
|
||||||
});
|
});
|
||||||
return created;
|
return created;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export function tempBanJobId(guildId: string, userId: string): string {
|
||||||
|
return `tempBan:${guildId}:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scheduleTempBanExpire(
|
export async function scheduleTempBanExpire(
|
||||||
@@ -63,10 +246,15 @@ export async function scheduleTempBanExpire(
|
|||||||
durationMs: number,
|
durationMs: number,
|
||||||
reason?: string
|
reason?: string
|
||||||
) {
|
) {
|
||||||
|
const jobId = tempBanJobId(guildId, userId);
|
||||||
|
const existing = await moderationQueue.getJob(jobId);
|
||||||
|
await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId });
|
||||||
|
|
||||||
await moderationQueue.add(
|
await moderationQueue.add(
|
||||||
'tempBanExpire',
|
'tempBanExpire',
|
||||||
{ guildId, userId, reason: reason ?? null },
|
{ guildId, userId, reason: reason ?? null },
|
||||||
{
|
{
|
||||||
|
jobId,
|
||||||
delay: durationMs,
|
delay: durationMs,
|
||||||
removeOnComplete: 1000,
|
removeOnComplete: 1000,
|
||||||
removeOnFail: 500
|
removeOnFail: 500
|
||||||
@@ -78,7 +266,8 @@ export async function applyWarnEscalation(
|
|||||||
interaction: ChatInputCommandInteraction,
|
interaction: ChatInputCommandInteraction,
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
userId: string,
|
userId: string,
|
||||||
moderatorId: string
|
moderatorId: string,
|
||||||
|
locale: Locale
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const guildId = interaction.guildId;
|
const guildId = interaction.guildId;
|
||||||
if (!guildId || !interaction.guild) {
|
if (!guildId || !interaction.guild) {
|
||||||
@@ -98,16 +287,25 @@ export async function applyWarnEscalation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const member = await interaction.guild.members.fetch(userId);
|
const member = await interaction.guild.members.fetch(userId);
|
||||||
const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`;
|
const reason =
|
||||||
|
rule.reason ??
|
||||||
|
t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount));
|
||||||
|
|
||||||
if (rule.action === 'TIMEOUT') {
|
if (rule.action === 'TIMEOUT') {
|
||||||
if (!rule.durationMs) {
|
if (!rule.durationMs) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing duration).`;
|
return t(locale, 'moderation.escalation.skippedDuration').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await member.timeout(rule.durationMs, reason);
|
const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS);
|
||||||
|
await member.timeout(durationMs, reason);
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
guildId,
|
guildId,
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
@@ -116,15 +314,28 @@ export async function applyWarnEscalation(
|
|||||||
reason,
|
reason,
|
||||||
channelId: interaction.channelId ?? undefined,
|
channelId: interaction.channelId ?? undefined,
|
||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, durationMs: rule.durationMs, warningCount }
|
metadata: { escalation: true, durationMs, warningCount }
|
||||||
});
|
});
|
||||||
return `Auto escalation applied: timeout (${rule.durationMs} ms).`;
|
return t(locale, 'moderation.escalation.appliedTimeout').replace(
|
||||||
|
'{durationMs}',
|
||||||
|
String(durationMs)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rule.action === 'KICK') {
|
if (rule.action === 'KICK') {
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
await notifyModerationTargetDm({
|
||||||
|
user: member.user,
|
||||||
|
guildName: interaction.guild.name,
|
||||||
|
reason,
|
||||||
|
locale,
|
||||||
|
action: 'kick'
|
||||||
|
});
|
||||||
await member.kick(reason);
|
await member.kick(reason);
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
guildId,
|
guildId,
|
||||||
@@ -136,12 +347,22 @@ export async function applyWarnEscalation(
|
|||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, warningCount }
|
metadata: { escalation: true, warningCount }
|
||||||
});
|
});
|
||||||
return 'Auto escalation applied: kick.';
|
return t(locale, 'moderation.escalation.appliedKick');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
|
return t(locale, 'moderation.escalation.skippedPermission').replace(
|
||||||
|
'{warnCount}',
|
||||||
|
String(warningCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
await notifyModerationTargetDm({
|
||||||
|
user: member.user,
|
||||||
|
guildName: interaction.guild.name,
|
||||||
|
reason,
|
||||||
|
locale,
|
||||||
|
action: 'ban'
|
||||||
|
});
|
||||||
await interaction.guild.members.ban(userId, { reason });
|
await interaction.guild.members.ban(userId, { reason });
|
||||||
await createCase(context, {
|
await createCase(context, {
|
||||||
guildId,
|
guildId,
|
||||||
@@ -153,5 +374,5 @@ export async function applyWarnEscalation(
|
|||||||
source: 'escalation',
|
source: 'escalation',
|
||||||
metadata: { escalation: true, warningCount }
|
metadata: { escalation: true, warningCount }
|
||||||
});
|
});
|
||||||
return 'Auto escalation applied: ban.';
|
return t(locale, 'moderation.escalation.appliedBan');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const scheduleCommandData = applyCommandDescription(
|
export const scheduleCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('schedule')
|
.setName('schedule')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((sub) =>
|
.addSubcommand((sub) =>
|
||||||
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
|
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
|
||||||
.addChannelOption((o) =>
|
.addChannelOption((o) =>
|
||||||
@@ -51,6 +52,7 @@ export const scheduleCommandData = applyCommandDescription(
|
|||||||
export const announceCommandData = applyCommandDescription(
|
export const announceCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('announce')
|
.setName('announce')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addChannelOption((o) =>
|
.addChannelOption((o) =>
|
||||||
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
|
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
EmbedBuilder,
|
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
type GuildTextBasedChannel,
|
type GuildTextBasedChannel,
|
||||||
type MessageCreateOptions,
|
type MessageCreateOptions,
|
||||||
type TextChannel
|
type TextChannel
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { WelcomeEmbedSchema, t, tf } from '@nexumi/shared';
|
import {
|
||||||
|
DEFAULT_GUILD_TIMEZONE,
|
||||||
|
WelcomeEmbedSchema,
|
||||||
|
expandBracketChannelMentions,
|
||||||
|
parseMessageComponentsV2,
|
||||||
|
resolveScheduleRunAt,
|
||||||
|
t,
|
||||||
|
tf
|
||||||
|
} from '@nexumi/shared';
|
||||||
import type { ScheduledMessage } from '@prisma/client';
|
import type { ScheduledMessage } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||||
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||||
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { scheduleQueue } from '../../queues.js';
|
import { scheduleQueue } from '../../queues.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
@@ -35,7 +45,7 @@ export type ScheduleCreateInput = AnnouncementInput & {
|
|||||||
cron?: string | null;
|
cron?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseScheduleWhen(input: string): Date {
|
function parseScheduleWhen(input: string, timeZone: string): Date {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (/^\d+[smhd]$/i.test(trimmed)) {
|
if (/^\d+[smhd]$/i.test(trimmed)) {
|
||||||
const ms = parseDuration(trimmed);
|
const ms = parseDuration(trimmed);
|
||||||
@@ -45,11 +55,79 @@ function parseScheduleWhen(input: string): Date {
|
|||||||
return new Date(Date.now() + ms);
|
return new Date(Date.now() + ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = Date.parse(trimmed);
|
try {
|
||||||
if (Number.isNaN(parsed) || parsed <= Date.now()) {
|
const parsed = resolveScheduleRunAt(trimmed, timeZone);
|
||||||
|
if (parsed.getTime() <= Date.now()) {
|
||||||
throw new SchedulerError('invalid_when');
|
throw new SchedulerError('invalid_when');
|
||||||
}
|
}
|
||||||
return new Date(parsed);
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
throw new SchedulerError('invalid_when');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise<string> {
|
||||||
|
const settings = await prisma.guildSettings.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { timezone: true }
|
||||||
|
});
|
||||||
|
return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleJobId(scheduleId: string): string {
|
||||||
|
return `schedule-${scheduleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BullMQ delayed instance ids look like `repeat:{schedulerId}:{millis}`. */
|
||||||
|
const REPEAT_INSTANCE_ID_RE = /^repeat:(.+):(\d+)$/;
|
||||||
|
|
||||||
|
function collectSchedulerIds(scheduleId: string, jobId: string | null | undefined): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
ids.add(scheduleJobId(scheduleId));
|
||||||
|
if (!jobId) {
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
const repeatMatch = jobId.match(REPEAT_INSTANCE_ID_RE);
|
||||||
|
if (repeatMatch?.[1]) {
|
||||||
|
ids.add(repeatMatch[1]);
|
||||||
|
} else if (!jobId.startsWith('repeat:')) {
|
||||||
|
ids.add(jobId);
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeScheduleJobSchedulers(
|
||||||
|
queue: typeof scheduleQueue,
|
||||||
|
scheduleId: string,
|
||||||
|
jobId: string | null | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
for (const schedulerId of collectSchedulerIds(scheduleId, jobId)) {
|
||||||
|
try {
|
||||||
|
await queue.removeJobScheduler(schedulerId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy / hash-keyed schedulers: match by payload scheduleId.
|
||||||
|
try {
|
||||||
|
const schedulers = await queue.getJobSchedulers();
|
||||||
|
for (const scheduler of schedulers) {
|
||||||
|
const data = scheduler.template?.data as { scheduleId?: string } | undefined;
|
||||||
|
if (data?.scheduleId !== scheduleId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = scheduler.id ?? scheduler.key;
|
||||||
|
try {
|
||||||
|
await queue.removeJobScheduler(id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ schedulerId: id, error }, 'Failed to remove matched schedule job scheduler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ scheduleId, error }, 'Failed to scan job schedulers for schedule');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidCron(pattern: string): boolean {
|
function isValidCron(pattern: string): boolean {
|
||||||
@@ -91,23 +169,31 @@ function parseStoredEmbed(raw: unknown) {
|
|||||||
export function buildAnnouncementPayload(schedule: {
|
export function buildAnnouncementPayload(schedule: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
embed: unknown;
|
embed: unknown;
|
||||||
|
components?: unknown;
|
||||||
rolePingId: string | null;
|
rolePingId: string | null;
|
||||||
|
id?: string;
|
||||||
}): MessageCreateOptions {
|
}): MessageCreateOptions {
|
||||||
|
const componentsData = parseMessageComponentsV2(schedule.components);
|
||||||
|
if (componentsData && schedule.id) {
|
||||||
|
const componentsPayload = buildComponentsV2Payload(componentsData, {
|
||||||
|
source: 's',
|
||||||
|
ref: schedule.id,
|
||||||
|
renderText: expandBracketChannelMentions
|
||||||
|
});
|
||||||
|
if (componentsPayload) {
|
||||||
|
if (schedule.rolePingId) {
|
||||||
|
// Components V2 cannot mix classic content; role ping is skipped.
|
||||||
|
}
|
||||||
|
return componentsPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const embedData = parseStoredEmbed(schedule.embed);
|
const embedData = parseStoredEmbed(schedule.embed);
|
||||||
const embeds: EmbedBuilder[] = [];
|
const embed = buildEmbedFromPayload(embedData, {
|
||||||
|
renderText: expandBracketChannelMentions
|
||||||
|
});
|
||||||
|
|
||||||
if (embedData) {
|
const baseContent = expandBracketChannelMentions(schedule.content?.trim() ?? '');
|
||||||
const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1);
|
|
||||||
if (embedData.title) {
|
|
||||||
embed.setTitle(embedData.title);
|
|
||||||
}
|
|
||||||
if (embedData.description) {
|
|
||||||
embed.setDescription(embedData.description);
|
|
||||||
}
|
|
||||||
embeds.push(embed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseContent = schedule.content?.trim() ?? '';
|
|
||||||
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
|
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
|
||||||
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
|
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
@@ -115,8 +201,8 @@ export function buildAnnouncementPayload(schedule: {
|
|||||||
if (content) {
|
if (content) {
|
||||||
payload.content = content;
|
payload.content = content;
|
||||||
}
|
}
|
||||||
if (embeds.length > 0) {
|
if (embed) {
|
||||||
payload.embeds = embeds;
|
payload.embeds = [embed];
|
||||||
}
|
}
|
||||||
if (schedule.rolePingId) {
|
if (schedule.rolePingId) {
|
||||||
payload.allowedMentions = { roles: [schedule.rolePingId] };
|
payload.allowedMentions = { roles: [schedule.rolePingId] };
|
||||||
@@ -148,38 +234,85 @@ async function assertSendableChannel(
|
|||||||
async function enqueueScheduleJob(
|
async function enqueueScheduleJob(
|
||||||
schedule: ScheduledMessage,
|
schedule: ScheduledMessage,
|
||||||
cron: string | null,
|
cron: string | null,
|
||||||
runAt: Date | null
|
runAt: Date | null,
|
||||||
|
timeZone: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const jobOptions: {
|
const jobId = scheduleJobId(schedule.id);
|
||||||
jobId: string;
|
|
||||||
removeOnComplete: boolean;
|
|
||||||
removeOnFail: number;
|
|
||||||
delay?: number;
|
|
||||||
repeat?: { pattern: string };
|
|
||||||
} = {
|
|
||||||
jobId: `schedule-${schedule.id}`,
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: 50
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cron) {
|
if (cron) {
|
||||||
jobOptions.repeat = { pattern: cron };
|
// BullMQ 5 job schedulers honor guild timezone for cron patterns.
|
||||||
} else if (runAt) {
|
await scheduleQueue.upsertJobScheduler(
|
||||||
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
|
jobId,
|
||||||
|
{ pattern: cron, tz: timeZone },
|
||||||
|
{
|
||||||
|
name: 'scheduleSend',
|
||||||
|
data: { scheduleId: schedule.id },
|
||||||
|
opts: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return jobId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
|
if (!runAt) {
|
||||||
|
throw new SchedulerError('missing_schedule_time');
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await scheduleQueue.add(
|
||||||
|
'scheduleSend',
|
||||||
|
{ scheduleId: schedule.id },
|
||||||
|
{
|
||||||
|
jobId,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 50,
|
||||||
|
delay: Math.max(0, runAt.getTime() - Date.now())
|
||||||
|
}
|
||||||
|
);
|
||||||
return String(job.id);
|
return String(job.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeScheduleJob(jobId: string | null): Promise<void> {
|
/**
|
||||||
if (!jobId) {
|
* Removes a one-shot delayed job and/or cron job scheduler. Locked (active)
|
||||||
return;
|
* jobs cannot be removed — do not call this from inside the scheduleSend
|
||||||
|
* worker for the job that is currently running.
|
||||||
|
*/
|
||||||
|
export async function removeScheduleJob(
|
||||||
|
jobId: string | null,
|
||||||
|
options?: { scheduleId?: string; cron?: string | null }
|
||||||
|
): Promise<void> {
|
||||||
|
if (options?.scheduleId) {
|
||||||
|
await removeScheduleJobSchedulers(scheduleQueue, options.scheduleId, jobId);
|
||||||
|
} else if (options?.cron && jobId) {
|
||||||
|
// Best-effort for callers without scheduleId: extract scheduler from repeat instance id.
|
||||||
|
for (const schedulerId of collectSchedulerIds('unknown', jobId)) {
|
||||||
|
if (schedulerId === 'schedule-unknown') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await scheduleQueue.removeJobScheduler(schedulerId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await scheduleQueue.getJob(jobId);
|
// One-shot delayed jobs only — never job.remove() on repeat:* scheduler children.
|
||||||
if (job) {
|
const oneShotIds = new Set<string>();
|
||||||
await job.remove();
|
if (jobId && !jobId.startsWith('repeat:')) {
|
||||||
|
oneShotIds.add(jobId);
|
||||||
|
}
|
||||||
|
if (options?.scheduleId) {
|
||||||
|
oneShotIds.add(scheduleJobId(options.scheduleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of oneShotIds) {
|
||||||
|
const job = await scheduleQueue.getJob(id);
|
||||||
|
if (job?.id && String(job.id).startsWith('repeat:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await safeRemoveBullJob(job, { label: 'schedule', jobId: id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +344,7 @@ export async function createScheduledMessage(
|
|||||||
|
|
||||||
let runAt: Date | null = null;
|
let runAt: Date | null = null;
|
||||||
let cronPattern: string | null = null;
|
let cronPattern: string | null = null;
|
||||||
|
const timeZone = await getGuildTimezone(context.prisma, input.guildId);
|
||||||
|
|
||||||
if (cron) {
|
if (cron) {
|
||||||
if (!isValidCron(cron)) {
|
if (!isValidCron(cron)) {
|
||||||
@@ -218,7 +352,7 @@ export async function createScheduledMessage(
|
|||||||
}
|
}
|
||||||
cronPattern = cron;
|
cronPattern = cron;
|
||||||
} else if (whenInput) {
|
} else if (whenInput) {
|
||||||
runAt = parseScheduleWhen(whenInput);
|
runAt = parseScheduleWhen(whenInput, timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertSendableChannel(context, input.channelId);
|
await assertSendableChannel(context, input.channelId);
|
||||||
@@ -238,7 +372,7 @@ export async function createScheduledMessage(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt);
|
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone);
|
||||||
return context.prisma.scheduledMessage.update({
|
return context.prisma.scheduledMessage.update({
|
||||||
where: { id: schedule.id },
|
where: { id: schedule.id },
|
||||||
data: { jobId }
|
data: { jobId }
|
||||||
@@ -264,7 +398,11 @@ export async function listScheduledMessages(
|
|||||||
.map((schedule) => {
|
.map((schedule) => {
|
||||||
const preview =
|
const preview =
|
||||||
schedule.content?.slice(0, 60) ??
|
schedule.content?.slice(0, 60) ??
|
||||||
(schedule.embed ? t(locale, 'scheduler.list.embedPreview') : '—');
|
(schedule.components
|
||||||
|
? t(locale, 'scheduler.list.componentsPreview')
|
||||||
|
: schedule.embed
|
||||||
|
? t(locale, 'scheduler.list.embedPreview')
|
||||||
|
: '—');
|
||||||
const timing = schedule.cron
|
const timing = schedule.cron
|
||||||
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
|
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
|
||||||
: schedule.runAt
|
: schedule.runAt
|
||||||
@@ -294,7 +432,7 @@ export async function deleteScheduledMessage(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await removeScheduleJob(match.jobId);
|
await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron });
|
||||||
await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
|
await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -322,7 +460,7 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
|
|||||||
try {
|
try {
|
||||||
const channel = await assertSendableChannel(context, schedule.channelId);
|
const channel = await assertSendableChannel(context, schedule.channelId);
|
||||||
const payload = buildAnnouncementPayload(schedule);
|
const payload = buildAnnouncementPayload(schedule);
|
||||||
if (!payload.content && !payload.embeds?.length) {
|
if (!payload.content && !payload.embeds?.length && !payload.components?.length) {
|
||||||
throw new SchedulerError('content_required');
|
throw new SchedulerError('content_required');
|
||||||
}
|
}
|
||||||
await (channel as TextChannel).send(payload);
|
await (channel as TextChannel).send(payload);
|
||||||
@@ -337,8 +475,10 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot: delete the DB row only. Do not remove the active BullMQ job
|
||||||
|
// from inside its own worker (locked remove throws → retry → duplicate send).
|
||||||
|
// removeOnComplete on enqueue cleans the job up after the processor returns.
|
||||||
if (!schedule.cron) {
|
if (!schedule.cron) {
|
||||||
await removeScheduleJob(schedule.jobId);
|
|
||||||
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {
|
|||||||
applyOptionDescription,
|
applyOptionDescription,
|
||||||
localizedChoice
|
localizedChoice
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import { ChannelType, SlashCommandBuilder } from 'discord.js';
|
import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const selfrolesCommandData = applyCommandDescription(
|
export const selfrolesCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('selfroles')
|
.setName('selfroles')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommandGroup((group) =>
|
.addSubcommandGroup((group) =>
|
||||||
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
|
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
|
||||||
.addSubcommand((sub) =>
|
.addSubcommand((sub) =>
|
||||||
|
|||||||
@@ -4,5 +4,9 @@ export { registerSelfRoleEvents } from './reactions.js';
|
|||||||
export {
|
export {
|
||||||
expireSelfRole,
|
expireSelfRole,
|
||||||
scheduleSelfRoleExpiry,
|
scheduleSelfRoleExpiry,
|
||||||
cancelSelfRoleExpiry
|
cancelSelfRoleExpiry,
|
||||||
|
runSelfRolePanelCreateJob,
|
||||||
|
runSelfRolePanelUpdateJob,
|
||||||
|
runSelfRolePanelDeleteJob,
|
||||||
|
SelfRoleError
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import type { SelfRolePanel } from '@prisma/client';
|
import type { SelfRolePanel } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||||
import { ticketQueue } from '../../queues.js';
|
import { ticketQueue } from '../../queues.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
@@ -266,13 +268,17 @@ export async function scheduleSelfRoleExpiry(
|
|||||||
roleId: string,
|
roleId: string,
|
||||||
expiresAt: Date
|
expiresAt: Date
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
|
||||||
|
const existing = await ticketQueue.getJob(jobId);
|
||||||
|
await safeRemoveBullJob(existing, { label: 'selfRoleExpire', jobId });
|
||||||
|
|
||||||
const delay = Math.max(0, expiresAt.getTime() - Date.now());
|
const delay = Math.max(0, expiresAt.getTime() - Date.now());
|
||||||
await ticketQueue.add(
|
await ticketQueue.add(
|
||||||
'selfRoleExpire',
|
'selfRoleExpire',
|
||||||
{ guildId, userId, roleId },
|
{ guildId, userId, roleId },
|
||||||
{
|
{
|
||||||
delay,
|
delay,
|
||||||
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
|
jobId,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
removeOnFail: 50
|
removeOnFail: 50
|
||||||
}
|
}
|
||||||
@@ -284,8 +290,9 @@ export async function cancelSelfRoleExpiry(
|
|||||||
userId: string,
|
userId: string,
|
||||||
roleId: string
|
roleId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
|
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
|
||||||
await job?.remove();
|
const job = await ticketQueue.getJob(jobId);
|
||||||
|
await safeRemoveBullJob(job, { label: 'selfRoleExpire', jobId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expireSelfRole(
|
export async function expireSelfRole(
|
||||||
@@ -416,6 +423,69 @@ export async function findPanelByMessageId(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function postOrEditPanelMessage(
|
||||||
|
context: BotContext,
|
||||||
|
panel: SelfRolePanel,
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
previous?: { channelId: string; messageId: string | null }
|
||||||
|
): Promise<SelfRolePanel> {
|
||||||
|
const mode = SelfRoleModeSchema.parse(panel.mode);
|
||||||
|
const { entries } = parseRolesJson(panel.roles);
|
||||||
|
const channelChanged =
|
||||||
|
previous !== undefined && previous.channelId !== panel.channelId;
|
||||||
|
|
||||||
|
if (panel.messageId && !channelChanged) {
|
||||||
|
try {
|
||||||
|
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
|
||||||
|
const message = await channel.messages.fetch(panel.messageId);
|
||||||
|
await message.edit({
|
||||||
|
embeds: [buildPanelEmbed(panel, locale)],
|
||||||
|
components: buildPanelComponents(panel)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'REACTIONS') {
|
||||||
|
for (const reaction of message.reactions.cache.values()) {
|
||||||
|
await reaction.remove().catch(() => undefined);
|
||||||
|
}
|
||||||
|
await applyReactionEmojis(message, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, panelId: panel.id }, 'Failed to edit self-role panel message; recreating');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous?.messageId && channelChanged) {
|
||||||
|
try {
|
||||||
|
const oldChannel = (await context.client.channels.fetch(previous.channelId)) as TextChannel;
|
||||||
|
const oldMessage = await oldChannel.messages.fetch(previous.messageId);
|
||||||
|
await oldMessage.delete();
|
||||||
|
} catch {
|
||||||
|
// Previous message may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await context.client.channels.fetch(panel.channelId);
|
||||||
|
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
|
||||||
|
throw new SelfRoleError('invalid_channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await channel.send({
|
||||||
|
embeds: [buildPanelEmbed(panel, locale)],
|
||||||
|
components: buildPanelComponents(panel)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === 'REACTIONS') {
|
||||||
|
await applyReactionEmojis(message, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.prisma.selfRolePanel.update({
|
||||||
|
where: { id: panel.id },
|
||||||
|
data: { messageId: message.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSelfRolePanel(
|
export async function createSelfRolePanel(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
params: {
|
params: {
|
||||||
@@ -427,6 +497,7 @@ export async function createSelfRolePanel(
|
|||||||
behavior: SelfRoleBehavior;
|
behavior: SelfRoleBehavior;
|
||||||
roles: SelfRoleEntry[];
|
roles: SelfRoleEntry[];
|
||||||
temporaryDurationMs?: number;
|
temporaryDurationMs?: number;
|
||||||
|
expiresAt?: Date | null;
|
||||||
},
|
},
|
||||||
locale: 'de' | 'en'
|
locale: 'de' | 'en'
|
||||||
): Promise<SelfRolePanel> {
|
): Promise<SelfRolePanel> {
|
||||||
@@ -451,24 +522,12 @@ export async function createSelfRolePanel(
|
|||||||
description: params.description ?? null,
|
description: params.description ?? null,
|
||||||
mode: params.mode,
|
mode: params.mode,
|
||||||
behavior: params.behavior,
|
behavior: params.behavior,
|
||||||
roles: serializeRolesJson(params.roles, params.temporaryDurationMs)
|
roles: serializeRolesJson(params.roles, params.temporaryDurationMs),
|
||||||
|
expiresAt: params.expiresAt ?? null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
|
return postOrEditPanelMessage(context, panel, locale);
|
||||||
const message = await channel.send({
|
|
||||||
embeds: [buildPanelEmbed(panel, locale)],
|
|
||||||
components: buildPanelComponents(panel)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (params.mode === 'REACTIONS') {
|
|
||||||
await applyReactionEmojis(message, params.roles);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.prisma.selfRolePanel.update({
|
|
||||||
where: { id: panel.id },
|
|
||||||
data: { messageId: message.id }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSelfRolePanel(
|
export async function updateSelfRolePanel(
|
||||||
@@ -476,12 +535,14 @@ export async function updateSelfRolePanel(
|
|||||||
panelId: string,
|
panelId: string,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
updates: {
|
updates: {
|
||||||
|
channelId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
mode?: SelfRoleMode;
|
mode?: SelfRoleMode;
|
||||||
behavior?: SelfRoleBehavior;
|
behavior?: SelfRoleBehavior;
|
||||||
roles?: SelfRoleEntry[];
|
roles?: SelfRoleEntry[];
|
||||||
temporaryDurationMs?: number;
|
temporaryDurationMs?: number;
|
||||||
|
expiresAt?: Date | null;
|
||||||
},
|
},
|
||||||
locale: 'de' | 'en'
|
locale: 'de' | 'en'
|
||||||
): Promise<SelfRolePanel> {
|
): Promise<SelfRolePanel> {
|
||||||
@@ -507,39 +568,100 @@ export async function updateSelfRolePanel(
|
|||||||
throw new SelfRoleError('reaction_emoji_required');
|
throw new SelfRoleError('reaction_emoji_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previous = { channelId: existing.channelId, messageId: existing.messageId };
|
||||||
const panel = await context.prisma.selfRolePanel.update({
|
const panel = await context.prisma.selfRolePanel.update({
|
||||||
where: { id: panelId },
|
where: { id: panelId },
|
||||||
data: {
|
data: {
|
||||||
|
channelId: updates.channelId ?? existing.channelId,
|
||||||
title: updates.title ?? existing.title,
|
title: updates.title ?? existing.title,
|
||||||
description: updates.description !== undefined ? updates.description : existing.description,
|
description: updates.description !== undefined ? updates.description : existing.description,
|
||||||
mode,
|
mode,
|
||||||
behavior,
|
behavior,
|
||||||
roles: serializeRolesJson(roles, temporaryDurationMs)
|
roles: serializeRolesJson(roles, temporaryDurationMs),
|
||||||
|
...(updates.expiresAt !== undefined ? { expiresAt: updates.expiresAt } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (panel.messageId) {
|
return postOrEditPanelMessage(context, panel, locale, previous);
|
||||||
try {
|
}
|
||||||
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
|
|
||||||
const message = await channel.messages.fetch(panel.messageId);
|
|
||||||
await message.edit({
|
|
||||||
embeds: [buildPanelEmbed(panel, locale)],
|
|
||||||
components: buildPanelComponents(panel)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mode === 'REACTIONS') {
|
/**
|
||||||
const existingReactions = message.reactions.cache;
|
* BullMQ entry points for dashboard create/update/delete. The WebUI has no
|
||||||
for (const reaction of existingReactions.values()) {
|
* discord.js Client, so panel posting is delegated here (same pattern as
|
||||||
await reaction.remove().catch(() => undefined);
|
* giveaways / dashboard messages).
|
||||||
}
|
*/
|
||||||
await applyReactionEmojis(message, roles);
|
export async function runSelfRolePanelCreateJob(
|
||||||
}
|
context: BotContext,
|
||||||
} catch (error) {
|
params: {
|
||||||
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
|
guildId: string;
|
||||||
}
|
channelId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
mode: SelfRoleMode;
|
||||||
|
behavior: SelfRoleBehavior;
|
||||||
|
roles: SelfRoleEntry[];
|
||||||
|
temporaryDurationMs?: number;
|
||||||
|
expiresAt?: string | null;
|
||||||
}
|
}
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, params.guildId);
|
||||||
|
const panel = await createSelfRolePanel(
|
||||||
|
context,
|
||||||
|
{
|
||||||
|
...params,
|
||||||
|
expiresAt: params.expiresAt ? new Date(params.expiresAt) : null
|
||||||
|
},
|
||||||
|
locale
|
||||||
|
);
|
||||||
|
return { id: panel.id };
|
||||||
|
}
|
||||||
|
|
||||||
return panel;
|
export async function runSelfRolePanelUpdateJob(
|
||||||
|
context: BotContext,
|
||||||
|
params: {
|
||||||
|
panelId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
mode?: SelfRoleMode;
|
||||||
|
behavior?: SelfRoleBehavior;
|
||||||
|
roles?: SelfRoleEntry[];
|
||||||
|
temporaryDurationMs?: number;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, params.guildId);
|
||||||
|
const panel = await updateSelfRolePanel(
|
||||||
|
context,
|
||||||
|
params.panelId,
|
||||||
|
params.guildId,
|
||||||
|
{
|
||||||
|
channelId: params.channelId,
|
||||||
|
title: params.title,
|
||||||
|
description: params.description,
|
||||||
|
mode: params.mode,
|
||||||
|
behavior: params.behavior,
|
||||||
|
roles: params.roles,
|
||||||
|
temporaryDurationMs: params.temporaryDurationMs,
|
||||||
|
expiresAt:
|
||||||
|
params.expiresAt === undefined
|
||||||
|
? undefined
|
||||||
|
: params.expiresAt
|
||||||
|
? new Date(params.expiresAt)
|
||||||
|
: null
|
||||||
|
},
|
||||||
|
locale
|
||||||
|
);
|
||||||
|
return { id: panel.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSelfRolePanelDeleteJob(
|
||||||
|
context: BotContext,
|
||||||
|
panelId: string,
|
||||||
|
guildId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await deleteSelfRolePanel(context, panelId, guildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSelfRolePanel(
|
export async function deleteSelfRolePanel(
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { ChannelType } from 'discord.js';
|
import { ChannelType, PermissionFlagsBits } from 'discord.js';
|
||||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const starboardCommandData = applyCommandDescription(
|
export const starboardCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('starboard')
|
.setName('starboard')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
|
applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
|
||||||
.addChannelOption((o) =>
|
.addChannelOption((o) =>
|
||||||
applyOptionDescription(
|
applyOptionDescription(
|
||||||
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
o
|
||||||
|
.setName('channel')
|
||||||
|
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
|
||||||
|
.setRequired(true),
|
||||||
'starboard.setup.options.channel'
|
'starboard.setup.options.channel'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -28,6 +32,9 @@ export const starboardCommandData = applyCommandDescription(
|
|||||||
'starboard.setup.options.allow_self_star'
|
'starboard.setup.options.allow_self_star'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('status'), 'starboard.status.description')
|
||||||
),
|
),
|
||||||
'starboard.description'
|
'starboard.description'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { PermissionFlagsBits } from 'discord.js';
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t, tf } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
import { starboardCommandData } from './command-definitions.js';
|
import { starboardCommandData } from './command-definitions.js';
|
||||||
import { getStarboardConfig, updateStarboardConfig } from './service.js';
|
import { botCanPostStarboard, getStarboardConfig, updateStarboardConfig } from './service.js';
|
||||||
|
|
||||||
const starboardCommand: SlashCommand = {
|
const starboardCommand: SlashCommand = {
|
||||||
data: starboardCommandData,
|
data: starboardCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!interaction.inGuild()) {
|
if (!interaction.inGuild()) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
@@ -22,11 +23,21 @@ const starboardCommand: SlashCommand = {
|
|||||||
const sub = interaction.options.getSubcommand();
|
const sub = interaction.options.getSubcommand();
|
||||||
|
|
||||||
if (sub === 'setup') {
|
if (sub === 'setup') {
|
||||||
const channel = interaction.options.getChannel('channel', true);
|
const channelOption = interaction.options.getChannel('channel', true);
|
||||||
const threshold = interaction.options.getInteger('threshold', true);
|
const threshold = interaction.options.getInteger('threshold', true);
|
||||||
const emoji = interaction.options.getString('emoji') ?? '⭐';
|
const emoji = interaction.options.getString('emoji') ?? '⭐';
|
||||||
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false;
|
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false;
|
||||||
|
|
||||||
|
const channel = await interaction.guild!.channels.fetch(channelOption.id).catch(() => null);
|
||||||
|
const me = interaction.guild!.members.me;
|
||||||
|
if (!channel || !me || !botCanPostStarboard(channel, me.id)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'starboard.invalidChannel'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await updateStarboardConfig(context.prisma, guildId, {
|
await updateStarboardConfig(context.prisma, guildId, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
@@ -35,13 +46,30 @@ const starboardCommand: SlashCommand = {
|
|||||||
allowSelfStar
|
allowSelfStar
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sub === 'status') {
|
||||||
const config = await getStarboardConfig(context.prisma, guildId);
|
const config = await getStarboardConfig(context.prisma, guildId);
|
||||||
if (!config.enabled || !config.channelId) {
|
if (!config.enabled || !config.channelId) {
|
||||||
await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'starboard.notConfigured'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'starboard.statusLine', {
|
||||||
|
channel: `<#${config.channelId}>`,
|
||||||
|
emoji: config.emoji,
|
||||||
|
threshold: String(config.threshold),
|
||||||
|
selfStar: config.allowSelfStar
|
||||||
|
? t(locale, 'starboard.selfStarOn')
|
||||||
|
: t(locale, 'starboard.selfStarOff')
|
||||||
|
}),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import type { Message, PartialMessage } from 'discord.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { processStarboardReaction } from './service.js';
|
import {
|
||||||
|
processStarboardReaction,
|
||||||
|
processStarboardSourceDelete,
|
||||||
|
processStarboardSourceUpdate
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
export function registerStarboardEvents(context: BotContext): void {
|
export function registerStarboardEvents(context: BotContext): void {
|
||||||
const handleReaction = async (
|
const handleReaction = async (
|
||||||
@@ -26,4 +31,48 @@ export function registerStarboardEvents(context: BotContext): void {
|
|||||||
}
|
}
|
||||||
await handleReaction(_reaction);
|
await handleReaction(_reaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
context.client.on('messageReactionRemoveAll', async (message) => {
|
||||||
|
try {
|
||||||
|
if (message.partial) {
|
||||||
|
await message.fetch().catch(() => null);
|
||||||
|
}
|
||||||
|
if (!message.guild || !('author' in message) || !message.author) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await processStarboardSourceUpdate(context, message as Message | PartialMessage);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Starboard remove-all handler failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on('messageReactionRemoveEmoji', async (reaction) => {
|
||||||
|
await handleReaction(reaction);
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on('messageDelete', async (message) => {
|
||||||
|
try {
|
||||||
|
await processStarboardSourceDelete(context, message);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Starboard source-delete handler failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on('messageDeleteBulk', async (messages) => {
|
||||||
|
for (const message of messages.values()) {
|
||||||
|
try {
|
||||||
|
await processStarboardSourceDelete(context, message);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Starboard bulk-delete handler failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on('messageUpdate', async (_oldMessage, newMessage) => {
|
||||||
|
try {
|
||||||
|
await processStarboardSourceUpdate(context, newMessage);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Starboard source-update handler failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
85
apps/bot/src/modules/starboard/service.test.ts
Normal file
85
apps/bot/src/modules/starboard/service.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { emojiMatches, shouldIgnoreMessage } from './service.js';
|
||||||
|
|
||||||
|
function reactionEmoji(partial: { id?: string | null; name?: string | null }) {
|
||||||
|
return {
|
||||||
|
id: partial.id ?? null,
|
||||||
|
name: partial.name ?? null,
|
||||||
|
identifier: partial.id ? `${partial.name}:${partial.id}` : (partial.name ?? ''),
|
||||||
|
toString() {
|
||||||
|
if (partial.id) {
|
||||||
|
return `<:${partial.name}:${partial.id}>`;
|
||||||
|
}
|
||||||
|
return partial.name ?? '';
|
||||||
|
}
|
||||||
|
} as unknown as import('discord.js').Emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('starboard emojiMatches', () => {
|
||||||
|
it('matches unicode emoji', () => {
|
||||||
|
expect(emojiMatches('⭐', reactionEmoji({ name: '⭐' }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches custom emoji forms', () => {
|
||||||
|
const emoji = reactionEmoji({ id: '123', name: 'star' });
|
||||||
|
expect(emojiMatches('<:star:123>', emoji)).toBe(true);
|
||||||
|
expect(emojiMatches('star:123', emoji)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('starboard shouldIgnoreMessage', () => {
|
||||||
|
const baseConfig = {
|
||||||
|
id: 'cfg',
|
||||||
|
guildId: 'g1',
|
||||||
|
enabled: true,
|
||||||
|
channelId: 'star-channel',
|
||||||
|
emoji: '⭐',
|
||||||
|
threshold: 3,
|
||||||
|
allowSelfStar: false,
|
||||||
|
ignoredChannelIds: ['ignored'],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
it('ignores bot authors', () => {
|
||||||
|
const message = {
|
||||||
|
author: { bot: true },
|
||||||
|
guild: { id: 'g1' },
|
||||||
|
channel: {
|
||||||
|
id: 'c1',
|
||||||
|
isTextBased: () => true,
|
||||||
|
isDMBased: () => false,
|
||||||
|
nsfw: false
|
||||||
|
}
|
||||||
|
} as unknown as import('discord.js').Message;
|
||||||
|
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the starboard channel itself', () => {
|
||||||
|
const message = {
|
||||||
|
author: { bot: false, id: 'u1' },
|
||||||
|
guild: { id: 'g1' },
|
||||||
|
channel: {
|
||||||
|
id: 'star-channel',
|
||||||
|
isTextBased: () => true,
|
||||||
|
isDMBased: () => false,
|
||||||
|
nsfw: false
|
||||||
|
}
|
||||||
|
} as unknown as import('discord.js').Message;
|
||||||
|
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores listed channels', () => {
|
||||||
|
const message = {
|
||||||
|
author: { bot: false, id: 'u1' },
|
||||||
|
guild: { id: 'g1' },
|
||||||
|
channel: {
|
||||||
|
id: 'ignored',
|
||||||
|
isTextBased: () => true,
|
||||||
|
isDMBased: () => false,
|
||||||
|
nsfw: false
|
||||||
|
}
|
||||||
|
} as unknown as import('discord.js').Message;
|
||||||
|
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
|
ChannelType,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
|
PermissionFlagsBits,
|
||||||
type Emoji,
|
type Emoji,
|
||||||
|
type GuildBasedChannel,
|
||||||
type Message,
|
type Message,
|
||||||
|
type PartialMessage,
|
||||||
type PartialMessageReaction,
|
type PartialMessageReaction,
|
||||||
type MessageReaction,
|
type MessageReaction,
|
||||||
type TextChannel
|
type TextChannel
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import type { PrismaClient, StarboardConfig } from '@prisma/client';
|
import type { PrismaClient, StarboardConfig } from '@prisma/client';
|
||||||
import { t } from '@nexumi/shared';
|
import { t, type Locale } from '@nexumi/shared';
|
||||||
import type { Locale } from '@nexumi/shared';
|
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
@@ -73,6 +76,27 @@ export function shouldIgnoreMessage(message: Message, config: StarboardConfig):
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function botCanPostStarboard(channel: GuildBasedChannel, meId: string): channel is TextChannel {
|
||||||
|
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
channel.type !== ChannelType.GuildText &&
|
||||||
|
channel.type !== ChannelType.GuildAnnouncement
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const permissions = channel.permissionsFor(meId);
|
||||||
|
if (!permissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
permissions.has(PermissionFlagsBits.ViewChannel) &&
|
||||||
|
permissions.has(PermissionFlagsBits.SendMessages) &&
|
||||||
|
permissions.has(PermissionFlagsBits.EmbedLinks)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function countMatchingStars(
|
export async function countMatchingStars(
|
||||||
message: Message,
|
message: Message,
|
||||||
config: StarboardConfig
|
config: StarboardConfig
|
||||||
@@ -109,6 +133,10 @@ function resolveImageUrl(message: Message): string | undefined {
|
|||||||
return embedImage ?? undefined;
|
return embedImage ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authorDisplayName(message: Message): string {
|
||||||
|
return message.member?.displayName || message.author.displayName || message.author.username;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStarboardEmbed(
|
export function buildStarboardEmbed(
|
||||||
message: Message,
|
message: Message,
|
||||||
starCount: number,
|
starCount: number,
|
||||||
@@ -119,7 +147,7 @@ export function buildStarboardEmbed(
|
|||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setColor(0x6366f1)
|
.setColor(0x6366f1)
|
||||||
.setAuthor({
|
.setAuthor({
|
||||||
name: message.author.tag,
|
name: authorDisplayName(message),
|
||||||
iconURL: message.author.displayAvatarURL()
|
iconURL: message.author.displayAvatarURL()
|
||||||
})
|
})
|
||||||
.setDescription(content.slice(0, 4096))
|
.setDescription(content.slice(0, 4096))
|
||||||
@@ -143,6 +171,17 @@ export function buildStarboardEmbed(
|
|||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchStarboardTextChannel(
|
||||||
|
context: BotContext,
|
||||||
|
channelId: string
|
||||||
|
): Promise<TextChannel | null> {
|
||||||
|
const channel = await context.client.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return channel as TextChannel;
|
||||||
|
}
|
||||||
|
|
||||||
async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
|
async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
|
||||||
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
|
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -152,10 +191,14 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
|
|||||||
try {
|
try {
|
||||||
const config = await getStarboardConfig(context.prisma, entry.guildId);
|
const config = await getStarboardConfig(context.prisma, entry.guildId);
|
||||||
if (config.channelId) {
|
if (config.channelId) {
|
||||||
const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
|
const channel = await fetchStarboardTextChannel(context, config.channelId);
|
||||||
const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null);
|
if (channel) {
|
||||||
|
const starboardMessage = await channel.messages
|
||||||
|
.fetch(entry.starboardMessageId)
|
||||||
|
.catch(() => null);
|
||||||
await starboardMessage?.delete().catch(() => undefined);
|
await starboardMessage?.delete().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ error, entryId }, 'Failed to delete starboard message');
|
logger.warn({ error, entryId }, 'Failed to delete starboard message');
|
||||||
}
|
}
|
||||||
@@ -163,6 +206,82 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
|
|||||||
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined);
|
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeStarboardEntryBySource(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
sourceMessageId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const entry = await context.prisma.starboardEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
guildId_sourceMessageId: { guildId, sourceMessageId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (entry) {
|
||||||
|
await removeStarboardEntry(context, entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrRecreateStarboardPost(
|
||||||
|
context: BotContext,
|
||||||
|
message: Message,
|
||||||
|
config: StarboardConfig,
|
||||||
|
locale: Locale,
|
||||||
|
starCount: number,
|
||||||
|
starboardChannel: TextChannel,
|
||||||
|
existingId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const embed = buildStarboardEmbed(message, starCount, config, locale);
|
||||||
|
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
await context.prisma.starboardEntry.update({
|
||||||
|
where: { id: existingId },
|
||||||
|
data: {
|
||||||
|
starboardMessageId: starboardMessage.id,
|
||||||
|
starCount,
|
||||||
|
sourceChannelId: message.channel.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.prisma.starboardEntry.create({
|
||||||
|
data: {
|
||||||
|
guildId: message.guild!.id,
|
||||||
|
sourceChannelId: message.channel.id,
|
||||||
|
sourceMessageId: message.id,
|
||||||
|
starboardMessageId: starboardMessage.id,
|
||||||
|
starCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Race: another reaction created the row first — keep the newer post, drop ours.
|
||||||
|
logger.warn({ error, messageId: message.id }, 'Starboard entry create raced; cleaning up');
|
||||||
|
await starboardMessage.delete().catch(() => undefined);
|
||||||
|
const winner = await context.prisma.starboardEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
guildId_sourceMessageId: {
|
||||||
|
guildId: message.guild!.id,
|
||||||
|
sourceMessageId: message.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (winner) {
|
||||||
|
await context.prisma.starboardEntry.update({
|
||||||
|
where: { id: winner.id },
|
||||||
|
data: { starCount }
|
||||||
|
});
|
||||||
|
const existingMsg = await starboardChannel.messages
|
||||||
|
.fetch(winner.starboardMessageId)
|
||||||
|
.catch(() => null);
|
||||||
|
if (existingMsg) {
|
||||||
|
await existingMsg.edit({ embeds: [embed] }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
|
export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
|
||||||
if (!message.guild) {
|
if (!message.guild) {
|
||||||
return;
|
return;
|
||||||
@@ -194,8 +313,13 @@ export async function processStarboardMessage(context: BotContext, message: Mess
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const starboardChannel = await fetchStarboardTextChannel(context, config.channelId);
|
||||||
|
if (!starboardChannel) {
|
||||||
|
logger.warn({ guildId: message.guild.id, channelId: config.channelId }, 'Starboard channel missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const embed = buildStarboardEmbed(message, starCount, config, locale);
|
const embed = buildStarboardEmbed(message, starCount, config, locale);
|
||||||
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
try {
|
try {
|
||||||
@@ -206,21 +330,28 @@ export async function processStarboardMessage(context: BotContext, message: Mess
|
|||||||
data: { starCount }
|
data: { starCount }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ error, messageId: message.id }, 'Failed to update starboard message');
|
logger.warn({ error, messageId: message.id }, 'Starboard message missing; recreating');
|
||||||
|
await createOrRecreateStarboardPost(
|
||||||
|
context,
|
||||||
|
message,
|
||||||
|
config,
|
||||||
|
locale,
|
||||||
|
starCount,
|
||||||
|
starboardChannel,
|
||||||
|
existing.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
|
await createOrRecreateStarboardPost(
|
||||||
await context.prisma.starboardEntry.create({
|
context,
|
||||||
data: {
|
message,
|
||||||
guildId: message.guild.id,
|
config,
|
||||||
sourceChannelId: message.channel.id,
|
locale,
|
||||||
sourceMessageId: message.id,
|
starCount,
|
||||||
starboardMessageId: starboardMessage.id,
|
starboardChannel
|
||||||
starCount
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processStarboardReaction(
|
export async function processStarboardReaction(
|
||||||
@@ -249,3 +380,41 @@ export async function processStarboardReaction(
|
|||||||
|
|
||||||
await processStarboardMessage(context, message as Message);
|
await processStarboardMessage(context, message as Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function processStarboardSourceDelete(
|
||||||
|
context: BotContext,
|
||||||
|
message: Message | PartialMessage
|
||||||
|
): Promise<void> {
|
||||||
|
if (!message.guildId || !message.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await removeStarboardEntryBySource(context, message.guildId, message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processStarboardSourceUpdate(
|
||||||
|
context: BotContext,
|
||||||
|
message: Message | PartialMessage
|
||||||
|
): Promise<void> {
|
||||||
|
if (!message.guildId || !message.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await context.prisma.starboardEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
guildId_sourceMessageId: {
|
||||||
|
guildId: message.guildId,
|
||||||
|
sourceMessageId: message.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = message.partial ? await message.fetch().catch(() => null) : message;
|
||||||
|
if (!full || !full.guild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processStarboardMessage(context, full as Message);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { invitesCommandData, statsCommandData } from './command-definitions.js';
|
|||||||
import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js';
|
import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js';
|
||||||
import {
|
import {
|
||||||
getStatsConfig,
|
getStatsConfig,
|
||||||
|
refreshGuildStatsChannels,
|
||||||
StatsSetupSchema,
|
StatsSetupSchema,
|
||||||
updateStatsChannels,
|
|
||||||
upsertStatsConfig
|
upsertStatsConfig
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ const statsCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await upsertStatsConfig(context.prisma, guildId, parsed.data);
|
await upsertStatsConfig(context.prisma, guildId, parsed.data);
|
||||||
await updateStatsChannels(context);
|
await refreshGuildStatsChannels(context, guildId);
|
||||||
|
|
||||||
await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true });
|
||||||
return;
|
return;
|
||||||
@@ -64,7 +64,7 @@ const statsCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateStatsChannels(context);
|
await refreshGuildStatsChannels(context, guildId);
|
||||||
await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export {
|
|||||||
ensureStatsRecurringJobs,
|
ensureStatsRecurringJobs,
|
||||||
startStatsWorker,
|
startStatsWorker,
|
||||||
updateStatsChannels,
|
updateStatsChannels,
|
||||||
|
refreshGuildStatsChannels,
|
||||||
|
runStatsGuildRefreshJob,
|
||||||
STATS_REFRESH_CRON,
|
STATS_REFRESH_CRON,
|
||||||
STATS_REFRESH_JOB_NAME
|
STATS_REFRESH_JOB_NAME,
|
||||||
|
STATS_GUILD_REFRESH_JOB_NAME
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export type StatsSetupInput = z.infer<typeof StatsSetupSchema>;
|
|||||||
|
|
||||||
export const STATS_REFRESH_CRON = '*/10 * * * *';
|
export const STATS_REFRESH_CRON = '*/10 * * * *';
|
||||||
export const STATS_REFRESH_JOB_NAME = 'statsRefresh';
|
export const STATS_REFRESH_JOB_NAME = 'statsRefresh';
|
||||||
|
/** One-shot refresh for a single guild (dashboard save / slash refresh). */
|
||||||
|
export const STATS_GUILD_REFRESH_JOB_NAME = 'statsRefreshGuild';
|
||||||
|
|
||||||
export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) {
|
export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
@@ -97,13 +99,17 @@ async function renameStatsChannel(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
|
export async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
|
||||||
const config = await context.prisma.statsConfig.findUnique({ where: { guildId } });
|
const config = await context.prisma.statsConfig.findUnique({ where: { guildId } });
|
||||||
if (!config?.enabled) {
|
if (!config?.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
// withCounts populates approximatePresenceCount / approximateMemberCount.
|
||||||
|
// GuildPresences is not privileged for us — presence cache would stay empty.
|
||||||
|
const guild = await context.client.guilds
|
||||||
|
.fetch({ guild: guildId, withCounts: true })
|
||||||
|
.catch(() => null);
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,9 +120,7 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await guild.members.fetch().catch(() => undefined);
|
const memberCount = guild.approximateMemberCount ?? guild.memberCount;
|
||||||
|
|
||||||
const memberCount = guild.memberCount;
|
|
||||||
const onlineCount = estimateOnlineCount(guild);
|
const onlineCount = estimateOnlineCount(guild);
|
||||||
const boostCount = guild.premiumSubscriptionCount ?? 0;
|
const boostCount = guild.premiumSubscriptionCount ?? 0;
|
||||||
|
|
||||||
@@ -127,6 +131,13 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string):
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runStatsGuildRefreshJob(
|
||||||
|
context: BotContext,
|
||||||
|
data: { guildId: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await refreshGuildStatsChannels(context, data.guildId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateStatsChannels(context: BotContext): Promise<void> {
|
export async function updateStatsChannels(context: BotContext): Promise<void> {
|
||||||
const configs = await context.prisma.statsConfig.findMany({
|
const configs = await context.prisma.statsConfig.findMany({
|
||||||
where: { enabled: true }
|
where: { enabled: true }
|
||||||
@@ -159,6 +170,15 @@ export function startStatsWorker(context: BotContext): Worker {
|
|||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name === STATS_REFRESH_JOB_NAME) {
|
if (job.name === STATS_REFRESH_JOB_NAME) {
|
||||||
await updateStatsChannels(context);
|
await updateStatsChannels(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.name === STATS_GUILD_REFRESH_JOB_NAME) {
|
||||||
|
const guildId = typeof job.data?.guildId === 'string' ? job.data.guildId : null;
|
||||||
|
if (!guildId) {
|
||||||
|
logger.warn({ jobId: job.id }, 'statsRefreshGuild missing guildId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runStatsGuildRefreshJob(context, { guildId });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ connection: redis }
|
{ connection: redis }
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
|
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
export const tagCommandData = applyCommandDescription(
|
export const tagCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('tag')
|
.setName('tag')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((sub) =>
|
.addSubcommand((sub) =>
|
||||||
applyCommandDescription(sub.setName('create'), 'tag.create.description')
|
applyCommandDescription(sub.setName('create'), 'tag.create.description')
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PermissionFlagsBits } from 'discord.js';
|
import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
|
||||||
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
|
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
@@ -67,7 +67,7 @@ const tagCommand: SlashCommand = {
|
|||||||
interaction.channelId!,
|
interaction.channelId!,
|
||||||
args
|
args
|
||||||
);
|
);
|
||||||
await interaction.reply(payload);
|
await interaction.reply(payload as InteractionReplyOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +172,9 @@ const tagCommand: SlashCommand = {
|
|||||||
tag.triggerWord
|
tag.triggerWord
|
||||||
? tf(locale, 'tag.info.trigger', { word: tag.triggerWord })
|
? tf(locale, 'tag.info.trigger', { word: tag.triggerWord })
|
||||||
: t(locale, 'tag.info.noTrigger'),
|
: t(locale, 'tag.info.noTrigger'),
|
||||||
|
tag.cooldownSeconds > 0
|
||||||
|
? tf(locale, 'tag.info.cooldown', { seconds: tag.cooldownSeconds })
|
||||||
|
: t(locale, 'tag.info.noCooldown'),
|
||||||
tag.allowedRoleIds.length > 0
|
tag.allowedRoleIds.length > 0
|
||||||
? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') })
|
? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') })
|
||||||
: t(locale, 'tag.info.allRoles'),
|
: t(locale, 'tag.info.allRoles'),
|
||||||
@@ -189,8 +192,12 @@ const tagCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TagError) {
|
if (error instanceof TagError) {
|
||||||
|
const content =
|
||||||
|
error.code === 'cooldown' && error.retryAfterSeconds !== undefined
|
||||||
|
? tf(locale, 'tag.error.cooldown', { seconds: error.retryAfterSeconds })
|
||||||
|
: t(locale, `tag.error.${error.code}`);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, `tag.error.${error.code}`),
|
content,
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import type { BotContext } from '../../types.js';
|
|||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import {
|
import {
|
||||||
|
applyTagCooldown,
|
||||||
buildTagReply,
|
buildTagReply,
|
||||||
findTriggeredTag,
|
findTriggeredTag,
|
||||||
|
getTagCooldownRemaining,
|
||||||
memberCanUseTag,
|
memberCanUseTag,
|
||||||
TagError
|
TagError
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
@@ -31,6 +33,18 @@ export function registerTagEvents(client: Client, context: BotContext): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel-scoped cooldown: silent skip to avoid spam replies.
|
||||||
|
if (tag.cooldownSeconds > 0) {
|
||||||
|
const remaining = await getTagCooldownRemaining(
|
||||||
|
message.guild.id,
|
||||||
|
tag.id,
|
||||||
|
message.channel.id
|
||||||
|
);
|
||||||
|
if (remaining > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await context.prisma.tag.update({
|
await context.prisma.tag.update({
|
||||||
where: { id: tag.id },
|
where: { id: tag.id },
|
||||||
data: { useCount: { increment: 1 } }
|
data: { useCount: { increment: 1 } }
|
||||||
@@ -38,6 +52,7 @@ export function registerTagEvents(client: Client, context: BotContext): void {
|
|||||||
|
|
||||||
const payload = buildTagReply(tag, member, message.guild, '');
|
const payload = buildTagReply(tag, member, message.guild, '');
|
||||||
await message.reply(payload);
|
await message.reply(payload);
|
||||||
|
await applyTagCooldown(message.guild.id, tag.id, message.channel.id, tag.cooldownSeconds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TagError) {
|
if (error instanceof TagError) {
|
||||||
const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null);
|
const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null);
|
||||||
|
|||||||
@@ -1,21 +1,57 @@
|
|||||||
import { EmbedBuilder, type Guild, type GuildMember } from 'discord.js';
|
import type { Guild, GuildMember, MessageCreateOptions } from 'discord.js';
|
||||||
import {
|
import {
|
||||||
applyTagPlaceholders,
|
applyTagPlaceholders,
|
||||||
|
componentsV2HasContent,
|
||||||
|
embedHasContent,
|
||||||
|
parseMessageComponentsV2,
|
||||||
TagResponseTypeSchema,
|
TagResponseTypeSchema,
|
||||||
WelcomeEmbedSchema,
|
WelcomeEmbedSchema,
|
||||||
type TagResponseType
|
type MessageComponentsV2,
|
||||||
|
type TagResponseType,
|
||||||
|
type WelcomeEmbed
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import type { Tag } from '@prisma/client';
|
import type { Tag } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||||
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||||
|
import { redis } from '../../redis.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
|
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
|
||||||
|
import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
|
||||||
|
|
||||||
|
export { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
|
||||||
|
|
||||||
export class TagError extends Error {
|
export class TagError extends Error {
|
||||||
constructor(public readonly code: string) {
|
constructor(
|
||||||
|
public readonly code: string,
|
||||||
|
public readonly retryAfterSeconds?: number
|
||||||
|
) {
|
||||||
super(code);
|
super(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns remaining cooldown seconds, or 0 if ready. */
|
||||||
|
export async function getTagCooldownRemaining(
|
||||||
|
guildId: string,
|
||||||
|
tagId: string,
|
||||||
|
scopeId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const ttl = await redis.ttl(tagCooldownKey(guildId, tagId, scopeId));
|
||||||
|
return ttl > 0 ? ttl : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyTagCooldown(
|
||||||
|
guildId: string,
|
||||||
|
tagId: string,
|
||||||
|
scopeId: string,
|
||||||
|
cooldownSeconds: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (cooldownSeconds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await redis.set(tagCooldownKey(guildId, tagId, scopeId), '1', 'EX', cooldownSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeTagName(name: string): string {
|
export function normalizeTagName(name: string): string {
|
||||||
return name.trim().toLowerCase();
|
return name.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -50,7 +86,9 @@ export function buildTagPlaceholderVars(
|
|||||||
'user.name': member?.displayName ?? '',
|
'user.name': member?.displayName ?? '',
|
||||||
'user.tag': member?.user.tag ?? '',
|
'user.tag': member?.user.tag ?? '',
|
||||||
'user.id': member?.id ?? '',
|
'user.id': member?.id ?? '',
|
||||||
|
'user.avatar': member?.user.displayAvatarURL({ size: 256 }) ?? '',
|
||||||
server: guild.name,
|
server: guild.name,
|
||||||
|
'server.icon': guild.iconURL({ size: 128 }) ?? '',
|
||||||
args
|
args
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -65,10 +103,7 @@ export function parseTagEmbed(raw: unknown) {
|
|||||||
return parsed.success ? parsed.data : null;
|
return parsed.success ? parsed.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TagReplyPayload = {
|
export type TagReplyPayload = MessageCreateOptions;
|
||||||
content?: string;
|
|
||||||
embeds?: EmbedBuilder[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildTagReply(
|
export function buildTagReply(
|
||||||
tag: Tag,
|
tag: Tag,
|
||||||
@@ -79,16 +114,22 @@ export function buildTagReply(
|
|||||||
const vars = buildTagPlaceholderVars(member, guild, args);
|
const vars = buildTagPlaceholderVars(member, guild, args);
|
||||||
const responseType = TagResponseTypeSchema.parse(tag.responseType);
|
const responseType = TagResponseTypeSchema.parse(tag.responseType);
|
||||||
|
|
||||||
|
if (responseType === 'COMPONENTS_V2') {
|
||||||
|
const components = parseMessageComponentsV2(tag.components);
|
||||||
|
const payload = buildComponentsV2Payload(components, {
|
||||||
|
source: 't',
|
||||||
|
ref: tag.id,
|
||||||
|
renderText: (value) => renderTagContent(value, vars)
|
||||||
|
});
|
||||||
|
return payload ?? { content: '—' };
|
||||||
|
}
|
||||||
|
|
||||||
if (responseType === 'EMBED') {
|
if (responseType === 'EMBED') {
|
||||||
const embedData = parseTagEmbed(tag.embed);
|
const embedData = parseTagEmbed(tag.embed);
|
||||||
const embed = new EmbedBuilder().setColor(embedData?.color ?? 0x6366f1);
|
const embed = buildEmbedFromPayload(embedData, {
|
||||||
if (embedData?.title) {
|
renderText: (value) => renderTagContent(value, vars)
|
||||||
embed.setTitle(renderTagContent(embedData.title, vars));
|
});
|
||||||
}
|
return embed ? { embeds: [embed] } : { content: '—' };
|
||||||
if (embedData?.description) {
|
|
||||||
embed.setDescription(renderTagContent(embedData.description, vars));
|
|
||||||
}
|
|
||||||
return { embeds: [embed] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = renderTagContent(tag.content ?? '', vars);
|
const content = renderTagContent(tag.content ?? '', vars);
|
||||||
@@ -132,8 +173,10 @@ export async function createTag(
|
|||||||
name: string;
|
name: string;
|
||||||
responseType: TagResponseType;
|
responseType: TagResponseType;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
embed?: { title?: string; description?: string; color?: number } | null;
|
embed?: WelcomeEmbed | null;
|
||||||
|
components?: MessageComponentsV2 | null;
|
||||||
triggerWord?: string | null;
|
triggerWord?: string | null;
|
||||||
|
cooldownSeconds?: number;
|
||||||
allowedRoleIds?: string[];
|
allowedRoleIds?: string[];
|
||||||
allowedChannelIds?: string[];
|
allowedChannelIds?: string[];
|
||||||
createdById: string;
|
createdById: string;
|
||||||
@@ -150,10 +193,14 @@ export async function createTag(
|
|||||||
throw new TagError('content_required');
|
throw new TagError('content_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.responseType === 'EMBED' && !params.embed?.title && !params.embed?.description) {
|
if (params.responseType === 'EMBED' && !embedHasContent(params.embed) && params.embed?.color === undefined) {
|
||||||
throw new TagError('embed_required');
|
throw new TagError('embed_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.responseType === 'COMPONENTS_V2' && !componentsV2HasContent(params.components)) {
|
||||||
|
throw new TagError('components_required');
|
||||||
|
}
|
||||||
|
|
||||||
const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } });
|
const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } });
|
||||||
try {
|
try {
|
||||||
await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount);
|
await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount);
|
||||||
@@ -171,7 +218,9 @@ export async function createTag(
|
|||||||
responseType: params.responseType,
|
responseType: params.responseType,
|
||||||
content: params.content ?? null,
|
content: params.content ?? null,
|
||||||
embed: params.embed ?? undefined,
|
embed: params.embed ?? undefined,
|
||||||
|
components: params.components ?? undefined,
|
||||||
triggerWord: params.triggerWord?.trim() || null,
|
triggerWord: params.triggerWord?.trim() || null,
|
||||||
|
cooldownSeconds: params.cooldownSeconds ?? 15,
|
||||||
allowedRoleIds: params.allowedRoleIds ?? [],
|
allowedRoleIds: params.allowedRoleIds ?? [],
|
||||||
allowedChannelIds: params.allowedChannelIds ?? [],
|
allowedChannelIds: params.allowedChannelIds ?? [],
|
||||||
createdById: params.createdById
|
createdById: params.createdById
|
||||||
@@ -187,8 +236,10 @@ export async function updateTag(
|
|||||||
newName?: string;
|
newName?: string;
|
||||||
responseType?: TagResponseType;
|
responseType?: TagResponseType;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
embed?: { title?: string; description?: string; color?: number } | null;
|
embed?: WelcomeEmbed | null;
|
||||||
|
components?: MessageComponentsV2 | null;
|
||||||
triggerWord?: string | null;
|
triggerWord?: string | null;
|
||||||
|
cooldownSeconds?: number;
|
||||||
allowedRoleIds?: string[];
|
allowedRoleIds?: string[];
|
||||||
allowedChannelIds?: string[];
|
allowedChannelIds?: string[];
|
||||||
}
|
}
|
||||||
@@ -201,15 +252,23 @@ export async function updateTag(
|
|||||||
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
|
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
|
||||||
const content = updates.content !== undefined ? updates.content : existing.content;
|
const content = updates.content !== undefined ? updates.content : existing.content;
|
||||||
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed);
|
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed);
|
||||||
|
const components =
|
||||||
|
updates.components !== undefined
|
||||||
|
? updates.components
|
||||||
|
: parseMessageComponentsV2(existing.components);
|
||||||
|
|
||||||
if (responseType === 'TEXT' && !content?.trim()) {
|
if (responseType === 'TEXT' && !content?.trim()) {
|
||||||
throw new TagError('content_required');
|
throw new TagError('content_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseType === 'EMBED' && !embed?.title && !embed?.description) {
|
if (responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
|
||||||
throw new TagError('embed_required');
|
throw new TagError('embed_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(components)) {
|
||||||
|
throw new TagError('components_required');
|
||||||
|
}
|
||||||
|
|
||||||
let newName: string | undefined;
|
let newName: string | undefined;
|
||||||
if (updates.newName) {
|
if (updates.newName) {
|
||||||
newName = normalizeTagName(updates.newName);
|
newName = normalizeTagName(updates.newName);
|
||||||
@@ -225,8 +284,11 @@ export async function updateTag(
|
|||||||
responseType,
|
responseType,
|
||||||
content,
|
content,
|
||||||
embed: embed ?? undefined,
|
embed: embed ?? undefined,
|
||||||
|
components: components ?? undefined,
|
||||||
triggerWord:
|
triggerWord:
|
||||||
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
|
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
|
||||||
|
cooldownSeconds:
|
||||||
|
updates.cooldownSeconds !== undefined ? updates.cooldownSeconds : existing.cooldownSeconds,
|
||||||
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
|
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
|
||||||
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
|
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
|
||||||
}
|
}
|
||||||
@@ -266,18 +328,22 @@ export async function executeTag(
|
|||||||
throw new TagError('not_allowed');
|
throw new TagError('not_allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scopeId = member?.id ?? channelId;
|
||||||
|
if (tag.cooldownSeconds > 0) {
|
||||||
|
const remaining = await getTagCooldownRemaining(guildId, tag.id, scopeId);
|
||||||
|
if (remaining > 0) {
|
||||||
|
throw new TagError('cooldown', remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await context.prisma.tag.update({
|
await context.prisma.tag.update({
|
||||||
where: { id: tag.id },
|
where: { id: tag.id },
|
||||||
data: { useCount: { increment: 1 } }
|
data: { useCount: { increment: 1 } }
|
||||||
});
|
});
|
||||||
|
|
||||||
return buildTagReply(tag, member, guild, args);
|
await applyTagCooldown(guildId, tag.id, scopeId, tag.cooldownSeconds);
|
||||||
}
|
|
||||||
|
|
||||||
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
|
return buildTagReply(tag, member, guild, args);
|
||||||
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
|
|
||||||
return pattern.test(content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findTriggeredTag(
|
export async function findTriggeredTag(
|
||||||
|
|||||||
15
apps/bot/src/modules/tags/tag-helpers.test.ts
Normal file
15
apps/bot/src/modules/tags/tag-helpers.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
|
||||||
|
|
||||||
|
describe('tagCooldownKey', () => {
|
||||||
|
it('builds a stable redis key', () => {
|
||||||
|
expect(tagCooldownKey('g1', 't1', 'c1')).toBe('tag:cooldown:g1:t1:c1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchesTriggerWord', () => {
|
||||||
|
it('matches whole words case-insensitively', () => {
|
||||||
|
expect(matchesTriggerWord('Hello world', 'hello')).toBe(true);
|
||||||
|
expect(matchesTriggerWord('say helloworld', 'hello')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
apps/bot/src/modules/tags/tag-helpers.ts
Normal file
10
apps/bot/src/modules/tags/tag-helpers.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/** Redis key for tag cooldowns (guild + tag + user or channel scope). */
|
||||||
|
export function tagCooldownKey(guildId: string, tagId: string, scopeId: string): string {
|
||||||
|
return `tag:cooldown:${guildId}:${tagId}:${scopeId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
|
||||||
|
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
|
||||||
|
return pattern.test(content);
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ import {
|
|||||||
applyOptionDescription,
|
applyOptionDescription,
|
||||||
localizedChoice
|
localizedChoice
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings.
|
// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings.
|
||||||
// SPEC `/ticket panel create` is represented as subcommand `panel_create`.
|
// SPEC `/ticket panel create` is represented as subcommand `panel_create`.
|
||||||
export const ticketCommandData = applyCommandDescription(
|
export const ticketCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('ticket')
|
.setName('ticket')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description')
|
applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description')
|
||||||
.addStringOption((o) =>
|
.addStringOption((o) =>
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { t, tf } from '@nexumi/shared';
|
|||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
import { ticketCommandData } from './command-definitions.js';
|
import { ticketCommandData } from './command-definitions.js';
|
||||||
import {
|
import {
|
||||||
addUserToTicket,
|
addUserToTicket,
|
||||||
assertTicketStaff,
|
assertTicketStaff,
|
||||||
buildPanelComponents,
|
|
||||||
buildPanelEmbed,
|
|
||||||
claimTicket,
|
claimTicket,
|
||||||
closeTicket,
|
closeTicket,
|
||||||
createCategoryWithRole,
|
createCategoryWithRole,
|
||||||
@@ -17,7 +16,9 @@ import {
|
|||||||
removeUserFromTicket,
|
removeUserFromTicket,
|
||||||
renameTicket,
|
renameTicket,
|
||||||
setTicketPriority,
|
setTicketPriority,
|
||||||
|
syncTicketPanel,
|
||||||
TicketError,
|
TicketError,
|
||||||
|
TicketPanelError,
|
||||||
upsertCategoryByName
|
upsertCategoryByName
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ async function ensureManageGuild(
|
|||||||
locale: 'de' | 'en'
|
locale: 'de' | 'en'
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!interaction.inGuild()) {
|
if (!interaction.inGuild()) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||||
@@ -37,7 +38,7 @@ const ticketCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!interaction.inGuild()) {
|
if (!interaction.inGuild()) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,10 @@ const ticketCommand: SlashCommand = {
|
|||||||
|
|
||||||
const categories = await getGuildCategories(context, interaction.guildId!);
|
const categories = await getGuildCategories(context, interaction.guildId!);
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.no_categories'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,15 +66,31 @@ const ticketCommand: SlashCommand = {
|
|||||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, 'ticket.error.invalid_channel'),
|
content: t(locale, 'ticket.error.invalid_channel'),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = buildPanelEmbed(locale, name, description);
|
try {
|
||||||
const components = buildPanelComponents(categories);
|
await syncTicketPanel(context, interaction.guildId!, {
|
||||||
await channel.send({ embeds: [embed], components });
|
channelId: channel.id,
|
||||||
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true });
|
title: name,
|
||||||
|
description
|
||||||
|
});
|
||||||
|
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ...ephemeral() });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TicketPanelError) {
|
||||||
|
const key =
|
||||||
|
error.code === 'no_categories'
|
||||||
|
? 'ticket.error.no_categories'
|
||||||
|
: error.code === 'not_configured'
|
||||||
|
? 'ticket.error.invalid_channel'
|
||||||
|
: 'ticket.error.missing_permission';
|
||||||
|
await interaction.reply({ content: t(locale, key), ...ephemeral() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,18 +109,28 @@ const ticketCommand: SlashCommand = {
|
|||||||
);
|
);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'ticket.category.created', { name: category.name }),
|
content: tf(locale, 'ticket.category.created', { name: category.name }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
|
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.not_in_ticket'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
const member = await interaction.guild!.members.fetch(interaction.user.id).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.member_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (sub === 'close') {
|
if (sub === 'close') {
|
||||||
@@ -115,40 +145,57 @@ const ticketCommand: SlashCommand = {
|
|||||||
const isOpener = ticket.openerId === interaction.user.id;
|
const isOpener = ticket.openerId === interaction.user.id;
|
||||||
|
|
||||||
if (!isStaff && !isOpener) {
|
if (!isStaff && !isOpener) {
|
||||||
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.not_staff'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = interaction.options.getString('reason') ?? undefined;
|
const reason = interaction.options.getString('reason') ?? undefined;
|
||||||
await closeTicket(context, ticket, interaction.user.id, locale, reason);
|
await closeTicket(context, ticket, interaction.user.id, locale, reason);
|
||||||
await interaction.reply({ content: t(locale, 'ticket.close.success'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'ticket.close.success'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'claim') {
|
if (sub === 'claim') {
|
||||||
await claimTicket(context, ticket, member, locale);
|
await claimTicket(context, ticket, member, locale);
|
||||||
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'add') {
|
if (sub === 'add') {
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const target = await interaction.guild!.members.fetch(user.id);
|
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.member_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await addUserToTicket(context, ticket, member, target, locale);
|
await addUserToTicket(context, ticket, member, target, locale);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
|
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'remove') {
|
if (sub === 'remove') {
|
||||||
const user = interaction.options.getUser('user', true);
|
const user = interaction.options.getUser('user', true);
|
||||||
const target = await interaction.guild!.members.fetch(user.id);
|
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.member_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await removeUserFromTicket(context, ticket, member, target, locale);
|
await removeUserFromTicket(context, ticket, member, target, locale);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
|
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,7 +205,7 @@ const ticketCommand: SlashCommand = {
|
|||||||
await renameTicket(context, ticket, member, name, locale);
|
await renameTicket(context, ticket, member, name, locale);
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'ticket.rename.success', { name }),
|
content: tf(locale, 'ticket.rename.success', { name }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -170,14 +217,14 @@ const ticketCommand: SlashCommand = {
|
|||||||
content: tf(locale, 'ticket.priority.success', {
|
content: tf(locale, 'ticket.priority.success', {
|
||||||
priority: t(locale, `ticket.priority.${level}`)
|
priority: t(locale, `ticket.priority.${level}`)
|
||||||
}),
|
}),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TicketError) {
|
if (error instanceof TicketError) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, `ticket.error.${error.code}`),
|
content: t(locale, `ticket.error.${error.code}`),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
export { ticketsCommands } from './commands.js';
|
export { ticketsCommands } from './commands.js';
|
||||||
export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
|
export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
|
||||||
export { closeInactiveTickets, checkInactiveTickets } from './service.js';
|
export {
|
||||||
|
closeInactiveTickets,
|
||||||
|
checkInactiveTickets,
|
||||||
|
runTicketPanelSyncJob,
|
||||||
|
runTicketDashboardActionJob,
|
||||||
|
TicketPanelError
|
||||||
|
} from './service.js';
|
||||||
export { registerTicketEvents } from './events.js';
|
export { registerTicketEvents } from './events.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
import type {
|
import type {
|
||||||
ButtonInteraction,
|
ButtonInteraction,
|
||||||
ModalSubmitInteraction,
|
ModalSubmitInteraction,
|
||||||
StringSelectMenuInteraction
|
StringSelectMenuInteraction,
|
||||||
|
UserSelectMenuInteraction
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t, tf } from '@nexumi/shared';
|
import { t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
import {
|
import {
|
||||||
|
addUserToTicket,
|
||||||
|
assertTicketStaff,
|
||||||
buildOpenModal,
|
buildOpenModal,
|
||||||
|
claimTicket,
|
||||||
|
closeTicket,
|
||||||
|
findTicketByChannel,
|
||||||
|
isTicketControlInteraction,
|
||||||
openTicketForCategory,
|
openTicketForCategory,
|
||||||
parseFormFields,
|
parseFormFields,
|
||||||
parseRateButton,
|
parseRateButton,
|
||||||
rateTicket,
|
rateTicket,
|
||||||
|
removeUserFromTicket,
|
||||||
|
setTicketPriority,
|
||||||
|
TICKET_CTRL_ADD,
|
||||||
|
TICKET_CTRL_CLAIM,
|
||||||
|
TICKET_CTRL_CLOSE,
|
||||||
|
TICKET_CTRL_PRIORITY,
|
||||||
|
TICKET_CTRL_REMOVE,
|
||||||
TICKET_MODAL_PREFIX,
|
TICKET_MODAL_PREFIX,
|
||||||
TICKET_OPEN_PREFIX,
|
TICKET_OPEN_PREFIX,
|
||||||
TICKET_RATE_PREFIX,
|
TICKET_RATE_PREFIX,
|
||||||
@@ -24,37 +39,45 @@ export function isTicketInteraction(customId: string): boolean {
|
|||||||
customId.startsWith(TICKET_OPEN_PREFIX) ||
|
customId.startsWith(TICKET_OPEN_PREFIX) ||
|
||||||
customId === TICKET_SELECT_ID ||
|
customId === TICKET_SELECT_ID ||
|
||||||
customId.startsWith(TICKET_MODAL_PREFIX) ||
|
customId.startsWith(TICKET_MODAL_PREFIX) ||
|
||||||
customId.startsWith(TICKET_RATE_PREFIX)
|
customId.startsWith(TICKET_RATE_PREFIX) ||
|
||||||
|
isTicketControlInteraction(customId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replyTicketError(
|
async function replyTicketError(
|
||||||
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
|
interaction:
|
||||||
|
| ButtonInteraction
|
||||||
|
| StringSelectMenuInteraction
|
||||||
|
| ModalSubmitInteraction
|
||||||
|
| UserSelectMenuInteraction,
|
||||||
locale: 'de' | 'en',
|
locale: 'de' | 'en',
|
||||||
code: string
|
code: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const content = t(locale, `ticket.error.${code}`);
|
const content = t(locale, `ticket.error.${code}`);
|
||||||
if (interaction.replied || interaction.deferred) {
|
if (interaction.replied || interaction.deferred) {
|
||||||
await interaction.followUp({ content, ephemeral: true });
|
await interaction.followUp({ content, ...ephemeral() });
|
||||||
} else {
|
} else {
|
||||||
await interaction.reply({ content, ephemeral: true });
|
await interaction.reply({ content, ...ephemeral() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleOpenTicketRequest(
|
async function handleOpenTicketRequest(
|
||||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
categoryId: string
|
categoryId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!interaction.inGuild() || !interaction.guild) {
|
if (!interaction.inGuild() || !interaction.guild) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||||
if (!category || category.guildId !== interaction.guildId) {
|
if (!category || category.guildId !== interaction.guildId) {
|
||||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.category_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +88,14 @@ export async function handleOpenTicketRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.member_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ticket = await openTicketForCategory(
|
const ticket = await openTicketForCategory(
|
||||||
context,
|
context,
|
||||||
interaction.guild,
|
interaction.guild,
|
||||||
@@ -76,7 +106,7 @@ export async function handleOpenTicketRequest(
|
|||||||
const channelRef = ticket.channelId ?? ticket.threadId;
|
const channelRef = ticket.channelId ?? ticket.threadId;
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TicketError) {
|
if (error instanceof TicketError) {
|
||||||
@@ -87,8 +117,131 @@ export async function handleOpenTicketRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTicketControl(
|
||||||
|
interaction: ButtonInteraction | UserSelectMenuInteraction | StringSelectMenuInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<void> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!interaction.inGuild() || !interaction.guild) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await findTicketByChannel(context.prisma, interaction.channelId);
|
||||||
|
if (!ticket) {
|
||||||
|
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ...ephemeral() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.member_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLOSE) {
|
||||||
|
const isOpener = ticket.openerId === interaction.user.id;
|
||||||
|
let isStaff = false;
|
||||||
|
try {
|
||||||
|
await assertTicketStaff(member, ticket);
|
||||||
|
isStaff = true;
|
||||||
|
} catch {
|
||||||
|
isStaff = false;
|
||||||
|
}
|
||||||
|
if (!isStaff && !isOpener) {
|
||||||
|
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ...ephemeral() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply(ephemeral());
|
||||||
|
await closeTicket(context, ticket, interaction.user.id, locale);
|
||||||
|
await interaction.editReply({ content: t(locale, 'ticket.close.success') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLAIM) {
|
||||||
|
await claimTicket(context, ticket, member, locale);
|
||||||
|
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_CTRL_PRIORITY) {
|
||||||
|
const level = interaction.values[0];
|
||||||
|
if (!level) {
|
||||||
|
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setTicketPriority(context, ticket, member, level, locale);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'ticket.priority.success', {
|
||||||
|
priority: t(locale, `ticket.priority.${level}`)
|
||||||
|
}),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_ADD) {
|
||||||
|
const userId = interaction.values[0];
|
||||||
|
if (!userId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.member_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await addUserToTicket(context, ticket, member, target, locale);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'ticket.add.success', { user: `<@${userId}>` }),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_REMOVE) {
|
||||||
|
const userId = interaction.values[0];
|
||||||
|
if (!userId) {
|
||||||
|
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.member_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await removeUserFromTicket(context, ticket, member, target, locale);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'ticket.remove.success', { user: `<@${userId}>` }),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TicketError) {
|
||||||
|
await replyTicketError(interaction, locale, error.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleTicketInteraction(
|
export async function handleTicketInteraction(
|
||||||
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
|
interaction:
|
||||||
|
| ButtonInteraction
|
||||||
|
| StringSelectMenuInteraction
|
||||||
|
| ModalSubmitInteraction
|
||||||
|
| UserSelectMenuInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
@@ -96,13 +249,27 @@ export async function handleTicketInteraction(
|
|||||||
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
|
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
|
||||||
const categoryId = interaction.values[0];
|
const categoryId = interaction.values[0];
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.category_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await handleOpenTicketRequest(interaction, context, categoryId);
|
await handleOpenTicketRequest(interaction, context, categoryId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
interaction.isButton() ||
|
||||||
|
interaction.isUserSelectMenu() ||
|
||||||
|
interaction.isStringSelectMenu()
|
||||||
|
) {
|
||||||
|
if (isTicketControlInteraction(interaction.customId)) {
|
||||||
|
await handleTicketControl(interaction, context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (interaction.isButton()) {
|
if (interaction.isButton()) {
|
||||||
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
|
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
|
||||||
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
|
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
|
||||||
@@ -122,7 +289,7 @@ export async function handleTicketInteraction(
|
|||||||
if (error instanceof TicketError) {
|
if (error instanceof TicketError) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: t(locale, `ticket.error.${error.code}`),
|
content: t(locale, `ticket.error.${error.code}`),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,14 +301,17 @@ export async function handleTicketInteraction(
|
|||||||
|
|
||||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
|
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
|
||||||
if (!interaction.inGuild() || !interaction.guild) {
|
if (!interaction.inGuild() || !interaction.guild) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
|
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
|
||||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||||
if (!category) {
|
if (!category) {
|
||||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.category_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +326,14 @@ export async function handleTicketInteraction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'ticket.error.member_not_found'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ticket = await openTicketForCategory(
|
const ticket = await openTicketForCategory(
|
||||||
context,
|
context,
|
||||||
interaction.guild,
|
interaction.guild,
|
||||||
@@ -169,7 +346,7 @@ export async function handleTicketInteraction(
|
|||||||
const channelRef = ticket.channelId ?? ticket.threadId;
|
const channelRef = ticket.channelId ?? ticket.threadId;
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TicketError) {
|
if (error instanceof TicketError) {
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ import {
|
|||||||
StringSelectMenuBuilder,
|
StringSelectMenuBuilder,
|
||||||
TextInputBuilder,
|
TextInputBuilder,
|
||||||
TextInputStyle,
|
TextInputStyle,
|
||||||
|
UserSelectMenuBuilder,
|
||||||
|
type APIMessageComponentEmoji,
|
||||||
type Guild,
|
type Guild,
|
||||||
|
type GuildBasedChannel,
|
||||||
type GuildMember,
|
type GuildMember,
|
||||||
|
type GuildTextBasedChannel,
|
||||||
type Message,
|
type Message,
|
||||||
|
type Role,
|
||||||
type SendableChannels,
|
type SendableChannels,
|
||||||
type TextChannel,
|
type TextChannel,
|
||||||
type ThreadChannel
|
type ThreadChannel
|
||||||
@@ -34,6 +39,14 @@ export const TICKET_OPEN_PREFIX = 'ticket:open:';
|
|||||||
export const TICKET_SELECT_ID = 'ticket:select';
|
export const TICKET_SELECT_ID = 'ticket:select';
|
||||||
export const TICKET_MODAL_PREFIX = 'ticket:modal:';
|
export const TICKET_MODAL_PREFIX = 'ticket:modal:';
|
||||||
export const TICKET_RATE_PREFIX = 'ticket:rate:';
|
export const TICKET_RATE_PREFIX = 'ticket:rate:';
|
||||||
|
export const TICKET_CTRL_CLAIM = 'ticket:ctrl:claim';
|
||||||
|
export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close';
|
||||||
|
export const TICKET_CTRL_ADD = 'ticket:ctrl:add';
|
||||||
|
export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove';
|
||||||
|
export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority';
|
||||||
|
|
||||||
|
/** Cap private-thread staff adds to stay within Discord rate limits. */
|
||||||
|
const MAX_THREAD_SUPPORT_MEMBERS = 50;
|
||||||
|
|
||||||
export const TicketFormFieldSchema = z.object({
|
export const TicketFormFieldSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -51,6 +64,189 @@ export class TicketError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TicketPanelError extends Error {
|
||||||
|
constructor(public readonly code: 'not_configured' | 'missing_permission' | 'no_categories') {
|
||||||
|
super(code);
|
||||||
|
this.name = 'TicketPanelError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnknownChannelError(error: unknown): boolean {
|
||||||
|
return Boolean(
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code: unknown }).code === 10003
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function botCanPostPanel(
|
||||||
|
channel: GuildBasedChannel,
|
||||||
|
meId: string
|
||||||
|
): channel is GuildTextBasedChannel {
|
||||||
|
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const permissions = channel.permissionsFor(meId);
|
||||||
|
if (!permissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
permissions.has(PermissionFlagsBits.ViewChannel) &&
|
||||||
|
permissions.has(PermissionFlagsBits.SendMessages) &&
|
||||||
|
permissions.has(PermissionFlagsBits.EmbedLinks)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | undefined {
|
||||||
|
if (!emoji) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
|
||||||
|
if (customMatch) {
|
||||||
|
return {
|
||||||
|
id: customMatch[3]!,
|
||||||
|
name: customMatch[2],
|
||||||
|
animated: Boolean(customMatch[1])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(emoji)) {
|
||||||
|
return { id: emoji };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: emoji };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Role#members` only includes cached guild members. Fetch the member list when
|
||||||
|
* the cache looks empty so support staff can be added to private threads.
|
||||||
|
*/
|
||||||
|
async function resolveSupportMembers(guild: Guild, roleIds: string[]): Promise<GuildMember[]> {
|
||||||
|
if (roleIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: Role[] = [];
|
||||||
|
for (const roleId of roleIds) {
|
||||||
|
const role =
|
||||||
|
guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null));
|
||||||
|
if (role) {
|
||||||
|
roles.push(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedCount = roles.reduce((sum, role) => sum + role.members.size, 0);
|
||||||
|
if (cachedCount === 0) {
|
||||||
|
await guild.members.fetch().catch((error) => {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to fetch members for ticket support roles');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const members: GuildMember[] = [];
|
||||||
|
for (const role of roles) {
|
||||||
|
for (const member of role.members.values()) {
|
||||||
|
if (member.user.bot || seen.has(member.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(member.id);
|
||||||
|
members.push(member);
|
||||||
|
if (members.length >= MAX_THREAD_SUPPORT_MEMBERS) {
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTicketControlInteraction(customId: string): boolean {
|
||||||
|
return (
|
||||||
|
customId === TICKET_CTRL_CLAIM ||
|
||||||
|
customId === TICKET_CTRL_CLOSE ||
|
||||||
|
customId === TICKET_CTRL_ADD ||
|
||||||
|
customId === TICKET_CTRL_REMOVE ||
|
||||||
|
customId === TICKET_CTRL_PRIORITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTicketControlPanel(locale: 'de' | 'en'): {
|
||||||
|
embeds: EmbedBuilder[];
|
||||||
|
components: ActionRowBuilder<
|
||||||
|
ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder
|
||||||
|
>[];
|
||||||
|
} {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(t(locale, 'ticket.control.title'))
|
||||||
|
.setDescription(t(locale, 'ticket.control.description'))
|
||||||
|
.setColor(0x6366f1);
|
||||||
|
|
||||||
|
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(TICKET_CTRL_CLAIM)
|
||||||
|
.setLabel(t(locale, 'ticket.control.claim'))
|
||||||
|
.setStyle(ButtonStyle.Success),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(TICKET_CTRL_CLOSE)
|
||||||
|
.setLabel(t(locale, 'ticket.control.close'))
|
||||||
|
.setStyle(ButtonStyle.Danger)
|
||||||
|
);
|
||||||
|
|
||||||
|
const priorityRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId(TICKET_CTRL_PRIORITY)
|
||||||
|
.setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder'))
|
||||||
|
.addOptions(
|
||||||
|
{
|
||||||
|
label: t(locale, 'ticket.priority.LOW'),
|
||||||
|
value: 'LOW',
|
||||||
|
description: t(locale, 'ticket.control.priorityLowHint')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(locale, 'ticket.priority.NORMAL'),
|
||||||
|
value: 'NORMAL',
|
||||||
|
description: t(locale, 'ticket.control.priorityNormalHint')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(locale, 'ticket.priority.HIGH'),
|
||||||
|
value: 'HIGH',
|
||||||
|
description: t(locale, 'ticket.control.priorityHighHint')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(locale, 'ticket.priority.URGENT'),
|
||||||
|
value: 'URGENT',
|
||||||
|
description: t(locale, 'ticket.control.priorityUrgentHint')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const addRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
|
||||||
|
new UserSelectMenuBuilder()
|
||||||
|
.setCustomId(TICKET_CTRL_ADD)
|
||||||
|
.setPlaceholder(t(locale, 'ticket.control.addPlaceholder'))
|
||||||
|
.setMinValues(1)
|
||||||
|
.setMaxValues(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
|
||||||
|
new UserSelectMenuBuilder()
|
||||||
|
.setCustomId(TICKET_CTRL_REMOVE)
|
||||||
|
.setPlaceholder(t(locale, 'ticket.control.removePlaceholder'))
|
||||||
|
.setMinValues(1)
|
||||||
|
.setMaxValues(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeds: [embed],
|
||||||
|
components: [buttons, priorityRow, addRow, removeRow]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSendableChannel(
|
async function fetchSendableChannel(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
channelId: string
|
channelId: string
|
||||||
@@ -185,8 +381,9 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
|
|||||||
.setCustomId(openButtonId(category.id))
|
.setCustomId(openButtonId(category.id))
|
||||||
.setLabel(category.name.slice(0, 80))
|
.setLabel(category.name.slice(0, 80))
|
||||||
.setStyle(ButtonStyle.Primary);
|
.setStyle(ButtonStyle.Primary);
|
||||||
if (category.emoji) {
|
const emoji = parseButtonEmoji(category.emoji);
|
||||||
button.setEmoji(category.emoji);
|
if (emoji) {
|
||||||
|
button.setEmoji(emoji);
|
||||||
}
|
}
|
||||||
row.addComponents(button);
|
row.addComponents(button);
|
||||||
}
|
}
|
||||||
@@ -197,17 +394,208 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
|
|||||||
.setCustomId(TICKET_SELECT_ID)
|
.setCustomId(TICKET_SELECT_ID)
|
||||||
.setPlaceholder('Select a category')
|
.setPlaceholder('Select a category')
|
||||||
.addOptions(
|
.addOptions(
|
||||||
categories.slice(0, 25).map((category) => ({
|
categories.slice(0, 25).map((category) => {
|
||||||
|
const emoji = parseButtonEmoji(category.emoji);
|
||||||
|
return {
|
||||||
label: category.name.slice(0, 100),
|
label: category.name.slice(0, 100),
|
||||||
value: category.id,
|
value: category.id,
|
||||||
description: category.description?.slice(0, 100) ?? undefined,
|
description: category.description?.slice(0, 100) ?? undefined,
|
||||||
emoji: category.emoji ? { name: category.emoji } : undefined
|
emoji
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
|
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts or updates the ticket panel. Used by `/ticket panel_create` and by the
|
||||||
|
* dashboard via BullMQ (`ticketPanelSync`).
|
||||||
|
*/
|
||||||
|
export async function syncTicketPanel(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
options: {
|
||||||
|
channelId?: string | null;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
} = {}
|
||||||
|
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, guildId);
|
||||||
|
const config = await getTicketConfig(context.prisma, guildId);
|
||||||
|
const categories = await getGuildCategories(context, guildId);
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
throw new TicketPanelError('no_categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetChannelId = options.channelId || config.panelChannelId;
|
||||||
|
if (!targetChannelId) {
|
||||||
|
throw new TicketPanelError('not_configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(guildId);
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me) {
|
||||||
|
throw new TicketPanelError('missing_permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
|
||||||
|
if (!channel || !botCanPostPanel(channel, me.id)) {
|
||||||
|
throw new TicketPanelError('missing_permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = options.title ?? t(locale, 'ticket.panel.defaultTitle');
|
||||||
|
const description =
|
||||||
|
options.description !== undefined
|
||||||
|
? options.description
|
||||||
|
: t(locale, 'ticket.panel.defaultDescription');
|
||||||
|
const payload = {
|
||||||
|
embeds: [buildPanelEmbed(locale, title, description)],
|
||||||
|
components: buildPanelComponents(categories)
|
||||||
|
};
|
||||||
|
|
||||||
|
const sameChannel =
|
||||||
|
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
|
||||||
|
|
||||||
|
if (sameChannel && config.panelMessageId) {
|
||||||
|
try {
|
||||||
|
const existing = await channel.messages.fetch(config.panelMessageId);
|
||||||
|
await existing.edit(payload);
|
||||||
|
if (!config.panelChannelId) {
|
||||||
|
await context.prisma.ticketConfig.update({
|
||||||
|
where: { guildId },
|
||||||
|
data: { panelChannelId: channel.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { panelChannelId: channel.id, panelMessageId: existing.id };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, guildId, messageId: config.panelMessageId },
|
||||||
|
'Failed to edit ticket panel; recreating'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
|
||||||
|
try {
|
||||||
|
const oldChannel = await guild.channels.fetch(config.panelChannelId);
|
||||||
|
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
|
||||||
|
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
|
||||||
|
await oldMessage.delete();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Previous panel may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await channel.send(payload);
|
||||||
|
await context.prisma.ticketConfig.update({
|
||||||
|
where: { guildId },
|
||||||
|
data: {
|
||||||
|
panelChannelId: channel.id,
|
||||||
|
panelMessageId: message.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { panelChannelId: channel.id, panelMessageId: message.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTicketPanelSyncJob(
|
||||||
|
context: BotContext,
|
||||||
|
data: { guildId: string }
|
||||||
|
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||||
|
return syncTicketPanel(context, data.guildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard ticket actions (close / claim / priority). Staff checks are done by
|
||||||
|
* the WebUI (`requireGuildAccess`); the bot applies Discord side effects.
|
||||||
|
*/
|
||||||
|
export async function runTicketDashboardActionJob(
|
||||||
|
context: BotContext,
|
||||||
|
data: {
|
||||||
|
ticketId: string;
|
||||||
|
guildId: string;
|
||||||
|
actorUserId: string;
|
||||||
|
action: 'close' | 'claim' | 'priority';
|
||||||
|
reason?: string;
|
||||||
|
priority?: string;
|
||||||
|
}
|
||||||
|
): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> {
|
||||||
|
const ticket = await context.prisma.ticket.findFirst({
|
||||||
|
where: { id: data.ticketId, guildId: data.guildId },
|
||||||
|
include: { category: true }
|
||||||
|
});
|
||||||
|
if (!ticket) {
|
||||||
|
throw new TicketError('not_found');
|
||||||
|
}
|
||||||
|
if (ticket.status === 'CLOSED') {
|
||||||
|
throw new TicketError('closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = await getGuildLocale(context.prisma, data.guildId);
|
||||||
|
|
||||||
|
if (data.action === 'close') {
|
||||||
|
await closeTicket(context, ticket, data.actorUserId, locale, data.reason);
|
||||||
|
const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } });
|
||||||
|
return {
|
||||||
|
id: closed.id,
|
||||||
|
status: closed.status,
|
||||||
|
priority: closed.priority,
|
||||||
|
claimedById: closed.claimedById
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.action === 'claim') {
|
||||||
|
if (ticket.claimedById === data.actorUserId) {
|
||||||
|
throw new TicketError('already_claimed');
|
||||||
|
}
|
||||||
|
const updated = await context.prisma.ticket.update({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
data: {
|
||||||
|
claimedById: data.actorUserId,
|
||||||
|
status: 'CLAIMED',
|
||||||
|
lastActivityAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const channelId = ticket.channelId ?? ticket.threadId;
|
||||||
|
if (channelId) {
|
||||||
|
const channel = await fetchSendableChannel(context, channelId);
|
||||||
|
if (channel) {
|
||||||
|
await channel.send(tf(locale, 'ticket.claimed', { user: `<@${data.actorUserId}>` }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
status: updated.status,
|
||||||
|
priority: updated.priority,
|
||||||
|
claimedById: updated.claimedById
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = TicketPrioritySchema.parse(data.priority);
|
||||||
|
const updated = await context.prisma.ticket.update({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
data: { priority: parsed, lastActivityAt: new Date() }
|
||||||
|
});
|
||||||
|
const channelId = ticket.channelId ?? ticket.threadId;
|
||||||
|
if (channelId) {
|
||||||
|
const channel = await fetchSendableChannel(context, channelId);
|
||||||
|
if (channel) {
|
||||||
|
await channel.send(
|
||||||
|
tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
status: updated.status,
|
||||||
|
priority: updated.priority,
|
||||||
|
claimedById: updated.claimedById
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
|
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
|
||||||
const fields = parseFormFields(category.formFields);
|
const fields = parseFormFields(category.formFields);
|
||||||
const modal = new ModalBuilder()
|
const modal = new ModalBuilder()
|
||||||
@@ -328,7 +716,11 @@ async function fetchTicketMessages(
|
|||||||
timestamp: message.createdAt.toISOString()
|
timestamp: message.createdAt.toISOString()
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isUnknownChannelError(error)) {
|
||||||
|
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while fetching messages');
|
||||||
|
} else {
|
||||||
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
|
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,13 +787,24 @@ export async function createTicketChannel(
|
|||||||
reason: `Ticket opened by ${params.opener.user.tag}`
|
reason: `Ticket opened by ${params.opener.user.tag}`
|
||||||
});
|
});
|
||||||
await thread.members.add(params.opener.id);
|
await thread.members.add(params.opener.id);
|
||||||
for (const roleId of supportRoleIds) {
|
|
||||||
const role = params.guild.roles.cache.get(roleId);
|
const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds);
|
||||||
if (role) {
|
for (const member of supportMembers) {
|
||||||
for (const [, member] of role.members) {
|
if (member.id === params.opener.id) {
|
||||||
await thread.members.add(member.id).catch(() => undefined);
|
continue;
|
||||||
}
|
}
|
||||||
|
await thread.members.add(member.id).catch((error) => {
|
||||||
|
logger.warn(
|
||||||
|
{ error, threadId: thread.id, userId: member.id },
|
||||||
|
'Failed to add support member to ticket thread'
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if (supportRoleIds.length > 0 && supportMembers.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
{ guildId: params.guild.id, supportRoleIds },
|
||||||
|
'No cached members found for ticket support roles – staff may need Server Members intent coverage'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
threadId = thread.id;
|
threadId = thread.id;
|
||||||
} else {
|
} else {
|
||||||
@@ -436,10 +839,13 @@ export async function createTicketChannel(
|
|||||||
|
|
||||||
const targetId = channelId ?? threadId!;
|
const targetId = channelId ?? threadId!;
|
||||||
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
|
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
|
||||||
|
|
||||||
|
const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' ');
|
||||||
const introLines = [
|
const introLines = [
|
||||||
|
roleMentions || null,
|
||||||
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
|
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
|
||||||
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
|
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
|
||||||
].filter(Boolean);
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
|
if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
|
||||||
const answerLines = Object.entries(params.formAnswers).map(
|
const answerLines = Object.entries(params.formAnswers).map(
|
||||||
@@ -448,7 +854,17 @@ export async function createTicketChannel(
|
|||||||
introLines.push(answerLines.join('\n'));
|
introLines.push(answerLines.join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
await target.send({ content: introLines.join('\n') });
|
await target.send({
|
||||||
|
content: introLines.join('\n'),
|
||||||
|
allowedMentions: {
|
||||||
|
roles: supportRoleIds,
|
||||||
|
users: [params.opener.id]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const control = buildTicketControlPanel(locale);
|
||||||
|
await target.send(control);
|
||||||
|
|
||||||
return ticket;
|
return ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,9 +1136,13 @@ export async function closeTicket(
|
|||||||
await channel.delete(`Ticket ${ticket.id} closed`);
|
await channel.delete(`Ticket ${ticket.id} closed`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isUnknownChannelError(error)) {
|
||||||
|
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while deleting');
|
||||||
|
} else {
|
||||||
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
|
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rateTicket(
|
export async function rateTicket(
|
||||||
|
|||||||
@@ -180,6 +180,40 @@ export const editsnipeCommandData = applyCommandDescription(
|
|||||||
export const embedCommandData = applyCommandDescription(
|
export const embedCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder().setName('embed'),
|
new SlashCommandBuilder().setName('embed'),
|
||||||
'utility.embed.description'
|
'utility.embed.description'
|
||||||
).addSubcommand((s) =>
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
|
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
|
||||||
);
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('components'), 'utility.embed.components.description')
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('channel').setRequired(true),
|
||||||
|
'utility.embed.components.options.channel'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('text').setRequired(true).setMaxLength(2000),
|
||||||
|
'utility.embed.components.options.text'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('image_url').setMaxLength(2048),
|
||||||
|
'utility.embed.components.options.image_url'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('button_label').setMaxLength(80),
|
||||||
|
'utility.embed.components.options.button_label'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('button_url').setMaxLength(2048),
|
||||||
|
'utility.embed.components.options.button_url'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
@@ -257,7 +257,9 @@ const emojiCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
|
||||||
|
botPermissions: PermissionFlagsBits.ManageGuildExpressions
|
||||||
|
}))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +328,9 @@ const stickerCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
|
||||||
|
botPermissions: PermissionFlagsBits.ManageGuildExpressions
|
||||||
|
}))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,10 +423,128 @@ const embedCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale, {
|
||||||
|
botPermissions: PermissionFlagsBits.ManageMessages
|
||||||
|
}))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
if (sub === 'builder') {
|
||||||
await showEmbedBuilderModal(interaction);
|
await showEmbedBuilderModal(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'components') {
|
||||||
|
const { ChannelType } = await import('discord.js');
|
||||||
|
const channel = interaction.options.getChannel('channel', true);
|
||||||
|
const text = interaction.options.getString('text', true);
|
||||||
|
const imageUrl = interaction.options.getString('image_url');
|
||||||
|
const buttonLabel = interaction.options.getString('button_label');
|
||||||
|
const buttonUrl = interaction.options.getString('button_url');
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'utility.embed.components.missingText'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!channel ||
|
||||||
|
(channel.type !== ChannelType.GuildText &&
|
||||||
|
channel.type !== ChannelType.GuildAnnouncement &&
|
||||||
|
channel.type !== ChannelType.PublicThread &&
|
||||||
|
channel.type !== ChannelType.PrivateThread)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'utility.error.channel_not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerChildren: import('@nexumi/shared').ComponentV2Node[] = [
|
||||||
|
{ type: 'text_display', content: text.trim() }
|
||||||
|
];
|
||||||
|
if (imageUrl?.trim()) {
|
||||||
|
containerChildren.push({
|
||||||
|
type: 'media_gallery',
|
||||||
|
items: [{ url: imageUrl.trim() }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const components: import('@nexumi/shared').MessageComponentsV2 = {
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'container',
|
||||||
|
components: containerChildren
|
||||||
|
}
|
||||||
|
],
|
||||||
|
actions: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (buttonLabel?.trim() && buttonUrl?.trim()) {
|
||||||
|
components.components.push({
|
||||||
|
type: 'action_row',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
style: 'Link',
|
||||||
|
label: buttonLabel.trim(),
|
||||||
|
url: buttonUrl.trim()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = await context.prisma.componentMessageBinding.create({
|
||||||
|
data: {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
channelId: channel.id,
|
||||||
|
payload: components,
|
||||||
|
createdById: interaction.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { buildComponentsV2Payload } = await import('../../lib/components-v2-payload.js');
|
||||||
|
const payload = buildComponentsV2Payload(components, {
|
||||||
|
source: 'm',
|
||||||
|
ref: binding.id
|
||||||
|
});
|
||||||
|
if (!payload) {
|
||||||
|
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textChannel = await context.client.channels.fetch(channel.id);
|
||||||
|
if (!textChannel?.isTextBased() || textChannel.isDMBased()) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'utility.error.channel_not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = await textChannel.send(payload);
|
||||||
|
await context.prisma.componentMessageBinding.update({
|
||||||
|
where: { id: binding.id },
|
||||||
|
data: { messageId: sent.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
const webui = (await import('../../env.js')).env.WEBUI_URL;
|
||||||
|
const hint = webui
|
||||||
|
? tf(locale, 'utility.embed.components.dashboardHint', {
|
||||||
|
url: `${webui}/dashboard/${interaction.guildId}/messages`
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: [t(locale, 'utility.embed.components.sent'), hint].filter(Boolean).join('\n'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,17 +32,18 @@ const translateContextMenu: ContextMenuCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
const targetLang = locale === 'de' ? 'de' : 'en';
|
const targetLang = locale === 'de' ? 'de' : 'en';
|
||||||
const translated = await translateText(content, targetLang);
|
const translated = await translateText(content, targetLang);
|
||||||
|
|
||||||
if (!translated) {
|
if (!translated) {
|
||||||
await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true });
|
await interaction.editReply({ content: t(locale, 'utility.translate.failed') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: t(locale, 'utility.translate.result').replace('{text}', translated),
|
content: t(locale, 'utility.translate.result').replace('{text}', translated)
|
||||||
ephemeral: true
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type TextChannel } from 'discord.js';
|
|||||||
import { t, tf } from '@nexumi/shared';
|
import { t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||||
import { reminderQueue } from '../../queues.js';
|
import { reminderQueue } from '../../queues.js';
|
||||||
import { parseWhen, UtilityError } from './service.js';
|
import { parseWhen, UtilityError } from './service.js';
|
||||||
|
|
||||||
@@ -107,9 +108,7 @@ export async function deleteReminder(
|
|||||||
|
|
||||||
if (match.jobId) {
|
if (match.jobId) {
|
||||||
const job = await reminderQueue.getJob(match.jobId);
|
const job = await reminderQueue.getJob(match.jobId);
|
||||||
if (job) {
|
await safeRemoveBullJob(job, { label: 'reminder', jobId: match.jobId });
|
||||||
await job.remove();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.prisma.reminder.delete({ where: { id: match.id } });
|
await context.prisma.reminder.delete({ where: { id: match.id } });
|
||||||
|
|||||||
83
apps/bot/src/modules/verification/alt-detection.test.ts
Normal file
83
apps/bot/src/modules/verification/alt-detection.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { detectAltAccount } from './alt-detection.js';
|
||||||
|
|
||||||
|
describe('detectAltAccount', () => {
|
||||||
|
const basePrior = {
|
||||||
|
userId: 'user-a',
|
||||||
|
inviteCode: 'abc123',
|
||||||
|
ipHash: 'ip-hash-1',
|
||||||
|
createdAt: new Date(),
|
||||||
|
flaggedAsAlt: false
|
||||||
|
};
|
||||||
|
|
||||||
|
it('matches identical IP hash within window', () => {
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId: 'user-b',
|
||||||
|
accountAgeDays: 30,
|
||||||
|
inviteCode: null,
|
||||||
|
ipHash: 'ip-hash-1',
|
||||||
|
priorRecords: [basePrior],
|
||||||
|
altIpWindowDays: 30,
|
||||||
|
altInviteWindowHours: 48,
|
||||||
|
altMaxAccountsPerInvite: 3
|
||||||
|
});
|
||||||
|
expect(match).toEqual({ reason: 'ip', matchedUserId: 'user-a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match when IP window expired', () => {
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId: 'user-b',
|
||||||
|
accountAgeDays: 30,
|
||||||
|
inviteCode: null,
|
||||||
|
ipHash: 'ip-hash-1',
|
||||||
|
priorRecords: [
|
||||||
|
{
|
||||||
|
...basePrior,
|
||||||
|
createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
altIpWindowDays: 30,
|
||||||
|
altInviteWindowHours: 48,
|
||||||
|
altMaxAccountsPerInvite: 3
|
||||||
|
});
|
||||||
|
expect(match).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags invite clusters for young accounts', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId: 'user-new',
|
||||||
|
accountAgeDays: 2,
|
||||||
|
inviteCode: 'raid',
|
||||||
|
ipHash: null,
|
||||||
|
priorRecords: [
|
||||||
|
{ ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) },
|
||||||
|
{ ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) },
|
||||||
|
{ ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) }
|
||||||
|
],
|
||||||
|
altIpWindowDays: 30,
|
||||||
|
altInviteWindowHours: 48,
|
||||||
|
altMaxAccountsPerInvite: 3
|
||||||
|
});
|
||||||
|
expect(match?.reason).toBe('invite_cluster');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invite clusters for older accounts', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId: 'user-old',
|
||||||
|
accountAgeDays: 90,
|
||||||
|
inviteCode: 'raid',
|
||||||
|
ipHash: null,
|
||||||
|
priorRecords: [
|
||||||
|
{ ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) },
|
||||||
|
{ ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) },
|
||||||
|
{ ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) }
|
||||||
|
],
|
||||||
|
altIpWindowDays: 30,
|
||||||
|
altInviteWindowHours: 48,
|
||||||
|
altMaxAccountsPerInvite: 3
|
||||||
|
});
|
||||||
|
expect(match).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
apps/bot/src/modules/verification/alt-detection.ts
Normal file
62
apps/bot/src/modules/verification/alt-detection.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export type AltSignalMatch = {
|
||||||
|
reason: 'ip' | 'invite_cluster';
|
||||||
|
matchedUserId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AltDetectionInput = {
|
||||||
|
userId: string;
|
||||||
|
accountAgeDays: number;
|
||||||
|
inviteCode: string | null;
|
||||||
|
ipHash: string | null;
|
||||||
|
/** Prior verification records in the same guild (excluding current user). */
|
||||||
|
priorRecords: Array<{
|
||||||
|
userId: string;
|
||||||
|
inviteCode: string | null;
|
||||||
|
ipHash: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
flaggedAsAlt: boolean;
|
||||||
|
}>;
|
||||||
|
altIpWindowDays: number;
|
||||||
|
altInviteWindowHours: number;
|
||||||
|
altMaxAccountsPerInvite: number;
|
||||||
|
/** Only flag invite clusters when the current account is this young or younger. */
|
||||||
|
youngAccountMaxDays?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects likely alt accounts from Discord + captcha signals.
|
||||||
|
* Does not use browser fingerprinting beyond salted IP / UA hashes from the captcha page.
|
||||||
|
*/
|
||||||
|
export function detectAltAccount(input: AltDetectionInput): AltSignalMatch | null {
|
||||||
|
const youngMax = input.youngAccountMaxDays ?? 7;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (input.ipHash) {
|
||||||
|
const ipWindowMs = input.altIpWindowDays * 24 * 60 * 60 * 1000;
|
||||||
|
const ipMatch = input.priorRecords.find(
|
||||||
|
(record) =>
|
||||||
|
record.ipHash === input.ipHash &&
|
||||||
|
now - record.createdAt.getTime() <= ipWindowMs
|
||||||
|
);
|
||||||
|
if (ipMatch) {
|
||||||
|
return { reason: 'ip', matchedUserId: ipMatch.userId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.inviteCode && input.accountAgeDays <= youngMax) {
|
||||||
|
const inviteWindowMs = input.altInviteWindowHours * 60 * 60 * 1000;
|
||||||
|
const recentSameInvite = input.priorRecords.filter(
|
||||||
|
(record) =>
|
||||||
|
record.inviteCode === input.inviteCode &&
|
||||||
|
now - record.createdAt.getTime() <= inviteWindowMs
|
||||||
|
);
|
||||||
|
if (recentSameInvite.length >= input.altMaxAccountsPerInvite) {
|
||||||
|
return {
|
||||||
|
reason: 'invite_cluster',
|
||||||
|
matchedUserId: recentSameInvite[0]?.userId ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -26,6 +26,18 @@ export const verifyCommandData = applyCommandDescription(
|
|||||||
{ name: 'Captcha', value: 'CAPTCHA' }
|
{ name: 'Captcha', value: 'CAPTCHA' }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('captcha_provider'),
|
||||||
|
'verify.setup.options.captcha_provider'
|
||||||
|
).addChoices(
|
||||||
|
{ name: 'Math', value: 'MATH' },
|
||||||
|
{ name: 'reCAPTCHA v2', value: 'RECAPTCHA_V2' },
|
||||||
|
{ name: 'reCAPTCHA v3', value: 'RECAPTCHA_V3' },
|
||||||
|
{ name: 'hCaptcha', value: 'HCAPTCHA' },
|
||||||
|
{ name: 'Turnstile', value: 'TURNSTILE' }
|
||||||
|
)
|
||||||
|
)
|
||||||
.addIntegerOption((o) =>
|
.addIntegerOption((o) =>
|
||||||
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
|
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
|
||||||
.setMinValue(0)
|
.setMinValue(0)
|
||||||
@@ -39,6 +51,9 @@ export const verifyCommandData = applyCommandDescription(
|
|||||||
{ name: 'None', value: 'NONE' }
|
{ name: 'None', value: 'NONE' }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('alt_detection'), 'verify.setup.options.alt_detection')
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('panel'), 'verify.panel.description')
|
applyCommandDescription(s.setName('panel'), 'verify.panel.description')
|
||||||
|
|||||||
@@ -3,19 +3,17 @@ import { t } from '@nexumi/shared';
|
|||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
import { verifyCommandData } from './command-definitions.js';
|
import { verifyCommandData } from './command-definitions.js';
|
||||||
import {
|
import { getVerificationConfig, updateVerificationConfig } from './service.js';
|
||||||
getVerificationConfig,
|
import { syncVerificationPanel, VerificationPanelError } from './handlers.js';
|
||||||
updateVerificationConfig
|
|
||||||
} from './service.js';
|
|
||||||
import { buildVerificationPanel } from './handlers.js';
|
|
||||||
|
|
||||||
const verifyCommand: SlashCommand = {
|
const verifyCommand: SlashCommand = {
|
||||||
data: verifyCommandData,
|
data: verifyCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!interaction.inGuild()) {
|
if (!interaction.inGuild()) {
|
||||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
@@ -30,11 +28,20 @@ const verifyCommand: SlashCommand = {
|
|||||||
const verifiedRole = interaction.options.getRole('verified_role', true);
|
const verifiedRole = interaction.options.getRole('verified_role', true);
|
||||||
const unverifiedRole = interaction.options.getRole('unverified_role');
|
const unverifiedRole = interaction.options.getRole('unverified_role');
|
||||||
const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA';
|
const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA';
|
||||||
|
const captchaProvider = (interaction.options.getString('captcha_provider') ??
|
||||||
|
'MATH') as
|
||||||
|
| 'MATH'
|
||||||
|
| 'RECAPTCHA_V2'
|
||||||
|
| 'RECAPTCHA_V3'
|
||||||
|
| 'HCAPTCHA'
|
||||||
|
| 'TURNSTILE';
|
||||||
const minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0;
|
const minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0;
|
||||||
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
|
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
|
||||||
| 'KICK'
|
| 'KICK'
|
||||||
| 'BAN'
|
| 'BAN'
|
||||||
| 'NONE';
|
| 'NONE';
|
||||||
|
const altDetectionEnabled =
|
||||||
|
interaction.options.getBoolean('alt_detection') ?? false;
|
||||||
|
|
||||||
await updateVerificationConfig(context.prisma, guildId, {
|
await updateVerificationConfig(context.prisma, guildId, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -42,42 +49,41 @@ const verifyCommand: SlashCommand = {
|
|||||||
verifiedRoleId: verifiedRole.id,
|
verifiedRoleId: verifiedRole.id,
|
||||||
unverifiedRoleId: unverifiedRole?.id ?? null,
|
unverifiedRoleId: unverifiedRole?.id ?? null,
|
||||||
mode,
|
mode,
|
||||||
|
captchaProvider,
|
||||||
minAccountAgeDays,
|
minAccountAgeDays,
|
||||||
failAction
|
failAction,
|
||||||
|
altDetectionEnabled
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.reply({ content: t(locale, 'verification.setupDone'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getVerificationConfig(context.prisma, guildId);
|
const config = await getVerificationConfig(context.prisma, guildId);
|
||||||
if (!config.enabled || !config.verifiedRoleId) {
|
if (!config.enabled || !config.verifiedRoleId) {
|
||||||
await interaction.reply({ content: t(locale, 'verification.notConfigured'), ephemeral: true });
|
await interaction.reply({
|
||||||
|
content: t(locale, 'verification.notConfigured'),
|
||||||
|
...ephemeral()
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelChannelOption = interaction.options.getChannel('channel');
|
const panelChannelOption = interaction.options.getChannel('channel');
|
||||||
const panelChannel = panelChannelOption
|
|
||||||
? await interaction.guild!.channels.fetch(panelChannelOption.id)
|
|
||||||
: config.channelId
|
|
||||||
? await interaction.guild!.channels.fetch(config.channelId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!panelChannel?.isTextBased() || panelChannel.isDMBased()) {
|
try {
|
||||||
await interaction.reply({ content: t(locale, 'verification.invalidChannel'), ephemeral: true });
|
await syncVerificationPanel(context, guildId, panelChannelOption?.id ?? null);
|
||||||
|
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof VerificationPanelError) {
|
||||||
|
const key =
|
||||||
|
error.code === 'not_configured'
|
||||||
|
? 'verification.notConfigured'
|
||||||
|
: 'verification.panelMissingPermission';
|
||||||
|
await interaction.reply({ content: t(locale, key), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
const panel = buildVerificationPanel(config);
|
|
||||||
const message = await panelChannel.send(panel);
|
|
||||||
await context.prisma.verificationConfig.update({
|
|
||||||
where: { guildId },
|
|
||||||
data: {
|
|
||||||
panelChannelId: panelChannel.id,
|
|
||||||
panelMessageId: message.id
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ephemeral: true });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
type ButtonInteraction,
|
type ButtonInteraction,
|
||||||
type GuildMember
|
type GuildBasedChannel,
|
||||||
|
type GuildMember,
|
||||||
|
type GuildTextBasedChannel
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t, tf } from '@nexumi/shared';
|
import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import {
|
import {
|
||||||
@@ -18,28 +20,104 @@ import {
|
|||||||
verificationButtonId,
|
verificationButtonId,
|
||||||
verificationCaptchaButtonId
|
verificationCaptchaButtonId
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
import { detectAltAccount } from './alt-detection.js';
|
||||||
import { env } from '../../env.js';
|
import { env } from '../../env.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
|
import { notifyModerationTargetDm } from '../moderation/service.js';
|
||||||
|
|
||||||
|
export class VerificationPanelError extends Error {
|
||||||
|
constructor(public readonly code: 'not_configured' | 'missing_permission') {
|
||||||
|
super(code);
|
||||||
|
this.name = 'VerificationPanelError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function botCanPostPanel(
|
||||||
|
channel: GuildBasedChannel,
|
||||||
|
meId: string
|
||||||
|
): channel is GuildTextBasedChannel {
|
||||||
|
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const permissions = channel.permissionsFor(meId);
|
||||||
|
if (!permissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
permissions.has(PermissionFlagsBits.ViewChannel) &&
|
||||||
|
permissions.has(PermissionFlagsBits.SendMessages) &&
|
||||||
|
permissions.has(PermissionFlagsBits.EmbedLinks)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompleteVerificationOptions = {
|
||||||
|
ipHash?: string | null;
|
||||||
|
userAgentHash?: string | null;
|
||||||
|
captchaProvider?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
async function applyFailAction(
|
async function applyFailAction(
|
||||||
|
context: BotContext,
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
failAction: string,
|
failAction: string,
|
||||||
reason: string
|
reason: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const me = member.guild.members.me;
|
const me = member.guild.members.me;
|
||||||
|
if (failAction !== 'KICK' && failAction !== 'BAN') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||||
if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||||
|
await notifyModerationTargetDm({
|
||||||
|
user: member.user,
|
||||||
|
guildName: member.guild.name,
|
||||||
|
reason,
|
||||||
|
locale,
|
||||||
|
action: 'kick'
|
||||||
|
});
|
||||||
await member.kick(reason);
|
await member.kick(reason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||||
|
await notifyModerationTargetDm({
|
||||||
|
user: member.user,
|
||||||
|
guildName: member.guild.name,
|
||||||
|
reason,
|
||||||
|
locale,
|
||||||
|
action: 'ban'
|
||||||
|
});
|
||||||
await member.guild.members.ban(member.id, { reason });
|
await member.guild.members.ban(member.id, { reason });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function logVerificationFailure(
|
||||||
|
context: BotContext,
|
||||||
|
member: GuildMember,
|
||||||
|
input: {
|
||||||
|
reason: string;
|
||||||
|
detail?: string;
|
||||||
|
failAction?: string;
|
||||||
|
matchedUserId?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const { logVerificationFail } = await import('../logging/events.js');
|
||||||
|
await logVerificationFail(context, {
|
||||||
|
guildId: member.guild.id,
|
||||||
|
userId: member.id,
|
||||||
|
userTag: member.user.tag,
|
||||||
|
reason: input.reason,
|
||||||
|
detail: input.detail,
|
||||||
|
failAction: input.failAction,
|
||||||
|
matchedUserId: input.matchedUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeVerification(
|
export async function completeVerification(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
options: CompleteVerificationOptions = {}
|
||||||
): Promise<{ ok: boolean; reason?: string }> {
|
): Promise<{ ok: boolean; reason?: string }> {
|
||||||
const config = await getVerificationConfig(context.prisma, guildId);
|
const config = await getVerificationConfig(context.prisma, guildId);
|
||||||
if (!config.enabled || !config.verifiedRoleId) {
|
if (!config.enabled || !config.verifiedRoleId) {
|
||||||
@@ -54,14 +132,108 @@ export async function completeVerification(
|
|||||||
|
|
||||||
const ageDays = accountAgeDays(member.user.createdAt);
|
const ageDays = accountAgeDays(member.user.createdAt);
|
||||||
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
|
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
|
||||||
await applyFailAction(
|
const failReason = `Verification failed: account too young (${ageDays}d)`;
|
||||||
member,
|
await logVerificationFailure(context, member, {
|
||||||
config.failAction,
|
reason: 'account_too_young',
|
||||||
`Verification failed: account too young (${ageDays}d)`
|
detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`,
|
||||||
);
|
failAction: config.failAction
|
||||||
|
});
|
||||||
|
await applyFailAction(context, member, config.failAction, failReason);
|
||||||
return { ok: false, reason: 'account_too_young' };
|
return { ok: false, reason: 'account_too_young' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteJoin = await context.prisma.inviteJoin.findUnique({
|
||||||
|
where: { guildId_userId: { guildId, userId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
let flaggedAsAlt = false;
|
||||||
|
let matchedUserId: string | null = null;
|
||||||
|
|
||||||
|
if (config.altDetectionEnabled) {
|
||||||
|
const lookbackMs = Math.max(
|
||||||
|
config.altIpWindowDays * 24 * 60 * 60 * 1000,
|
||||||
|
config.altInviteWindowHours * 60 * 60 * 1000
|
||||||
|
);
|
||||||
|
const since = new Date(Date.now() - lookbackMs);
|
||||||
|
const priorRecords = await context.prisma.verificationRecord.findMany({
|
||||||
|
where: {
|
||||||
|
guildId,
|
||||||
|
userId: { not: userId },
|
||||||
|
createdAt: { gte: since }
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
inviteCode: true,
|
||||||
|
ipHash: true,
|
||||||
|
createdAt: true,
|
||||||
|
flaggedAsAlt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId,
|
||||||
|
accountAgeDays: ageDays,
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
priorRecords,
|
||||||
|
altIpWindowDays: config.altIpWindowDays,
|
||||||
|
altInviteWindowHours: config.altInviteWindowHours,
|
||||||
|
altMaxAccountsPerInvite: config.altMaxAccountsPerInvite
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
flaggedAsAlt = true;
|
||||||
|
matchedUserId = match.matchedUserId;
|
||||||
|
logger.info(
|
||||||
|
{ guildId, userId, reason: match.reason, matchedUserId },
|
||||||
|
'Verification alt-account signal'
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.prisma.verificationRecord.upsert({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
inviterId: inviteJoin?.inviterId ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
userAgentHash: options.userAgentHash ?? null,
|
||||||
|
captchaProvider: options.captchaProvider ?? null,
|
||||||
|
flaggedAsAlt: true,
|
||||||
|
matchedUserId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
inviterId: inviteJoin?.inviterId ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
userAgentHash: options.userAgentHash ?? null,
|
||||||
|
captchaProvider: options.captchaProvider ?? null,
|
||||||
|
flaggedAsAlt: true,
|
||||||
|
matchedUserId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.altBlockOnMatch) {
|
||||||
|
const failReason = `Verification failed: possible alt account (${match.reason})`;
|
||||||
|
await logVerificationFailure(context, member, {
|
||||||
|
reason: 'alt_detected',
|
||||||
|
detail: `Signal: ${match.reason}`,
|
||||||
|
failAction: config.failAction,
|
||||||
|
matchedUserId
|
||||||
|
});
|
||||||
|
await applyFailAction(context, member, config.failAction, failReason);
|
||||||
|
return { ok: false, reason: 'alt_detected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await logVerificationFailure(context, member, {
|
||||||
|
reason: 'alt_detected_flagged',
|
||||||
|
detail: `Signal: ${match.reason} (flagged only, not blocked)`,
|
||||||
|
failAction: 'NONE',
|
||||||
|
matchedUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const me = guild.members.me;
|
const me = guild.members.me;
|
||||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||||
return { ok: false, reason: 'missing_permissions' };
|
return { ok: false, reason: 'missing_permissions' };
|
||||||
@@ -82,13 +254,40 @@ export async function completeVerification(
|
|||||||
await member.roles.add(config.verifiedRoleId);
|
await member.roles.add(config.verifiedRoleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await context.prisma.verificationRecord.upsert({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
inviterId: inviteJoin?.inviterId ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
userAgentHash: options.userAgentHash ?? null,
|
||||||
|
captchaProvider: options.captchaProvider ?? null,
|
||||||
|
flaggedAsAlt,
|
||||||
|
matchedUserId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
inviterId: inviteJoin?.inviterId ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
userAgentHash: options.userAgentHash ?? null,
|
||||||
|
captchaProvider: options.captchaProvider ?? null,
|
||||||
|
flaggedAsAlt,
|
||||||
|
matchedUserId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVerificationConfig>>) {
|
export function buildVerificationPanel(
|
||||||
|
config: Awaited<ReturnType<typeof getVerificationConfig>>,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
) {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle('Verification')
|
.setTitle(t(locale, 'verification.panel.title'))
|
||||||
.setDescription('Click the button below to verify and gain access to the server.')
|
.setDescription(t(locale, 'verification.panel.description'))
|
||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
|
|
||||||
const customId =
|
const customId =
|
||||||
@@ -99,13 +298,97 @@ export function buildVerificationPanel(config: Awaited<ReturnType<typeof getVeri
|
|||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId(customId)
|
.setCustomId(customId)
|
||||||
.setLabel(config.mode === 'CAPTCHA' ? 'Verify (Captcha)' : 'Verify')
|
.setLabel(
|
||||||
|
config.mode === 'CAPTCHA'
|
||||||
|
? t(locale, 'verification.panel.buttonCaptcha')
|
||||||
|
: t(locale, 'verification.panel.button')
|
||||||
|
)
|
||||||
.setStyle(ButtonStyle.Success)
|
.setStyle(ButtonStyle.Success)
|
||||||
);
|
);
|
||||||
|
|
||||||
return { embeds: [embed], components: [row] };
|
return { embeds: [embed], components: [row] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts or updates the verification panel in the configured channel.
|
||||||
|
* Used by `/verify panel` and by the dashboard via BullMQ (`verificationPanelSync`).
|
||||||
|
*/
|
||||||
|
export async function syncVerificationPanel(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
preferredChannelId?: string | null
|
||||||
|
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, guildId);
|
||||||
|
const config = await getVerificationConfig(context.prisma, guildId);
|
||||||
|
|
||||||
|
if (!config.enabled || !config.verifiedRoleId) {
|
||||||
|
throw new VerificationPanelError('not_configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetChannelId = preferredChannelId || config.channelId;
|
||||||
|
if (!targetChannelId) {
|
||||||
|
throw new VerificationPanelError('not_configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(guildId);
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me) {
|
||||||
|
throw new VerificationPanelError('missing_permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
|
||||||
|
if (!channel || !botCanPostPanel(channel, me.id)) {
|
||||||
|
throw new VerificationPanelError('missing_permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = buildVerificationPanel(config, locale);
|
||||||
|
const sameChannel =
|
||||||
|
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
|
||||||
|
|
||||||
|
if (sameChannel && config.panelMessageId) {
|
||||||
|
try {
|
||||||
|
const existing = await channel.messages.fetch(config.panelMessageId);
|
||||||
|
await existing.edit(panel);
|
||||||
|
return { panelChannelId: channel.id, panelMessageId: existing.id };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, guildId, messageId: config.panelMessageId },
|
||||||
|
'Failed to edit verification panel; recreating'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
|
||||||
|
try {
|
||||||
|
const oldChannel = await guild.channels.fetch(config.panelChannelId);
|
||||||
|
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
|
||||||
|
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
|
||||||
|
await oldMessage.delete();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Previous panel may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await channel.send(panel);
|
||||||
|
await context.prisma.verificationConfig.update({
|
||||||
|
where: { guildId },
|
||||||
|
data: {
|
||||||
|
panelChannelId: channel.id,
|
||||||
|
panelMessageId: message.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { panelChannelId: channel.id, panelMessageId: message.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runVerificationPanelSyncJob(
|
||||||
|
context: BotContext,
|
||||||
|
data: { guildId: string }
|
||||||
|
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||||
|
return syncVerificationPanel(context, data.guildId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleVerificationButton(
|
export async function handleVerificationButton(
|
||||||
interaction: ButtonInteraction,
|
interaction: ButtonInteraction,
|
||||||
context: BotContext
|
context: BotContext
|
||||||
@@ -119,25 +402,29 @@ export async function handleVerificationButton(
|
|||||||
const config = await getVerificationConfig(context.prisma, parsed.guildId);
|
const config = await getVerificationConfig(context.prisma, parsed.guildId);
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
await interaction.reply({ content: t(locale, 'verification.disabled'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'verification.disabled'), ...ephemeral() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.mode === 'captcha') {
|
if (parsed.mode === 'captcha') {
|
||||||
|
const providerParse = CaptchaProviderSchema.safeParse(config.captchaProvider);
|
||||||
|
const provider: CaptchaProvider = providerParse.success ? providerParse.data : 'MATH';
|
||||||
const challenge = await createCaptchaChallenge(
|
const challenge = await createCaptchaChallenge(
|
||||||
context.redis,
|
context.redis,
|
||||||
parsed.guildId,
|
parsed.guildId,
|
||||||
interaction.user.id
|
interaction.user.id,
|
||||||
|
provider
|
||||||
);
|
);
|
||||||
const url = `${env.PUBLIC_BASE_URL}/verify/captcha?token=${challenge.token}`;
|
const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, '');
|
||||||
|
const url = `${base}/verify/captcha?token=${challenge.token}`;
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
content: tf(locale, 'verification.captchaLink', { url }),
|
content: tf(locale, 'verification.captchaLink', { url }),
|
||||||
ephemeral: true
|
...ephemeral()
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await interaction.deferReply(ephemeral());
|
||||||
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
|
const result = await completeVerification(context, parsed.guildId, interaction.user.id);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
await interaction.editReply({ content: t(locale, 'verification.success') });
|
await interaction.editReply({ content: t(locale, 'verification.success') });
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { randomBytes, randomInt, createHash } from 'node:crypto';
|
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
||||||
|
import type { CaptchaProvider, VerificationFailAction, VerificationMode } from '@nexumi/shared';
|
||||||
import type { PrismaClient } from '@prisma/client';
|
import type { PrismaClient } from '@prisma/client';
|
||||||
import type { VerificationFailAction, VerificationMode } from '@nexumi/shared';
|
|
||||||
import { ensureGuild } from '../../guild.js';
|
|
||||||
import type { Redis } from 'ioredis';
|
import type { Redis } from 'ioredis';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
|
||||||
const CAPTCHA_PREFIX = 'verify:captcha:';
|
const CAPTCHA_PREFIX = 'verify:captcha:';
|
||||||
|
|
||||||
|
export type VerificationConfigUpdate = {
|
||||||
|
enabled: boolean;
|
||||||
|
channelId: string;
|
||||||
|
verifiedRoleId: string;
|
||||||
|
unverifiedRoleId?: string | null;
|
||||||
|
mode: VerificationMode;
|
||||||
|
captchaProvider?: CaptchaProvider;
|
||||||
|
minAccountAgeDays: number;
|
||||||
|
failAction: VerificationFailAction;
|
||||||
|
altDetectionEnabled?: boolean;
|
||||||
|
altBlockOnMatch?: boolean;
|
||||||
|
altIpWindowDays?: number;
|
||||||
|
altInviteWindowHours?: number;
|
||||||
|
altMaxAccountsPerInvite?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function getVerificationConfig(prisma: PrismaClient, guildId: string) {
|
export async function getVerificationConfig(prisma: PrismaClient, guildId: string) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
let config = await prisma.verificationConfig.findUnique({ where: { guildId } });
|
let config = await prisma.verificationConfig.findUnique({ where: { guildId } });
|
||||||
@@ -18,15 +34,7 @@ export async function getVerificationConfig(prisma: PrismaClient, guildId: strin
|
|||||||
export async function updateVerificationConfig(
|
export async function updateVerificationConfig(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
data: {
|
data: VerificationConfigUpdate
|
||||||
enabled: boolean;
|
|
||||||
channelId: string;
|
|
||||||
verifiedRoleId: string;
|
|
||||||
unverifiedRoleId?: string | null;
|
|
||||||
mode: VerificationMode;
|
|
||||||
minAccountAgeDays: number;
|
|
||||||
failAction: VerificationFailAction;
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
return prisma.verificationConfig.upsert({
|
return prisma.verificationConfig.upsert({
|
||||||
@@ -65,27 +73,32 @@ export type CaptchaChallenge = {
|
|||||||
token: string;
|
token: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
question: string;
|
provider: CaptchaProvider;
|
||||||
answerHash: string;
|
question?: string;
|
||||||
|
answerHash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createCaptchaChallenge(
|
export async function createCaptchaChallenge(
|
||||||
redis: Redis,
|
redis: Redis,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
provider: CaptchaProvider = 'MATH'
|
||||||
): Promise<CaptchaChallenge> {
|
): Promise<CaptchaChallenge> {
|
||||||
const a = randomInt(2, 12);
|
|
||||||
const b = randomInt(2, 12);
|
|
||||||
const answer = String(a + b);
|
|
||||||
const token = randomBytes(24).toString('hex');
|
const token = randomBytes(24).toString('hex');
|
||||||
const answerHash = createHash('sha256').update(answer).digest('hex');
|
|
||||||
const challenge: CaptchaChallenge = {
|
const challenge: CaptchaChallenge = {
|
||||||
token,
|
token,
|
||||||
guildId,
|
guildId,
|
||||||
userId,
|
userId,
|
||||||
question: `${a} + ${b}`,
|
provider
|
||||||
answerHash
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (provider === 'MATH') {
|
||||||
|
const a = randomInt(2, 12);
|
||||||
|
const b = randomInt(2, 12);
|
||||||
|
challenge.question = `${a} + ${b}`;
|
||||||
|
challenge.answerHash = createHash('sha256').update(String(a + b)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600);
|
await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600);
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
@@ -98,7 +111,11 @@ export async function getCaptchaChallenge(
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return JSON.parse(raw) as CaptchaChallenge;
|
const parsed = JSON.parse(raw) as CaptchaChallenge;
|
||||||
|
if (!parsed.provider) {
|
||||||
|
parsed.provider = 'MATH';
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise<void> {
|
export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise<void> {
|
||||||
@@ -113,3 +130,7 @@ export function accountAgeDays(userCreatedAt: Date): number {
|
|||||||
const ms = Date.now() - userCreatedAt.getTime();
|
const ms = Date.now() - userCreatedAt.getTime();
|
||||||
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashClientSignal(salt: string, value: string): string {
|
||||||
|
return createHash('sha256').update(`${salt}:${value}`).digest('hex');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PermissionFlagsBits } from 'discord.js';
|
import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
|
||||||
import { t } from '@nexumi/shared';
|
import { t } from '@nexumi/shared';
|
||||||
import type { SlashCommand } from '../../types.js';
|
import type { SlashCommand } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
@@ -7,6 +7,26 @@ import { welcomeCommandData } from './command-definitions.js';
|
|||||||
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
|
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
|
||||||
import { buildWelcomeMessage } from './renderer.js';
|
import { buildWelcomeMessage } from './renderer.js';
|
||||||
|
|
||||||
|
function toInteractionReply(
|
||||||
|
message: Awaited<ReturnType<typeof buildWelcomeMessage>>,
|
||||||
|
ephemeral: boolean
|
||||||
|
): InteractionReplyOptions {
|
||||||
|
const flags =
|
||||||
|
typeof message.flags === 'number'
|
||||||
|
? ephemeral
|
||||||
|
? message.flags | MessageFlags.Ephemeral
|
||||||
|
: message.flags
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: message.content,
|
||||||
|
embeds: message.embeds,
|
||||||
|
files: message.files,
|
||||||
|
components: message.components,
|
||||||
|
...(flags !== undefined ? { flags } : ephemeral ? { ephemeral: true } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const welcomeCommand: SlashCommand = {
|
const welcomeCommand: SlashCommand = {
|
||||||
data: welcomeCommandData,
|
data: welcomeCommandData,
|
||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
@@ -32,7 +52,7 @@ const welcomeCommand: SlashCommand = {
|
|||||||
|
|
||||||
if (sub === 'preview') {
|
if (sub === 'preview') {
|
||||||
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
||||||
await interaction.reply({ ...message, ephemeral: true });
|
await interaction.reply(toInteractionReply(message, true));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +68,9 @@ const welcomeCommand: SlashCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
||||||
|
if ('send' in channel) {
|
||||||
await channel.send(message);
|
await channel.send(message);
|
||||||
|
}
|
||||||
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,75 @@
|
|||||||
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
|
import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js';
|
||||||
|
import { t, tf } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
|
||||||
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
async function resolveRole(member: GuildMember, roleId: string): Promise<Role | null> {
|
||||||
|
const cached = member.guild.roles.cache.get(roleId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return member.guild.roles.fetch(roleId).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
|
async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
|
||||||
const me = member.guild.members.me;
|
const uniqueIds = [...new Set(roleIds.filter(Boolean))];
|
||||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
if (uniqueIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assignable = roleIds.filter((roleId) => {
|
|
||||||
const role = member.guild.roles.cache.get(roleId);
|
const me =
|
||||||
return role && role.position < me.roles.highest.position;
|
member.guild.members.me ?? (await member.guild.members.fetchMe().catch(() => null));
|
||||||
});
|
if (!me) {
|
||||||
|
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: bot member unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||||
|
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: missing ManageRoles permission');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignable: string[] = [];
|
||||||
|
for (const roleId of uniqueIds) {
|
||||||
|
if (roleId === member.guild.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (member.roles.cache.has(roleId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await resolveRole(member, roleId);
|
||||||
|
if (!role) {
|
||||||
|
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: role not found');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (role.managed) {
|
||||||
|
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: managed role');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!role.editable) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
guildId: member.guild.id,
|
||||||
|
roleId,
|
||||||
|
rolePosition: role.position,
|
||||||
|
botHighest: me.roles.highest.position
|
||||||
|
},
|
||||||
|
'Autorole skipped: role above bot or not editable'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assignable.push(roleId);
|
||||||
|
}
|
||||||
|
|
||||||
if (assignable.length === 0) {
|
if (assignable.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await member.roles.add(assignable).catch((error) => {
|
|
||||||
logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles');
|
await member.roles.add(assignable, 'Nexumi welcome autorole').catch((error) => {
|
||||||
|
logger.warn({ error, memberId: member.id, assignable }, 'Failed to assign autoroles');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +93,7 @@ export async function handleMemberWelcome(context: BotContext, member: GuildMemb
|
|||||||
|
|
||||||
if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) {
|
if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) {
|
||||||
await member
|
await member
|
||||||
.send({ content: config.welcomeDmContent.replace('{user}', `<@${member.id}>`) })
|
.send({ content: renderWelcomeText(config.welcomeDmContent, member, member.guild) })
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,8 +107,34 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
|
|||||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = config.leaveContent ?? '{user.tag} left {server}.';
|
await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild);
|
||||||
await sendLeaveMessage(channel, content, member, member.guild);
|
|
||||||
|
if (!config.leaveAnnounceBan || member.user.bot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ban = await member.guild.bans.fetch(member.id).catch(() => null);
|
||||||
|
if (!ban) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||||
|
const reason = ban.reason ?? t(locale, 'moderation.reason.none');
|
||||||
|
if ('send' in channel) {
|
||||||
|
await channel
|
||||||
|
.send(
|
||||||
|
tf(locale, 'welcome.leave.bannedNotice', {
|
||||||
|
user: member.user.tag,
|
||||||
|
reason
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn(
|
||||||
|
{ error, guildId: member.guild.id, userId: member.id },
|
||||||
|
'Failed to send leave ban notice'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerWelcomeEvents(context: BotContext): void {
|
export function registerWelcomeEvents(context: BotContext): void {
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
import {
|
import {
|
||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
EmbedBuilder,
|
|
||||||
type Guild,
|
type Guild,
|
||||||
type GuildMember,
|
type GuildMember,
|
||||||
|
type MessageCreateOptions,
|
||||||
type SendableChannels,
|
type SendableChannels,
|
||||||
type TextBasedChannel
|
type TextBasedChannel
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
import type { WelcomePayload } from './service.js';
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||||
import { renderWelcomeText } from './service.js';
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||||
|
import type { LeavePayload, WelcomePayload } from './service.js';
|
||||||
|
import { renderWelcomeEmbedText, renderWelcomeText } from './service.js';
|
||||||
|
|
||||||
export async function buildWelcomeMessage(
|
export async function buildWelcomeMessage(
|
||||||
payload: WelcomePayload,
|
payload: WelcomePayload,
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
guild: Guild
|
guild: Guild
|
||||||
): Promise<{ content?: string; embeds?: EmbedBuilder[]; files?: AttachmentBuilder[] }> {
|
): Promise<MessageCreateOptions> {
|
||||||
|
if (payload.type === 'COMPONENTS_V2') {
|
||||||
|
const componentsPayload = buildComponentsV2Payload(payload.components, {
|
||||||
|
source: 'w',
|
||||||
|
ref: guild.id,
|
||||||
|
renderText: (value) => renderWelcomeText(value, member, guild)
|
||||||
|
});
|
||||||
|
if (componentsPayload) {
|
||||||
|
return componentsPayload;
|
||||||
|
}
|
||||||
|
return { content: renderWelcomeText('{user}', member, guild) };
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.type === 'EMBED') {
|
if (payload.type === 'EMBED') {
|
||||||
const embed = new EmbedBuilder();
|
const embed = buildEmbedFromPayload(payload.embed, {
|
||||||
const data = payload.embed;
|
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field),
|
||||||
if (data?.title) {
|
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
||||||
embed.setTitle(renderWelcomeText(data.title, member, guild));
|
});
|
||||||
}
|
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) };
|
||||||
if (data?.description) {
|
|
||||||
embed.setDescription(renderWelcomeText(data.description, member, guild));
|
|
||||||
}
|
|
||||||
if (data?.color !== undefined) {
|
|
||||||
embed.setColor(data.color);
|
|
||||||
}
|
|
||||||
embed.setThumbnail(member.user.displayAvatarURL({ size: 256 }));
|
|
||||||
return { embeds: [embed] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === 'IMAGE') {
|
if (payload.type === 'IMAGE') {
|
||||||
@@ -71,14 +77,15 @@ async function renderWelcomeCard(
|
|||||||
const title = payload.imageTitle ?? 'Welcome!';
|
const title = payload.imageTitle ?? 'Welcome!';
|
||||||
const subtitle =
|
const subtitle =
|
||||||
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
|
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
|
||||||
|
const plain = { mentionUser: false as const };
|
||||||
|
|
||||||
ctx.fillStyle = '#f9fafb';
|
ctx.fillStyle = '#f9fafb';
|
||||||
ctx.font = 'bold 36px sans-serif';
|
ctx.font = 'bold 36px sans-serif';
|
||||||
ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130);
|
ctx.fillText(renderWelcomeText(title, member, guild, plain).slice(0, 40), 220, 130);
|
||||||
|
|
||||||
ctx.fillStyle = '#d1d5db';
|
ctx.fillStyle = '#d1d5db';
|
||||||
ctx.font = '24px sans-serif';
|
ctx.font = '24px sans-serif';
|
||||||
ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180);
|
ctx.fillText(renderWelcomeText(subtitle, member, guild, plain).slice(0, 60), 220, 180);
|
||||||
|
|
||||||
return canvas.toBuffer('image/png');
|
return canvas.toBuffer('image/png');
|
||||||
}
|
}
|
||||||
@@ -95,13 +102,47 @@ export async function sendWelcomeMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildLeaveMessage(
|
||||||
|
payload: LeavePayload,
|
||||||
|
member: GuildMember,
|
||||||
|
guild: Guild
|
||||||
|
): Promise<MessageCreateOptions> {
|
||||||
|
if (payload.type === 'COMPONENTS_V2') {
|
||||||
|
const componentsPayload = buildComponentsV2Payload(payload.components, {
|
||||||
|
source: 'l',
|
||||||
|
ref: guild.id,
|
||||||
|
renderText: (value) => renderWelcomeText(value, member, guild)
|
||||||
|
});
|
||||||
|
if (componentsPayload) {
|
||||||
|
return componentsPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'EMBED') {
|
||||||
|
const embed = buildEmbedFromPayload(payload.embed, {
|
||||||
|
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field),
|
||||||
|
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
||||||
|
});
|
||||||
|
if (embed) {
|
||||||
|
return { embeds: [embed] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = payload.content ?? '{user.tag} left {server}.';
|
||||||
|
return { content: renderWelcomeText(content, member, guild) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendLeaveMessage(
|
export async function sendLeaveMessage(
|
||||||
channel: TextBasedChannel,
|
channel: TextBasedChannel,
|
||||||
content: string,
|
payload: LeavePayload | string,
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
guild: Guild
|
guild: Guild
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if ('send' in channel) {
|
if ('send' in channel) {
|
||||||
await (channel as SendableChannels).send({ content: renderWelcomeText(content, member, guild) });
|
const message =
|
||||||
|
typeof payload === 'string'
|
||||||
|
? { content: renderWelcomeText(payload, member, guild) }
|
||||||
|
: await buildLeaveMessage(payload, member, guild);
|
||||||
|
await (channel as SendableChannels).send(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user