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 907 additions and 819 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', eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled', registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig', registerConfig: 'registerConfig',
serverStatsEnabled: 'serverStatsEnabled',
serverStatsConfig: 'serverStatsConfig',
supportRoleId: 'supportRoleId', supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt', updatedAt: 'updatedAt',
createdAt: 'createdAt' createdAt: 'createdAt'
@@ -272,7 +274,7 @@ exports.Prisma.RegisterFormFieldScalarFieldEnum = {
label: 'label', label: 'label',
type: 'type', type: 'type',
required: 'required', required: 'required',
sortOrder: 'sortOrder' order: 'order'
}; };
exports.Prisma.RegisterApplicationScalarFieldEnum = { exports.Prisma.RegisterApplicationScalarFieldEnum = {

View File

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

@@ -9,26 +9,31 @@ datasource db {
} }
model GuildSettings { model GuildSettings {
guildId String @id guildId String @id
welcomeChannelId String? welcomeChannelId String?
logChannelId String? logChannelId String?
automodEnabled Boolean? automodEnabled Boolean?
automodConfig Json? automodConfig Json?
levelingEnabled Boolean? levelingEnabled Boolean?
ticketsEnabled Boolean? ticketsEnabled Boolean?
musicEnabled Boolean? musicEnabled Boolean?
statuspageEnabled Boolean? statuspageEnabled Boolean?
statuspageConfig Json? statuspageConfig Json?
dynamicVoiceEnabled Boolean? dynamicVoiceEnabled Boolean?
dynamicVoiceConfig Json? dynamicVoiceConfig Json?
supportLoginConfig Json? supportLoginConfig Json?
birthdayEnabled Boolean? birthdayEnabled Boolean?
birthdayConfig Json? birthdayConfig Json?
reactionRolesEnabled Boolean? reactionRolesEnabled Boolean?
reactionRolesConfig Json? reactionRolesConfig Json?
eventsEnabled Boolean?\n registerEnabled Boolean?\n registerConfig Json?\n supportRoleId String? eventsEnabled Boolean?
updatedAt DateTime @updatedAt registerEnabled Boolean?
createdAt DateTime @default(now()) registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
} }
model Ticket { model Ticket {
@@ -172,18 +177,17 @@ model RegisterForm {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
fields RegisterFormField[] fields RegisterFormField[]
applications RegisterApplication[]
@@index([guildId, isActive]) @@index([guildId, isActive])
} }
model RegisterFormField { model RegisterFormField {
id String @id @default(cuid()) id String @id @default(cuid())
formId String formId String
label String label String
type String type String
required Boolean @default(false) required Boolean @default(false)
sortOrder Int @default(0) order Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade) form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
} }

View File

@@ -1,73 +1,64 @@
# Papo Discord Bot # 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 ## Highlights
- Ticketsystem: Slash-Commands (/ticket, /claim, /close, /ticketpriority, /ticketstatus, /transcript, /ticketpanel), Panels, Transcripts unter `./transcripts`, Support-Login-Panel mit Rollen-Vergabe/On-Duty-Logging. - Ticketsystem mit Panels, Transcripts und Support-Login (Slash-Commands wie `/ticket`, `/claim`, `/close`).
- Automod: Link-Filter (Whitelist), Spam/Caps-Erkennung, Bad-Word-Listen (Custom), Timeouts, Logging. - Automod (Link-Whitelist, Spam/Caps, Bad-Word-Listen), Logging für relevante Events.
- Musik: play/skip/stop/pause/resume/loop, Queue, aktivierbar/deaktivierbar pro Guild. - Musik (play/skip/stop/pause/resume/loop) pro Guild aktivierbar.
- Welcome: konfigurierbare Embeds (Channel, Farbe, Texte, Bilder/Uploads), Preview im Dashboard, Text-Fallback. - Welcome, Leveling, dynamische Voice, Birthdays, Reaction Roles, Events mit Remindern.
- Logging: Join/Leave, Message Edit/Delete, Automod/Ticket/Musik-Events mit konfigurierbarem Log-Channel/Kategorien. - Statuspage-Modul, Rich Presence und modulbasierte Dashboard-Navigation.
- 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.
## Tech-Stack ## Tech-Stack
- Node.js 20 (Docker-Basis), TypeScript (CommonJS) - Node.js 20, TypeScript (CommonJS)
- discord.js 14, play-dl, @discordjs/voice - discord.js 14, play-dl, @discordjs/voice
- Express + OAuth2-Login, Prisma ORM (PostgreSQL) - Express + OAuth2-Login
- Dockerfile + docker-compose (App + Postgres) - Prisma ORM (PostgreSQL)
- Dockerfile + docker-compose
## Setup (lokal, Entwicklung) ## Quickstart (lokal)
1. Repo klonen, in das Verzeichnis wechseln. 1. Repo klonen, in das Verzeichnis wechseln.
2. `cp .env.example .env` und Variablen setzen (siehe unten). 2. `.env` anlegen: `cp .env.example .env` und Werte setzen.
3. Dependencies installieren: `npm ci` (oder `npm install`). 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`. 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). 5. Start Dev: `npm run dev` (ts-node-dev). Dashboard/Bot auf `PORT` (Standard 3000).
6. Slash-Commands werden beim Start fuer die IDs in `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert. 6. Slash-Commands werden beim Start für `DISCORD_GUILD_IDS` (oder `DISCORD_GUILD_ID`) registriert.
## Setup mit Docker ## Quickstart (Docker)
- `.dockerignore` blendet lokale node_modules/.env aus. - Dev-Stack: `docker-compose up --build` (Dockerfile + Postgres 15, env aus `.env`, startet `npm run dev`).
- Dev-Stack: `docker-compose up --build` (nutzt `Dockerfile`, Postgres 15, env aus `.env`, `npm run dev` im Container). - Eigenes Image: `docker build .` (Prisma-Generate läuft im Build). `.dockerignore` blendet lokale `node_modules`/`.env` aus.
- Eigenes Image: `docker build .` (Prisma-Generate laeuft im Build).
## Environment-Variablen ## Environment-Variablen (Auswahl)
- `DISCORD_TOKEN` (Pflicht, Bot Token) - `DISCORD_TOKEN` (Pflicht, Bot Token)
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Pflicht fuer Dashboard-OAuth) - `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` (Dashboard-OAuth)
- `DISCORD_GUILD_ID` (optional Einzel-Guild fuer Commands) - `DISCORD_GUILD_ID` (optional Einzel-Guild) / `DISCORD_GUILD_IDS` (kommagetrennt)
- `DISCORD_GUILD_IDS` (kommagetrennt, mehrere Guilds)
- `DATABASE_URL` (Pflicht, Postgres) - `DATABASE_URL` (Pflicht, Postgres)
- `PORT` (Webserver/Dashboard, default 3000) - `PORT` (Dashboard/Bot, default 3000)
- `SESSION_SECRET` (Express Session Secret, default `papo_dev_secret`) - `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) - `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) - `SUPPORT_ROLE_ID` (optional Ticket/Support-Login Rolle)
## Datenbank / Prisma ## 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`. - 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 dev` Entwicklung (ts-node-dev)
- `npm run build` TypeScript build - `npm run build` TypeScript build
- `npm start` Start aus `dist` - `npm start` Start aus `dist`
- Prisma-CLI: `npx prisma ...` (nutzt Schema aus `src/database/schema.prisma`) - 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`. - 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. - `/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. - Settings/Module über `/api/settings`, `/api/modules`, Tickets unter `/api/tickets*`, weitere Endpoints für Events, Reaction Roles, Birthday, Statuspage.
## Deployment-Hinweise ## Deployment-Hinweise
- Produktion: `npm run build` + `npm start` oder Docker-Image nutzen. - 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 ## 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 member.ban({ reason }).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` }); 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 member.kick(reason);
await interaction.reply({ content: `${user.tag} wurde gekickt.` }); 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 member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` }); 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 member.ban({ reason: `${reason} | ${minutes} Minuten` });
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` }); 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 () => { setTimeout(async () => {
await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null); 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 member.timeout(minutes * 60 * 1000, reason).catch(() => null);
await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` }); 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 member.timeout(null).catch(() => null);
await interaction.reply({ content: `${user.tag} ist nun entmuted.` }); 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 = { const command: SlashCommand = {
data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'), data: new SlashCommandBuilder().setName('help').setDescription('Zeigt Befehle und Module.'),
async execute(interaction: ChatInputCommandInteraction) { async execute(interaction: ChatInputCommandInteraction) {
const avatar = interaction.client.user?.displayAvatarURL({ size: 256 }) ?? null;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Papo Hilfe') .setTitle('Papo Hilfe')
.setDescription('Multi-Guild ready | Admin, Tickets, Musik, Automod, Dashboard') .setColor(0xf97316)
.setThumbnail(avatar)
.setDescription('Dein All-in-One Assistant: Tickets, Automod, Musik, Stats, Dashboard.')
.addFields( .addFields(
{ name: 'Admin', value: '/ban /kick /mute /timeout /clear', inline: false }, { name: '🛡️ Admin', value: '`/ban` `/kick` `/mute` `/timeout` `/clear`', inline: false },
{ name: 'Tickets', value: '/ticket /ticketpanel /ticketpriority /ticketstatus /transcript', 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: '🎵 Musik', value: '`/play` `/pause` `/resume` `/skip` `/stop` `/queue` `/loop`', inline: false },
{ name: 'Utility', value: '/ping /configure /serverinfo /rank', 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 }); await interaction.reply({ embeds: [embed], ephemeral: true });
} }
}; };

View File

@@ -15,12 +15,15 @@ import { EventService } from '../services/eventService';
import { TicketAutomationService } from '../services/ticketAutomationService'; import { TicketAutomationService } from '../services/ticketAutomationService';
import { KnowledgeBaseService } from '../services/knowledgeBaseService'; import { KnowledgeBaseService } from '../services/knowledgeBaseService';
import { RegisterService } from '../services/registerService'; import { RegisterService } from '../services/registerService';
import { StatsService } from '../services/statsService';
const logging = new LoggingService();
export const context = { export const context = {
client: null as Client | null, client: null as Client | null,
commandHandler: null as CommandHandler | null, commandHandler: null as CommandHandler | null,
automod: new AutoModService(true, true), logging,
logging: new LoggingService(), automod: new AutoModService(logging, true, true),
music: new MusicService(), music: new MusicService(),
tickets: new TicketService(), tickets: new TicketService(),
leveling: new LevelService(), leveling: new LevelService(),
@@ -33,7 +36,8 @@ export const context = {
events: new EventService(), events: new EventService(),
ticketAutomation: new TicketAutomationService(), ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService(), knowledgeBase: new KnowledgeBaseService(),
register: new RegisterService() register: new RegisterService(),
stats: new StatsService()
}; };
context.modules.setHooks({ context.modules.setHooks({
@@ -63,6 +67,10 @@ context.modules.setHooks({
}, },
eventsEnabled: { eventsEnabled: {
onEnable: async (guildId: string) => context.events.tick().catch(() => undefined) 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; reviewChannelId?: string;
notifyRoleIds?: string[]; notifyRoleIds?: string[];
}; };
serverStatsEnabled?: boolean;
serverStatsConfig?: {
enabled?: boolean;
categoryId?: string;
categoryName?: string;
refreshMinutes?: number;
cleanupOrphans?: boolean;
items?: any[];
};
supportRoleId?: string; supportRoleId?: string;
welcomeEnabled?: boolean; welcomeEnabled?: boolean;
} }
@@ -74,23 +83,23 @@ class SettingsStore {
private applyModuleDefaults(cfg: GuildSettings): GuildSettings { private applyModuleDefaults(cfg: GuildSettings): GuildSettings {
const normalized: GuildSettings = { ...cfg }; const normalized: GuildSettings = { ...cfg };
( const defaultOn = [
[ 'ticketsEnabled',
'ticketsEnabled', 'automodEnabled',
'automodEnabled', 'welcomeEnabled',
'welcomeEnabled', 'levelingEnabled',
'levelingEnabled', 'musicEnabled',
'musicEnabled', 'dynamicVoiceEnabled',
'dynamicVoiceEnabled', 'statuspageEnabled',
'statuspageEnabled', 'birthdayEnabled',
'birthdayEnabled', 'reactionRolesEnabled',
'reactionRolesEnabled', 'eventsEnabled',
'eventsEnabled', 'registerEnabled'
'registerEnabled' ] as const;
] as const defaultOn.forEach((key) => {
).forEach((key) => {
if (normalized[key] === undefined) normalized[key] = true; if (normalized[key] === undefined) normalized[key] = true;
}); });
if (normalized.serverStatsEnabled === undefined) normalized.serverStatsEnabled = false;
// keep welcomeConfig flag in sync when present // keep welcomeConfig flag in sync when present
if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) { if (normalized.welcomeConfig && normalized.welcomeConfig.enabled === undefined && normalized.welcomeEnabled !== undefined) {
normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled }; normalized.welcomeConfig = { ...normalized.welcomeConfig, enabled: normalized.welcomeEnabled };
@@ -124,6 +133,8 @@ class SettingsStore {
reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined, reactionRolesConfig: (row as any).reactionRolesConfig ?? undefined,
registerEnabled: (row as any).registerEnabled ?? undefined, registerEnabled: (row as any).registerEnabled ?? undefined,
registerConfig: (row as any).registerConfig ?? undefined, registerConfig: (row as any).registerConfig ?? undefined,
serverStatsEnabled: (row as any).serverStatsEnabled ?? undefined,
serverStatsConfig: (row as any).serverStatsConfig ?? undefined,
supportRoleId: row.supportRoleId ?? undefined supportRoleId: row.supportRoleId ?? undefined
} satisfies GuildSettings; } satisfies GuildSettings;
this.cache.set(row.guildId, this.applyModuleDefaults(cfg)); this.cache.set(row.guildId, this.applyModuleDefaults(cfg));
@@ -206,6 +217,8 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null, reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null, registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null, registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null supportRoleId: merged.supportRoleId ?? null
}, },
create: { create: {
@@ -228,6 +241,8 @@ class SettingsStore {
reactionRolesConfig: merged.reactionRolesConfig ?? null, reactionRolesConfig: merged.reactionRolesConfig ?? null,
registerEnabled: merged.registerEnabled ?? null, registerEnabled: merged.registerEnabled ?? null,
registerConfig: merged.registerConfig ?? null, registerConfig: merged.registerConfig ?? null,
serverStatsEnabled: (merged as any).serverStatsEnabled ?? null,
serverStatsConfig: (merged as any).serverStatsConfig ?? null,
supportRoleId: merged.supportRoleId ?? null supportRoleId: merged.supportRoleId ?? null
} }
}); });

View File

@@ -1,69 +1,2 @@
-- AlterTable -- Placeholder recreated because this migration was applied in the database already.
ALTER TABLE "GuildSettings" ADD COLUMN "registerConfig" JSONB, -- No schema changes required locally; keeps migration history aligned.
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;

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? eventsEnabled Boolean?
registerEnabled Boolean? registerEnabled Boolean?
registerConfig Json? registerConfig Json?
serverStatsEnabled Boolean?
serverStatsConfig Json?
supportRoleId String? supportRoleId String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -180,12 +182,12 @@ model RegisterForm {
} }
model RegisterFormField { model RegisterFormField {
id String @id @default(cuid()) id String @id @default(cuid())
formId String formId String
label String label String
type String type String
required Boolean @default(false) required Boolean @default(false)
sortOrder Int @default(0) order Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade) form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
} }

View File

@@ -7,6 +7,7 @@ const event: EventHandler = {
execute(channel: GuildChannel) { execute(channel: GuildChannel) {
if (!channel.guild) return; if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel erstellt: ${channel.name} (${channel.id})`); 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) { execute(channel: GuildChannel) {
if (!channel.guild) return; if (!channel.guild) return;
context.logging.logSystem(channel.guild, `Channel gelöscht: ${channel.name} (${channel.id})`); 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 = { const event: EventHandler = {
name: 'guildBanAdd', name: 'guildBanAdd',
execute(ban: GuildBan) { 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() const embed = new EmbedBuilder()
.setTitle(welcomeCfg.embedTitle || 'Willkommen!') .setTitle(welcomeCfg.embedTitle || 'Willkommen!')
.setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`) .setDescription(welcomeCfg.embedDescription || `${member} ist beigetreten.`)
.setColor(isNaN(colorVal) ? 0x00ff99 : colorVal) .setColor(isNaN(colorVal) ? 0x00ff99 : colorVal);
.setFooter({ text: welcomeCfg.embedFooter || '' }); const footerText = (welcomeCfg.embedFooter || '').trim();
if (footerText) {
embed.setFooter({ text: footerText });
}
if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) { if (welcomeCfg.embedThumbnailData && welcomeCfg.embedThumbnailData.startsWith('data:')) {
const [meta, b64] = welcomeCfg.embedThumbnailData.split(','); const [meta, b64] = welcomeCfg.embedThumbnailData.split(',');
const ext = meta.includes('gif') ? 'gif' : 'png'; const ext = meta.includes('gif') ? 'gif' : 'png';
@@ -47,6 +50,7 @@ const event: EventHandler = {
} }
} }
context.logging.logMemberJoin(member); context.logging.logMemberJoin(member);
context.stats.refreshGuild(member.guild.id).catch(() => undefined);
} }
}; };

View File

@@ -6,6 +6,7 @@ const event: EventHandler = {
name: 'guildMemberRemove', name: 'guildMemberRemove',
execute(member: GuildMember) { execute(member: GuildMember) {
context.logging.logMemberLeave(member); 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) { async execute(message: Message) {
const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined; const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined;
if (message.guildId) context.admin.trackEvent('message', message.guildId); 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); if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
// Ticket SLA + KB // Ticket SLA + KB
await context.tickets.trackFirstResponse(message); await context.tickets.trackFirstResponse(message);

View File

@@ -38,6 +38,10 @@ const event: EventHandler = {
for (const gid of settingsStore.all().keys()) { for (const gid of settingsStore.all().keys()) {
context.reactionRoles.resyncGuild(gid).catch((err) => logger.warn(`reaction roles sync failed for ${gid}: ${err}`)); 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) { } catch (err) {
logger.warn(`Ready handler failed: ${err}`); logger.warn(`Ready handler failed: ${err}`);
} }

View File

@@ -35,6 +35,7 @@ async function bootstrap() {
context.events.setClient(client); context.events.setClient(client);
context.events.startScheduler(); context.events.startScheduler();
context.register.setClient(client); context.register.setClient(client);
context.stats.setClient(client);
await context.reactionRoles.loadCache(); await context.reactionRoles.loadCache();
logger.setSink((entry) => context.admin.pushLog(entry)); logger.setSink((entry) => context.admin.pushLog(entry));
for (const gid of settingsStore.all().keys()) { 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 { logger } from '../utils/logger';
import { GuildSettings } from '../config/state';
import { LoggingService } from './loggingService';
export interface AutomodConfig { export interface AutomodConfig {
spamThreshold?: number; spamThreshold?: number;
@@ -37,11 +39,13 @@ export class AutoModService {
}; };
private defaultBadwords = ['badword', 'spamword']; 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) { public async checkMessage(message: Message, cfg?: AutomodConfig | GuildSettings) {
if (message.author.bot) return; if (message.author.bot || message.webhookId) return;
const config = { ...this.defaults, ...(cfg ?? {}) }; if (!message.inGuild()) return;
const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg;
const config = { ...this.defaults, ...(guildConfig ?? {}) };
const member = message.member; const member = message.member;
if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) { 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 (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false; await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`);
message.delete().catch(() => undefined); const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`;
message.channel
.send({ content: `${message.author}, Links sind hier nicht erlaubt.` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
logger.info(`Deleted link from ${message.author.tag}`); 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; return true;
} }
if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) { if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) {
if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false; await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`);
message.delete().catch(() => undefined); await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content);
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);
return true; return true;
} }
@@ -74,11 +71,9 @@ export class AutoModService {
const letters = message.content.replace(/[^a-zA-Z]/g, ''); const letters = message.content.replace(/[^a-zA-Z]/g, '');
const upper = letters.replace(/[^A-Z]/g, ''); const upper = letters.replace(/[^A-Z]/g, '');
if (letters.length >= 10 && upper.length / letters.length > 0.7) { if (letters.length >= 10 && upper.length / letters.length > 0.7) {
message.delete().catch(() => undefined); await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`);
message.channel const ratio = Math.round((upper.length / letters.length) * 100);
.send({ content: `${message.author}, bitte weniger Capslock nutzen.` }) await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content);
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
await this.logAutomodAction(message, config, 'capslock', message.content);
return true; return true;
} }
} }
@@ -98,12 +93,11 @@ export class AutoModService {
if (tracker.count >= threshold) { if (tracker.count >= threshold) {
const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000; const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000;
message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined); message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined);
message.channel await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`);
.send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` })
.then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000));
logger.warn(`Timed out ${message.author.tag} for spam`); logger.warn(`Timed out ${message.author.tag} for spam`);
this.spamTracker.delete(message.author.id); 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; return true;
} }
} }
@@ -111,24 +105,52 @@ export class AutoModService {
} }
private containsBadword(content: string, custom: string[] = []) { 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; if (!combined.length) return false;
const lower = content.toLowerCase(); 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[] = []) { private containsLink(content: string, whitelist: string[] = []) {
const normalized = whitelist.map((w) => w.toLowerCase()).filter(Boolean); 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; if (!match) return false;
const url = match[0].toLowerCase(); const url = match[0].toLowerCase();
return !normalized.some((w) => url.includes(w)); 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 { try {
const guild = message.guild; const guild = message.guild;
if (!guild) return; 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 loggingCfg = config.loggingConfig || {};
const flags = loggingCfg.categories || {}; const flags = loggingCfg.categories || {};
if (flags.automodActions === false) return; if (flags.automodActions === false) return;
@@ -136,8 +158,8 @@ export class AutoModService {
if (!channelId) return; if (!channelId) return;
const channel = await guild.channels.fetch(channelId).catch(() => null); const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`; const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`;
await channel.send({ content }); await channel.send({ content: body });
} catch (err) { } catch (err) {
logger.error('Automod log failed', err); logger.error('Automod log failed', err);
} }

View File

@@ -45,7 +45,7 @@ export class LoggingService {
private resolve(guild: Guild) { private resolve(guild: Guild) {
const cfg = settingsStore.get(guild.id); const cfg = settingsStore.get(guild.id);
const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {}; 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 flags = loggingCfg.categories || {};
const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null; const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null;
return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags }; 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) { logAction(user: User | GuildMember, action: string, reason?: string, guild?: Guild) {
const guild = user instanceof GuildMember ? user.guild : null; const resolvedGuild = guild ?? (user instanceof GuildMember ? user.guild : null);
if (!guild) return; if (!resolvedGuild) return;
if (!this.shouldLog(guild, 'automodActions')) return; if (!this.shouldLog(resolvedGuild, 'automodActions')) return;
const { channel } = this.resolve(guild); const { channel } = this.resolve(resolvedGuild);
if (!channel) return; if (!channel) return;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Moderation') .setTitle('Moderation')
@@ -141,7 +141,7 @@ export class LoggingService {
.setColor(0x7289da) .setColor(0x7289da)
.setTimestamp(); .setTimestamp();
channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err)); 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) { if (guildId) {
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({
guildId, 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[]) { logRoleUpdate(member: GuildMember, added: string[], removed: string[]) {
const guildId = member.guild.id; const guildId = member.guild.id;
adminSink?.pushGuildLog({ adminSink?.pushGuildLog({

View File

@@ -11,7 +11,8 @@ export type ModuleKey =
| 'birthdayEnabled' | 'birthdayEnabled'
| 'reactionRolesEnabled' | 'reactionRolesEnabled'
| 'eventsEnabled' | 'eventsEnabled'
| 'registerEnabled'; | 'registerEnabled'
| 'serverStatsEnabled';
export interface GuildModuleState { export interface GuildModuleState {
key: ModuleKey; key: ModuleKey;
@@ -31,7 +32,8 @@ const MODULES: Record<ModuleKey, { name: string; description: string }> = {
birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' }, birthdayEnabled: { name: 'Birthday', description: 'Geburtstage speichern und Glueckwuensche senden.' },
reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' }, reactionRolesEnabled: { name: 'Reaction Roles', description: 'Reaktionen vergeben und entfernen Rollen.' },
eventsEnabled: { name: 'Termine', description: 'Events planen, erinnern und Anmeldungen sammeln.' }, 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 { export class BotModuleService {
@@ -53,6 +55,7 @@ export class BotModuleService {
if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true; if (key === 'birthdayEnabled') enabled = cfg.birthdayEnabled === true || cfg.birthdayConfig?.enabled === true;
if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true; if (key === 'reactionRolesEnabled') enabled = cfg.reactionRolesEnabled === true || cfg.reactionRolesConfig?.enabled === true;
if (key === 'eventsEnabled') enabled = (cfg as any).eventsEnabled !== false; 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 { return {
key: key as ModuleKey, key: key as ModuleKey,
name: meta.name, 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 { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
import { Readable } from 'stream'; import play from 'play-dl';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { settingsStore } from '../config/state'; import { settingsStore } from '../config/state';
@@ -8,8 +8,7 @@ export type LoopMode = 'off' | 'song' | 'queue';
interface QueueItem { interface QueueItem {
title: string; title: string;
streamUrl: string; url: string;
displayUrl?: string;
requester: string; requester: string;
originalQuery?: string; originalQuery?: string;
} }
@@ -25,7 +24,6 @@ interface QueueState {
export class MusicService { export class MusicService {
private queues = new Map<string, QueueState>(); private queues = new Map<string, QueueState>();
private spotifyToken: { value: string; expiresAt: number } | null = null;
private getQueue(guildId: string) { private getQueue(guildId: string) {
const cfg = settingsStore.get(guildId); const cfg = settingsStore.get(guildId);
@@ -84,13 +82,7 @@ export class MusicService {
await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true }); await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true });
return; return;
} }
const queueItem: QueueItem = { const queueItem: QueueItem = { title: track.title ?? 'Unbekannt', url: track.url, requester: interaction.user.tag, originalQuery: trimmedQuery };
title: track.title ?? 'Unbekannt',
streamUrl: track.url,
displayUrl: track.url,
requester: interaction.user.tag,
originalQuery: trimmedQuery
};
const queue = this.getQueue(interaction.guildId); const queue = this.getQueue(interaction.guildId);
if (!queue) { if (!queue) {
const player = createAudioPlayer(); const player = createAudioPlayer();
@@ -168,7 +160,7 @@ export class MusicService {
next = queue.current; next = queue.current;
} }
if (!next) break; 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)) { if (streamUrlCheck && streamUrlCheck !== 'undefined' && /^https?:\/\//i.test(streamUrlCheck)) {
break; break;
} }
@@ -180,16 +172,24 @@ export class MusicService {
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined); queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
return; return;
} }
const streamUrl = (next.streamUrl || '').trim(); const streamUrl = (next.url || '').trim();
queue.current = next; queue.current = next;
try { try {
if (!/^https?:\/\//i.test(streamUrl)) throw new Error('spotify_stream_url_invalid'); const kind = await play.validate(streamUrl);
const res = await fetch(streamUrl); if (kind !== 'so_track') {
if (!res.ok || !res.body) throw new Error('spotify_stream_fetch_failed'); logger.error('Music stream error', { reason: 'unsupported_url', kind, item: next });
const body: any = typeof (res as any).body?.getReader === 'function' ? Readable.fromWeb(res.body as any) : (res as any).body; queue.channel.send({ content: `Nur SoundCloud wird unterstuetzt, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
const resource: AudioResource = createAudioResource(body, { queue.current = undefined;
inputType: StreamType.Arbitrary 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.player.play(resource);
queue.connection.subscribe(queue.player); 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> { private async resolveTrack(query: string, opts?: { skipPlaylist?: boolean }): Promise<{ title: string; url: string } | null> {
const trimmed = query.trim(); const trimmed = query.trim();
if (!trimmed) return null; if (!trimmed) return null;
const token = await this.getSpotifyToken(); try {
const trackId = this.extractSpotifyTrackId(trimmed); let validation: string | null = null;
if (trackId) { try {
const track = await this.fetchSpotifyTrack(trackId, token); validation = await play.validate(trimmed);
if (track) return track; } catch (err) {
logger.warn('Music validate error', err);
}
if (validation === 'so_track') {
return { title: trimmed, url: trimmed };
}
// nur SoundCloud erlaubt, alles andere ignorieren
} catch (err) {
logger.error('Music resolve error', err);
} }
const search = await this.searchSpotifyTrack(trimmed, token);
return search;
}
private async getSpotifyToken(): Promise<string> { const scSearch = await play.search(trimmed, { source: { soundcloud: 'tracks' }, limit: 1 }).catch((err) => {
if (this.spotifyToken && this.spotifyToken.expiresAt > Date.now() + 30000) { logger.warn('SoundCloud search skipped', err?.message || err);
return this.spotifyToken.value; return [];
}
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'
}); });
if (!res.ok) throw new Error('spotify_auth_failed'); if (scSearch && scSearch.length) {
const data = (await res.json()) as { access_token: string; expires_in: number }; const sc = scSearch[0];
const expiresInMs = Math.max(30_000, (data.expires_in || 3600) * 1000); const url = sc.url || '';
this.spotifyToken = { value: data.access_token, expiresAt: Date.now() + expiresInMs }; if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url };
return data.access_token; }
}
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; return null;
} }
private async fetchSpotifyTrack(id: string, token: string): Promise<{ title: string; url: string } | null> { private buildVideoUrl(details: any): string | null {
const res = await fetch(`https://api.spotify.com/v1/tracks/${id}`, { if (!details) return null;
headers: { Authorization: `Bearer ${token}` } const url = details.url || details.permalink;
}); if (typeof url === 'string' && /^https?:\/\//i.test(url)) return url;
if (!res.ok) { if (details.id) return `https://www.youtube.com/watch?v=${details.id}`;
logger.warn('Spotify track fetch failed', { status: res.status }); if (details.videoId) return `https://www.youtube.com/watch?v=${details.videoId}`;
return null; 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) { 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: { public async saveForm(form: {
@@ -55,10 +55,10 @@ export class RegisterService {
label: f.label, label: f.label,
type: f.type, type: f.type,
required: f.required ?? false, 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({ const created = await prisma.registerForm.create({
data: { data: {
@@ -73,11 +73,11 @@ export class RegisterService {
label: f.label, label: f.label,
type: f.type, type: f.type,
required: f.required ?? false, required: f.required ?? false,
sortOrder: f.order ?? idx order: f.order ?? idx
})) }))
} }
}, },
include: { fields: { orderBy: { sortOrder: 'asc' } } } include: { fields: { orderBy: { order: 'asc' } } }
}); });
return created; return created;
} }
@@ -113,7 +113,7 @@ export class RegisterService {
public async handleButton(interaction: ButtonInteraction) { public async handleButton(interaction: ButtonInteraction) {
if (interaction.customId.startsWith('register:form:')) { if (interaction.customId.startsWith('register:form:')) {
const formId = interaction.customId.split(':')[2]; 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 }); if (!form) return interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`); const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`);
const components: any[] = []; const components: any[] = [];
@@ -164,7 +164,7 @@ export class RegisterService {
const formId = interaction.customId.split(':')[2]; const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({ const form = await prisma.registerForm.findFirst({
where: { id: formId }, where: { id: formId },
include: { fields: { orderBy: { sortOrder: 'asc' } } } include: { fields: { orderBy: { order: 'asc' } } }
}); });
if (!form) { if (!form) {
await interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true }); 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); const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
const member = await guild.members.fetch(userId).catch(() => null); 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 answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } });
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`) .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, dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
statuspageEnabled: (modules as any).statuspageEnabled !== false, statuspageEnabled: (modules as any).statuspageEnabled !== false,
birthdayEnabled: (modules as any).birthdayEnabled !== 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 }); 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) => { router.post('/settings', requireAuth, async (req, res) => {
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {}; const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
const { const {
@@ -779,7 +801,9 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled, reactionRolesEnabled,
reactionRolesConfig, reactionRolesConfig,
registerEnabled, registerEnabled,
registerConfig registerConfig,
serverStatsEnabled,
serverStatsConfig
} = req.body; } = req.body;
if (!guildId) return res.status(400).json({ error: 'guildId required' }); if (!guildId) return res.status(400).json({ error: 'guildId required' });
const normalizeArray = (val: any) => const normalizeArray = (val: any) =>
@@ -913,7 +937,9 @@ router.post('/settings', requireAuth, async (req, res) => {
reactionRolesEnabled: parsedReactionRoles.enabled, reactionRolesEnabled: parsedReactionRoles.enabled,
reactionRolesConfig: parsedReactionRoles, reactionRolesConfig: parsedReactionRoles,
registerEnabled: parsedRegister.enabled, registerEnabled: parsedRegister.enabled,
registerConfig: parsedRegister registerConfig: parsedRegister,
serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled,
serverStatsConfig: serverStatsConfig
}); });
// Live update logging target // Live update logging target
context.logging = new LoggingService(updated.logChannelId); context.logging = new LoggingService(updated.logChannelId);

View File

@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router } from 'express';
const router = Router(); const router = Router();
@@ -43,21 +43,21 @@ router.get('/', (req, res) => {
<aside class="sidebar"> <aside class="sidebar">
<div class="brand">Papo Control</div> <div class="brand">Papo Control</div>
<div class="nav"> <div class="nav">
<a class="active" href="#overview" data-target="overview"><span class="icon">-</span> Uebersicht</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="#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="#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="#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="#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="#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="#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="#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="#serverstats" data-target="serverstats" class="serverstats-link"><span class="icon">📈</span> Server Stats</a>
<a href="#modules" data-target="modules"><span class="icon">-</span> Module</a> <a href="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a>
<a href="#register" data-target="register" class="register-link"><span class="icon">-</span> Register</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="#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 href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛠️</span> Admin</a>
</div> </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> <button id="logoutBtn" class="logout">Logout</button>
</aside> </aside>
`; `;
@@ -215,11 +215,6 @@ router.get('/', (req, res) => {
.module-meta { display:flex; flex-direction:column; gap:4px; } .module-meta { display:flex; flex-direction:column; gap:4px; }
.module-title { font-weight:700; color:var(--text); } .module-title { font-weight:700; color:var(--text); }
.module-desc { color:var(--muted); font-size:13px; } .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 { 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.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); } .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 class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
<div> <div>
<p class="section-title">Tickets</p> <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>
<div class="row" style="gap:8px; flex-wrap:wrap;"> <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="pipeline">Pipeline</button>
<button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</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> <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 style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div> <div>
<p class="section-title">Ticketliste</p> <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>
<div class="row" style="gap:10px;"> <div class="row" style="gap:10px;">
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button> <button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button>
@@ -387,7 +382,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px;"> <div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
<div> <div>
<p class="section-title">Status-Pipeline</p> <p class="section-title">Status-Pipeline</p>
<p class="section-sub">Tickets nach Phase. Status per Dropdown ndern.</p> <p class="section-sub">Tickets nach Phase. Status per Dropdown ndern.</p>
</div> </div>
</div> </div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;" id="pipelineGrid"> <div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;" id="pipelineGrid">
@@ -436,7 +431,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;"> <div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div> <div>
<p class="section-title">Automationen</p> <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> </div>
<button class="secondary-btn" id="addAutomation">Neue Regel</button> <button class="secondary-btn" id="addAutomation">Neue Regel</button>
</div> </div>
@@ -473,7 +468,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;"> <div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div> <div>
<p class="section-title">Knowledge-Base</p> <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> </div>
<button class="secondary-btn" id="addKb">Neuer Artikel</button> <button class="secondary-btn" id="addKb">Neuer Artikel</button>
</div> </div>
@@ -592,7 +587,7 @@ router.get('/', (req, res) => {
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label">Embed Footer</label> <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> </div>
<div class="form-field"> <div class="form-field">
@@ -615,7 +610,7 @@ router.get('/', (req, res) => {
<div class="embed-color" id="welcomePreviewColor"></div> <div class="embed-color" id="welcomePreviewColor"></div>
<div class="embed-body"> <div class="embed-body">
<div class="embed-title" id="welcomePreviewTitle">Willkommen!</div> <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> <div class="embed-footer" id="welcomePreviewFooter">Footer</div>
<img id="welcomePreviewImage" class="embed-image" style="display:none;" /> <img id="welcomePreviewImage" class="embed-image" style="display:none;" />
</div> </div>
@@ -676,79 +671,6 @@ router.get('/', (req, res) => {
<div id="moduleList" class="module-list"></div> <div id="moduleList" class="module-list"></div>
</section> </section>
</div> </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"> <div class="section" data-section="birthday">
<section class="card"> <section class="card">
<div class="row" style="justify-content:space-between; align-items:flex-start; gap:12px;"> <div class="row" style="justify-content:space-between; align-items:flex-start; gap:12px;">
@@ -815,7 +737,7 @@ router.get('/', (req, res) => {
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label">Eintraege (Emoji | Role ID | Label | Beschreibung)</label> <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> <p class="muted">Eine Zeile pro Zuordnung. Label/Beschreibung optional.</p>
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:10px;"> <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> <div id="statuspageServices" class="module-list"></div>
</section> </section>
</div> </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"> <div class="section" data-section="events">
<section class="card"> <section class="card">
<div class="row" style="justify-content:space-between; align-items:center;"> <div class="row" style="justify-content:space-between; align-items:center;">
@@ -889,7 +838,7 @@ router.get('/', (req, res) => {
<section class="card"> <section class="card">
<div class="row" style="justify-content:space-between;"> <div class="row" style="justify-content:space-between;">
<div> <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> <p class="section-sub">Events/Commands pro Stunde</p>
</div> </div>
</div> </div>
@@ -899,7 +848,7 @@ router.get('/', (req, res) => {
<div class="row" style="justify-content:space-between;"> <div class="row" style="justify-content:space-between;">
<div> <div>
<p class="section-title">Logs</p> <p class="section-title">Logs</p>
<p class="section-sub">Neueste Eintr ge</p> <p class="section-sub">Neueste Einträge</p>
</div> </div>
</div> </div>
<ul class="log-list" id="adminLogs"></ul> <ul class="log-list" id="adminLogs"></ul>
@@ -926,13 +875,13 @@ router.get('/', (req, res) => {
</div> </div>
</div> </div>
<div class="option-grid"> <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>👋 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 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>🗑️ 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>🛡️ 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>🎫 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>🎵 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>⚙️ System / Channels</span><div id="logSystem" class="switch on"></div></div>
</div> </div>
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;"> <div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;">
<button id="loggingSave" type="button">Logging speichern</button> <button id="loggingSave" type="button">Logging speichern</button>
@@ -1282,6 +1231,7 @@ router.get('/', (req, res) => {
let activeModal = null; let activeModal = null;
let automodConfigCache = {}; let automodConfigCache = {};
let modulesCache = {}; let modulesCache = {};
let serverStatsCache = { items: [] };
let dynamicVoiceCache = {}; let dynamicVoiceCache = {};
let isAdmin = false; let isAdmin = false;
let statuspageCache = { services: [] }; let statuspageCache = { services: [] };
@@ -1290,10 +1240,6 @@ router.get('/', (req, res) => {
let editingReactionRole = null; let editingReactionRole = null;
let supportLoginCache = {}; let supportLoginCache = {};
let eventsCache = []; let eventsCache = [];
let registerFormsCache = [];
let registerAppsCache = [];
let registerSelectedApp = null;
let registerConfigCache = {};
function activateSection(key) { function activateSection(key) {
sections.forEach((s) => s.classList.toggle('active', s.dataset.section === 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 welcomeEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'welcomeEnabled') ? modulesCache['welcomeEnabled'] : true;
const dynamicVoiceEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'dynamicVoiceEnabled') ? modulesCache['dynamicVoiceEnabled'] : true; const dynamicVoiceEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'dynamicVoiceEnabled') ? modulesCache['dynamicVoiceEnabled'] : true;
const statuspageEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'statuspageEnabled') ? modulesCache['statuspageEnabled'] : 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 birthdayEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'birthdayEnabled') ? modulesCache['birthdayEnabled'] : true;
const reactionRolesEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'reactionRolesEnabled') ? modulesCache['reactionRolesEnabled'] : true; const reactionRolesEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'reactionRolesEnabled') ? modulesCache['reactionRolesEnabled'] : true;
const eventsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'eventsEnabled') ? modulesCache['eventsEnabled'] : 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 (automodNav) automodNav.classList.toggle('hidden', !autoEnabled);
if (welcomeNav) welcomeNav.classList.toggle('hidden', !welcomeEnabled); if (welcomeNav) welcomeNav.classList.toggle('hidden', !welcomeEnabled);
if (dynamicVoiceNav) dynamicVoiceNav.classList.toggle('hidden', !dynamicVoiceEnabled); if (dynamicVoiceNav) dynamicVoiceNav.classList.toggle('hidden', !dynamicVoiceEnabled);
const statuspageNav = document.querySelector('.nav .statuspage-link'); const statuspageNav = document.querySelector('.nav .statuspage-link');
if (statuspageNav) statuspageNav.classList.toggle('hidden', !statuspageEnabled); 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'); const birthdayNav = document.querySelector('.nav .birthday-link');
if (birthdayNav) birthdayNav.classList.toggle('hidden', !birthdayEnabled); if (birthdayNav) birthdayNav.classList.toggle('hidden', !birthdayEnabled);
const reactionRolesNav = document.querySelector('.nav .reactionroles-link'); const reactionRolesNav = document.querySelector('.nav .reactionroles-link');
if (reactionRolesNav) reactionRolesNav.classList.toggle('hidden', !reactionRolesEnabled); if (reactionRolesNav) reactionRolesNav.classList.toggle('hidden', !reactionRolesEnabled);
const eventsNav = document.querySelector('.nav .events-link'); const eventsNav = document.querySelector('.nav .events-link');
if (eventsNav) eventsNav.classList.toggle('hidden', !eventsEnabled); 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'); const adminNav = document.querySelector('.nav .admin-link');
if (adminNav) adminNav.classList.toggle('hidden', !isAdmin); if (adminNav) adminNav.classList.toggle('hidden', !isAdmin);
const current = location.hash.replace('#','') || 'overview'; const current = location.hash.replace('#','') || 'overview';
@@ -1352,10 +1294,10 @@ router.get('/', (req, res) => {
(current === 'welcome' && !welcomeEnabled) || (current === 'welcome' && !welcomeEnabled) ||
(current === 'dynamicvoice' && !dynamicVoiceEnabled) || (current === 'dynamicvoice' && !dynamicVoiceEnabled) ||
(current === 'statuspage' && !statuspageEnabled) || (current === 'statuspage' && !statuspageEnabled) ||
(current === 'serverstats' && !serverStatsEnabled) ||
(current === 'birthday' && !birthdayEnabled) || (current === 'birthday' && !birthdayEnabled) ||
(current === 'reactionroles' && !reactionRolesEnabled) || (current === 'reactionroles' && !reactionRolesEnabled) ||
(current === 'events' && !eventsEnabled) || (current === 'events' && !eventsEnabled) ||
(current === 'register' && !registerEnabled) ||
(current === 'admin' && !isAdmin) (current === 'admin' && !isAdmin)
) { ) {
activateSection('overview'); activateSection('overview');
@@ -1374,7 +1316,7 @@ router.get('/', (req, res) => {
function parseList(val) { function parseList(val) {
return (val || '') return (val || '')
.split(/[,\n]/) .split(/[,\\n]/)
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
} }
@@ -1463,6 +1405,103 @@ router.get('/', (req, res) => {
if (!logs.length) guildLogs.innerHTML = '<li class="muted">Keine Logs</li>'; 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() { async function loadStatuspage() {
if (!currentGuild) return; if (!currentGuild) return;
const res = await fetch('/api/statuspage?guildId=' + encodeURIComponent(currentGuild)); const res = await fetch('/api/statuspage?guildId=' + encodeURIComponent(currentGuild));
@@ -1510,7 +1549,7 @@ router.get('/', (req, res) => {
actions.className = 'row'; actions.className = 'row';
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'danger-btn'; del.className = 'danger-btn';
del.textContent = 'L schen'; del.textContent = 'Löschen';
del.addEventListener('click', async () => { del.addEventListener('click', async () => {
await deleteService(svc.id); await deleteService(svc.id);
}); });
@@ -1568,7 +1607,7 @@ router.get('/', (req, res) => {
if (res.ok) { if (res.ok) {
await loadStatuspage(); await loadStatuspage();
} else { } else {
showToast('Service l schen fehlgeschlagen', true); showToast('Service löschen fehlgeschlagen', true);
} }
} }
@@ -1846,7 +1885,7 @@ router.get('/', (req, res) => {
'</div>' + '</div>' +
'<div class=\"ticket-meta\">User: ' + '<div class=\"ticket-meta\">User: ' +
(t.userId || '-') + (t.userId || '-') +
(t.claimedBy ? ' Supporter: ' + t.claimedBy : '') + (t.claimedBy ? ' Supporter: ' + t.claimedBy : '') +
'</div>'; '</div>';
const select = document.createElement('select'); const select = document.createElement('select');
select.innerHTML = select.innerHTML =
@@ -1960,14 +1999,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillAutomationForm(r)); edit.addEventListener('click', () => fillAutomationForm(r));
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'danger-btn'; del.className = 'danger-btn';
del.textContent = 'L schen'; del.textContent = 'Lschen';
del.addEventListener('click', async () => { del.addEventListener('click', async () => {
const res = await fetch('/api/automations/' + r.id, { const res = await fetch('/api/automations/' + r.id, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild }) 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(); if (res.ok) loadAutomations();
}); });
actions.appendChild(edit); actions.appendChild(edit);
@@ -2027,14 +2066,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillKbForm(a)); edit.addEventListener('click', () => fillKbForm(a));
const del = document.createElement('button'); const del = document.createElement('button');
del.className = 'danger-btn'; del.className = 'danger-btn';
del.textContent = 'L schen'; del.textContent = 'Lschen';
del.addEventListener('click', async () => { del.addEventListener('click', async () => {
const res = await fetch('/api/kb/' + a.id, { const res = await fetch('/api/kb/' + a.id, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild }) 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(); if (res.ok) loadKb();
}); });
actions.appendChild(edit); actions.appendChild(edit);
@@ -2082,6 +2121,7 @@ router.get('/', (req, res) => {
modulesCache['welcomeEnabled'] = (cfg.welcomeConfig?.enabled ?? cfg.automodConfig?.welcomeConfig?.enabled ?? true) !== false; modulesCache['welcomeEnabled'] = (cfg.welcomeConfig?.enabled ?? cfg.automodConfig?.welcomeConfig?.enabled ?? true) !== false;
modulesCache['dynamicVoiceEnabled'] = cfg.dynamicVoiceEnabled !== false; modulesCache['dynamicVoiceEnabled'] = cfg.dynamicVoiceEnabled !== false;
modulesCache['statuspageEnabled'] = cfg.statuspageEnabled !== false && cfg.automodConfig?.statuspageEnabled !== 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['birthdayEnabled'] = cfg.birthdayEnabled !== false && cfg.birthdayConfig?.enabled !== false;
modulesCache['reactionRolesEnabled'] = cfg.reactionRolesEnabled !== false && cfg.reactionRolesConfig?.enabled !== false; modulesCache['reactionRolesEnabled'] = cfg.reactionRolesEnabled !== false && cfg.reactionRolesConfig?.enabled !== false;
modulesCache['eventsEnabled'] = cfg.eventsEnabled !== false; modulesCache['eventsEnabled'] = cfg.eventsEnabled !== false;
@@ -2207,7 +2247,7 @@ router.get('/', (req, res) => {
(s.userId || '-') + (s.userId || '-') +
'</strong></div><div class="muted">Ende: ' + '</strong></div><div class="muted">Ende: ' +
formatDate(s.endedAt || Date.now()) + formatDate(s.endedAt || Date.now()) +
' Dauer: ' + ' · Dauer: ' +
dur + dur +
'</div>'; '</div>';
supportRecentList.appendChild(div); supportRecentList.appendChild(div);
@@ -2265,9 +2305,9 @@ router.get('/', (req, res) => {
(ev.repeatType || 'none') + (ev.repeatType || 'none') +
'</span></div><div class="ticket-meta">Start: ' + '</span></div><div class="ticket-meta">Start: ' +
formatDate(ev.startTime) + formatDate(ev.startTime) +
' Channel: ' + ' · Channel: ' +
(ev.channelId || '-') + (ev.channelId || '-') +
' Anmeldungen: ' + ' · Anmeldungen: ' +
(ev._count?.signups ?? 0) + (ev._count?.signups ?? 0) +
'</div>'; '</div>';
const actions = document.createElement('div'); const actions = document.createElement('div');
@@ -2480,7 +2520,7 @@ router.get('/', (req, res) => {
meta.className = 'module-meta'; meta.className = 'module-meta';
const descParts = ['Channel: ' + (set.channelId || '-')]; const descParts = ['Channel: ' + (set.channelId || '-')];
if (set.messageId) descParts.push('Message: ' + set.messageId); 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'); const actions = document.createElement('div');
actions.className = 'row'; actions.className = 'row';
const editBtn = document.createElement('button'); 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. // TODO: MODULE: Liste um Musik/Forum/Automod-Konfiguration ergaenzen.
// - Module-Status inkl. Direktlinks zu Detailseiten (Automod/Welcome/Musik) rendern. // - Module-Status inkl. Direktlinks zu Detailseiten (Automod/Welcome/Musik) rendern.
// - Module-Flags aus BotModuleService spiegeln statt doppeltem Fetch. // - Module-Flags aus BotModuleService spiegeln statt doppeltem Fetch.
@@ -2804,10 +2588,10 @@ router.get('/', (req, res) => {
list.innerHTML = ''; list.innerHTML = '';
let ticketsActive = false; let ticketsActive = false;
let statuspageActive = false; let statuspageActive = false;
let serverStatsActive = false;
let birthdayActive = false; let birthdayActive = false;
let reactionRolesActive = false; let reactionRolesActive = false;
let eventsActive = false; let eventsActive = false;
let registerActive = false;
(data.modules || []).forEach((m) => { (data.modules || []).forEach((m) => {
modulesCache[m.key] = !!m.enabled; modulesCache[m.key] = !!m.enabled;
const row = document.createElement('div'); const row = document.createElement('div');
@@ -2826,20 +2610,18 @@ router.get('/', (req, res) => {
showToast(willEnable ? m.name + ' aktiviert' : m.name + ' deaktiviert'); showToast(willEnable ? m.name + ' aktiviert' : m.name + ' deaktiviert');
modulesCache[m.key] = willEnable; modulesCache[m.key] = willEnable;
if (m.key === 'ticketsEnabled') applyTicketsVisibility(willEnable); if (m.key === 'ticketsEnabled') applyTicketsVisibility(willEnable);
if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable; if (m.key === 'automodEnabled') modulesCache['automodEnabled'] = willEnable;
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable; if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = willEnable;
if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable; if (m.key === 'statuspageEnabled') modulesCache['statuspageEnabled'] = willEnable;
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable; if (m.key === 'serverStatsEnabled') modulesCache['serverStatsEnabled'] = willEnable;
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable; if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable; if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
if (m.key === 'registerEnabled') modulesCache['registerEnabled'] = willEnable; if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
applyNavVisibility(); applyNavVisibility();
if (m.key === 'registerEnabled' && willEnable) { await loadRegisterForms(); await loadRegisterApps(); } } else {
if (m.key === 'registerEnabled' && !willEnable) { clearRegisterUi(); } showToast('Speichern fehlgeschlagen', true);
} else { }
showToast('Speichern fehlgeschlagen', true); });
}
});
row.appendChild(meta); row.appendChild(meta);
row.appendChild(toggle); row.appendChild(toggle);
list.appendChild(row); list.appendChild(row);
@@ -2848,24 +2630,24 @@ router.get('/', (req, res) => {
if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = !!m.enabled; if (m.key === 'welcomeEnabled') modulesCache['welcomeEnabled'] = !!m.enabled;
if (m.key === 'dynamicVoiceEnabled') modulesCache['dynamicVoiceEnabled'] = !!m.enabled; if (m.key === 'dynamicVoiceEnabled') modulesCache['dynamicVoiceEnabled'] = !!m.enabled;
if (m.key === 'statuspageEnabled') statuspageActive = !!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 === 'birthdayEnabled') birthdayActive = !!m.enabled;
if (m.key === 'reactionRolesEnabled') reactionRolesActive = !!m.enabled; if (m.key === 'reactionRolesEnabled') reactionRolesActive = !!m.enabled;
if (m.key === 'eventsEnabled') eventsActive = !!m.enabled; if (m.key === 'eventsEnabled') eventsActive = !!m.enabled;
if (m.key === 'registerEnabled') registerActive = !!m.enabled;
}); });
applyNavVisibility(); applyNavVisibility();
applyTicketsVisibility(ticketsActive); applyTicketsVisibility(ticketsActive);
if (statuspageActive) loadStatuspage(); if (statuspageActive) loadStatuspage();
if (serverStatsActive) loadServerStats();
if (birthdayActive) loadBirthday(); if (birthdayActive) loadBirthday();
if (reactionRolesActive) loadReactionRoles(); if (reactionRolesActive) loadReactionRoles();
if (eventsActive) loadEvents(); if (eventsActive) loadEvents();
if (registerActive) { loadRegisterForms(); loadRegisterApps(); }
} }
async function saveModuleToggle(key, enabled) { async function saveModuleToggle(key, enabled) {
if (!currentGuild) return false; if (!currentGuild) return false;
const payload = { guildId: currentGuild }; 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]; if (modulesCache[k] !== undefined) payload[k] = modulesCache[k];
}); });
payload['dynamicVoiceEnabled'] = modulesCache['dynamicVoiceEnabled']; 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'); const slaRange = document.getElementById('slaRange');
if (slaRange) slaRange.addEventListener('change', loadSla); 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'); 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 (el) el.addEventListener('click', () => el.classList.toggle('on'));
}); });
if (logSystem) logSystem.addEventListener('click', () => logSystem.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 (statuspageInterval) statuspageInterval.addEventListener('change', saveStatuspageConfig);
if (statuspageChannel) statuspageChannel.addEventListener('change', saveStatuspageConfig); if (statuspageChannel) statuspageChannel.addEventListener('change', saveStatuspageConfig);
if (statuspageAddService) statuspageAddService.addEventListener('click', addServicePrompt); 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) => { [welcomeTitle, welcomeDescription, welcomeFooter, welcomeColor].forEach((el) => {
if (el) el.addEventListener('input', updateWelcomePreview); if (el) el.addEventListener('input', updateWelcomePreview);
}); });
@@ -3256,24 +3022,3 @@ router.get('/settings', (_req, res) => {
export default router; export default router;

View File