Add Discord notification provider (#417)

This commit is contained in:
Flaminel
2026-01-13 18:53:40 +02:00
committed by GitHub
parent 6abb542271
commit 8bd6b86018
40 changed files with 2861 additions and 25 deletions

View File

@@ -152,6 +152,11 @@ export class DocumentationService {
'telegram.topicId': 'topic-id',
'telegram.sendSilently': 'send-silently'
},
'notifications/discord': {
'discord.webhookUrl': 'webhook-url',
'discord.username': 'username',
'discord.avatarUrl': 'avatar-url'
},
};
constructor(private applicationPathService: ApplicationPathService) {}

View File

@@ -230,6 +230,40 @@ export interface TestTelegramProviderRequest {
sendSilently: boolean;
}
export interface CreateDiscordProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
webhookUrl: string;
username: string;
avatarUrl: string;
}
export interface UpdateDiscordProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
webhookUrl: string;
username: string;
avatarUrl: string;
}
export interface TestDiscordProviderRequest {
webhookUrl: string;
username: string;
avatarUrl: string;
}
@Injectable({
providedIn: 'root'
})
@@ -287,6 +321,13 @@ export class NotificationProviderService {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/telegram`, provider);
}
/**
* Create a new Discord provider
*/
createDiscordProvider(provider: CreateDiscordProviderRequest): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/discord`, provider);
}
/**
* Update an existing Notifiarr provider
*/
@@ -322,6 +363,13 @@ export class NotificationProviderService {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/telegram/${id}`, provider);
}
/**
* Update an existing Discord provider
*/
updateDiscordProvider(id: string, provider: UpdateDiscordProviderRequest): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/discord/${id}`, provider);
}
/**
* Delete a notification provider
*/
@@ -364,6 +412,13 @@ export class NotificationProviderService {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/telegram/test`, testRequest);
}
/**
* Test a Discord provider (without ID - for testing configuration before saving)
*/
testDiscordProvider(testRequest: TestDiscordProviderRequest): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/discord/test`, testRequest);
}
/**
* Generic create method that delegates to provider-specific methods
*/
@@ -379,6 +434,8 @@ export class NotificationProviderService {
return this.createPushoverProvider(provider as CreatePushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.createTelegramProvider(provider as CreateTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.createDiscordProvider(provider as CreateDiscordProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
@@ -399,6 +456,8 @@ export class NotificationProviderService {
return this.updatePushoverProvider(id, provider as UpdatePushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.updateTelegramProvider(id, provider as UpdateTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.updateDiscordProvider(id, provider as UpdateDiscordProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
@@ -419,6 +478,8 @@ export class NotificationProviderService {
return this.testPushoverProvider(testRequest as TestPushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.testTelegramProvider(testRequest as TestTelegramProviderRequest);
case NotificationProviderType.Discord:
return this.testDiscordProvider(testRequest as TestDiscordProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}

View File

@@ -0,0 +1,71 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure Discord Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)">
<!-- Provider-specific configuration goes here -->
<div slot="provider-config">
<!-- Webhook URL Field -->
<div class="field">
<label for="webhook-url">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('discord.webhookUrl')"></i>
Webhook URL *
</label>
<input
id="webhook-url"
type="password"
pInputText
[formControl]="webhookUrlControl"
placeholder="https://discord.com/api/webhooks/..."
class="w-full" />
<small *ngIf="hasFieldError(webhookUrlControl, 'required')" class="form-error-text">Webhook URL is required</small>
<small *ngIf="hasFieldError(webhookUrlControl, 'pattern')" class="form-error-text">Must be a valid Discord webhook URL</small>
<small class="form-helper-text">Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations.</small>
</div>
<!-- Username Field (Optional) -->
<div class="field">
<label for="username">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('discord.username')"></i>
Username (Optional)
</label>
<input
id="username"
type="text"
pInputText
[formControl]="usernameControl"
placeholder="Cleanuparr"
class="w-full" />
<small *ngIf="hasFieldError(usernameControl, 'maxlength')" class="form-error-text">Username cannot exceed 80 characters</small>
<small class="form-helper-text">Override the default webhook username. Leave empty to use the webhook's default name.</small>
</div>
<!-- Avatar URL Field (Optional) -->
<div class="field">
<label for="avatar-url">
<i class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('discord.avatarUrl')"></i>
Avatar URL (Optional)
</label>
<input
id="avatar-url"
type="text"
pInputText
[formControl]="avatarUrlControl"
placeholder="https://example.com/avatar.png"
class="w-full" />
<small *ngIf="hasFieldError(avatarUrlControl, 'pattern')" class="form-error-text">Must be a valid URL</small>
<small class="form-helper-text">Override the default webhook avatar. Leave empty to use the webhook's default avatar.</small>
</div>
</div>
</app-notification-provider-base>

View File

@@ -0,0 +1 @@
@use '../../../styles/settings-shared.scss';

View File

@@ -0,0 +1,117 @@
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { InputTextModule } from 'primeng/inputtext';
import { DiscordFormData, BaseProviderFormData } from '../../models/provider-modal.model';
import { DocumentationService } from '../../../../core/services/documentation.service';
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component';
@Component({
selector: 'app-discord-provider',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
InputTextModule,
NotificationProviderBaseComponent
],
templateUrl: './discord-provider.component.html',
styleUrls: ['./discord-provider.component.scss']
})
export class DiscordProviderComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Input() saving = false;
@Input() testing = false;
@Output() save = new EventEmitter<DiscordFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<DiscordFormData>();
// Provider-specific form controls
webhookUrlControl = new FormControl('', [Validators.required, Validators.pattern(/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/.+/)]);
usernameControl = new FormControl('', [Validators.maxLength(80)]);
avatarUrlControl = new FormControl('', [Validators.pattern(/^(https?:\/\/.+)?$/)]);
private documentationService = inject(DocumentationService);
/** Exposed for template to open documentation for discord fields */
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('notifications/discord', fieldName);
}
ngOnInit(): void {
// Initialize component but don't populate yet - wait for ngOnChanges
}
ngOnChanges(changes: SimpleChanges): void {
// Populate provider-specific fields when editingProvider input changes
if (changes['editingProvider']) {
if (this.editingProvider) {
this.populateProviderFields();
} else {
// Reset fields when editingProvider is cleared
this.resetProviderFields();
}
}
}
private populateProviderFields(): void {
if (this.editingProvider) {
const config = this.editingProvider.configuration as any;
this.webhookUrlControl.setValue(config?.webhookUrl || '');
this.usernameControl.setValue(config?.username || '');
this.avatarUrlControl.setValue(config?.avatarUrl || '');
}
}
private resetProviderFields(): void {
this.webhookUrlControl.setValue('');
this.usernameControl.setValue('');
this.avatarUrlControl.setValue('https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true');
}
protected hasFieldError(control: FormControl, errorType: string): boolean {
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
}
onSave(baseData: BaseProviderFormData): void {
if (this.webhookUrlControl.valid && this.usernameControl.valid && this.avatarUrlControl.valid) {
const discordData: DiscordFormData = {
...baseData,
webhookUrl: this.webhookUrlControl.value || '',
username: this.usernameControl.value || '',
avatarUrl: this.avatarUrlControl.value || ''
};
this.save.emit(discordData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.webhookUrlControl.markAsTouched();
this.usernameControl.markAsTouched();
this.avatarUrlControl.markAsTouched();
}
}
onCancel(): void {
this.cancel.emit();
}
onTest(baseData: BaseProviderFormData): void {
if (this.webhookUrlControl.valid && this.usernameControl.valid && this.avatarUrlControl.valid) {
const discordData: DiscordFormData = {
...baseData,
webhookUrl: this.webhookUrlControl.value || '',
username: this.usernameControl.value || '',
avatarUrl: this.avatarUrlControl.value || ''
};
this.test.emit(discordData);
} else {
// Mark provider-specific fields as touched to show validation errors
this.webhookUrlControl.markAsTouched();
this.usernameControl.markAsTouched();
this.avatarUrlControl.markAsTouched();
}
}
}

View File

@@ -57,8 +57,15 @@ export class ProviderTypeSelectionComponent {
iconUrl: 'icons/ext/telegram-light.svg',
iconUrlHover: 'icons/ext/telegram.svg',
description: 'https://core.telegram.org/bots'
}
];
},
{
type: NotificationProviderType.Discord,
name: 'Discord',
iconUrl: 'icons/ext/discord-light.svg',
iconUrlHover: 'icons/ext/discord.svg',
description: 'https://discord.com'
},
].sort((a, b) => a.name.localeCompare(b.name));
selectProvider(type: NotificationProviderType) {
this.providerSelected.emit(type);

View File

@@ -72,6 +72,12 @@ export interface TelegramFormData extends BaseProviderFormData {
sendSilently: boolean;
}
export interface DiscordFormData extends BaseProviderFormData {
webhookUrl: string;
username: string;
avatarUrl: string;
}
// Events for modal communication
export interface ProviderModalEvents {
save: (data: any) => void;

View File

@@ -135,7 +135,6 @@
<h3>Provider Type Not Yet Supported</h3>
<p class="text-color-secondary">
This provider type is not yet supported by the new modal system.
<br>Please select Notifiarr or Apprise for now.
</p>
<button
pButton
@@ -209,6 +208,17 @@
(test)="onTelegramTest($event)"
></app-telegram-provider>
<!-- Discord Provider Modal -->
<app-discord-provider
[visible]="showDiscordModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onDiscordSave($event)"
(cancel)="onProviderCancel()"
(test)="onDiscordTest($event)"
></app-discord-provider>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -8,7 +8,7 @@ import {
} from "../../shared/models/notification-provider.model";
import { NotificationProviderType } from "../../shared/models/enums";
import { DocumentationService } from "../../core/services/documentation.service";
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData } from "./models/provider-modal.model";
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData, DiscordFormData } from "./models/provider-modal.model";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
// New modal components
@@ -18,6 +18,7 @@ import { AppriseProviderComponent } from "./modals/apprise-provider/apprise-prov
import { NtfyProviderComponent } from "./modals/ntfy-provider/ntfy-provider.component";
import { PushoverProviderComponent } from "./modals/pushover-provider/pushover-provider.component";
import { TelegramProviderComponent } from "./modals/telegram-provider/telegram-provider.component";
import { DiscordProviderComponent } from "./modals/discord-provider/discord-provider.component";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -55,6 +56,7 @@ import { NotificationService } from "../../core/services/notification.service";
NtfyProviderComponent,
PushoverProviderComponent,
TelegramProviderComponent,
DiscordProviderComponent,
],
providers: [NotificationProviderConfigStore, ConfirmationService, MessageService],
templateUrl: "./notification-settings.component.html",
@@ -66,12 +68,13 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
// Modal state
showProviderModal = false; // Legacy modal for unsupported types
showTypeSelectionModal = false; // New: Provider type selection modal
showNotifiarrModal = false; // New: Notifiarr provider modal
showAppriseModal = false; // New: Apprise provider modal
showNtfyModal = false; // New: Ntfy provider modal
showPushoverModal = false; // New: Pushover provider modal
showTelegramModal = false; // New: Telegram provider modal
showTypeSelectionModal = false;
showNotifiarrModal = false;
showAppriseModal = false;
showNtfyModal = false;
showPushoverModal = false;
showTelegramModal = false;
showDiscordModal = false;
modalMode: 'add' | 'edit' = 'add';
editingProvider: NotificationProviderDto | null = null;
@@ -186,6 +189,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Telegram:
this.showTelegramModal = true;
break;
case NotificationProviderType.Discord:
this.showDiscordModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
@@ -242,6 +248,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Telegram:
this.showTelegramModal = true;
break;
case NotificationProviderType.Discord:
this.showDiscordModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
@@ -327,6 +336,14 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
sendSilently: telegramConfig.sendSilently || false,
};
break;
case NotificationProviderType.Discord:
const discordConfig = provider.configuration as any;
testRequest = {
webhookUrl: discordConfig.webhookUrl,
username: discordConfig.username || "",
avatarUrl: discordConfig.avatarUrl || "",
};
break;
default:
this.notificationService.showError("Testing not supported for this provider type");
return;
@@ -369,6 +386,8 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
return "Pushover";
case NotificationProviderType.Telegram:
return "Telegram";
case NotificationProviderType.Discord:
return "Discord";
default:
return "Unknown";
}
@@ -537,6 +556,33 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
});
}
/**
* Handle Discord provider save
*/
onDiscordSave(data: DiscordFormData): void {
if (this.modalMode === "edit" && this.editingProvider) {
this.updateDiscordProvider(data);
} else {
this.createDiscordProvider(data);
}
}
/**
* Handle Discord provider test
*/
onDiscordTest(data: DiscordFormData): void {
const testRequest = {
webhookUrl: data.webhookUrl,
username: data.username,
avatarUrl: data.avatarUrl,
};
this.notificationProviderStore.testProvider({
testRequest,
type: NotificationProviderType.Discord,
});
}
/**
* Handle provider modal cancel
*/
@@ -554,6 +600,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
this.showNtfyModal = false;
this.showPushoverModal = false;
this.showTelegramModal = false;
this.showDiscordModal = false;
this.showProviderModal = false;
this.editingProvider = null;
this.notificationProviderStore.clearTestResult();
@@ -848,6 +895,59 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
this.monitorProviderOperation("updated");
}
/**
* Create new Discord provider
*/
private createDiscordProvider(data: DiscordFormData): void {
const createDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
webhookUrl: data.webhookUrl,
username: data.username,
avatarUrl: data.avatarUrl,
};
this.notificationProviderStore.createProvider({
provider: createDto,
type: NotificationProviderType.Discord,
});
this.monitorProviderOperation("created");
}
/**
* Update existing Discord provider
*/
private updateDiscordProvider(data: DiscordFormData): void {
if (!this.editingProvider) return;
const updateDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
webhookUrl: data.webhookUrl,
username: data.username,
avatarUrl: data.avatarUrl,
};
this.notificationProviderStore.updateProvider({
id: this.editingProvider.id,
provider: updateDto,
type: NotificationProviderType.Discord,
});
this.monitorProviderOperation("updated");
}
/**
* Monitor provider operation completion and close modals
*/

View File

@@ -16,6 +16,7 @@ export enum NotificationProviderType {
Ntfy = "Ntfy",
Pushover = "Pushover",
Telegram = "Telegram",
Discord = "Discord",
}
export enum AppriseMode {