Compare commits

..

21 Commits

Author SHA1 Message Date
Pascal Prießnitz
6b95e7fd85 [deploy] Fix Welcome-Embed Footer Validation
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-05 17:06:17 +01:00
Pascal Prießnitz
8c53160812 [deploy] Automod Filter greifen wieder (Links/Badwords)
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 21:47:36 +01:00
Pascal Prießnitz
c18441eb9a [deploy] Automod logging reasons und Modul-Fix
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-04 21:06:33 +01:00
Pascal Prießnitz
c95444feac [deploy] Fix stats duplication and polish help embed
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 18:41:57 +01:00
Pascal Prießnitz
544f04655c [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 18:17:43 +01:00
Pascal Prießnitz
85951ecfb4 Add emojis to dashboard sidebar 2025-12-04 18:10:07 +01:00
Pascal Prießnitz
9579dc7510 [deploy] Restore server stats UI and sanitize dashboard encoding
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 18s
2025-12-04 15:41:34 +01:00
Pascal Prießnitz
8127e81564 [deploy] Restore dashboard sections and clean navigation icons
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 14:32:31 +01:00
Pascal Prießnitz
975d0552bf [deploy] Clean automation renderer strings
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 13:15:21 +01:00
Pascal Prießnitz
aefb5b3c72 [deploy] Guard module list rendering when element missing
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 13:00:24 +01:00
Pascal Prießnitz
f4f4efb722 [deploy] Remove leftover TS casts in dashboard settings
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-04 12:58:06 +01:00
Pascal Prießnitz
313b2c0613 [deploy] Guard settings inputs in dashboard loadSettings
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 12:53:38 +01:00
Pascal Prießnitz
71a716e214 [deploy] Define welcome file inputs to avoid runtime reference error
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 12:48:26 +01:00
Pascal Prießnitz
afac2e7c68 [deploy] Remove TS type annotation from dashboard inline script
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-04 12:32:18 +01:00
Pascal Prießnitz
cfc4559312 [deploy] Fix dashboard inline JS syntax error
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 12:22:31 +01:00
Pascal Prießnitz
bebca808b0 [deploy] Harden dashboard event listeners for missing elements
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-04 12:19:26 +01:00
Pascal Prießnitz
78578fcc1c [deploy] Fix dashboard icons and add missing register migration placeholder
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 12:15:16 +01:00
Pascal Prießnitz
5aef575f41 [deploy] Add server stats module with dashboard controls
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-04 11:37:49 +01:00
Pascal Prießnitz
c66da87207 Improve README structure and quickstart
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 13s
2025-12-03 22:33:21 +01:00
Pascal Prießnitz
962ee4aafc [deploy] Add RegisterForm applications relation
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-03 22:25:59 +01:00
Pascal Prießnitz
27f96092dd [deploy] Fix RegisterFormField order column
Some checks failed
Deploy Discord Bot / deploy (push) Failing after 20s
2025-12-03 22:24:21 +01:00
37 changed files with 906 additions and 818 deletions

10
node_modules/.prisma/client/edge.js generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -143,6 +143,8 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -272,7 +274,7 @@ exports.Prisma.RegisterFormFieldScalarFieldEnum = {
label: 'label',
type: 'type',
required: 'required',
sortOrder: 'sortOrder'
order: 'order'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {

View File

@@ -2086,6 +2086,7 @@ export namespace Prisma {
reactionRolesEnabled: boolean | null
eventsEnabled: boolean | null
registerEnabled: boolean | null
serverStatsEnabled: boolean | null
supportRoleId: string | null
updatedAt: Date | null
createdAt: Date | null
@@ -2105,6 +2106,7 @@ export namespace Prisma {
reactionRolesEnabled: boolean | null
eventsEnabled: boolean | null
registerEnabled: boolean | null
serverStatsEnabled: boolean | null
supportRoleId: string | null
updatedAt: Date | null
createdAt: Date | null
@@ -2131,6 +2133,8 @@ export namespace Prisma {
eventsEnabled: number
registerEnabled: number
registerConfig: number
serverStatsEnabled: number
serverStatsConfig: number
supportRoleId: number
updatedAt: number
createdAt: number
@@ -2152,6 +2156,7 @@ export namespace Prisma {
reactionRolesEnabled?: true
eventsEnabled?: true
registerEnabled?: true
serverStatsEnabled?: true
supportRoleId?: true
updatedAt?: true
createdAt?: true
@@ -2171,6 +2176,7 @@ export namespace Prisma {
reactionRolesEnabled?: true
eventsEnabled?: true
registerEnabled?: true
serverStatsEnabled?: true
supportRoleId?: true
updatedAt?: true
createdAt?: true
@@ -2197,6 +2203,8 @@ export namespace Prisma {
eventsEnabled?: true
registerEnabled?: true
registerConfig?: true
serverStatsEnabled?: true
serverStatsConfig?: true
supportRoleId?: true
updatedAt?: true
createdAt?: true
@@ -2296,6 +2304,8 @@ export namespace Prisma {
eventsEnabled: boolean | null
registerEnabled: boolean | null
registerConfig: JsonValue | null
serverStatsEnabled: boolean | null
serverStatsConfig: JsonValue | null
supportRoleId: string | null
updatedAt: Date
createdAt: Date
@@ -2339,6 +2349,8 @@ export namespace Prisma {
eventsEnabled?: boolean
registerEnabled?: boolean
registerConfig?: boolean
serverStatsEnabled?: boolean
serverStatsConfig?: boolean
supportRoleId?: boolean
updatedAt?: boolean
createdAt?: boolean
@@ -2365,6 +2377,8 @@ export namespace Prisma {
eventsEnabled?: boolean
registerEnabled?: boolean
registerConfig?: boolean
serverStatsEnabled?: boolean
serverStatsConfig?: boolean
supportRoleId?: boolean
updatedAt?: boolean
createdAt?: boolean
@@ -2391,6 +2405,8 @@ export namespace Prisma {
eventsEnabled?: boolean
registerEnabled?: boolean
registerConfig?: boolean
serverStatsEnabled?: boolean
serverStatsConfig?: boolean
supportRoleId?: boolean
updatedAt?: boolean
createdAt?: boolean
@@ -2421,6 +2437,8 @@ export namespace Prisma {
eventsEnabled: boolean | null
registerEnabled: boolean | null
registerConfig: Prisma.JsonValue | null
serverStatsEnabled: boolean | null
serverStatsConfig: Prisma.JsonValue | null
supportRoleId: string | null
updatedAt: Date
createdAt: Date
@@ -2837,6 +2855,8 @@ export namespace Prisma {
readonly eventsEnabled: FieldRef<"GuildSettings", 'Boolean'>
readonly registerEnabled: FieldRef<"GuildSettings", 'Boolean'>
readonly registerConfig: FieldRef<"GuildSettings", 'Json'>
readonly serverStatsEnabled: FieldRef<"GuildSettings", 'Boolean'>
readonly serverStatsConfig: FieldRef<"GuildSettings", 'Json'>
readonly supportRoleId: FieldRef<"GuildSettings", 'String'>
readonly updatedAt: FieldRef<"GuildSettings", 'DateTime'>
readonly createdAt: FieldRef<"GuildSettings", 'DateTime'>
@@ -12712,11 +12732,11 @@ export namespace Prisma {
}
export type RegisterFormFieldAvgAggregateOutputType = {
sortOrder: number | null
order: number | null
}
export type RegisterFormFieldSumAggregateOutputType = {
sortOrder: number | null
order: number | null
}
export type RegisterFormFieldMinAggregateOutputType = {
@@ -12725,7 +12745,7 @@ export namespace Prisma {
label: string | null
type: string | null
required: boolean | null
sortOrder: number | null
order: number | null
}
export type RegisterFormFieldMaxAggregateOutputType = {
@@ -12734,7 +12754,7 @@ export namespace Prisma {
label: string | null
type: string | null
required: boolean | null
sortOrder: number | null
order: number | null
}
export type RegisterFormFieldCountAggregateOutputType = {
@@ -12743,17 +12763,17 @@ export namespace Prisma {
label: number
type: number
required: number
sortOrder: number
order: number
_all: number
}
export type RegisterFormFieldAvgAggregateInputType = {
sortOrder?: true
order?: true
}
export type RegisterFormFieldSumAggregateInputType = {
sortOrder?: true
order?: true
}
export type RegisterFormFieldMinAggregateInputType = {
@@ -12762,7 +12782,7 @@ export namespace Prisma {
label?: true
type?: true
required?: true
sortOrder?: true
order?: true
}
export type RegisterFormFieldMaxAggregateInputType = {
@@ -12771,7 +12791,7 @@ export namespace Prisma {
label?: true
type?: true
required?: true
sortOrder?: true
order?: true
}
export type RegisterFormFieldCountAggregateInputType = {
@@ -12780,7 +12800,7 @@ export namespace Prisma {
label?: true
type?: true
required?: true
sortOrder?: true
order?: true
_all?: true
}
@@ -12876,7 +12896,7 @@ export namespace Prisma {
label: string
type: string
required: boolean
sortOrder: number
order: number
_count: RegisterFormFieldCountAggregateOutputType | null
_avg: RegisterFormFieldAvgAggregateOutputType | null
_sum: RegisterFormFieldSumAggregateOutputType | null
@@ -12904,7 +12924,7 @@ export namespace Prisma {
label?: boolean
type?: boolean
required?: boolean
sortOrder?: boolean
order?: boolean
form?: boolean | RegisterFormDefaultArgs<ExtArgs>
}, ExtArgs["result"]["registerFormField"]>
@@ -12914,7 +12934,7 @@ export namespace Prisma {
label?: boolean
type?: boolean
required?: boolean
sortOrder?: boolean
order?: boolean
form?: boolean | RegisterFormDefaultArgs<ExtArgs>
}, ExtArgs["result"]["registerFormField"]>
@@ -12924,7 +12944,7 @@ export namespace Prisma {
label?: boolean
type?: boolean
required?: boolean
sortOrder?: boolean
order?: boolean
}
export type RegisterFormFieldInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
@@ -12945,7 +12965,7 @@ export namespace Prisma {
label: string
type: string
required: boolean
sortOrder: number
order: number
}, ExtArgs["result"]["registerFormField"]>
composites: {}
}
@@ -13345,7 +13365,7 @@ export namespace Prisma {
readonly label: FieldRef<"RegisterFormField", 'String'>
readonly type: FieldRef<"RegisterFormField", 'String'>
readonly required: FieldRef<"RegisterFormField", 'Boolean'>
readonly sortOrder: FieldRef<"RegisterFormField", 'Int'>
readonly order: FieldRef<"RegisterFormField", 'Int'>
}
@@ -15629,6 +15649,8 @@ export namespace Prisma {
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -15791,7 +15813,7 @@ export namespace Prisma {
label: 'label',
type: 'type',
required: 'required',
sortOrder: 'sortOrder'
order: 'order'
};
export type RegisterFormFieldScalarFieldEnum = (typeof RegisterFormFieldScalarFieldEnum)[keyof typeof RegisterFormFieldScalarFieldEnum]
@@ -15971,6 +15993,8 @@ export namespace Prisma {
eventsEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
registerEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
registerConfig?: JsonNullableFilter<"GuildSettings">
serverStatsEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
serverStatsConfig?: JsonNullableFilter<"GuildSettings">
supportRoleId?: StringNullableFilter<"GuildSettings"> | string | null
updatedAt?: DateTimeFilter<"GuildSettings"> | Date | string
createdAt?: DateTimeFilter<"GuildSettings"> | Date | string
@@ -15997,6 +16021,8 @@ export namespace Prisma {
eventsEnabled?: SortOrderInput | SortOrder
registerEnabled?: SortOrderInput | SortOrder
registerConfig?: SortOrderInput | SortOrder
serverStatsEnabled?: SortOrderInput | SortOrder
serverStatsConfig?: SortOrderInput | SortOrder
supportRoleId?: SortOrderInput | SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -16026,6 +16052,8 @@ export namespace Prisma {
eventsEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
registerEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
registerConfig?: JsonNullableFilter<"GuildSettings">
serverStatsEnabled?: BoolNullableFilter<"GuildSettings"> | boolean | null
serverStatsConfig?: JsonNullableFilter<"GuildSettings">
supportRoleId?: StringNullableFilter<"GuildSettings"> | string | null
updatedAt?: DateTimeFilter<"GuildSettings"> | Date | string
createdAt?: DateTimeFilter<"GuildSettings"> | Date | string
@@ -16052,6 +16080,8 @@ export namespace Prisma {
eventsEnabled?: SortOrderInput | SortOrder
registerEnabled?: SortOrderInput | SortOrder
registerConfig?: SortOrderInput | SortOrder
serverStatsEnabled?: SortOrderInput | SortOrder
serverStatsConfig?: SortOrderInput | SortOrder
supportRoleId?: SortOrderInput | SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -16084,6 +16114,8 @@ export namespace Prisma {
eventsEnabled?: BoolNullableWithAggregatesFilter<"GuildSettings"> | boolean | null
registerEnabled?: BoolNullableWithAggregatesFilter<"GuildSettings"> | boolean | null
registerConfig?: JsonNullableWithAggregatesFilter<"GuildSettings">
serverStatsEnabled?: BoolNullableWithAggregatesFilter<"GuildSettings"> | boolean | null
serverStatsConfig?: JsonNullableWithAggregatesFilter<"GuildSettings">
supportRoleId?: StringNullableWithAggregatesFilter<"GuildSettings"> | string | null
updatedAt?: DateTimeWithAggregatesFilter<"GuildSettings"> | Date | string
createdAt?: DateTimeWithAggregatesFilter<"GuildSettings"> | Date | string
@@ -16831,7 +16863,7 @@ export namespace Prisma {
label?: StringFilter<"RegisterFormField"> | string
type?: StringFilter<"RegisterFormField"> | string
required?: BoolFilter<"RegisterFormField"> | boolean
sortOrder?: IntFilter<"RegisterFormField"> | number
order?: IntFilter<"RegisterFormField"> | number
form?: XOR<RegisterFormRelationFilter, RegisterFormWhereInput>
}
@@ -16841,7 +16873,7 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
sortOrder?: SortOrder
order?: SortOrder
form?: RegisterFormOrderByWithRelationInput
}
@@ -16854,7 +16886,7 @@ export namespace Prisma {
label?: StringFilter<"RegisterFormField"> | string
type?: StringFilter<"RegisterFormField"> | string
required?: BoolFilter<"RegisterFormField"> | boolean
sortOrder?: IntFilter<"RegisterFormField"> | number
order?: IntFilter<"RegisterFormField"> | number
form?: XOR<RegisterFormRelationFilter, RegisterFormWhereInput>
}, "id">
@@ -16864,7 +16896,7 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
sortOrder?: SortOrder
order?: SortOrder
_count?: RegisterFormFieldCountOrderByAggregateInput
_avg?: RegisterFormFieldAvgOrderByAggregateInput
_max?: RegisterFormFieldMaxOrderByAggregateInput
@@ -16881,7 +16913,7 @@ export namespace Prisma {
label?: StringWithAggregatesFilter<"RegisterFormField"> | string
type?: StringWithAggregatesFilter<"RegisterFormField"> | string
required?: BoolWithAggregatesFilter<"RegisterFormField"> | boolean
sortOrder?: IntWithAggregatesFilter<"RegisterFormField"> | number
order?: IntWithAggregatesFilter<"RegisterFormField"> | number
}
export type RegisterApplicationWhereInput = {
@@ -17028,6 +17060,8 @@ export namespace Prisma {
eventsEnabled?: boolean | null
registerEnabled?: boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: string | null
updatedAt?: Date | string
createdAt?: Date | string
@@ -17054,6 +17088,8 @@ export namespace Prisma {
eventsEnabled?: boolean | null
registerEnabled?: boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: string | null
updatedAt?: Date | string
createdAt?: Date | string
@@ -17080,6 +17116,8 @@ export namespace Prisma {
eventsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: NullableStringFieldUpdateOperationsInput | string | null
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@@ -17106,6 +17144,8 @@ export namespace Prisma {
eventsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: NullableStringFieldUpdateOperationsInput | string | null
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@@ -17132,6 +17172,8 @@ export namespace Prisma {
eventsEnabled?: boolean | null
registerEnabled?: boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: string | null
updatedAt?: Date | string
createdAt?: Date | string
@@ -17158,6 +17200,8 @@ export namespace Prisma {
eventsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: NullableStringFieldUpdateOperationsInput | string | null
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@@ -17184,6 +17228,8 @@ export namespace Prisma {
eventsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
registerConfig?: NullableJsonNullValueInput | InputJsonValue
serverStatsEnabled?: NullableBoolFieldUpdateOperationsInput | boolean | null
serverStatsConfig?: NullableJsonNullValueInput | InputJsonValue
supportRoleId?: NullableStringFieldUpdateOperationsInput | string | null
updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string
createdAt?: DateTimeFieldUpdateOperationsInput | Date | string
@@ -18031,7 +18077,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
sortOrder?: number
order?: number
form: RegisterFormCreateNestedOneWithoutFieldsInput
}
@@ -18041,7 +18087,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
sortOrder?: number
order?: number
}
export type RegisterFormFieldUpdateInput = {
@@ -18049,7 +18095,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
sortOrder?: IntFieldUpdateOperationsInput | number
order?: IntFieldUpdateOperationsInput | number
form?: RegisterFormUpdateOneRequiredWithoutFieldsNestedInput
}
@@ -18059,7 +18105,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
sortOrder?: IntFieldUpdateOperationsInput | number
order?: IntFieldUpdateOperationsInput | number
}
export type RegisterFormFieldCreateManyInput = {
@@ -18068,7 +18114,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
sortOrder?: number
order?: number
}
export type RegisterFormFieldUpdateManyMutationInput = {
@@ -18076,7 +18122,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
sortOrder?: IntFieldUpdateOperationsInput | number
order?: IntFieldUpdateOperationsInput | number
}
export type RegisterFormFieldUncheckedUpdateManyInput = {
@@ -18085,7 +18131,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
sortOrder?: IntFieldUpdateOperationsInput | number
order?: IntFieldUpdateOperationsInput | number
}
export type RegisterApplicationCreateInput = {
@@ -18310,6 +18356,8 @@ export namespace Prisma {
eventsEnabled?: SortOrder
registerEnabled?: SortOrder
registerConfig?: SortOrder
serverStatsEnabled?: SortOrder
serverStatsConfig?: SortOrder
supportRoleId?: SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -18329,6 +18377,7 @@ export namespace Prisma {
reactionRolesEnabled?: SortOrder
eventsEnabled?: SortOrder
registerEnabled?: SortOrder
serverStatsEnabled?: SortOrder
supportRoleId?: SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -18348,6 +18397,7 @@ export namespace Prisma {
reactionRolesEnabled?: SortOrder
eventsEnabled?: SortOrder
registerEnabled?: SortOrder
serverStatsEnabled?: SortOrder
supportRoleId?: SortOrder
updatedAt?: SortOrder
createdAt?: SortOrder
@@ -19020,11 +19070,11 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
sortOrder?: SortOrder
order?: SortOrder
}
export type RegisterFormFieldAvgOrderByAggregateInput = {
sortOrder?: SortOrder
order?: SortOrder
}
export type RegisterFormFieldMaxOrderByAggregateInput = {
@@ -19033,7 +19083,7 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
sortOrder?: SortOrder
order?: SortOrder
}
export type RegisterFormFieldMinOrderByAggregateInput = {
@@ -19042,11 +19092,11 @@ export namespace Prisma {
label?: SortOrder
type?: SortOrder
required?: SortOrder
sortOrder?: SortOrder
order?: SortOrder
}
export type RegisterFormFieldSumOrderByAggregateInput = {
sortOrder?: SortOrder
order?: SortOrder
}
export type RegisterApplicationAnswerListRelationFilter = {
@@ -19801,7 +19851,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
sortOrder?: number
order?: number
}
export type RegisterFormFieldUncheckedCreateWithoutFormInput = {
@@ -19809,7 +19859,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
sortOrder?: number
order?: number
}
export type RegisterFormFieldCreateOrConnectWithoutFormInput = {
@@ -19879,7 +19929,7 @@ export namespace Prisma {
label?: StringFilter<"RegisterFormField"> | string
type?: StringFilter<"RegisterFormField"> | string
required?: BoolFilter<"RegisterFormField"> | boolean
sortOrder?: IntFilter<"RegisterFormField"> | number
order?: IntFilter<"RegisterFormField"> | number
}
export type RegisterApplicationUpsertWithWhereUniqueWithoutFormInput = {
@@ -20193,7 +20243,7 @@ export namespace Prisma {
label: string
type: string
required?: boolean
sortOrder?: number
order?: number
}
export type RegisterApplicationCreateManyFormInput = {
@@ -20211,7 +20261,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
sortOrder?: IntFieldUpdateOperationsInput | number
order?: IntFieldUpdateOperationsInput | number
}
export type RegisterFormFieldUncheckedUpdateWithoutFormInput = {
@@ -20219,7 +20269,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
sortOrder?: IntFieldUpdateOperationsInput | number
order?: IntFieldUpdateOperationsInput | number
}
export type RegisterFormFieldUncheckedUpdateManyWithoutFormInput = {
@@ -20227,7 +20277,7 @@ export namespace Prisma {
label?: StringFieldUpdateOperationsInput | string
type?: StringFieldUpdateOperationsInput | string
required?: BoolFieldUpdateOperationsInput | boolean
sortOrder?: IntFieldUpdateOperationsInput | number
order?: IntFieldUpdateOperationsInput | number
}
export type RegisterApplicationUpdateWithoutFormInput = {

10
node_modules/.prisma/client/index.js generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -143,6 +143,8 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -272,7 +274,7 @@ exports.Prisma.RegisterFormFieldScalarFieldEnum = {
label: 'label',
type: 'type',
required: 'required',
sortOrder: 'sortOrder'
order: 'order'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {

View File

@@ -0,0 +1,2 @@
-- Placeholder recreated because migration was already applied in the database.
-- Schema changes are already present; this file keeps the migration timeline consistent.

View File

@@ -26,7 +26,12 @@ model GuildSettings {
birthdayConfig Json?
reactionRolesEnabled Boolean?
reactionRolesConfig Json?
eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String?
eventsEnabled Boolean?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
@@ -172,7 +177,6 @@ model RegisterForm {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields RegisterFormField[]
applications RegisterApplication[]
@@index([guildId, isActive])
}
@@ -183,7 +187,7 @@ model RegisterFormField {
label String
type String
required Boolean @default(false)
sortOrder Int @default(0)
order Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

View File

@@ -1,73 +1,64 @@
# Papo Discord Bot
Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support.
Discord-Bot (discord.js 14, TypeScript) mit Web-Dashboard, Prisma/PostgreSQL und Docker-Support..
## Was drin ist
- Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging.
- Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging.
- Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild.
- Welcome: konfigurierbare Embeds (Channel, Farbe, Texte, Bilder/Uploads), Preview im Dashboard, Text-Fallback.
- Logging: Join/Leave, Message Edit/Delete, Automod/Ticket/Musik-Events mit konfigurierbarem Log-Channel/Kategorien.
- Leveling: XP/Level pro Nachricht, /rank, toggelbar.
- Dynamische Voice: Lobby erzeugt private Voice-Channels mit Template/Userlimit.
- Birthday: /birthday + geplante Glueckwuensche mit Template/Channel.
- Reaction Roles: Verwaltung im Dashboard, Sync/Loeschen/Erstellen.
- Events: Einmalig/recurring, Reminder, Signups, Buttons.
- Statuspage-Modul vorhanden (Config/API), plus Modul-Toggles im Dashboard.
- Dashboard: OAuth2 (Scopes identify, guilds), zeigt nur Guilds, die der Nutzer besitzt oder mit Manage Guild/Admin-Rechten verwalten darf **und** in denen der Bot ist. Modulabhaengige Navigation.
- Rich Presence: rotiert mit `/help`, Dashboard-URL und Guild-Zaehler.
## Highlights
- Ticketsystem mit Panels, Transcripts und Support-Login (Slash-Commands wie `/ticket`, `/claim`, `/close`).
- Automod (Link-Whitelist, Spam/Caps, Bad-Word-Listen), Logging für relevante Events.
- Musik (play/skip/stop/pause/resume/loop) pro Guild aktivierbar.
- Welcome, Leveling, dynamische Voice, Birthdays, Reaction Roles, Events mit Remindern.
- Statuspage-Modul, Rich Presence und modulbasierte Dashboard-Navigation.
## Tech-Stack
- Node.js 20 (Docker-Basis), TypeScript (CommonJS)
- Node.js 20, TypeScript (CommonJS)
- discord.js 14, play-dl, @discordjs/voice
- Express + OAuth2-Login, Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose (App + Postgres)
- Express + OAuth2-Login
- Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose
## Setup (lokal, Entwicklung)
## Quickstart (lokal)
1. Repo klonen, in das Verzeichnis wechseln.
2. `cp .env.example .env` und Variablen setzen (siehe unten).
3. Dependencies installieren: `npm ci` (oder `npm install`).
2. `.env` anlegen: `cp .env.example .env` und Werte setzen.
3. Abhängigkeiten: `npm ci` (oder `npm install`).
4. Prisma: `npx prisma generate --schema=src/database/schema.prisma` und `npx prisma migrate dev --name init`.
5. Start Dev: `npm run dev` (ts-node-dev). Dashboard und Bot laufen auf `PORT` (default 3000).
6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
5. Start Dev: `npm run dev` (ts-node-dev). Dashboard/Bot auf `PORT` (Standard 3000).
6. Slash-Commands werden beim Start für `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
## Setup mit Docker
- `.dockerignore` blendet lokale node_modules/.env aus.
- Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container).
- Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build).
## Quickstart (Docker)
- Dev-Stack: `docker-compose up --build` (Dockerfile + Postgres 15, env aus `.env`, startet `npm run dev`).
- Eigenes Image: `docker build .` (Prisma-Generate läuft im Build). `.dockerignore` blendet lokale `node_modules`/`.env` aus.
## Environment-Variablen
## Environment-Variablen (Auswahl)
- `DISCORD_TOKEN` (Pflicht, Bot Token)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands)
- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild) / `DISCORD_GUILD_IDS` (kommagetrennt)
- `DATABASE_URL` (Pflicht, Postgres)
- `PORT` (Webserver/Dashboard, default 3000)
- `PORT` (Dashboard/Bot, default 3000)
- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`)
- `DASHBOARD_BASE_URL` (Public Base URL, fuer OAuth Redirect)
- `DASHBOARD_BASE_URL` (Public Base URL für OAuth Redirect)
- `WEB_BASE_PATH` (Default `/ucp`, ohne Slash am Ende)
- `OWNER_IDS` (kommagetrennte Owner fuer Admin-UI)
- `OWNER_IDS` (kommagetrennte Owner für Admin-UI)
- `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
## Datenbank / Prisma
- Schema: `src/database/schema.prisma` (zweites Schema in `prisma/schema.prisma` fuer Binary Targets).
- Hauptschema: `src/database/schema.prisma` (zweites in `prisma/schema.prisma` für Binary Targets).
- Migrationen: `npx prisma migrate dev --name <name>`; danach `npx prisma generate --schema=src/database/schema.prisma`.
- Kern-Tabellen: GuildSettings (Module/Config), Ticket, TicketSupportSession, Event/EventSignup, Birthday, ReactionRoleSet, Level.
- Kern-Tabellen: GuildSettings, Ticket, TicketSupportSession, Event/EventSignup, RegisterForm/RegisterApplication, Birthday, ReactionRoleSet, Level.
## Kommandos & Scripts
## Scripts
- `npm run dev` Entwicklung (ts-node-dev)
- `npm run build` TypeScript build
- `npm start` Start aus `dist`
- Prisma-CLI: `npx prisma ...` (nutzt Schema aus `src/database/schema.prisma`)
## Dashboard / API Kurzinfo
## API/Dashboard Kurzinfo
- Auth-Gate (`/api/*`), Login `/auth/discord`, Callback `/auth/callback`, Logout `/auth/logout`.
- `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf und in denen der Bot ist.
- Module/Settings ueber `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints fuer Events, Reaction Roles, Birthday, Statuspage.
- `/api/guilds` filtert auf Guilds, die der eingeloggte User besitzt oder managen darf **und** in denen der Bot ist.
- Settings/Module über `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints für Events, Reaction Roles, Birthday, Statuspage.
## Deployment-Hinweise
- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen.
- Transcripts werden unter `./transcripts` abgelegt (Volume mounten, falls Container).
- Transcripts liegen unter `./transcripts` (bei Containern als Volume mounten).
## Credits/Lizenz
- Autoren/Lizenz nicht hinterlegt. Bitte vor Nutzung pruefen.
- Autoren/Lizenz nicht hinterlegt bitte vor Nutzung prüfen.

View File

@@ -23,7 +23,7 @@ const command: SlashCommand = {
await member.ban({ reason }).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` });
context.logging.logAction(user, 'Ban', reason);
context.logging.logAction(user, 'Ban', reason, interaction.guild);
}
};

View File

@@ -21,7 +21,7 @@ const command: SlashCommand = {
}
await member.kick(reason);
await interaction.reply({ content: `${user.tag} wurde gekickt.` });
context.logging.logAction(user, 'Kick', reason);
context.logging.logAction(user, 'Kick', reason, interaction.guild);
}
};

View File

@@ -23,7 +23,7 @@ const command: SlashCommand = {
}
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` });
context.logging.logAction(user, 'Mute', reason);
context.logging.logAction(user, 'Mute', reason, interaction.guild);
}
};

View File

@@ -25,7 +25,7 @@ const command: SlashCommand = {
await member.ban({ reason: `${reason} | ${minutes} Minuten` });
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` });
context.logging.logAction(user, 'Tempban', reason);
context.logging.logAction(user, 'Tempban', reason, interaction.guild);
setTimeout(async () => {
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null);

View File

@@ -23,7 +23,7 @@ const command: SlashCommand = {
}
await member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` });
context.logging.logAction(user, 'Timeout', reason);
context.logging.logAction(user, 'Timeout', reason, interaction.guild);
}
};

View File

@@ -19,7 +19,7 @@ const command: SlashCommand = {
}
await member.timeout(null).catch(() => null);
await interaction.reply({ content: `${user.tag} ist nun entmuted.` });
context.logging.logAction(user, 'Unmute');
context.logging.logAction(user, 'Unmute', undefined, interaction.guild);
}
};

View File

@@ -4,15 +4,19 @@ import { SlashCommand } from '../../utils/types';
const command: SlashCommand = {
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),
async execute(interaction: ChatInputCommandInteraction) {
const avatar = interaction.client.user?.displayAvatarURL({ size: 256 }) ?? null;
const embed = new EmbedBuilder()
.setTitle('Papo Hilfe')
.setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard')
.setTitle('Papo Hilfe')
.setColor(0xf97316)
.setThumbnail(avatar)
.setDescription('Dein All-in-One Assistant: Tickets, Automod, Musik, Stats, Dashboard.')
.addFields(
{ name: 'Admin', value: '/ban /kick /mute /timeout /clear', inline: false },
{ name: 'Tickets', value: '/ticket /ticketpanel /ticketpriority /ticketstatus /transcript', inline: false },
{ name: 'Musik', value: '/play /pause /resume /skip /stop /queue /loop', inline: false },
{ name: 'Utility', value: '/ping /configure /serverinfo /rank', inline: false }
);
{ name: '🛡️ Admin', value: '`/ban` `/kick` `/mute` `/timeout` `/clear`', inline: false },
{ name: '🎫 Tickets', value: '`/ticket` `/ticketpanel` `/ticketpriority` `/ticketstatus` `/transcript`', inline: false },
{ name: '🎵 Musik', value: '`/play` `/pause` `/resume` `/skip` `/stop` `/queue` `/loop`', inline: false },
{ name: '📊 Server-Tools', value: '`/configure` `/serverinfo` `/rank`', inline: false }
)
.setFooter({ text: 'Tipp: Nutze /configure für Module & Dashboard-Link' });
await interaction.reply({ embeds: [embed], ephemeral: true });
}
};

View File

@@ -15,12 +15,15 @@ import { EventService } from '../services/eventService';
import { TicketAutomationService } from '../services/ticketAutomationService';
import { KnowledgeBaseService } from '../services/knowledgeBaseService';
import { RegisterService } from '../services/registerService';
import { StatsService } from '../services/statsService';
const logging = new LoggingService();
export const context = {
client: null as Client | null,
commandHandler: null as CommandHandler | null,
automod: new AutoModService(true, true),
logging: new LoggingService(),
logging,
automod: new AutoModService(logging, true, true),
music: new MusicService(),
tickets: new TicketService(),
leveling: new LevelService(),
@@ -33,7 +36,8 @@ export const context = {
events: new EventService(),
ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService()
register: new RegisterService(),
stats: new StatsService()
};
context.modules.setHooks({
@@ -63,6 +67,10 @@ context.modules.setHooks({
},
eventsEnabled: {
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined)
},
serverStatsEnabled: {
onEnable: async (guildId: string) => context.stats.refreshGuild(guildId).catch(() => undefined),
onDisable: async (guildId: string) => context.stats.disableGuild(guildId).catch(() => undefined)
}
});

View File

@@ -65,6 +65,15 @@ export interface GuildSettings {
reviewChannelId?: string;
notifyRoleIds?: string[];
};
serverStatsEnabled?: boolean;
serverStatsConfig?: {
enabled?: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes?: number;
cleanupOrphans?: boolean;
items?: any[];
};
supportRoleId?: string;
welcomeEnabled?: boolean;
}
@@ -74,8 +83,7 @@ class SettingsStore {
private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg };
(
[
const defaultOn = [
'ticketsEnabled',
'automodEnabled',
'welcomeEnabled',
@@ -87,10 +95,11 @@ class SettingsStore {
'reactionRolesEnabled',
'eventsEnabled',
'registerEnabled'
] as const
).forEach((key) => {
] as const;
defaultOn.forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true;
});
if (normalized.serverStatsEnabled === undefined) normalized.serverStatsEnabled = false;
// keep welcomeConfig flag in sync when present
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
@@ -124,6 +133,8 @@ class SettingsStore {
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
registerEnabled: (row as any).registerEnabled ?? undefined,
registerConfig: (row as any).registerConfig ?? undefined,
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -206,6 +217,8 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null
},
create: {
@@ -228,6 +241,8 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null
}
});

View File

@@ -1,69 +1,2 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "registerConfig" JSONB,
ADD COLUMN "registerEnabled" BOOLEAN;
-- CreateTable
CREATE TABLE "RegisterForm" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"reviewChannelId" TEXT,
"notifyRoleIds" TEXT[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisterForm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterFormField" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"type" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "RegisterFormField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterApplication" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisterApplication_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterApplicationAnswer" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "RegisterApplicationAnswer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RegisterForm_guildId_isActive_idx" ON "RegisterForm"("guildId", "isActive");
-- CreateIndex
CREATE INDEX "RegisterApplication_guildId_formId_status_idx" ON "RegisterApplication"("guildId", "formId", "status");
-- AddForeignKey
ALTER TABLE "RegisterFormField" ADD CONSTRAINT "RegisterFormField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "RegisterForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegisterApplication" ADD CONSTRAINT "RegisterApplication_formId_fkey" FOREIGN KEY ("formId") REFERENCES "RegisterForm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegisterApplicationAnswer" ADD CONSTRAINT "RegisterApplicationAnswer_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "RegisterApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Placeholder recreated because this migration was applied in the database already.
-- No schema changes required locally; keeps migration history aligned.

View File

@@ -0,0 +1,4 @@
-- Add server stats module configuration
ALTER TABLE "GuildSettings"
ADD COLUMN "serverStatsEnabled" BOOLEAN,
ADD COLUMN "serverStatsConfig" JSONB;

View File

@@ -28,6 +28,8 @@ model GuildSettings {
eventsEnabled Boolean?
registerEnabled Boolean?
registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@ -185,7 +187,7 @@ model RegisterFormField {
label String
type String
required Boolean @default(false)
sortOrder Int @default(0)
order Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

View File

@@ -7,6 +7,7 @@ const event: EventHandler = {
execute(channel: GuildChannel) {
if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel erstellt: ${channel.name} (${channel.id})`);
context.stats.refreshGuild(channel.guild.id).catch(() => undefined);
}
};

View File

@@ -7,6 +7,7 @@ const event: EventHandler = {
execute(channel: GuildChannel) {
if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel gelöscht: ${channel.name} (${channel.id})`);
context.stats.refreshGuild(channel.guild.id).catch(() => undefined);
}
};

View File

@@ -5,7 +5,7 @@ import { context } from '../config/context';
const event: EventHandler = {
name: 'guildBanAdd',
execute(ban: GuildBan) {
context.logging.logAction(ban.user, 'Ban');
context.logging.logAction(ban.user, 'Ban', undefined, ban.guild);
}
};

View File

@@ -16,8 +16,11 @@ const event: EventHandler = {
const embed = new EmbedBuilder()
.setTitle(welcomeCfg.embedTitle || 'Willkommen!')
.setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`)
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal)
.setFooter({ text: welcomeCfg.embedFooter || '' });
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal);
const footerText = (welcomeCfg.embedFooter || '').trim();
if (footerText) {
embed.setFooter({ text: footerText });
}
if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) {
const [meta, b64] = welcomeCfg.embedThumbnailData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png';
@@ -47,6 +50,7 @@ const event: EventHandler = {
}
}
context.logging.logMemberJoin(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
}
};

View File

@@ -6,6 +6,7 @@ const event: EventHandler = {
name: 'guildMemberRemove',
execute(member: GuildMember) {
context.logging.logMemberLeave(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
}
};

View File

@@ -8,7 +8,7 @@ const event: EventHandler = {
async execute(message: Message) {
const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined;
if (message.guildId) context.admin.trackEvent('message', message.guildId);
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig);
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg);
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
// Ticket SLA + KB
await context.tickets.trackFirstResponse(message);

View File

@@ -38,6 +38,10 @@ const event: EventHandler = {
for (const gid of settingsStore.all().keys()) {
context.reactionRoles.resyncGuild(gid).catch((err) => logger.warn(`reaction roles sync failed for ${gid}: ${err}`));
}
context.stats.startScheduler();
for (const [gid] of client.guilds.cache) {
context.stats.refreshGuild(gid).catch((err) => logger.warn(`stats refresh failed for ${gid}: ${err}`));
}
} catch (err) {
logger.warn(`Ready handler failed: ${err}`);
}

View File

@@ -35,6 +35,7 @@ async function bootstrap() {
context.events.setClient(client);
context.events.startScheduler();
context.register.setClient(client);
context.stats.setClient(client);
await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry));
for (const gid of settingsStore.all().keys()) {

View File

@@ -1,5 +1,7 @@
import { Collection, Message, PermissionFlagsBits } from 'discord.js';
import { Collection, Message } from 'discord.js';
import { logger } from '../utils/logger';
import { GuildSettings } from '../config/state';
import { LoggingService } from './loggingService';
export interface AutomodConfig {
spamThreshold?: number;
@@ -37,11 +39,13 @@ export class AutoModService {
};
private defaultBadwords = ['badword', 'spamword'];
constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {}
constructor(private logging?: LoggingService, private linkFilterEnabled = true, private antiSpamEnabled = true) {}
public async checkMessage(message: Message, cfg?: AutomodConfig) {
if (message.author.bot) return;
const config = { ...this.defaults, ...(cfg ?? {}) };
public async checkMessage(message: Message, cfg?: AutomodConfig | GuildSettings) {
if (message.author.bot || message.webhookId) return;
if (!message.inGuild()) return;
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
const config = { ...this.defaults, ...(guildConfig ?? {}) };
const member = message.member;
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) {
@@ -50,23 +54,16 @@ export class AutoModService {
}
if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, Links sind hier nicht erlaubt.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
logger.info(`Deleted link from ${message.author.tag}`);
await this.logAutomodAction(message, config, 'link_filter');
await this.logAutomodAction(message, config, 'link_filter', reason);
return true;
}
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false;
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte auf deine Wortwahl achten.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'badword', message.content);
await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
return true;
}
@@ -74,11 +71,9 @@ export class AutoModService {
const letters = message.content.replace(/[^a-zA-Z]/g, '');
const upper = letters.replace(/[^A-Z]/g, '');
if (letters.length >= 10 && upper.length / letters.length > 0.7) {
message.delete().catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte weniger Capslock nutzen.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'capslock', message.content);
await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`);
const ratio = Math.round((upper.length / letters.length) * 100);
await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content);
return true;
}
}
@@ -98,12 +93,11 @@ export class AutoModService {
if (tracker.count >= threshold) {
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
message.channel
.send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`);
logger.warn(`Timed out ${message.author.tag} for spam`);
this.spamTracker.delete(message.author.id);
await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`);
const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`;
await this.logAutomodAction(message, config, 'spam', reason);
return true;
}
}
@@ -111,24 +105,52 @@ export class AutoModService {
}
private containsBadword(content: string, custom: string[] = []) {
const combined = [...this.defaultBadwords, ...(custom || [])].filter(Boolean).map((w) => w.toLowerCase());
const combined = [...this.defaultBadwords, ...(custom || [])]
.map((w) => w?.toString().trim().toLowerCase())
.filter(Boolean);
if (!combined.length) return false;
const lower = content.toLowerCase();
return combined.some((w) => lower.includes(w));
return combined.some((w) => {
// Try to match word boundaries first, fall back to substring to remain permissive
const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
return regex.test(lower) || lower.includes(w);
});
}
private containsLink(content: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean);
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+)/i.exec(content);
// Match common link formats, even without protocol
const match = /(https?:\/\/[^\s]+|discord\.gg\/[^\s]+|www\.[^\s]+|[a-z0-9.-]+\.[a-z]{2,}\/?[^\s]*)/i.exec(content);
if (!match) return false;
const url = match[0].toLowerCase();
return !normalized.some((w) => url.includes(w));
}
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) {
private async deleteMessageWithReason(message: Message, response: string) {
await message.delete().catch(() => undefined);
await message.channel
.send({ content: response })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000))
.catch(() => undefined);
}
private async logAutomodAction(message: Message, config: AutomodConfig, action: string, reason: string, content?: string) {
try {
const guild = message.guild;
if (!guild) return;
if (this.logging) {
this.logging.logAutomodAction(guild, {
userTag: message.author.tag,
userId: message.author.id,
action,
reason,
content,
channel: guild.channels.cache.get(message.channelId) ?? null,
messageUrl: message.url
});
return;
}
const loggingCfg = config.loggingConfig || {};
const flags = loggingCfg.categories || {};
if (flags.automodActions === false) return;
@@ -136,8 +158,8 @@ export class AutoModService {
if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`;
await channel.send({ content });
const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`;
await channel.send({ content: body });
} catch (err) {
logger.error('Automod log failed', err);
}

View File

@@ -45,7 +45,7 @@ export class LoggingService {
private resolve(guild: Guild) {
const cfg = settingsStore.get(guild.id);
const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {};
const logChannelId = loggingCfg.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId;
const logChannelId = loggingCfg.logChannelId || cfg?.automodConfig?.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId;
const flags = loggingCfg.categories || {};
const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null;
return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags };
@@ -128,11 +128,11 @@ export class LoggingService {
});
}
logAction(user: User, action: string, reason?: string) {
const guild = user instanceof GuildMember ? user.guild : null;
if (!guild) return;
if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(guild);
logAction(user: User | GuildMember, action: string, reason?: string, guild?: Guild) {
const resolvedGuild = guild ?? (user instanceof GuildMember ? user.guild : null);
if (!resolvedGuild) return;
if (!this.shouldLog(resolvedGuild, 'automodActions')) return;
const { channel } = this.resolve(resolvedGuild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Moderation')
@@ -141,7 +141,7 @@ export class LoggingService {
.setColor(0x7289da)
.setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err));
const guildId = (user as GuildMember)?.guild?.id;
const guildId = resolvedGuild.id;
if (guildId) {
adminSink?.pushGuildLog({
guildId,
@@ -154,6 +154,36 @@ export class LoggingService {
}
}
logAutomodAction(guild: Guild, options: { userTag: string; userId: string; action: string; reason: string; content?: string; channel?: GuildChannel | null; messageUrl?: string }) {
if (!this.shouldLog(guild, 'automodActions')) return;
const { channel } = this.resolve(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Automod')
.setDescription(`${options.userTag} (${options.userId}) -> ${options.action}`)
.addFields(
{ name: 'Grund', value: this.safeField(options.reason) },
{ name: 'Kanal', value: options.channel ? `<#${options.channel.id}>` : 'Unbekannt' }
)
.setColor(0xff006e)
.setTimestamp();
if (options.content) {
embed.addFields({ name: 'Nachricht', value: this.safeField(options.content) });
}
if (options.messageUrl) {
embed.addFields({ name: 'Link', value: options.messageUrl });
}
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err));
adminSink?.pushGuildLog({
guildId: guild.id,
level: 'INFO',
message: `Automod: ${options.action} (${options.userTag})`,
timestamp: Date.now(),
category: 'automodActions'
});
adminSink?.trackGuildEvent(guild.id, 'automod');
}
logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
const guildId = member.guild.id;
adminSink?.pushGuildLog({

View File

@@ -11,7 +11,8 @@ export type ModuleKey =
| 'birthdayEnabled'
| 'reactionRolesEnabled'
| 'eventsEnabled'
| 'registerEnabled';
| 'registerEnabled'
| 'serverStatsEnabled';
export interface GuildModuleState {
key: ModuleKey;
@@ -31,7 +32,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' },
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' },
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' }
registerEnabled: { name: 'Register', description: 'Registrierungsformulare und Bewerbungen.' },
serverStatsEnabled: { name: 'Server Stats', description: 'Zeigt Member-/Channel-Zahlen als Voice-Statistiken an.' }
};
export class BotModuleService {
@@ -53,6 +55,7 @@ export class BotModuleService {
if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true;
if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true;
if (key === 'eventsEnabled') enabled = (cfg as any).eventsEnabled !== false;
if (key === 'serverStatsEnabled') enabled = (cfg as any).serverStatsEnabled === true || (cfg as any).serverStatsConfig?.enabled === true;
return {
key: key as ModuleKey,
name: meta.name,

View File

@@ -1,6 +1,6 @@
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, StreamType, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
import { Readable } from 'stream';
import play from 'play-dl';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
@@ -8,8 +8,7 @@ export type LoopMode = 'off' | 'song' | 'queue';
interface QueueItem {
title: string;
streamUrl: string;
displayUrl?: string;
url: string;
requester: string;
originalQuery?: string;
}
@@ -25,7 +24,6 @@ interface QueueState {
export class MusicService {
private queues = new Map<string, QueueState>();
private spotifyToken: { value: string; expiresAt: number } | null = null;
private getQueue(guildId: string) {
const cfg = settingsStore.get(guildId);
@@ -84,13 +82,7 @@ export class MusicService {
await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true });
return;
}
const queueItem: QueueItem = {
title: track.title ?? 'Unbekannt',
streamUrl: track.url,
displayUrl: track.url,
requester: interaction.user.tag,
originalQuery: trimmedQuery
};
const queueItem: QueueItem = { title: track.title ?? 'Unbekannt', url: track.url, requester: interaction.user.tag, originalQuery: trimmedQuery };
const queue = this.getQueue(interaction.guildId);
if (!queue) {
const player = createAudioPlayer();
@@ -168,7 +160,7 @@ export class MusicService {
next = queue.current;
}
if (!next) break;
const streamUrlCheck = typeof next.streamUrl === 'string' ? next.streamUrl.trim() : '';
const streamUrlCheck = typeof next.url === 'string' ? next.url.trim() : '';
if (streamUrlCheck && streamUrlCheck !== 'undefined' && /^https?:\/\//i.test(streamUrlCheck)) {
break;
}
@@ -180,16 +172,24 @@ export class MusicService {
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
return;
}
const streamUrl = (next.streamUrl || '').trim();
const streamUrl = (next.url || '').trim();
queue.current = next;
try {
if (!/^https?:\/\//i.test(streamUrl)) throw new Error('spotify_stream_url_invalid');
const res = await fetch(streamUrl);
if (!res.ok || !res.body) throw new Error('spotify_stream_fetch_failed');
const body: any = typeof (res as any).body?.getReader === 'function' ? Readable.fromWeb(res.body as any) : (res as any).body;
const resource: AudioResource = createAudioResource(body, {
inputType: StreamType.Arbitrary
const kind = await play.validate(streamUrl);
if (kind !== 'so_track') {
logger.error('Music stream error', { reason: 'unsupported_url', kind, item: next });
queue.channel.send({ content: `Nur SoundCloud wird unterstuetzt, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
queue.current = undefined;
this.processQueue(guildId);
return;
}
const finalUrl = streamUrl;
if (!finalUrl || !/^https?:\/\//i.test(finalUrl) || finalUrl === 'undefined') throw new Error('soundcloud_url_invalid');
const stream = await play.stream(finalUrl);
if (!stream?.stream) throw new Error('stream_invalid');
const resource: AudioResource = createAudioResource(stream.stream, {
inputType: stream.type
});
queue.player.play(resource);
queue.connection.subscribe(queue.player);
@@ -234,79 +234,39 @@ export class MusicService {
private async resolveTrack(query: string, opts?: { skipPlaylist?: boolean }): Promise<{ title: string; url: string } | null> {
const trimmed = query.trim();
if (!trimmed) return null;
const token = await this.getSpotifyToken();
const trackId = this.extractSpotifyTrackId(trimmed);
if (trackId) {
const track = await this.fetchSpotifyTrack(trackId, token);
if (track) return track;
try {
let validation: string | null = null;
try {
validation = await play.validate(trimmed);
} catch (err) {
logger.warn('Music validate error', err);
}
const search = await this.searchSpotifyTrack(trimmed, token);
return search;
if (validation === 'so_track') {
return { title: trimmed, url: trimmed };
}
// nur SoundCloud erlaubt, alles andere ignorieren
} catch (err) {
logger.error('Music resolve error', err);
}
private async getSpotifyToken(): Promise<string> {
if (this.spotifyToken && this.spotifyToken.expiresAt > Date.now() + 30000) {
return this.spotifyToken.value;
}
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error('missing_spotify_credentials');
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
const scSearch = await play.search(trimmed, { source: { soundcloud: 'tracks' }, limit: 1 }).catch((err) => {
logger.warn('SoundCloud search skipped', err?.message || err);
return [];
});
if (!res.ok) throw new Error('spotify_auth_failed');
const data = (await res.json()) as { access_token: string; expires_in: number };
const expiresInMs = Math.max(30_000, (data.expires_in || 3600) * 1000);
this.spotifyToken = { value: data.access_token, expiresAt: Date.now() + expiresInMs };
return data.access_token;
if (scSearch && scSearch.length) {
const sc = scSearch[0];
const url = sc.url || '';
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url };
}
private extractSpotifyTrackId(query: string) {
const urlMatch = query.match(/spotify\.com\/track\/([A-Za-z0-9]+)/i);
if (urlMatch?.[1]) return urlMatch[1];
const uriMatch = query.match(/spotify:track:([A-Za-z0-9]+)/i);
if (uriMatch?.[1]) return uriMatch[1];
return null;
}
private async fetchSpotifyTrack(id: string, token: string): Promise<{ title: string; url: string } | null> {
const res = await fetch(`https://api.spotify.com/v1/tracks/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
logger.warn('Spotify track fetch failed', { status: res.status });
private buildVideoUrl(details: any): string | null {
if (!details) return null;
const url = details.url || details.permalink;
if (typeof url === 'string' && /^https?:\/\//i.test(url)) return url;
if (details.id) return `https://www.youtube.com/watch?v=${details.id}`;
if (details.videoId) return `https://www.youtube.com/watch?v=${details.videoId}`;
return null;
}
const data: any = await res.json();
if (!data?.preview_url) {
logger.warn('Spotify track has no preview_url', { id });
return null;
}
const artists = Array.isArray(data.artists) ? data.artists.map((a: any) => a.name).filter(Boolean).join(', ') : '';
const title = [data.name, artists].filter(Boolean).join(' - ');
return { title: title || data.name || 'Track', url: data.preview_url };
}
private async searchSpotifyTrack(query: string, token: string): Promise<{ title: string; url: string } | null> {
const market = process.env.SPOTIFY_MARKET || 'DE';
const res = await fetch(`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=1&market=${market}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
logger.warn('Spotify search failed', { status: res.status });
return null;
}
const data: any = await res.json();
const track = data?.tracks?.items?.[0];
if (!track || !track.preview_url) return null;
const artists = Array.isArray(track.artists) ? track.artists.map((a: any) => a.name).filter(Boolean).join(', ') : '';
const title = [track.name, artists].filter(Boolean).join(' - ');
return { title: title || track.name || 'Track', url: track.preview_url };
}
}

View File

@@ -23,7 +23,7 @@ export class RegisterService {
}
public async listForms(guildId: string) {
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { sortOrder: 'asc' } } }, orderBy: { createdAt: 'desc' } });
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { order: 'asc' } } }, orderBy: { createdAt: 'desc' } });
}
public async saveForm(form: {
@@ -55,10 +55,10 @@ export class RegisterService {
label: f.label,
type: f.type,
required: f.required ?? false,
sortOrder: f.order ?? idx
order: f.order ?? idx
}))
});
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { order: 'asc' } } } });
}
const created = await prisma.registerForm.create({
data: {
@@ -73,11 +73,11 @@ export class RegisterService {
label: f.label,
type: f.type,
required: f.required ?? false,
sortOrder: f.order ?? idx
order: f.order ?? idx
}))
}
},
include: { fields: { orderBy: { sortOrder: 'asc' } } }
include: { fields: { orderBy: { order: 'asc' } } }
});
return created;
}
@@ -113,7 +113,7 @@ export class RegisterService {
public async handleButton(interaction: ButtonInteraction) {
if (interaction.customId.startsWith('register:form:')) {
const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { order: 'asc' } } } });
if (!form) return interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`);
const components: any[] = [];
@@ -164,7 +164,7 @@ export class RegisterService {
const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({
where: { id: formId },
include: { fields: { orderBy: { sortOrder: 'asc' } } }
include: { fields: { orderBy: { order: 'asc' } } }
});
if (!form) {
await interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
@@ -200,7 +200,7 @@ export class RegisterService {
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const member = await guild.members.fetch(userId).catch(() => null);
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { sortOrder: 'asc' } });
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { order: 'asc' } });
const answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } });
const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`)
@@ -254,5 +254,3 @@ export class RegisterService {
});
}
}

View File

@@ -0,0 +1,271 @@
import { randomUUID } from 'crypto';
import { CategoryChannel, ChannelType, Client, Guild, PermissionFlagsBits } from 'discord.js';
import { settingsStore } from '../config/state';
import { logger } from '../utils/logger';
export type StatCounterType =
| 'members_total'
| 'members_humans'
| 'members_bots'
| 'boosts'
| 'text_channels'
| 'voice_channels'
| 'roles';
export interface StatCounter {
id: string;
type: StatCounterType;
label?: string;
format?: string;
channelId?: string;
}
export interface ServerStatsConfig {
enabled: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes: number;
cleanupOrphans?: boolean;
items: StatCounter[];
}
const STAT_META: Record<
StatCounterType,
{
label: string;
defaultFormat: string;
}
> = {
members_total: { label: 'Mitglieder', defaultFormat: '{label}: {value}' },
members_humans: { label: 'Menschen', defaultFormat: '{label}: {value}' },
members_bots: { label: 'Bots', defaultFormat: '{label}: {value}' },
boosts: { label: 'Boosts', defaultFormat: '{label}: {value}' },
text_channels: { label: 'Text Channels', defaultFormat: '{label}: {value}' },
voice_channels: { label: 'Voice Channels', defaultFormat: '{label}: {value}' },
roles: { label: 'Rollen', defaultFormat: '{label}: {value}' }
};
export class StatsService {
private client: Client | null = null;
private interval?: NodeJS.Timeout;
private lastRun = new Map<string, number>();
private syncLocks = new Map<string, Promise<void>>();
public setClient(client: Client) {
this.client = client;
}
public stop() {
if (this.interval) clearInterval(this.interval);
this.interval = undefined;
}
public startScheduler() {
this.stop();
this.interval = setInterval(() => this.tick(), 60 * 1000);
}
public async getConfig(guildId: string): Promise<ServerStatsConfig> {
const cfg = settingsStore.get(guildId);
const statsCfg = (cfg as any)?.serverStatsConfig || {};
const enabled = (cfg as any)?.serverStatsEnabled ?? statsCfg.enabled ?? false;
return this.normalizeConfig({ ...statsCfg, enabled });
}
public async saveConfig(guildId: string, config: Partial<ServerStatsConfig>) {
return this.withGuildLock(guildId, async () => {
const previous = await this.getConfig(guildId);
const normalized = this.normalizeConfig({ ...previous, ...config });
const synced = await this.syncGuild(guildId, normalized, previous);
await settingsStore.set(guildId, { serverStatsEnabled: synced.enabled, serverStatsConfig: synced } as any);
this.lastRun.set(guildId, Date.now());
return synced;
});
}
public async refreshGuild(guildId: string) {
return this.withGuildLock(guildId, async () => {
const cfg = await this.getConfig(guildId);
if (!cfg.enabled) return cfg;
const synced = await this.syncGuild(guildId, cfg, cfg);
await settingsStore.set(guildId, { serverStatsEnabled: synced.enabled, serverStatsConfig: synced } as any);
this.lastRun.set(guildId, Date.now());
return synced;
});
}
public async disableGuild(guildId: string) {
await settingsStore.set(guildId, { serverStatsEnabled: false } as any);
}
private normalizeConfig(config: Partial<ServerStatsConfig>): ServerStatsConfig {
const fallbackItems = Array.isArray(config.items) ? config.items : [];
const items = fallbackItems
.filter((i) => i && (i as any).type && STAT_META[(i as any).type as StatCounterType])
.slice(0, 8)
.map((i) => {
const type = (i as any).type as StatCounterType;
const meta = STAT_META[type];
return {
id: i.id || randomUUID(),
type,
label: i.label || meta.label,
format: i.format || meta.defaultFormat,
channelId: i.channelId
} as StatCounter;
});
const refreshMinutes = Number.isFinite(config.refreshMinutes) ? Number(config.refreshMinutes) : 10;
return {
enabled: !!config.enabled,
categoryId: config.categoryId || undefined,
categoryName: config.categoryName || '📊 Server Stats',
refreshMinutes: Math.max(1, Math.min(180, refreshMinutes)),
cleanupOrphans: config.cleanupOrphans ?? false,
items: items.length ? items : [this.defaultItem()]
};
}
private defaultItem(): StatCounter {
return {
id: randomUUID(),
type: 'members_total',
label: STAT_META['members_total'].label,
format: STAT_META['members_total'].defaultFormat
};
}
private formatName(item: StatCounter, value: number) {
const meta = STAT_META[item.type];
const label = item.label || meta.label;
const base = (item.format || meta.defaultFormat || '{label}: {value}')
.replace('{label}', label)
.replace('{value}', value.toLocaleString('de-DE'));
return base.slice(0, 96);
}
private async syncGuild(guildId: string, cfg: ServerStatsConfig, previous?: ServerStatsConfig): Promise<ServerStatsConfig> {
if (!this.client) return cfg;
const guild = await this.client.guilds.fetch(guildId).catch(() => null);
if (!guild) return cfg;
const category = await this.ensureCategory(guild, cfg);
const managedIds = new Set<string>();
for (const item of cfg.items) {
const value = this.computeValue(guild, item.type);
const desiredName = this.formatName(item, value);
let channel =
(item.channelId && (await guild.channels.fetch(item.channelId).catch(() => null))) ||
null;
if (!channel) {
channel = await guild.channels
.create({
name: desiredName,
type: ChannelType.GuildVoice,
parent: category?.id,
permissionOverwrites: [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }],
userLimit: 0,
bitrate: 8000
})
.catch((err) => {
logger.warn(`Failed to create stats channel in ${guild.id}: ${err?.message || err}`);
return null;
});
if (channel) item.channelId = channel.id;
} else if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) {
const needsParent = category && channel.parentId !== category.id;
const overwritesMissing = !channel.permissionOverwrites.cache.some(
(ow) => ow.id === guild.roles.everyone.id && ow.deny.has(PermissionFlagsBits.Connect)
);
const editData: any = { name: desiredName };
if (needsParent) editData.parent = category.id;
if (overwritesMissing) editData.permissionOverwrites = [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }];
if ((channel as any).userLimit !== 0) editData.userLimit = 0;
await channel.edit(editData).catch(() => undefined);
}
if (channel?.id) managedIds.add(channel.id);
}
if (cfg.cleanupOrphans && previous?.items) {
for (const old of previous.items) {
if (old.channelId && !managedIds.has(old.channelId)) {
const ch = await guild.channels.fetch(old.channelId).catch(() => null);
if (ch && ch.parentId === category?.id) {
await ch.delete('Papo Server Stats entfernt').catch(() => undefined);
}
}
}
}
return { ...cfg, categoryId: category?.id };
}
private async ensureCategory(guild: Guild, cfg: ServerStatsConfig) {
if (cfg.categoryId) {
const existing = await guild.channels.fetch(cfg.categoryId).catch(() => null);
if (existing && existing.type === ChannelType.GuildCategory) return existing as CategoryChannel;
}
const name = cfg.categoryName || '📊 Server Stats';
const found = guild.channels.cache.find(
(c) => c.type === ChannelType.GuildCategory && c.name.toLowerCase() === name.toLowerCase()
) as CategoryChannel | undefined;
if (found) return found;
const created = await guild.channels
.create({
name,
type: ChannelType.GuildCategory,
permissionOverwrites: [{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.Connect] }]
})
.catch(() => null);
return created as CategoryChannel | null;
}
private computeValue(guild: Guild, type: StatCounterType): number {
switch (type) {
case 'members_total':
return guild.memberCount ?? 0;
case 'members_humans': {
const humans = guild.members.cache.filter((m) => !m.user.bot).size;
return humans || Math.max(0, (guild.memberCount ?? 0) - guild.members.cache.filter((m) => m.user.bot).size);
}
case 'members_bots':
return guild.members.cache.filter((m) => m.user.bot).size;
case 'boosts':
return guild.premiumSubscriptionCount ?? 0;
case 'text_channels':
return guild.channels.cache.filter((c) => c.isTextBased() && c.type !== ChannelType.GuildVoice && c.type !== ChannelType.GuildStageVoice).size;
case 'voice_channels':
return guild.channels.cache.filter((c) => c.isVoiceBased()).size;
case 'roles':
return guild.roles.cache.size;
default:
return guild.memberCount ?? 0;
}
}
private async tick() {
const now = Date.now();
for (const guildId of settingsStore.all().keys()) {
const cfg = await this.getConfig(guildId);
if (!cfg.enabled) continue;
const last = this.lastRun.get(guildId) || 0;
const intervalMs = Math.max(1, cfg.refreshMinutes) * 60 * 1000;
if (now - last < intervalMs) continue;
await this.refreshGuild(guildId).catch(() => undefined);
}
}
private async withGuildLock<T>(guildId: string, task: () => Promise<T>): Promise<T> {
const waitFor = this.syncLocks.get(guildId) || Promise.resolve();
const run = (async () => {
await waitFor.catch(() => undefined);
return task();
})();
this.syncLocks.set(guildId, run.then(() => undefined, () => undefined));
try {
return await run;
} finally {
const current = this.syncLocks.get(guildId);
if (current === run) this.syncLocks.delete(guildId);
}
}
}

View File

@@ -85,7 +85,8 @@ router.get('/guild/info', requireAuth, async (req, res) => {
dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
statuspageEnabled: (modules as any).statuspageEnabled !== false,
birthdayEnabled: (modules as any).birthdayEnabled !== false,
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false,
serverStatsEnabled: (modules as any).serverStatsEnabled === true || (modules as any).serverStatsConfig?.enabled === true
}
}
});
@@ -754,6 +755,27 @@ router.delete('/statuspage/service/:id', requireAuth, async (req, res) => {
res.json({ ok: true });
});
router.get('/server-stats', requireAuth, async (req, res) => {
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const cfg = await context.stats.getConfig(guildId);
res.json({ config: cfg });
});
router.post('/server-stats', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const cfg = await context.stats.saveConfig(guildId, req.body.config || {});
res.json({ config: cfg });
});
router.post('/server-stats/refresh', requireAuth, async (req, res) => {
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
await context.stats.refreshGuild(guildId);
res.json({ ok: true });
});
router.post('/settings', requireAuth, async (req, res) => {
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
const {
@@ -779,7 +801,9 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled,
reactionRolesConfig,
registerEnabled,
registerConfig
registerConfig,
serverStatsEnabled,
serverStatsConfig
} = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) =>
@@ -913,7 +937,9 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled: parsedReactionRoles.enabled,
reactionRolesConfig: parsedReactionRoles,
registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister
registerConfig: parsedRegister,
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
});
// Live update logging target
context.logging = new LoggingService(updated.logChannelId);

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
const router = Router();
@@ -43,21 +43,21 @@ router.get('/', (req, res) => {
<aside class="sidebar">
<div class="brand">Papo Control</div>
<div class="nav">
<a class="active" href="#overview" data-target="overview"><span class="icon">-</span> Uebersicht</a>
<a href="#tickets" data-target="tickets"><span class="icon">-</span> Ticketsystem</a>
<a href="#automod" data-target="automod" class="automod-link"><span class="icon">-</span> Automod</a>
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon">-</span> Willkommen</a>
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">-</span> Dynamic Voice</a>
<a href="#birthday" data-target="birthday" class="birthday-link"><span class="icon">-</span> Birthday</a>
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">-</span> Reaction Roles</a>
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">-</span> Statuspage</a>
<a href="#settings" data-target="settings"><span class="icon">-</span> Einstellungen</a>
<a href="#modules" data-target="modules"><span class="icon">-</span> Module</a>
<a href="#register" data-target="register" class="register-link"><span class="icon">-</span> Register</a>
<a href="#events" data-target="events" class="events-link"><span class="icon">-</span> Events</a>
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">-</span> Admin</a>
<a class="active" href="#overview" data-target="overview"><span class="icon">🏠</span> Uebersicht</a>
<a href="#tickets" data-target="tickets"><span class="icon">🎫</span> Ticketsystem</a>
<a href="#automod" data-target="automod" class="automod-link"><span class="icon">🛡️</span> Automod</a>
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon">👋</span> Willkommen</a>
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">🎙️</span> Dynamic Voice</a>
<a href="#birthday" data-target="birthday" class="birthday-link"><span class="icon">🎂</span> Birthday</a>
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">🎭</span> Reaction Roles</a>
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">📡</span> Statuspage</a>
<a href="#serverstats" data-target="serverstats" class="serverstats-link"><span class="icon">📈</span> Server Stats</a>
<a href="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a>
<a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</a>
<a href="#events" data-target="events" class="events-link"><span class="icon">📅</span> Events</a>
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛠️</span> Admin</a>
</div>
<div class="muted"> Angemeldet als <span id="userInfo"></span></div>
<div class="muted">Angemeldet als <span id="userInfo"></span></div>
<button id="logoutBtn" class="logout">Logout</button>
</aside>
`;
@@ -215,11 +215,6 @@ router.get('/', (req, res) => {
.module-meta { display:flex; flex-direction:column; gap:4px; }
.module-title { font-weight:700; color:var(--text); }
.module-desc { color:var(--muted); font-size:13px; }
.register-tabs { display:flex; gap:8px; flex-wrap:wrap; margin-top:6px; }
.register-tab { display:none; }
.register-tab.active { display:block; }
.register-meta { display:flex; gap:8px; flex-wrap:wrap; color:var(--muted); font-size:12px; }
.register-answer { padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.04); }
.toast { position:fixed; top:20px; right:24px; padding:12px 14px; border-radius:12px; background:rgba(249,115,22,0.18); border:1px solid rgba(249,115,22,0.45); color:#ffe6d0; font-weight:700; box-shadow:0 12px 32px rgba(0,0,0,0.34); opacity:0; transform:translateY(-10px); transition:opacity 150ms ease, transform 150ms ease; z-index:1100; backdrop-filter:blur(10px); }
.toast.error { background:rgba(239,68,68,0.18); border-color:rgba(239,68,68,0.4); color:#ffe4e6; }
.toast.show { opacity:1; transform:translateY(0); }
@@ -336,10 +331,10 @@ router.get('/', (req, res) => {
<div class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
<div>
<p class="section-title">Tickets</p>
<p class="section-sub"> bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
<p class="section-sub">bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap;">
<button class="secondary-btn ticket-tab-btn active" data-tab="overview"> bersicht</button>
<button class="secondary-btn ticket-tab-btn active" data-tab="overview">bersicht</button>
<button class="secondary-btn ticket-tab-btn" data-tab="pipeline">Pipeline</button>
<button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</button>
<button class="secondary-btn ticket-tab-btn" data-tab="automations">Automationen</button>
@@ -352,7 +347,7 @@ router.get('/', (req, res) => {
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<p class="section-title">Ticketliste</p>
<p class="section-sub">Links ausw hlen, Details im Modal. Plus ffnet Panel-Erstellung.</p>
<p class="section-sub">Links auswhlen, Details im Modal. Plus ffnet Panel-Erstellung.</p>
</div>
<div class="row" style="gap:10px;">
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button>
@@ -436,7 +431,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Automationen</p>
<p class="section-sub">Regeln f r Ticket-Aktionen.</p>
<p class="section-sub">Regeln fr Ticket-Aktionen.</p>
</div>
<button class="secondary-btn" id="addAutomation">Neue Regel</button>
</div>
@@ -473,7 +468,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Knowledge-Base</p>
<p class="section-sub">Artikel f r Self-Service.</p>
<p class="section-sub">Artikel fr Self-Service.</p>
</div>
<button class="secondary-btn" id="addKb">Neuer Artikel</button>
</div>
@@ -592,7 +587,7 @@ router.get('/', (req, res) => {
</div>
<div class="form-field">
<label class="form-label">Embed Footer</label>
<input id="welcomeFooter" placeholder="Sch n, dass du da bist!" />
<input id="welcomeFooter" placeholder="Schön, dass du da bist!" />
</div>
</div>
<div class="form-field">
@@ -615,7 +610,7 @@ router.get('/', (req, res) => {
<div class="embed-color" id="welcomePreviewColor"></div>
<div class="embed-body">
<div class="embed-title" id="welcomePreviewTitle">Willkommen!</div>
<div class="embed-desc" id="welcomePreviewDesc">Sch n, dass du da bist.</div>
<div class="embed-desc" id="welcomePreviewDesc">Schön, dass du da bist.</div>
<div class="embed-footer" id="welcomePreviewFooter">Footer</div>
<img id="welcomePreviewImage" class="embed-image" style="display:none;" />
</div>
@@ -676,79 +671,6 @@ router.get('/', (req, res) => {
<div id="moduleList" class="module-list"></div>
</section>
</div>
<div class="section" data-section="register">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Register Modul</p>
<p class="section-sub">Formulare verwalten und Antraege einsehen.</p>
</div>
<span class="badge" id="registerStatus">Aktiv</span>
</div>
<div class="register-tabs">
<button class="ticket-tab-btn register-tab-btn active" data-tab="forms">Formulare</button>
<button class="ticket-tab-btn register-tab-btn" data-tab="apps">Registrierungen</button>
</div>
</section>
<div class="register-tab ticket-tab active" data-tab="forms">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Formulare</p>
<p class="section-sub">Formulare anlegen, bearbeiten und Panels senden.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap;">
<button class="secondary-btn" id="registerFormsReload" type="button">Reload</button>
<button class="secondary-btn" id="registerFormNew" type="button">Neues Formular</button>
</div>
</div>
<div id="registerFormList" class="module-list" style="margin-top:12px;"></div>
</section>
<section class="card">
<form id="registerFormForm" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:12px;">
<input type="hidden" id="registerFormId" />
<div class="form-field"><label class="form-label">Name</label><input id="registerFormName" placeholder="Bewerbung" /></div>
<div class="form-field"><label class="form-label">Review Channel ID</label><input id="registerFormChannel" placeholder="123456789012345678" /></div>
<div class="form-field"><label class="form-label">Notify Rollen (kommagetrennt)</label><input id="registerFormRoles" placeholder="123,456" /></div>
<div class="form-field"><label class="form-label">Aktiv</label><div id="registerFormActive" class="switch on"></div></div>
<div class="form-field" style="grid-column:1/-1;"><label class="form-label">Beschreibung</label><textarea id="registerFormDescription" rows="2" placeholder="Kurze Beschreibung"></textarea></div>
<div class="form-field" style="grid-column:1/-1;"><label class="form-label">Felder (Label | Typ | Pflicht)</label><textarea id="registerFormFields" rows="4" placeholder="Name | shortText | true&#10;Begruendung | longText | true"></textarea><p class="muted">Typ: shortText oder longText. Eine Zeile pro Feld.</p></div>
<div class="form-field" style="grid-column:1/-1; display:flex; gap:8px;">
<button type="submit">Speichern</button>
<button type="button" class="secondary-btn" id="registerFormReset">Reset</button>
</div>
</form>
<p class="muted" id="registerFormStatus"></p>
</section>
</div>
<div class="register-tab ticket-tab" data-tab="apps">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Registrierungen</p>
<p class="section-sub">Antraege filtern und einsehen.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap; align-items:center;">
<select id="registerAppsFilter" class="muted" style="min-width:140px;">
<option value="">Alle Status</option>
<option value="pending">Pending</option>
<option value="accepted">Accepted</option>
<option value="invited">Invited</option>
<option value="rejected">Rejected</option>
</select>
<select id="registerAppsFormFilter" class="muted" style="min-width:160px;">
<option value="">Alle Formulare</option>
</select>
<button class="secondary-btn" id="registerAppsReload" type="button">Reload</button>
</div>
</div>
<div id="registerAppsList" class="ticket-list-pane"></div>
</section>
<section class="card">
<div id="registerAppDetail" class="muted">Waehle einen Antrag aus der Liste.</div>
</section>
</div>
</div>
<div class="section" data-section="birthday">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:flex-start; gap:12px;">
@@ -815,7 +737,7 @@ router.get('/', (req, res) => {
</div>
<div class="form-field">
<label class="form-label">Eintraege (Emoji | Role ID | Label | Beschreibung)</label>
<textarea id="reactionRoleEntries" rows="4" placeholder=" | 123456789 | Freunde | Erhaelt Freunde Rolle"></textarea>
<textarea id="reactionRoleEntries" rows="4" placeholder="😀 | 123456789 | Freunde | Erhaelt Freunde Rolle"></textarea>
<p class="muted">Eine Zeile pro Zuordnung. Label/Beschreibung optional.</p>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
@@ -861,6 +783,33 @@ router.get('/', (req, res) => {
<div id="statuspageServices" class="module-list"></div>
</section>
</div>
<div class="section" data-section="serverstats">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
<div>
<p class="section-title">Server Stats</p>
<p class="section-sub">Kategorie und Counter verwalten.</p>
</div>
<div class="row" style="gap:8px; align-items:center;">
<label class="form-label">Kategorie-Name</label>
<input id="statsCategoryName" placeholder="Server Stats" />
<label class="form-label">Refresh (Min)</label>
<input id="statsRefresh" type="number" min="1" step="1" placeholder="10" />
<div id="statsToggle" class="switch"></div>
</div>
</div>
</section>
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center;">
<div>
<p class="section-title">Statistiken</p>
<p class="section-sub">Counter und Format anpassen.</p>
</div>
<button class="icon-button" id="statsAddItem">+</button>
</div>
<div id="statsItems" class="module-list"></div>
</section>
</div>
<div class="section" data-section="events">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center;">
@@ -889,7 +838,7 @@ router.get('/', (req, res) => {
<section class="card">
<div class="row" style="justify-content:space-between;">
<div>
<p class="section-title">Aktivit t (letzte 24h)</p>
<p class="section-title">Aktivität (letzte 24h)</p>
<p class="section-sub">Events/Commands pro Stunde</p>
</div>
</div>
@@ -899,7 +848,7 @@ router.get('/', (req, res) => {
<div class="row" style="justify-content:space-between;">
<div>
<p class="section-title">Logs</p>
<p class="section-sub">Neueste Eintr ge</p>
<p class="section-sub">Neueste Einträge</p>
</div>
</div>
<ul class="log-list" id="adminLogs"></ul>
@@ -926,13 +875,13 @@ router.get('/', (req, res) => {
</div>
</div>
<div class="option-grid">
<div class="option-card"><span> User Join / Leave</span><div id="logJoinLeave" class="switch on"></div></div>
<div class="option-card"><span> Message Edit</span><div id="logMsgEdit" class="switch on"></div></div>
<div class="option-card"><span> Message Delete</span><div id="logMsgDelete" class="switch on"></div></div>
<div class="option-card"><span> Automod Actions</span><div id="logAutomod" class="switch on"></div></div>
<div class="option-card"><span> Ticket Actions</span><div id="logTickets" class="switch on"></div></div>
<div class="option-card"><span> Musik-Events</span><div id="logMusic" class="switch on"></div></div>
<div class="option-card"><span> System / Channels</span><div id="logSystem" class="switch on"></div></div>
<div class="option-card"><span>👋 User Join / Leave</span><div id="logJoinLeave" class="switch on"></div></div>
<div class="option-card"><span>✏️ Message Edit</span><div id="logMsgEdit" class="switch on"></div></div>
<div class="option-card"><span>🗑️ Message Delete</span><div id="logMsgDelete" class="switch on"></div></div>
<div class="option-card"><span>🛡️ Automod Actions</span><div id="logAutomod" class="switch on"></div></div>
<div class="option-card"><span>🎫 Ticket Actions</span><div id="logTickets" class="switch on"></div></div>
<div class="option-card"><span>🎵 Musik-Events</span><div id="logMusic" class="switch on"></div></div>
<div class="option-card"><span>⚙️ System / Channels</span><div id="logSystem" class="switch on"></div></div>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;">
<button id="loggingSave" type="button">Logging speichern</button>
@@ -1282,6 +1231,7 @@ router.get('/', (req, res) => {
let activeModal = null;
let automodConfigCache = {};
let modulesCache = {};
let serverStatsCache = { items: [] };
let dynamicVoiceCache = {};
let isAdmin = false;
let statuspageCache = { services: [] };
@@ -1290,10 +1240,6 @@ router.get('/', (req, res) => {
let editingReactionRole = null;
let supportLoginCache = {};
let eventsCache = [];
let registerFormsCache = [];
let registerAppsCache = [];
let registerSelectedApp = null;
let registerConfigCache = {};
function activateSection(key) {
sections.forEach((s) => s.classList.toggle('active', s.dataset.section === key));
@@ -1323,27 +1269,23 @@ router.get('/', (req, res) => {
const welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true;
const dynamicVoiceEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'dynamicVoiceEnabled') ? modulesCache['dynamicVoiceEnabled'] : true;
const statuspageEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'statuspageEnabled') ? modulesCache['statuspageEnabled'] : true;
const serverStatsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'serverStatsEnabled') ? modulesCache['serverStatsEnabled'] : false;
const birthdayEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'birthdayEnabled') ? modulesCache['birthdayEnabled'] : true;
const reactionRolesEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'reactionRolesEnabled') ? modulesCache['reactionRolesEnabled'] : true;
const eventsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'eventsEnabled') ? modulesCache['eventsEnabled'] : true;
const registerEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'registerEnabled') ? modulesCache['registerEnabled'] : true;
if (automodNav) automodNav.classList.toggle('hidden', !autoEnabled);
if (welcomeNav) welcomeNav.classList.toggle('hidden', !welcomeEnabled);
if (dynamicVoiceNav) dynamicVoiceNav.classList.toggle('hidden', !dynamicVoiceEnabled);
const statuspageNav = document.querySelector('.nav .statuspage-link');
if (statuspageNav) statuspageNav.classList.toggle('hidden', !statuspageEnabled);
const serverstatsNav = document.querySelector('.nav .serverstats-link');
if (serverstatsNav) serverstatsNav.classList.toggle('hidden', !serverStatsEnabled);
const birthdayNav = document.querySelector('.nav .birthday-link');
if (birthdayNav) birthdayNav.classList.toggle('hidden', !birthdayEnabled);
const reactionRolesNav = document.querySelector('.nav .reactionroles-link');
if (reactionRolesNav) reactionRolesNav.classList.toggle('hidden', !reactionRolesEnabled);
const eventsNav = document.querySelector('.nav .events-link');
if (eventsNav) eventsNav.classList.toggle('hidden', !eventsEnabled);
if (registerNav) registerNav.classList.toggle('hidden', !registerEnabled);
if (registerSection) registerSection.classList.toggle('hidden', !registerEnabled);
if (registerStatus) {
registerStatus.textContent = registerEnabled ? 'Aktiv' : 'Deaktiviert';
registerStatus.className = 'badge' + (registerEnabled ? ' active' : '');
}
const adminNav = document.querySelector('.nav .admin-link');
if (adminNav) adminNav.classList.toggle('hidden', !isAdmin);
const current = location.hash.replace('#','') || 'overview';
@@ -1352,10 +1294,10 @@ router.get('/', (req, res) => {
(current === 'welcome' && !welcomeEnabled) ||
(current === 'dynamicvoice' && !dynamicVoiceEnabled) ||
(current === 'statuspage' && !statuspageEnabled) ||
(current === 'serverstats' && !serverStatsEnabled) ||
(current === 'birthday' && !birthdayEnabled) ||
(current === 'reactionroles' && !reactionRolesEnabled) ||
(current === 'events' && !eventsEnabled) ||
(current === 'register' && !registerEnabled) ||
(current === 'admin' && !isAdmin)
) {
activateSection('overview');
@@ -1463,6 +1405,103 @@ router.get('/', (req, res) => {
if (!logs.length) guildLogs.innerHTML = '<li class="muted">Keine Logs</li>';
}
const STAT_LABELS = {
members_total: 'Mitglieder (gesamt)',
members_humans: 'Mitglieder (ohne Bots)',
members_bots: 'Bots',
boosts: 'Server Boosts',
text_channels: 'Text Channels',
voice_channels: 'Voice Channels',
roles: 'Rollen'
};
async function loadServerStats() {
if (!currentGuild) return;
const res = await fetch('/api/server-stats?guildId=' + encodeURIComponent(currentGuild));
if (!res.ok) return;
const data = await res.json();
serverStatsCache = data.config || { items: [] };
setSwitch(statsToggle, serverStatsCache.enabled !== false);
if (statsCategoryName) statsCategoryName.value = serverStatsCache.categoryName || 'Server Stats';
if (statsRefresh) statsRefresh.value = serverStatsCache.refreshMinutes ?? 10;
renderServerStats();
}
function renderServerStats() {
if (!statsItems) return;
statsItems.innerHTML = '';
(serverStatsCache.items || []).forEach((item) => {
const row = document.createElement('div');
row.className = 'module-item';
const meta = document.createElement('div');
meta.className = 'module-meta';
const label = STAT_LABELS[item.type] || item.type;
meta.innerHTML =
'<div class="module-title">' + label + '</div><div class="module-desc">' + (item.label || label) + ' - ' + (item.format || '{label}: {value}') + '</div>';
const buttons = document.createElement('div');
buttons.className = 'row';
const edit = document.createElement('button');
edit.className = 'secondary-btn';
edit.textContent = 'Bearbeiten';
edit.addEventListener('click', () => editServerStat(item));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'Loeschen';
del.addEventListener('click', () => {
serverStatsCache.items = (serverStatsCache.items || []).filter((x) => x !== item);
renderServerStats();
saveServerStats();
});
buttons.appendChild(edit);
buttons.appendChild(del);
row.appendChild(meta);
row.appendChild(buttons);
statsItems.appendChild(row);
});
if (!(serverStatsCache.items || []).length) statsItems.innerHTML = '<div class="muted">Keine Statistiken</div>';
}
function editServerStat(item) {
const typeKeys = Object.keys(STAT_LABELS);
const nextType = prompt('Typ (' + typeKeys.join(', ') + ')', item?.type || 'members_total');
if (!nextType || !STAT_LABELS[nextType]) return;
const nextLabel = prompt('Label', item?.label || STAT_LABELS[nextType]) || STAT_LABELS[nextType];
const nextFormat = prompt('Format ({label} / {value})', item?.format || '{label}: {value}') || '{label}: {value}';
if (item) {
item.type = nextType;
item.label = nextLabel;
item.format = nextFormat;
} else {
(serverStatsCache.items = serverStatsCache.items || []).push({
id: (crypto.randomUUID && crypto.randomUUID()) || String(Date.now()),
type: nextType,
label: nextLabel,
format: nextFormat
});
}
renderServerStats();
saveServerStats();
}
async function saveServerStats() {
if (!currentGuild) return;
const payload = {
guildId: currentGuild,
config: {
enabled: getSwitch(statsToggle),
categoryName: statsCategoryName?.value || undefined,
refreshMinutes: statsRefresh?.value ? Number(statsRefresh.value) : undefined,
items: serverStatsCache.items || []
}
};
const res = await fetch('/api/server-stats', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) showToast('Server Stats speichern fehlgeschlagen', true);
}
async function loadStatuspage() {
if (!currentGuild) return;
const res = await fetch('/api/statuspage?guildId=' + encodeURIComponent(currentGuild));
@@ -1510,7 +1549,7 @@ router.get('/', (req, res) => {
actions.className = 'row';
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'L schen';
del.textContent = 'Löschen';
del.addEventListener('click', async () => {
await deleteService(svc.id);
});
@@ -1568,7 +1607,7 @@ router.get('/', (req, res) => {
if (res.ok) {
await loadStatuspage();
} else {
showToast('Service l schen fehlgeschlagen', true);
showToast('Service löschen fehlgeschlagen', true);
}
}
@@ -1960,14 +1999,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillAutomationForm(r));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'L schen';
del.textContent = 'Lschen';
del.addEventListener('click', async () => {
const res = await fetch('/api/automations/' + r.id, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild })
});
showToast(res.ok ? 'Regel gel scht' : 'L schen fehlgeschlagen', !res.ok);
showToast(res.ok ? 'Regel gelscht' : 'Lschen fehlgeschlagen', !res.ok);
if (res.ok) loadAutomations();
});
actions.appendChild(edit);
@@ -2027,14 +2066,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillKbForm(a));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'L schen';
del.textContent = 'Lschen';
del.addEventListener('click', async () => {
const res = await fetch('/api/kb/' + a.id, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild })
});
showToast(res.ok ? 'Artikel gel scht' : 'L schen fehlgeschlagen', !res.ok);
showToast(res.ok ? 'Artikel gelscht' : 'Lschen fehlgeschlagen', !res.ok);
if (res.ok) loadKb();
});
actions.appendChild(edit);
@@ -2082,6 +2121,7 @@ router.get('/', (req, res) => {
modulesCache['welcomeEnabled'] = (cfg.welcomeConfig?.enabled ?? cfg.automodConfig?.welcomeConfig?.enabled ?? true) !== false;
modulesCache['dynamicVoiceEnabled'] = cfg.dynamicVoiceEnabled !== false;
modulesCache['statuspageEnabled'] = cfg.statuspageEnabled !== false && cfg.automodConfig?.statuspageEnabled !== false;
modulesCache['serverStatsEnabled'] = cfg.serverStatsEnabled === true || cfg.serverStatsConfig?.enabled === true;
modulesCache['birthdayEnabled'] = cfg.birthdayEnabled !== false && cfg.birthdayConfig?.enabled !== false;
modulesCache['reactionRolesEnabled'] = cfg.reactionRolesEnabled !== false && cfg.reactionRolesConfig?.enabled !== false;
modulesCache['eventsEnabled'] = cfg.eventsEnabled !== false;
@@ -2207,7 +2247,7 @@ router.get('/', (req, res) => {
(s.userId || '-') +
'</strong></div><div class="muted">Ende: ' +
formatDate(s.endedAt || Date.now()) +
' Dauer: ' +
' · Dauer: ' +
dur +
'</div>';
supportRecentList.appendChild(div);
@@ -2265,9 +2305,9 @@ router.get('/', (req, res) => {
(ev.repeatType || 'none') +
'</span></div><div class="ticket-meta">Start: ' +
formatDate(ev.startTime) +
' Channel: ' +
' · Channel: ' +
(ev.channelId || '-') +
' Anmeldungen: ' +
' · Anmeldungen: ' +
(ev._count?.signups ?? 0) +
'</div>';
const actions = document.createElement('div');
@@ -2480,7 +2520,7 @@ router.get('/', (req, res) => {
meta.className = 'module-meta';
const descParts = ['Channel: ' + (set.channelId || '-')];
if (set.messageId) descParts.push('Message: ' + set.messageId);
meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' - ') + '</div>';
meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' ') + '</div>';
const actions = document.createElement('div');
actions.className = 'row';
const editBtn = document.createElement('button');
@@ -2536,262 +2576,6 @@ router.get('/', (req, res) => {
}
}
function parseRegisterFields(raw) {
return (raw || '')
.split('\\n')
.map((l, idx) => {
const parts = l.split('|').map((p) => p.trim());
const label = parts[0];
if (!label) return null;
const typeRaw = (parts[1] || 'shortText').toLowerCase();
const type = typeRaw.includes('long') ? 'longText' : 'shortText';
const requiredRaw = (parts[2] || '').toLowerCase();
const required = ['true', '1', 'yes', 'ja'].includes(requiredRaw);
return { label, type, required, order: idx };
})
.filter(Boolean);
}
function formatRegisterFields(fields) {
return (fields || [])
.slice()
.sort((a, b) => (a.sortOrder ?? a.order ?? 0) - (b.sortOrder ?? b.order ?? 0))
.map((f) => [f.label || 'Feld', f.type || 'shortText', f.required ? 'true' : 'false'].join(' | '))
.join('\\n');
}
function setRegisterFormDefaults() {
if (registerFormId) registerFormId.value = '';
if (registerFormName) registerFormName.value = '';
if (registerFormDescription) registerFormDescription.value = '';
if (registerFormChannel) registerFormChannel.value = registerConfigCache.reviewChannelId || '';
if (registerFormRoles) registerFormRoles.value = (registerConfigCache.notifyRoleIds || []).join(', ');
if (registerFormFields) registerFormFields.value = '';
setSwitch(registerFormActive, true);
if (registerFormStatus) registerFormStatus.textContent = '';
}
function fillRegisterForm(form) {
if (!form) return;
if (registerFormId) registerFormId.value = form.id || '';
if (registerFormName) registerFormName.value = form.name || '';
if (registerFormDescription) registerFormDescription.value = form.description || '';
if (registerFormChannel) registerFormChannel.value = form.reviewChannelId || registerConfigCache.reviewChannelId || '';
if (registerFormRoles) registerFormRoles.value = (form.notifyRoleIds || []).join(', ');
setSwitch(registerFormActive, form.isActive !== false);
if (registerFormFields) registerFormFields.value = formatRegisterFields(form.fields || []);
if (registerFormStatus) registerFormStatus.textContent = 'Bearbeitung aktiv';
}
function clearRegisterUi() {
if (registerFormList) registerFormList.innerHTML = '<div class="muted">Modul deaktiviert.</div>';
if (registerAppsList) registerAppsList.innerHTML = '<div class="muted">Modul deaktiviert.</div>';
if (registerAppDetail) registerAppDetail.innerHTML = '<div class="muted">Register deaktiviert.</div>';
setRegisterFormDefaults();
}
async function loadRegisterForms() {
if (!currentGuild) return;
if (modulesCache['registerEnabled'] === false) { clearRegisterUi(); return; }
const res = await fetch('/api/register/forms?guildId=' + encodeURIComponent(currentGuild));
if (!res.ok) return;
const data = await res.json();
registerFormsCache = data.forms || [];
renderRegisterForms();
}
function renderRegisterForms() {
if (!registerFormList) return;
registerFormList.innerHTML = '';
if (!registerFormsCache.length) {
registerFormList.innerHTML = '<div class="muted">Keine Formulare.</div>';
} else {
registerFormsCache.forEach((form) => {
const row = document.createElement('div');
row.className = 'module-item';
const meta = document.createElement('div');
meta.className = 'module-meta';
const descParts = [];
descParts.push('Felder: ' + ((form.fields || []).length));
if (form.reviewChannelId) descParts.push('Review: ' + form.reviewChannelId);
if (form.notifyRoleIds?.length) descParts.push('Notify: ' + form.notifyRoleIds.join(', '));
meta.innerHTML = '<div class="module-title">' + (form.name || 'Formular') + '</div><div class="module-desc">' + descParts.join(' - ') + '</div>';
const actions = document.createElement('div');
actions.className = 'row';
const activeBadge = document.createElement('span');
activeBadge.className = 'badge' + (form.isActive !== false ? ' active' : '');
activeBadge.textContent = form.isActive !== false ? 'Aktiv' : 'Inaktiv';
actions.appendChild(activeBadge);
const editBtn = document.createElement('button');
editBtn.className = 'secondary-btn';
editBtn.style.padding = '8px 10px';
editBtn.style.fontSize = '12px';
editBtn.textContent = 'Bearbeiten';
editBtn.addEventListener('click', () => fillRegisterForm(form));
const panelBtn = document.createElement('button');
panelBtn.className = 'secondary-btn';
panelBtn.style.padding = '8px 10px';
panelBtn.style.fontSize = '12px';
panelBtn.textContent = 'Panel senden';
panelBtn.addEventListener('click', () => sendRegisterPanel(form));
const delBtn = document.createElement('button');
delBtn.className = 'danger-btn';
delBtn.style.padding = '8px 10px';
delBtn.style.fontSize = '12px';
delBtn.textContent = 'Loeschen';
delBtn.addEventListener('click', () => deleteRegisterForm(form.id));
actions.appendChild(editBtn);
actions.appendChild(panelBtn);
actions.appendChild(delBtn);
row.appendChild(meta);
row.appendChild(actions);
registerFormList.appendChild(row);
});
}
populateRegisterFormFilter();
}
async function sendRegisterPanel(form) {
if (!currentGuild || !form?.id) return;
const channelId = prompt('Channel ID fuer Panel', form.reviewChannelId || registerConfigCache.reviewChannelId || '') || '';
if (!channelId.trim()) return;
const message = prompt('Nachricht im Panel (optional)', 'Klicke auf Registrieren, um das Formular zu oeffnen.');
const res = await fetch('/api/register/forms/' + form.id + '/panel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild, channelId, message: message || undefined })
});
showToast(res.ok ? 'Panel gesendet' : 'Panel fehlgeschlagen', !res.ok);
}
async function deleteRegisterForm(id) {
if (!currentGuild || !id) return;
const res = await fetch('/api/register/forms/' + id, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guildId: currentGuild }) });
showToast(res.ok ? 'Formular geloescht' : 'Loeschen fehlgeschlagen', !res.ok);
if (res.ok) await loadRegisterForms();
}
function parseRegisterRoles(raw) {
return (raw || '')
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
}
function populateRegisterFormFilter() {
if (!registerAppsFormFilter) return;
const current = registerAppsFormFilter.value;
registerAppsFormFilter.innerHTML = '<option value="">Alle Formulare</option>';
registerFormsCache.forEach((f) => {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name || 'Formular';
registerAppsFormFilter.appendChild(opt);
});
if (current) registerAppsFormFilter.value = current;
}
async function saveRegisterForm(e) {
if (e) e.preventDefault();
if (!currentGuild) return;
const fields = parseRegisterFields(registerFormFields?.value || '');
const payload = {
guildId: currentGuild,
name: registerFormName?.value || 'Formular',
description: registerFormDescription?.value || '',
reviewChannelId: registerFormChannel?.value || undefined,
notifyRoleIds: parseRegisterRoles(registerFormRoles?.value || ''),
isActive: getSwitch(registerFormActive),
fields
};
const id = registerFormId?.value;
const url = id ? '/api/register/forms/' + id : '/api/register/forms';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (registerFormStatus) registerFormStatus.textContent = res.ok ? 'Gespeichert' : 'Fehler';
showToast(res.ok ? 'Formular gespeichert' : 'Speichern fehlgeschlagen', !res.ok);
if (res.ok) {
setRegisterFormDefaults();
await loadRegisterForms();
}
}
async function loadRegisterApps() {
if (!currentGuild) return;
if (modulesCache['registerEnabled'] === false) { clearRegisterUi(); return; }
registerSelectedApp = null;
if (registerAppDetail) registerAppDetail.innerHTML = 'Waehle einen Antrag aus der Liste.';
const qs = new URLSearchParams({ guildId: currentGuild });
if (registerAppsFilter?.value) qs.set('status', registerAppsFilter.value);
if (registerAppsFormFilter?.value) qs.set('formId', registerAppsFormFilter.value);
const res = await fetch('/api/register/apps?' + qs.toString());
if (!res.ok) return;
const data = await res.json();
registerAppsCache = data.applications || [];
renderRegisterApps();
}
function registerStatusClass(status) {
const val = (status || '').toLowerCase();
if (val === 'accepted') return 'status-open';
if (val === 'invited') return 'status-in-progress';
if (val === 'rejected') return 'status-closed';
return 'status-open';
}
function renderRegisterApps() {
if (!registerAppsList) return;
registerAppsList.innerHTML = '';
if (!registerAppsCache.length) {
registerSelectedApp = null;
registerAppsList.innerHTML = '<div class="ticket-empty">Keine Antraege.</div>';
if (registerAppDetail) registerAppDetail.innerHTML = 'Waehle einen Antrag aus der Liste.';
return;
}
registerAppsCache.forEach((app) => {
const row = document.createElement('div');
row.className = 'ticket-list-item';
row.innerHTML = '<div class="ticket-item-top"><div class="ticket-title">' + (app.form?.name || 'Formular') + '</div><span class="ticket-status-badge ' + registerStatusClass(app.status) + '">' + (app.status || 'pending') + '</span></div>' +
'<div class="ticket-meta">User: ' + (app.userId || '-') + ' - Erstellt: ' + formatDate(app.createdAt) + '</div>';
row.addEventListener('click', () => loadRegisterApplication(app.id));
registerAppsList.appendChild(row);
});
}
async function loadRegisterApplication(id) {
if (!id) return;
const res = await fetch('/api/register/apps/' + id);
if (!res.ok) return;
const data = await res.json();
registerSelectedApp = data.application || null;
renderRegisterDetail();
}
function renderRegisterDetail() {
if (!registerAppDetail) return;
if (!registerSelectedApp) {
registerAppDetail.innerHTML = '<div class="muted">Waehle einen Antrag.</div>';
return;
}
const app = registerSelectedApp;
const formFields = registerFormsCache.find((f) => f.id === app.formId)?.fields || [];
const answers = app.answers || [];
const answersHtml = answers
.map((a) => {
const field = formFields.find((f) => f.id === a.fieldId);
const label = field?.label || 'Feld';
const value = (a.value || '').replace(/</g, '&lt;');
return '<div class="register-answer"><div class="form-label">' + label + '</div><div class="module-desc">' + value + '</div></div>';
})
.join('');
registerAppDetail.innerHTML =
'<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">' +
'<div><p class="section-title">' + (app.form?.name || 'Formular') + '</p><p class="section-sub">User: ' + (app.userId || '-') + '</p></div>' +
'<span class="ticket-status-badge ' + registerStatusClass(app.status) + '">' + (app.status || 'pending') + '</span>' +
'</div>' +
'<div class="module-list" style="margin-top:12px;">' + (answersHtml || '<div class="muted">Keine Antworten vorhanden.</div>') + '</div>';
}
// TODO: MODULE: Liste um Musik/Forum/Automod-Konfiguration ergaenzen.
// - Module-Status inkl. Direktlinks zu Detailseiten (Automod/Welcome/Musik) rendern.
// - Module-Flags aus BotModuleService spiegeln statt doppeltem Fetch.
@@ -2804,10 +2588,10 @@ router.get('/', (req, res) => {
list.innerHTML = '';
let ticketsActive = false;
let statuspageActive = false;
let serverStatsActive = false;
let birthdayActive = false;
let reactionRolesActive = false;
let eventsActive = false;
let registerActive = false;
(data.modules || []).forEach((m) => {
modulesCache[m.key] = !!m.enabled;
const row = document.createElement('div');
@@ -2829,13 +2613,11 @@ router.get('/', (req, res) => {
if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable;
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable;
if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable;
if (m.key === 'serverStatsEnabled') modulesCache['serverStatsEnabled'] = willEnable;
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
if (m.key === 'registerEnabled') modulesCache['registerEnabled'] = willEnable;
applyNavVisibility();
if (m.key === 'registerEnabled' && willEnable) { await loadRegisterForms(); await loadRegisterApps(); }
if (m.key === 'registerEnabled' && !willEnable) { clearRegisterUi(); }
} else {
showToast('Speichern fehlgeschlagen', true);
}
@@ -2848,24 +2630,24 @@ router.get('/', (req, res) => {
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = !!m.enabled;
if (m.key === 'dynamicVoiceEnabled') modulesCache['dynamicVoiceEnabled'] = !!m.enabled;
if (m.key === 'statuspageEnabled') statuspageActive = !!m.enabled;
if (m.key === 'serverStatsEnabled') serverStatsActive = !!m.enabled;
if (m.key === 'birthdayEnabled') birthdayActive = !!m.enabled;
if (m.key === 'reactionRolesEnabled') reactionRolesActive = !!m.enabled;
if (m.key === 'eventsEnabled') eventsActive = !!m.enabled;
if (m.key === 'registerEnabled') registerActive = !!m.enabled;
});
applyNavVisibility();
applyTicketsVisibility(ticketsActive);
if (statuspageActive) loadStatuspage();
if (serverStatsActive) loadServerStats();
if (birthdayActive) loadBirthday();
if (reactionRolesActive) loadReactionRoles();
if (eventsActive) loadEvents();
if (registerActive) { loadRegisterForms(); loadRegisterApps(); }
}
async function saveModuleToggle(key, enabled) {
if (!currentGuild) return false;
const payload = { guildId: currentGuild };
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled', 'registerEnabled'].forEach((k) => {
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'serverStatsEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled'].forEach((k) => {
if (modulesCache[k] !== undefined) payload[k] = modulesCache[k];
});
payload['dynamicVoiceEnabled'] = modulesCache['dynamicVoiceEnabled'];
@@ -2976,26 +2758,6 @@ router.get('/', (req, res) => {
});
});
const registerTabs = Array.from(document.querySelectorAll('.register-tab'));
document.querySelectorAll('.register-tab-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
document.querySelectorAll('.register-tab-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab || 'forms';
registerTabs.forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
if (tab === 'forms') await loadRegisterForms();
if (tab === 'apps') await loadRegisterApps();
});
});
if (registerFormForm) registerFormForm.addEventListener('submit', saveRegisterForm);
if (registerFormReset) registerFormReset.addEventListener('click', (e) => { e.preventDefault(); setRegisterFormDefaults(); });
if (registerFormNew) registerFormNew.addEventListener('click', () => setRegisterFormDefaults());
if (registerFormsReload) registerFormsReload.addEventListener('click', loadRegisterForms);
if (registerAppsReload) registerAppsReload.addEventListener('click', loadRegisterApps);
if (registerAppsFilter) registerAppsFilter.addEventListener('change', loadRegisterApps);
if (registerAppsFormFilter) registerAppsFormFilter.addEventListener('change', loadRegisterApps);
const slaRange = document.getElementById('slaRange');
if (slaRange) slaRange.addEventListener('change', loadSla);
@@ -3079,7 +2841,7 @@ router.get('/', (req, res) => {
document.getElementById('logoutBtn').addEventListener('click', () => window.location.href = BASE_AUTH + '/logout');
[automodToggle, badWordToggle, linkFilterToggle, spamFilterToggle, capsFilterToggle, logJoinLeave, logMsgEdit, logMsgDelete, logAutomod, logTickets, logMusic, dynamicVoiceToggle, supportLoginAuto, registerFormActive].forEach((el) => {
[automodToggle, badWordToggle, linkFilterToggle, spamFilterToggle, capsFilterToggle, logJoinLeave, logMsgEdit, logMsgDelete, logAutomod, logTickets, logMusic, dynamicVoiceToggle, supportLoginAuto].forEach((el) => {
if (el) el.addEventListener('click', () => el.classList.toggle('on'));
});
if (logSystem) logSystem.addEventListener('click', () => logSystem.classList.toggle('on'));
@@ -3089,6 +2851,10 @@ router.get('/', (req, res) => {
if (statuspageInterval) statuspageInterval.addEventListener('change', saveStatuspageConfig);
if (statuspageChannel) statuspageChannel.addEventListener('change', saveStatuspageConfig);
if (statuspageAddService) statuspageAddService.addEventListener('click', addServicePrompt);
if (statsToggle) statsToggle.addEventListener('click', async () => { statsToggle.classList.toggle('on'); await saveServerStats(); });
if (statsCategoryName) statsCategoryName.addEventListener('change', saveServerStats);
if (statsRefresh) statsRefresh.addEventListener('change', saveServerStats);
if (statsAddItem) statsAddItem.addEventListener('click', () => editServerStat(null));
[welcomeTitle, welcomeDescription, welcomeFooter, welcomeColor].forEach((el) => {
if (el) el.addEventListener('input', updateWelcomePreview);
});
@@ -3256,24 +3022,3 @@ router.get('/settings', (_req, res) => {
export default router;

View File