husteno-o

[email protected]
2026-09-10 17:34:09 +0600 committed NaN years ago
9c700b8
commit 9c700b8
7 files changed
+317 -4
▼apps/bot/src/commands/index.ts
+2-0
11
11
import { prCommand } from "./pr.js";
12
12
import { releaseCommand } from "./release.js";
13
13
import { repoCommand } from "./repo.js";
14
+import { roleCommand } from "./role.js";
14
15
import { searchCommand } from "./search.js";
15
16
import { securityCommand } from "./security.js";
16
17
import { settingsCommand } from "./settings.js";
42
43
askCommand,
43
44
toolsCommand,
44
45
settingsCommand,
46
+ roleCommand,
45
47
helpCommand,
46
48
];
47
49
48
50
▼apps/bot/src/commands/role.ts
+300-0
1
+import { db, auditLogs, servers, serverMemberships, users } from "@devpulse/database";
2
+import { getUserRole, requirePermission } from "@devpulse/permissions";
3
+import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js";
4
+import { and, eq } from "drizzle-orm";
5
+import { BrandColors } from "../ui/colors.js";
6
+import { createBaseEmbed, createErrorEmbed } from "../ui/embeds.js";
7
+import { NF } from "../ui/icons.js";
8
+import type { Command } from "./types.js";
9
+
10
+type AppRole = "admin" | "lead" | "developer" | "viewer";
11
+
12
+export const roleCommand: Command = {
13
+ data: new SlashCommandBuilder()
14
+ .setName("role")
15
+ .setDescription("Manage team roles: assign, list, and remove developer permissions")
16
+ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
17
+ .addSubcommand((sub) =>
18
+ sub
19
+ .setName("assign")
20
+ .setDescription("Assign a role to a team member")
21
+ .addUserOption((opt) =>
22
+ opt.setName("user").setDescription("Discord user to assign role to").setRequired(true),
23
+ )
24
+ .addStringOption((opt) =>
25
+ opt
26
+ .setName("role")
27
+ .setDescription("Role to assign")
28
+ .addChoices(
29
+ { name: "Admin (Full access)", value: "admin" },
30
+ { name: "Lead (PR merge, review)", value: "lead" },
31
+ { name: "Developer (Standard access)", value: "developer" },
32
+ { name: "Viewer (Read-only)", value: "viewer" },
33
+ )
34
+ .setRequired(true),
35
+ ),
36
+ )
37
+ .addSubcommand((sub) =>
38
+ sub.setName("list").setDescription("List all role assignments in this server"),
39
+ )
40
+ .addSubcommand((sub) =>
41
+ sub
42
+ .setName("remove")
43
+ .setDescription("Remove a user's role assignment (reset to viewer)")
44
+ .addUserOption((opt) =>
45
+ opt.setName("user").setDescription("Discord user to remove role from").setRequired(true),
46
+ ),
47
+ ),
48
+
49
+ async execute(interaction) {
50
+ if (!interaction.guildId) {
51
+ await interaction.reply({
52
+ content: `${NF.cross} This command can only be used in a Discord server.`,
53
+ ephemeral: true,
54
+ });
55
+ return;
56
+ }
57
+
58
+ const subcommand = interaction.options.getSubcommand();
59
+ const guildId = interaction.guildId;
60
+
61
+ try {
62
+ if (subcommand === "list") {
63
+ await handleRoleList(interaction, guildId);
64
+ return;
65
+ }
66
+
67
+ // Assign and Remove require admin role
68
+ await requirePermission(guildId, interaction.user.id, "admin");
69
+
70
+ if (subcommand === "assign") {
71
+ await handleRoleAssign(interaction, guildId);
72
+ return;
73
+ }
74
+
75
+ if (subcommand === "remove") {
76
+ await handleRoleRemove(interaction, guildId);
77
+ return;
78
+ }
79
+ } catch (err: unknown) {
80
+ const errorEmbed = createErrorEmbed(err instanceof Error ? err : new Error(String(err)));
81
+ if (interaction.replied || interaction.deferred) {
82
+ await interaction.editReply({ embeds: [errorEmbed] }).catch(() => {});
83
+ } else {
84
+ await interaction.reply({ embeds: [errorEmbed], ephemeral: true }).catch(() => {});
85
+ }
86
+ }
87
+ },
88
+};
89
+
90
+const ROLE_HIERARCHY: Record<string, number> = { admin: 40, lead: 30, developer: 20, viewer: 10 };
91
+const ROLE_ICONS: Record<string, string> = {
92
+ admin: "👑",
93
+ lead: "⭐",
94
+ developer: "💻",
95
+ viewer: "👁️",
96
+};
97
+
98
+async function handleRoleAssign(
99
+ interaction: any,
100
+ guildId: string,
101
+): Promise<void> {
102
+ const targetUser = interaction.options.getUser("user");
103
+ const role = interaction.options.getString("role") as AppRole;
104
+
105
+ if (!targetUser) {
106
+ await interaction.reply({ content: `${NF.cross} User not found.`, ephemeral: true });
107
+ return;
108
+ }
109
+
110
+ const assignerRole = await getUserRole(guildId, interaction.user.id);
111
+ if (ROLE_HIERARCHY[role] >= ROLE_HIERARCHY[assignerRole]) {
112
+ await interaction.reply({
113
+ content: `${NF.cross} You cannot assign a role equal to or higher than your own (\`${assignerRole}\`).`,
114
+ ephemeral: true,
115
+ });
116
+ return;
117
+ }
118
+
119
+ if (targetUser.id === interaction.user.id && role === "admin") {
120
+ await interaction.reply({
121
+ content: `${NF.cross} You cannot assign yourself the admin role.`,
122
+ ephemeral: true,
123
+ });
124
+ return;
125
+ }
126
+
127
+ const existing = await db.query.serverMemberships.findFirst({
128
+ where: and(
129
+ eq(serverMemberships.guildId, guildId),
130
+ eq(serverMemberships.userId, targetUser.id),
131
+ ),
132
+ });
133
+
134
+ const previousRole = existing?.role || "viewer";
135
+
136
+ if (existing) {
137
+ await db
138
+ .update(serverMemberships)
139
+ .set({ role: role as AppRole })
140
+ .where(
141
+ and(
142
+ eq(serverMemberships.guildId, guildId),
143
+ eq(serverMemberships.userId, targetUser.id),
144
+ ),
145
+ );
146
+ } else {
147
+ const dbUser = await db.query.users.findFirst({
148
+ where: eq(users.id, targetUser.id),
149
+ });
150
+ if (!dbUser) {
151
+ await db.insert(users).values({
152
+ id: targetUser.id,
153
+ username: targetUser.username,
154
+ });
155
+ }
156
+
157
+ await db.insert(serverMemberships).values({
158
+ id: `${guildId}-${targetUser.id}-${Date.now()}`,
159
+ guildId,
160
+ userId: targetUser.id,
161
+ role: role as AppRole,
162
+ });
163
+ }
164
+
165
+ await db.insert(auditLogs).values({
166
+ id: crypto.randomUUID(),
167
+ guildId,
168
+ userId: interaction.user.id,
169
+ action: "role_assign",
170
+ details: {
171
+ targetUserId: targetUser.id,
172
+ targetUsername: targetUser.username,
173
+ previousRole,
174
+ newRole: role,
175
+ },
176
+ });
177
+
178
+ await interaction.reply({
179
+ embeds: [
180
+ createBaseEmbed(`${NF.check} Role Assigned`)
181
+ .setColor(BrandColors.success)
182
+ .setDescription(
183
+ `**${ROLE_ICONS[role]} ${targetUser.toString()}** now has the **${role}** role.\n\n` +
184
+ `• Previous: \`${previousRole}\`\n` +
185
+ `• New: \`${role}\`\n\n` +
186
+ `_Assigned by ${interaction.user.toString()}_`,
187
+ ),
188
+ ],
189
+ });
190
+}
191
+
192
+async function handleRoleRemove(
193
+ interaction: any,
194
+ guildId: string,
195
+): Promise<void> {
196
+ const targetUser = interaction.options.getUser("user");
197
+
198
+ if (!targetUser) {
199
+ await interaction.reply({ content: `${NF.cross} User not found.`, ephemeral: true });
200
+ return;
201
+ }
202
+
203
+ const existing = await db.query.serverMemberships.findFirst({
204
+ where: and(
205
+ eq(serverMemberships.guildId, guildId),
206
+ eq(serverMemberships.userId, targetUser.id),
207
+ ),
208
+ });
209
+
210
+ if (!existing) {
211
+ await interaction.reply({
212
+ content: `${NF.warning} ${targetUser.toString()} doesn't have a role assignment in this server.`,
213
+ ephemeral: true,
214
+ });
215
+ return;
216
+ }
217
+
218
+ const previousRole = existing.role;
219
+
220
+ await db
221
+ .delete(serverMemberships)
222
+ .where(
223
+ and(
224
+ eq(serverMemberships.guildId, guildId),
225
+ eq(serverMemberships.userId, targetUser.id),
226
+ ),
227
+ );
228
+
229
+ await db.insert(auditLogs).values({
230
+ id: crypto.randomUUID(),
231
+ guildId,
232
+ userId: interaction.user.id,
233
+ action: "role_remove",
234
+ details: {
235
+ targetUserId: targetUser.id,
236
+ targetUsername: targetUser.username,
237
+ previousRole,
238
+ },
239
+ });
240
+
241
+ await interaction.reply({
242
+ embeds: [
243
+ createBaseEmbed(`${NF.check} Role Removed`)
244
+ .setColor(BrandColors.warning)
245
+ .setDescription(
246
+ `**${targetUser.toString()}**'s role has been removed (reset to viewer).\n\n` +
247
+ `• Previous: \`${previousRole}\`\n` +
248
+ `• New: \`viewer\` (default)\n\n` +
249
+ `_Removed by ${interaction.user.toString()}_`,
250
+ ),
251
+ ],
252
+ });
253
+}
254
+
255
+async function handleRoleList(
256
+ interaction: any,
257
+ guildId: string,
258
+): Promise<void> {
259
+ const memberships = await db.query.serverMemberships.findMany({
260
+ where: eq(serverMemberships.guildId, guildId),
261
+ });
262
+
263
+ const grouped: Record<string, string[]> = { admin: [], lead: [], developer: [], viewer: [] };
264
+ for (const m of memberships) {
265
+ const role = m.role as keyof typeof grouped;
266
+ if (grouped[role]) {
267
+ grouped[role].push(`<@${m.userId}>`);
268
+ }
269
+ }
270
+
271
+ const server = await db.query.servers.findFirst({
272
+ where: eq(servers.id, guildId),
273
+ });
274
+
275
+ if (server?.ownerId) {
276
+ grouped.admin = [`<@${server.ownerId}> (Owner)`];
277
+ }
278
+
279
+ const lines: string[] = [];
280
+ for (const [role, members] of Object.entries(grouped)) {
281
+ if (members.length > 0) {
282
+ lines.push(
283
+ `**${ROLE_ICONS[role]} ${role.charAt(0).toUpperCase() + role.slice(1)}:** ${members.join(", ")}`,
284
+ );
285
+ }
286
+ }
287
+
288
+ const embed = createBaseEmbed(`${NF.users} Team Roles`)
289
+ .setColor(BrandColors.primary)
290
+ .setDescription(
291
+ lines.length > 0
292
+ ? lines.join("\n\n")
293
+ : "No role assignments configured. Use `/role assign` to get started.",
294
+ )
295
+ .setFooter({
296
+ text: "Roles control access to write operations (PR merge, review, settings). Everyone starts as viewer.",
297
+ });
298
+
299
+ await interaction.reply({ embeds: [embed] });
300
+}
0
301
▼bun.lock
+1-0
225
225
"name": "@devpulse/permissions",
226
226
"version": "1.0.0",
227
227
"dependencies": {
228
+ "@devpulse/cache": "workspace:*",
228
229
"@devpulse/config": "workspace:*",
229
230
"@devpulse/core": "workspace:*",
230
231
"@devpulse/database": "workspace:*",
231
232
▼packages/database/src/init.ts
+1-1
46
46
id TEXT PRIMARY KEY,
47
47
guild_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
48
48
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
49
- role TEXT NOT NULL DEFAULT 'developer',
49
+ role TEXT NOT NULL DEFAULT 'viewer',
50
50
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
51
51
);`,
52
52
`CREATE UNIQUE INDEX IF NOT EXISTS memberships_guild_user_idx ON server_memberships (guild_id, user_id);`,
53
53
▼packages/database/src/schema.ts
+1-1
63
63
.references(() => users.id, { onDelete: "cascade" }),
64
64
role: text("role", { enum: ["admin", "lead", "developer", "viewer"] })
65
65
.notNull()
66
- .default("developer"),
66
+ .default("viewer"),
67
67
createdAt: integer("created_at", { mode: "timestamp_ms" })
68
68
.notNull()
69
69
.default(sql`(unixepoch() * 1000)`),
70
70
▼packages/permissions/package.json
+1-0
9
9
"build": "tsc"
10
10
},
11
11
"dependencies": {
12
+ "@devpulse/cache": "workspace:*",
12
13
"@devpulse/config": "workspace:*",
13
14
"@devpulse/core": "workspace:*",
14
15
"@devpulse/database": "workspace:*",
15
16
▼packages/permissions/src/index.ts
+11-2
1
1
import { UnauthorizedError } from "@devpulse/core";
2
+import { cache } from "@devpulse/cache";
2
3
import { db, serverMemberships, servers } from "@devpulse/database";
3
4
import { and, eq } from "drizzle-orm";
4
5
11
12
viewer: 10,
12
13
};
13
14
15
+const ROLE_CACHE_TTL = 300; // 5 minutes
16
+
14
17
export async function getUserRole(guildId: string, userId: string): Promise<Role> {
15
- // Check if user is server owner
18
+ const cacheKey = `perm:${guildId}:${userId}`;
19
+ const cached = await cache.get<Role>(cacheKey);
20
+ if (cached) return cached;
21
+
16
22
const server = await db.query.servers.findFirst({
17
23
where: eq(servers.id, guildId),
18
24
});
19
25
20
26
if (server && server.ownerId === userId) {
27
+ await cache.set(cacheKey, "admin", ROLE_CACHE_TTL);
21
28
return "admin";
22
29
}
23
30
25
32
where: and(eq(serverMemberships.guildId, guildId), eq(serverMemberships.userId, userId)),
26
33
});
27
34
28
- return (membership?.role as Role) || "developer";
35
+ const role = (membership?.role as Role) || "viewer";
36
+ await cache.set(cacheKey, role, ROLE_CACHE_TTL);
37
+ return role;
29
38
}
30
39
31
40
export function hasPermission(userRole: Role, requiredRole: Role): boolean {
32
41