mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
This commit is contained in:
@@ -150,7 +150,11 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
return { success: false, error: 'Username must be at least 3 characters' };
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||
if (!password) {
|
||||
return { success: false, error: 'Password is required' };
|
||||
}
|
||||
if (!allowWeakPassword && password.length < 8) {
|
||||
return { success: false, error: 'Password must be at least 8 characters' };
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface DownloadClientConfig {
|
||||
localPath?: string;
|
||||
category?: string; // Default: 'readmeabook'
|
||||
customPath?: string; // Relative sub-path appended to download_dir
|
||||
postImportCategory?: string; // Category to assign after import (torrent clients only)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
/**
|
||||
* Component: Notification Service
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { prisma } from '../db';
|
||||
|
||||
const logger = RMABLogger.create('NotificationService');
|
||||
|
||||
// Event types
|
||||
export type NotificationEvent =
|
||||
| 'request_pending_approval'
|
||||
| 'request_approved'
|
||||
| 'request_available'
|
||||
| 'request_error';
|
||||
|
||||
// Backend types
|
||||
export type NotificationBackendType =
|
||||
| 'discord'
|
||||
| 'pushover'
|
||||
| 'email'
|
||||
| 'slack'
|
||||
| 'telegram'
|
||||
| 'webhook';
|
||||
|
||||
// Config interfaces
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
appToken: string;
|
||||
device?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export type NotificationConfig = DiscordConfig | PushoverConfig;
|
||||
|
||||
// Notification payload
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error events
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Discord embed colors by event type
|
||||
const DISCORD_COLORS = {
|
||||
request_pending_approval: 0xfbbf24, // yellow-400
|
||||
request_approved: 0x22c55e, // green-500
|
||||
request_available: 0x3b82f6, // blue-500
|
||||
request_error: 0xef4444, // red-500
|
||||
};
|
||||
|
||||
// Discord embed titles
|
||||
const DISCORD_TITLES = {
|
||||
request_pending_approval: '📬 New Request Pending Approval',
|
||||
request_approved: '✅ Request Approved',
|
||||
request_available: '🎉 Audiobook Available',
|
||||
request_error: '❌ Request Error',
|
||||
};
|
||||
|
||||
// Pushover priorities
|
||||
const PUSHOVER_PRIORITIES = {
|
||||
request_pending_approval: 0, // Normal
|
||||
request_approved: 0, // Normal
|
||||
request_available: 1, // High
|
||||
request_error: 1, // High
|
||||
};
|
||||
|
||||
export class NotificationService {
|
||||
private encryptionService = getEncryptionService();
|
||||
|
||||
/**
|
||||
* Send notification to all enabled backends subscribed to the event
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<void> {
|
||||
try {
|
||||
// Get all enabled backends subscribed to this event
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
events: {
|
||||
array_contains: payload.event,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (backends.length === 0) {
|
||||
logger.debug(`No backends subscribed to event: ${payload.event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Sending notification to ${backends.length} backend(s)`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Send to all backends in parallel (atomic per-backend)
|
||||
const results = await Promise.allSettled(
|
||||
backends.map((backend) =>
|
||||
this.sendToBackend(backend.type as NotificationBackendType, backend.config, payload)
|
||||
)
|
||||
);
|
||||
|
||||
// Log results
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Log individual failures
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
logger.error(`Failed to send to backend ${backends[index].name}`, {
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
backend: backends[index].type,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification to type-specific sender
|
||||
*/
|
||||
private async sendToBackend(
|
||||
type: NotificationBackendType,
|
||||
config: any,
|
||||
payload: NotificationPayload
|
||||
): Promise<void> {
|
||||
// Decrypt config
|
||||
const decryptedConfig = this.decryptConfig(config);
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
return this.sendDiscord(decryptedConfig as DiscordConfig, payload);
|
||||
case 'pushover':
|
||||
return this.sendPushover(decryptedConfig as PushoverConfig, payload);
|
||||
default:
|
||||
throw new Error(`Unsupported backend type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Discord webhook notification
|
||||
*/
|
||||
private async sendDiscord(config: DiscordConfig, payload: NotificationPayload): Promise<void> {
|
||||
const embed = this.formatDiscordEmbed(payload);
|
||||
|
||||
const body = {
|
||||
username: config.username || 'ReadMeABook',
|
||||
avatar_url: config.avatarUrl,
|
||||
embeds: [embed],
|
||||
};
|
||||
|
||||
const response = await fetch(config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Pushover notification
|
||||
*/
|
||||
private async sendPushover(config: PushoverConfig, payload: NotificationPayload): Promise<void> {
|
||||
const { title, message } = this.formatPushoverMessage(payload);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
token: config.appToken,
|
||||
user: config.userKey,
|
||||
title,
|
||||
message,
|
||||
priority: String(config.priority ?? PUSHOVER_PRIORITIES[payload.event]),
|
||||
...(config.device && { device: config.device }),
|
||||
});
|
||||
|
||||
const response = await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status !== 1) {
|
||||
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Discord rich embed
|
||||
*/
|
||||
private formatDiscordEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
|
||||
const fields = [
|
||||
{ name: 'Title', value: title, inline: false },
|
||||
{ name: 'Author', value: author, inline: true },
|
||||
{ name: 'Requested By', value: userName, inline: true },
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
title: DISCORD_TITLES[event],
|
||||
color: DISCORD_COLORS[event],
|
||||
fields,
|
||||
footer: {
|
||||
text: `Request ID: ${requestId}`,
|
||||
},
|
||||
timestamp: timestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Pushover message
|
||||
*/
|
||||
private formatPushoverMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
let eventTitle = '';
|
||||
let eventEmoji = '';
|
||||
|
||||
switch (event) {
|
||||
case 'request_pending_approval':
|
||||
eventTitle = 'New Request Pending Approval';
|
||||
eventEmoji = '📬';
|
||||
break;
|
||||
case 'request_approved':
|
||||
eventTitle = 'Request Approved';
|
||||
eventEmoji = '✅';
|
||||
break;
|
||||
case 'request_available':
|
||||
eventTitle = 'Audiobook Available';
|
||||
eventEmoji = '🎉';
|
||||
break;
|
||||
case 'request_error':
|
||||
eventTitle = 'Request Error';
|
||||
eventEmoji = '❌';
|
||||
break;
|
||||
}
|
||||
|
||||
const messageLines = [
|
||||
`${eventEmoji} ${eventTitle}`,
|
||||
'',
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', `⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitle,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(config: any): any {
|
||||
const decrypted = { ...config };
|
||||
|
||||
// Discord: decrypt webhookUrl
|
||||
if (decrypted.webhookUrl && this.isEncrypted(decrypted.webhookUrl)) {
|
||||
decrypted.webhookUrl = this.encryptionService.decrypt(decrypted.webhookUrl);
|
||||
}
|
||||
|
||||
// Pushover: decrypt userKey and appToken
|
||||
if (decrypted.userKey && this.isEncrypted(decrypted.userKey)) {
|
||||
decrypted.userKey = this.encryptionService.decrypt(decrypted.userKey);
|
||||
}
|
||||
if (decrypted.appToken && this.isEncrypted(decrypted.appToken)) {
|
||||
decrypted.appToken = this.encryptionService.decrypt(decrypted.appToken);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive config values before saving
|
||||
*/
|
||||
encryptConfig(type: NotificationBackendType, config: any): any {
|
||||
const encrypted = { ...config };
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
if (encrypted.webhookUrl && !this.isEncrypted(encrypted.webhookUrl)) {
|
||||
encrypted.webhookUrl = this.encryptionService.encrypt(encrypted.webhookUrl);
|
||||
}
|
||||
break;
|
||||
case 'pushover':
|
||||
if (encrypted.userKey && !this.isEncrypted(encrypted.userKey)) {
|
||||
encrypted.userKey = this.encryptionService.encrypt(encrypted.userKey);
|
||||
}
|
||||
if (encrypted.appToken && !this.isEncrypted(encrypted.appToken)) {
|
||||
encrypted.appToken = this.encryptionService.encrypt(encrypted.appToken);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive config values for API responses
|
||||
*/
|
||||
maskConfig(type: NotificationBackendType, config: any): any {
|
||||
const masked = { ...config };
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
if (masked.webhookUrl) {
|
||||
masked.webhookUrl = '••••••••';
|
||||
}
|
||||
break;
|
||||
case 'pushover':
|
||||
if (masked.userKey) {
|
||||
masked.userKey = '••••••••';
|
||||
}
|
||||
if (masked.appToken) {
|
||||
masked.appToken = '••••••••';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationService: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationService) {
|
||||
notificationService = new NotificationService();
|
||||
}
|
||||
return notificationService;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Notification Provider Interface
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
// Event types
|
||||
export type NotificationEvent =
|
||||
| 'request_pending_approval'
|
||||
| 'request_approved'
|
||||
| 'request_available'
|
||||
| 'request_error';
|
||||
|
||||
// Backend type — string-based, registry is the runtime source of truth
|
||||
export type NotificationBackendType = string;
|
||||
|
||||
// Notification payload
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error events
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Provider config field definition for dynamic UI rendering
|
||||
export interface ProviderConfigField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'select' | 'number';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
options?: { label: string; value: string | number }[];
|
||||
}
|
||||
|
||||
// Provider metadata for self-describing providers
|
||||
export interface ProviderMetadata {
|
||||
type: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
iconLabel: string;
|
||||
iconColor: string;
|
||||
configFields: ProviderConfigField[];
|
||||
}
|
||||
|
||||
export interface INotificationProvider {
|
||||
/** Provider identifier */
|
||||
type: string;
|
||||
|
||||
/** Config field names that need encryption/masking */
|
||||
sensitiveFields: string[];
|
||||
|
||||
/** Self-describing metadata for UI and validation */
|
||||
metadata: ProviderMetadata;
|
||||
|
||||
/** Send notification with already-decrypted config */
|
||||
send(config: Record<string, any>, payload: NotificationPayload): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Notification Service - Public API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
// Interface + shared types
|
||||
export type {
|
||||
INotificationProvider,
|
||||
NotificationEvent,
|
||||
NotificationBackendType,
|
||||
NotificationPayload,
|
||||
ProviderConfigField,
|
||||
ProviderMetadata,
|
||||
} from './INotificationProvider';
|
||||
|
||||
// Core service
|
||||
export {
|
||||
NotificationService,
|
||||
getNotificationService,
|
||||
registerProvider,
|
||||
getProvider,
|
||||
getRegisteredProviderTypes,
|
||||
getAllProviderMetadata,
|
||||
} from './notification.service';
|
||||
|
||||
// Provider types
|
||||
export type { AppriseConfig } from './providers/apprise.provider';
|
||||
export type { DiscordConfig } from './providers/discord.provider';
|
||||
export type { NtfyConfig } from './providers/ntfy.provider';
|
||||
export type { PushoverConfig } from './providers/pushover.provider';
|
||||
|
||||
// Provider classes
|
||||
export { AppriseProvider } from './providers/apprise.provider';
|
||||
export { DiscordProvider } from './providers/discord.provider';
|
||||
export { NtfyProvider } from './providers/ntfy.provider';
|
||||
export { PushoverProvider } from './providers/pushover.provider';
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Component: Notification Service
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { getEncryptionService } from '../encryption.service';
|
||||
import { RMABLogger } from '../../utils/logger';
|
||||
import { prisma } from '../../db';
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from './INotificationProvider';
|
||||
import { AppriseProvider } from './providers/apprise.provider';
|
||||
import { DiscordProvider } from './providers/discord.provider';
|
||||
import { NtfyProvider } from './providers/ntfy.provider';
|
||||
import { PushoverProvider } from './providers/pushover.provider';
|
||||
|
||||
const logger = RMABLogger.create('NotificationService');
|
||||
|
||||
// Provider registry
|
||||
const providers = new Map<string, INotificationProvider>();
|
||||
|
||||
export function registerProvider(provider: INotificationProvider): void {
|
||||
providers.set(provider.type, provider);
|
||||
}
|
||||
|
||||
export function getProvider(type: string): INotificationProvider | undefined {
|
||||
return providers.get(type);
|
||||
}
|
||||
|
||||
// Register built-in providers
|
||||
registerProvider(new AppriseProvider());
|
||||
registerProvider(new DiscordProvider());
|
||||
registerProvider(new NtfyProvider());
|
||||
registerProvider(new PushoverProvider());
|
||||
|
||||
export function getRegisteredProviderTypes(): string[] {
|
||||
return Array.from(providers.keys());
|
||||
}
|
||||
|
||||
export function getAllProviderMetadata(): ProviderMetadata[] {
|
||||
return Array.from(providers.values()).map((p) => p.metadata);
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
private encryptionService = getEncryptionService();
|
||||
|
||||
/**
|
||||
* Send notification to all enabled backends subscribed to the event
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<void> {
|
||||
try {
|
||||
// Get all enabled backends subscribed to this event
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
events: {
|
||||
array_contains: payload.event,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (backends.length === 0) {
|
||||
logger.debug(`No backends subscribed to event: ${payload.event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Sending notification to ${backends.length} backend(s)`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Send to all backends in parallel (atomic per-backend)
|
||||
const results = await Promise.allSettled(
|
||||
backends.map((backend) =>
|
||||
this.sendToBackend(backend.type, backend.config, payload)
|
||||
)
|
||||
);
|
||||
|
||||
// Log results
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Log individual failures
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
logger.error(`Failed to send to backend ${backends[index].name}`, {
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
backend: backends[index].type,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification to type-specific provider
|
||||
*/
|
||||
async sendToBackend(
|
||||
type: string,
|
||||
config: any,
|
||||
payload: NotificationPayload
|
||||
): Promise<void> {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
throw new Error(`Unsupported backend type: ${type}`);
|
||||
}
|
||||
|
||||
const decryptedConfig = this.decryptConfig(provider.sensitiveFields, config);
|
||||
return provider.send(decryptedConfig, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive config values before saving
|
||||
*/
|
||||
encryptConfig(type: string, config: any): any {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
const encrypted = { ...config };
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (encrypted[field] && !this.isEncrypted(encrypted[field])) {
|
||||
encrypted[field] = this.encryptionService.encrypt(encrypted[field]);
|
||||
}
|
||||
}
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive config values for API responses
|
||||
*/
|
||||
maskConfig(type: string, config: any): any {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
const masked = { ...config };
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (masked[field]) {
|
||||
masked[field] = '••••••••';
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(sensitiveFields: string[], config: any): any {
|
||||
const decrypted = { ...config };
|
||||
for (const field of sensitiveFields) {
|
||||
if (decrypted[field] && this.isEncrypted(decrypted[field])) {
|
||||
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationService: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationService) {
|
||||
notificationService = new NotificationService();
|
||||
}
|
||||
return notificationService;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Component: Apprise Notification Provider
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
|
||||
export interface AppriseConfig {
|
||||
serverUrl: string;
|
||||
urls?: string;
|
||||
configKey?: string;
|
||||
tag?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
// Apprise notification types by event
|
||||
const APPRISE_TYPES: Record<string, string> = {
|
||||
request_pending_approval: 'info',
|
||||
request_approved: 'success',
|
||||
request_available: 'success',
|
||||
request_error: 'failure',
|
||||
};
|
||||
|
||||
export class AppriseProvider implements INotificationProvider {
|
||||
type = 'apprise' as const;
|
||||
sensitiveFields = ['urls', 'authToken'];
|
||||
metadata: ProviderMetadata = {
|
||||
type: 'apprise',
|
||||
displayName: 'Apprise',
|
||||
description: 'Send notifications via Apprise API to 100+ services',
|
||||
iconLabel: 'A',
|
||||
iconColor: 'bg-purple-500',
|
||||
configFields: [
|
||||
{ name: 'serverUrl', label: 'Server URL', type: 'text', required: true, placeholder: 'http://apprise:8000' },
|
||||
{ name: 'urls', label: 'Notification URLs', type: 'password', required: false, placeholder: 'slack://token, discord://webhook_id/token, ...' },
|
||||
{ name: 'configKey', label: 'Config Key', type: 'text', required: false, placeholder: 'Persistent configuration key' },
|
||||
{ name: 'tag', label: 'Tag', type: 'text', required: false, placeholder: 'Filter tag for stateful config' },
|
||||
{ name: 'authToken', label: 'Auth Token', type: 'password', required: false, placeholder: 'Optional API auth token' },
|
||||
],
|
||||
};
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const appriseConfig = config as unknown as AppriseConfig;
|
||||
const { title, body } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
|
||||
const notificationType = APPRISE_TYPES[payload.event] || 'info';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (appriseConfig.authToken) {
|
||||
headers['Authorization'] = `Bearer ${appriseConfig.authToken}`;
|
||||
}
|
||||
|
||||
// Stateful mode: use configKey endpoint
|
||||
if (appriseConfig.configKey) {
|
||||
const url = `${serverUrl}/notify/${appriseConfig.configKey}`;
|
||||
const requestBody: Record<string, string> = {
|
||||
title,
|
||||
body,
|
||||
type: notificationType,
|
||||
};
|
||||
|
||||
if (appriseConfig.tag) {
|
||||
requestBody.tag = appriseConfig.tag;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Apprise API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Stateless mode: send URLs directly
|
||||
if (!appriseConfig.urls) {
|
||||
throw new Error('Apprise requires either notification URLs or a config key');
|
||||
}
|
||||
|
||||
const url = `${serverUrl}/notify/`;
|
||||
const requestBody = {
|
||||
urls: appriseConfig.urls,
|
||||
title,
|
||||
body,
|
||||
type: notificationType,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Apprise API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
const eventTitles: Record<string, string> = {
|
||||
request_pending_approval: 'New Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
const messageLines = [
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(`⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitles[event],
|
||||
body: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Component: Discord Notification Provider
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
// Discord embed colors by event type
|
||||
const DISCORD_COLORS = {
|
||||
request_pending_approval: 0xfbbf24, // yellow-400
|
||||
request_approved: 0x22c55e, // green-500
|
||||
request_available: 0x3b82f6, // blue-500
|
||||
request_error: 0xef4444, // red-500
|
||||
};
|
||||
|
||||
// Discord embed titles
|
||||
const DISCORD_TITLES = {
|
||||
request_pending_approval: '📬 New Request Pending Approval',
|
||||
request_approved: '✅ Request Approved',
|
||||
request_available: '🎉 Audiobook Available',
|
||||
request_error: '❌ Request Error',
|
||||
};
|
||||
|
||||
export class DiscordProvider implements INotificationProvider {
|
||||
type = 'discord' as const;
|
||||
sensitiveFields = ['webhookUrl'];
|
||||
metadata: ProviderMetadata = {
|
||||
type: 'discord',
|
||||
displayName: 'Discord',
|
||||
description: 'Send notifications via Discord webhook',
|
||||
iconLabel: 'D',
|
||||
iconColor: 'bg-indigo-500',
|
||||
configFields: [
|
||||
{ name: 'webhookUrl', label: 'Webhook URL', type: 'text', required: true, placeholder: 'https://discord.com/api/webhooks/...' },
|
||||
{ name: 'username', label: 'Username', type: 'text', required: false, placeholder: 'ReadMeABook', defaultValue: 'ReadMeABook' },
|
||||
{ name: 'avatarUrl', label: 'Avatar URL', type: 'text', required: false, placeholder: 'https://example.com/avatar.png', defaultValue: '' },
|
||||
],
|
||||
};
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const discordConfig = config as unknown as DiscordConfig;
|
||||
const embed = this.formatEmbed(payload);
|
||||
|
||||
const body = {
|
||||
username: discordConfig.username || 'ReadMeABook',
|
||||
avatar_url: discordConfig.avatarUrl,
|
||||
embeds: [embed],
|
||||
};
|
||||
|
||||
const response = await fetch(discordConfig.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
|
||||
const fields = [
|
||||
{ name: 'Title', value: title, inline: false },
|
||||
{ name: 'Author', value: author, inline: true },
|
||||
{ name: 'Requested By', value: userName, inline: true },
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
title: DISCORD_TITLES[event],
|
||||
color: DISCORD_COLORS[event],
|
||||
fields,
|
||||
footer: {
|
||||
text: `Request ID: ${requestId}`,
|
||||
},
|
||||
timestamp: timestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Component: ntfy Notification Provider
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
|
||||
export interface NtfyConfig {
|
||||
serverUrl?: string;
|
||||
topic: string;
|
||||
accessToken?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SERVER_URL = 'https://ntfy.sh';
|
||||
|
||||
// ntfy priorities by event type (1=min, 2=low, 3=default, 4=high, 5=urgent)
|
||||
const NTFY_PRIORITIES = {
|
||||
request_pending_approval: 3, // Default
|
||||
request_approved: 3, // Default
|
||||
request_available: 4, // High
|
||||
request_error: 4, // High
|
||||
};
|
||||
|
||||
// ntfy tags (emojis) by event type
|
||||
const NTFY_TAGS = {
|
||||
request_pending_approval: ['mailbox_with_mail'],
|
||||
request_approved: ['white_check_mark'],
|
||||
request_available: ['tada'],
|
||||
request_error: ['x'],
|
||||
};
|
||||
|
||||
export class NtfyProvider implements INotificationProvider {
|
||||
type = 'ntfy' as const;
|
||||
sensitiveFields = ['accessToken'];
|
||||
metadata: ProviderMetadata = {
|
||||
type: 'ntfy',
|
||||
displayName: 'ntfy',
|
||||
description: 'Send notifications via ntfy pub/sub',
|
||||
iconLabel: 'N',
|
||||
iconColor: 'bg-teal-500',
|
||||
configFields: [
|
||||
{ name: 'serverUrl', label: 'Server URL', type: 'text', required: false, placeholder: 'https://ntfy.sh', defaultValue: 'https://ntfy.sh' },
|
||||
{ name: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'readmeabook' },
|
||||
{ name: 'accessToken', label: 'Access Token', type: 'password', required: false, placeholder: 'tk_...' },
|
||||
],
|
||||
};
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const ntfyConfig = config as unknown as NtfyConfig;
|
||||
const { title, message } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
|
||||
const url = `${serverUrl}/${ntfyConfig.topic}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (ntfyConfig.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${ntfyConfig.accessToken}`;
|
||||
}
|
||||
|
||||
const body = {
|
||||
topic: ntfyConfig.topic,
|
||||
title,
|
||||
message,
|
||||
priority: ntfyConfig.priority ?? NTFY_PRIORITIES[payload.event],
|
||||
tags: NTFY_TAGS[payload.event],
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`ntfy API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
const eventTitles = {
|
||||
request_pending_approval: 'New Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
const messageLines = [
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(`⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitles[event],
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Component: Pushover Notification Provider
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
appToken: string;
|
||||
device?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// Pushover priorities by event type
|
||||
const PUSHOVER_PRIORITIES = {
|
||||
request_pending_approval: 0, // Normal
|
||||
request_approved: 0, // Normal
|
||||
request_available: 1, // High
|
||||
request_error: 1, // High
|
||||
};
|
||||
|
||||
export class PushoverProvider implements INotificationProvider {
|
||||
type = 'pushover' as const;
|
||||
sensitiveFields = ['userKey', 'appToken'];
|
||||
metadata: ProviderMetadata = {
|
||||
type: 'pushover',
|
||||
displayName: 'Pushover',
|
||||
description: 'Send notifications via Pushover API',
|
||||
iconLabel: 'P',
|
||||
iconColor: 'bg-blue-500',
|
||||
configFields: [
|
||||
{ name: 'userKey', label: 'User Key', type: 'text', required: true, placeholder: 'Your Pushover user key' },
|
||||
{ name: 'appToken', label: 'App Token', type: 'text', required: true, placeholder: 'Your Pushover app token' },
|
||||
{ name: 'device', label: 'Device', type: 'text', required: false, placeholder: 'Optional device name' },
|
||||
{
|
||||
name: 'priority', label: 'Priority', type: 'select', required: false, defaultValue: 0,
|
||||
options: [
|
||||
{ label: 'Lowest', value: -2 },
|
||||
{ label: 'Low', value: -1 },
|
||||
{ label: 'Normal', value: 0 },
|
||||
{ label: 'High', value: 1 },
|
||||
{ label: 'Emergency', value: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const pushoverConfig = config as unknown as PushoverConfig;
|
||||
const { title, message } = this.formatMessage(payload);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
token: pushoverConfig.appToken,
|
||||
user: pushoverConfig.userKey,
|
||||
title,
|
||||
message,
|
||||
priority: String(pushoverConfig.priority ?? PUSHOVER_PRIORITIES[payload.event]),
|
||||
...(pushoverConfig.device && { device: pushoverConfig.device }),
|
||||
});
|
||||
|
||||
const response = await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status !== 1) {
|
||||
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
let eventTitle = '';
|
||||
let eventEmoji = '';
|
||||
|
||||
switch (event) {
|
||||
case 'request_pending_approval':
|
||||
eventTitle = 'New Request Pending Approval';
|
||||
eventEmoji = '📬';
|
||||
break;
|
||||
case 'request_approved':
|
||||
eventTitle = 'Request Approved';
|
||||
eventEmoji = '✅';
|
||||
break;
|
||||
case 'request_available':
|
||||
eventTitle = 'Audiobook Available';
|
||||
eventEmoji = '🎉';
|
||||
break;
|
||||
case 'request_error':
|
||||
eventTitle = 'Request Error';
|
||||
eventEmoji = '❌';
|
||||
break;
|
||||
}
|
||||
|
||||
const messageLines = [
|
||||
`${eventEmoji} ${eventTitle}`,
|
||||
'',
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', `⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitle,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user