added discord container for renaming and role-management

This commit is contained in:
PxlLoewe
2025-06-05 01:03:13 -07:00
parent 3c620b9b67
commit 6c9942a984
26 changed files with 824 additions and 28 deletions

19
apps/discord-server/.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare module "next-auth/jwt" {
interface JWT {
uid: string;
firstname: string;
lastname: string;
email: string;
}
}
declare module "cookie-parser";
import type { User } from "@repo/db";
declare global {
namespace Express {
interface Request {
user?: User | null;
}
}
}

View File

@@ -0,0 +1,6 @@
node_modules
Dockerfile
.dockerignore
nodemon.json
.env
.env.example

View File

@@ -0,0 +1,7 @@
DISCORD_SERVER_PORT=3005
DISCORD_GUILD_ID=1077269395019141140
DISCORD_OAUTH_CLIENT_ID=930384053344034846
DISCORD_OAUTH_SECRET=96aSvmIePqFTbGc54mad0QsZfDnYwhl1
DISCORD_BOT_TOKEN=OTMwMzg0MDUzMzQ0MDM0ODQ2.G7zIy-._hE3dTbtUv6sd7nIP2PUn3d8s-2MFk0x3nYMg8
DISCORD_REDIRECT_URL=https://hub.premiumag.de/api/discord-redirect
NEXT_PUBLIC_DISCORD_URL=https://discord.com/oauth2/authorize?client_id=930384053344034846&response_type=code&redirect_uri=https%3A%2F%2Fhub.premiumag.de%2Fapi%2Fdiscord-redirect&scope=identify+guilds+email

View File

@@ -0,0 +1,50 @@
FROM node:22-alpine AS base
ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /usr/app
COPY . .
RUN ls -lh
RUN turbo prune discord-server --docker
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /usr/app
COPY --from=builder /usr/app/out/json/ .
RUN pnpm install
# Build the project
COPY --from=builder /usr/app/out/full/ .
RUN turbo run build
FROM base AS runner
WORKDIR /usr/app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./
# Expose the application port
EXPOSE 3003
CMD ["pnpm", "--dir", "apps/discord-server", "run", "start"]

View File

@@ -0,0 +1,19 @@
import "dotenv/config";
import express from "express";
import { createServer } from "http";
import router from "routes/router";
import cookieParser from "cookie-parser";
import cors from "cors";
import "modules/chron";
const app = express();
const server = createServer(app);
app.use(cors());
app.use(express.json());
app.use(cookieParser());
app.use(router);
server.listen(process.env.DISCORD_SERVER_PORT, () => {
console.log(`Server running on port ${process.env.DISCORD_SERVER_PORT}`);
});

View File

@@ -0,0 +1,62 @@
import { MissionLog, prisma } from "@repo/db";
import cron from "node-cron";
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
where: {
state: "running",
},
});
oldMissions.forEach(async (mission) => {
const lastAlert = (mission.missionLog as unknown as MissionLog[]).find((l) => {
return l.type === "alert-log";
});
const lastAlertTime = lastAlert ? new Date(lastAlert.timeStamp) : null;
const aircraftsInMission = await prisma.connectedAircraft.findMany({
where: {
stationId: {
in: mission.missionStationIds,
},
},
});
if (
!aircraftsInMission ||
!aircraftsInMission.some((a) => ["1", "2", "6"].includes(a.fmsStatus))
)
return;
const now = new Date();
if (!lastAlertTime) return;
// change State to closed if last alert was more than 180 minutes ago
if (now.getTime() - lastAlertTime.getTime() < 30 * 60 * 1000) return;
const log: MissionLog = {
type: "completed-log",
auto: true,
timeStamp: new Date().toISOString(),
data: {},
};
await prisma.mission.update({
where: {
id: mission.id,
},
data: {
state: "finished",
missionLog: {
push: log as any,
},
},
});
console.log(`Mission ${mission.id} closed due to inactivity.`);
});
};
cron.schedule("*/5 * * * *", async () => {
try {
await removeClosedMissions();
} catch (error) {
console.error("Error removing closed missions:", error);
}
});

View File

@@ -0,0 +1,19 @@
import { Client, GatewayIntentBits } from "discord.js";
const client = new Client({
intents: [GatewayIntentBits.Guilds],
});
const token = process.env.DISCORD_BOT_TOKEN;
if (!token) {
throw new Error("DISCORD_BOT_TOKEN environment variable is not set.");
}
client.login(token);
client.on("ready", () => {
console.log(`Logged in as ${client.user?.tag}`);
});
export default client;

View File

@@ -0,0 +1,5 @@
{
"watch": ["."],
"ext": "ts",
"exec": "tsx index.ts"
}

View File

@@ -0,0 +1,35 @@
{
"name": "discord-server",
"exports": {
"helpers": "./helper"
},
"scripts": {
"dev": "nodemon --signal SIGINT",
"start": "tsx index.ts --transpile-only",
"build": "tsc"
},
"packageManager": "pnpm@10.11.0",
"devDependencies": {
"@repo/db": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
"@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2",
"typescript": "latest"
},
"dependencies": {
"axios": "^1.9.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"discord.js": "^14.19.3",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"node-cron": "^4.1.0",
"nodemon": "^3.1.10",
"react": "^19.1.0",
"tsx": "^4.19.4"
}
}

View File

@@ -0,0 +1,69 @@
import { Router, Request, Response } from "express";
import client from "modules/discord";
const GUILD_ID = process.env.DISCORD_GUILD_ID;
if (!GUILD_ID) {
throw new Error("DISCORD_GUILD_ID environment variable is not set.");
}
const router: Router = Router();
const getMember = async (memberId: string) => {
const guild = client.guilds.cache.get(GUILD_ID);
if (!guild) throw new Error("Guild not found");
try {
return guild.members.cache.get(memberId) ?? (await guild.members.fetch(memberId));
} catch (error) {
console.error("Error fetching member:", error);
throw new Error("Member not found");
}
};
router.post("/rename", async (req: Request, res: Response) => {
const { newName, memberId } = req.body;
if (typeof newName !== "string" || !memberId) {
res.status(400).json({ error: "Invalid or missing newName or memberId" });
return;
}
try {
const member = await getMember(memberId);
await member.setNickname(newName);
console.log(`Member ${member.id} renamed to ${newName}`);
res.status(200).json({ message: "Member renamed successfully" });
} catch (error) {
console.error("Error renaming member:", error);
res.status(500).json({ error: "Failed to rename member" });
}
});
const handleRoleChange = (action: "add" | "remove") => async (req: Request, res: Response) => {
const { roleIds, memberId } = req.body;
if (!Array.isArray(roleIds) || !memberId) {
res.status(400).json({ error: "Invalid or missing roleIds or memberId" });
return;
}
try {
const member = await getMember(memberId);
const currentRoleIds = member.roles.cache.map((role) => role.id);
const filteredRoleIds =
action === "add"
? roleIds.filter((id: string) => !currentRoleIds.includes(id))
: roleIds.filter((id: string) => currentRoleIds.includes(id));
if (filteredRoleIds.length === 0) {
res.status(200).json({ message: `No roles to ${action}` });
return;
}
await member.roles[action](roleIds);
res.status(200).json({ message: `Roles ${action}ed successfully` });
} catch (error) {
console.error(`Error ${action}ing roles:`, error);
res.status(500).json({ error: `Failed to ${action} roles` });
}
};
router.post("/add-role", handleRoleChange("add"));
router.post("/remove-role", handleRoleChange("remove"));
export default router;

View File

@@ -0,0 +1,8 @@
import { Router } from "express";
import memberRouter from "./member";
const router: Router = Router();
router.use("/member", memberRouter);
export default router;

View File

@@ -0,0 +1,11 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"allowImportingTsExtensions": false,
"baseUrl": ".",
"jsx": "react"
},
"include": ["**/*.ts", "./index.ts", "**/*.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,6 +1,7 @@
DISPATCH_SERVER_PORT=3002
REDIS_HOST=localhost
REDIS_PORT=6379
DISCORD_SERVER_URL=http://discord-server
DISPATCH_APP_TOKEN=dispatch
LIVEKIT_API_KEY=APIAnsGdtdYp2Ho
LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC

View File

@@ -0,0 +1,38 @@
import axios from "axios";
const discordAxiosClient = axios.create({
baseURL: process.env.DISCORD_SERVER_URL || "https://discord.com/api/v10",
});
export const renameMember = async (memberId: string, newName: string) => {
discordAxiosClient
.post("/member/rename", {
memberId,
newName,
})
.catch((error) => {
console.error("Error renaming member:", error);
});
};
export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
discordAxiosClient
.post("/member/add-role", {
memberId,
roleIds,
})
.catch((error) => {
console.error("Error adding roles to member:", error);
});
};
export const removeRolesFromMember = async (memberId: string, roleIds: string[]) => {
discordAxiosClient
.post("/member/remove-role", {
memberId,
roleIds,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
});
};

View File

@@ -1,4 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
export const handleConnectDispatch =
@@ -67,6 +69,21 @@ export const handleConnectDispatch =
},
});
const discordAccount = await prisma.discordAccount.findFirst({
where: {
userId: user.id,
},
});
if (discordAccount?.id) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${selectedZone}`,
);
await addRolesToMember(discordAccount.discordId.toString(), [
DISCORD_ROLES.ONLINE_DISPATCHER,
]);
}
socket.join("dispatchers"); // Dem Dispatcher-Raum beitreten
socket.join(`user:${user.id}`); // Dem User-Raum beitreten
@@ -85,6 +102,15 @@ export const handleConnectDispatch =
});
io.to("dispatchers").emit("dispatchers-update");
io.to("pilots").emit("dispatchers-update");
if (discordAccount?.id) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName} - ${user.publicId}`,
);
await removeRolesFromMember(discordAccount.discordId.toString(), [
DISCORD_ROLES.ONLINE_DISPATCHER,
]);
}
});
} catch (error) {
console.error("Error connecting to dispatch server:", error);

View File

@@ -1,5 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { channel } from "diagnostics_channel";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
export const handleConnectPilot =
@@ -18,7 +19,6 @@ export const handleConnectPilot =
if (!user) return Error("User not found");
console.log("Pilot connected:", user.publicId);
const existingConnection = await prisma.connectedAircraft.findFirst({
where: {
userId: user.id,
@@ -65,6 +65,20 @@ export const handleConnectPilot =
stationId: parseInt(stationId),
},
});
const discordAccount = await prisma.discordAccount.findFirst({
where: {
userId: user.id,
},
});
if (discordAccount?.id) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${Station?.bosCallsignShort}`,
);
await addRolesToMember(discordAccount.discordId.toString(), [DISCORD_ROLES.ONLINE_PILOT]);
}
socket.join("dispatchers"); // Join the dispatchers room
socket.join(`user:${userId}`); // Join the user-specific room
socket.join(`station:${stationId}`); // Join the station-specific room
@@ -106,6 +120,15 @@ export const handleConnectPilot =
.catch(console.error);
io.to("dispatchers").emit("update-connectedAircraft");
io.to("pilots").emit("pilots-update");
if (discordAccount?.id) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName} - ${user.publicId}`,
);
await removeRolesFromMember(discordAccount.discordId.toString(), [
DISCORD_ROLES.ONLINE_PILOT,
]);
}
});
} catch (error) {
console.error("Error connecting to dispatch server:", error);

View File

@@ -1,9 +1,10 @@
import { getMoodleCourseCompletionStatus, getMoodleUserById } from "./moodle";
import { CronJob } from "cron";
import { prisma } from "@repo/db";
import { DISCORD_ROLES, prisma } from "@repo/db";
import { sendCourseCompletedEmail } from "modules/mail";
import { handleParticipantFinished } from "modules/event";
import { eventCompleted } from "helper/events";
import { addRolesToMember, removeRolesFromMember } from "modules/discord";
const syncMoodleIds = async () => {
try {
@@ -101,19 +102,91 @@ export const checkFinishedParticipants = async () => {
});
};
const checkUnfinishedParticipants = async () => {
const participantsPending = await prisma.participant.findMany({
where: {
completetionWorkflowFinished: false,
},
include: {
Event: true,
User: {
include: {
discordAccounts: true,
},
},
},
});
participantsPending.forEach(async (p) => {
if (!p.User) return;
const completed = eventCompleted(p.Event, p);
if (completed) return;
if (p.User.discordAccounts[0] && p.Event.discordRoleId) {
await addRolesToMember(p.User.discordAccounts[0].discordId, [p.Event.discordRoleId]);
prisma.participant.update({
where: {
id: p.id,
},
data: {
inscriptionWorkflowCompleted: true,
statusLog: {
push: {
event: "Discord-Rolle hinzugefügt",
timestamp: new Date(),
user: "system",
},
},
},
});
}
});
};
const checkDiscordRoles = async () => {
const user = await prisma.user.findMany({
where: {
discordAccounts: {
some: {},
},
},
include: {
discordAccounts: true,
},
});
for (const u of user) {
// Here ony member Roles regarding their rights are checked
if (!u.discordAccounts[0]) continue;
const discordAccount = u.discordAccounts[0];
// For Pilot
if (u.permissions.includes("PILOT")) {
await addRolesToMember(discordAccount.discordId, [DISCORD_ROLES.PILOT]); // ONLINE_PILOT
} else {
await removeRolesFromMember(discordAccount.discordId, [DISCORD_ROLES.PILOT]); // ONLINE_PILOT
}
// for Dispatcher
if (u.permissions.includes("DISPO")) {
await addRolesToMember(discordAccount.discordId, [DISCORD_ROLES.DISPATCHER]); // ONLINE_DISPATCHER
} else {
await removeRolesFromMember(discordAccount.discordId, [DISCORD_ROLES.DISPATCHER]); // ONLINE_DISPATCHER
}
}
};
CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true });
CronJob.from({
cronTime: "*/1 * * * *",
onTick: async () => {
await updateParticipantMoodleResults();
await checkFinishedParticipants();
await checkUnfinishedParticipants();
},
start: true,
});
const debug = async () => {
await updateParticipantMoodleResults();
await checkFinishedParticipants();
};
debug();
CronJob.from({
cronTime: "0 * * * *",
onTick: checkDiscordRoles,
start: true,
});

View File

@@ -0,0 +1,38 @@
import axios from "axios";
const discordAxiosClient = axios.create({
baseURL: process.env.DISCORD_SERVER_URL || "https://discord.com/api/v10",
});
export const renameMember = async (memberId: string, newName: string) => {
discordAxiosClient
.post("/member/rename", {
memberId,
newName,
})
.catch((error) => {
console.error("Error renaming member:", error);
});
};
export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
discordAxiosClient
.post("/member/add-role", {
memberId,
roleIds,
})
.catch((error) => {
console.error("Error adding roles to member:", error);
});
};
export const removeRolesFromMember = async (memberId: string, roleIds: string[]) => {
discordAxiosClient
.post("/member/remove-role", {
memberId,
roleIds,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
});
};

View File

@@ -1,4 +1,5 @@
import { Event, Participant, prisma, User } from "@repo/db";
import { removeRolesFromMember } from "modules/discord";
import { sendCourseCompletedEmail } from "modules/mail";
export const handleParticipantFinished = async (
@@ -6,7 +7,7 @@ export const handleParticipantFinished = async (
participant: Participant,
user: User,
) => {
const discordID = await prisma.discordAccount.findFirst({
const discordAccount = await prisma.discordAccount.findFirst({
where: {
userId: user.id,
},
@@ -33,7 +34,9 @@ export const handleParticipantFinished = async (
},
});
//TODO: Send Discord Message
if (event.discordRoleId && discordAccount) {
await removeRolesFromMember(discordAccount.discordId, [event.discordRoleId]);
}
await sendCourseCompletedEmail(user.email, user, event);
await prisma.participant.update({

View File

@@ -14,7 +14,7 @@ export const GET = async (req: NextRequest) => {
if (
!process.env.DISCORD_OAUTH_CLIENT_ID ||
!process.env.DISCORD_OAUTH_SECRET ||
!process.env.DISCORD_REDIRECT ||
!process.env.DISCORD_REDIRECT_URL ||
!code
) {
return NextResponse.json(
@@ -30,7 +30,7 @@ export const GET = async (req: NextRequest) => {
const params = new URLSearchParams({
client_id: process.env.DISCORD_OAUTH_CLIENT_ID,
client_secret: process.env.DISCORD_OAUTH_SECRET,
redirect_uri: process.env.DISCORD_REDIRECT,
redirect_uri: process.env.DISCORD_REDIRECT_URL,
grant_type: "authorization_code",
code,
});

View File

@@ -0,0 +1,39 @@
"use server";
import axios from "axios";
const discordAxiosClient = axios.create({
baseURL: process.env.DISCORD_SERVER_URL || "https://discord.com/api/v10",
});
export const renameMember = async (memberId: string, newName: string) => {
discordAxiosClient
.post("/member/rename", {
memberId,
newName,
})
.catch((error) => {
console.error("Error renaming member:", error);
});
};
export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
discordAxiosClient
.post("/member/add-role", {
memberId,
roleIds,
})
.catch((error) => {
console.error("Error adding roles to member:", error);
});
};
export const removeRolesFromMember = async (memberId: string, roleIds: string[]) => {
discordAxiosClient
.post("/member/remove-role", {
memberId,
roleIds,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
});
};