diff --git a/Dockerfile b/Dockerfile index 2f96199a52..70a9dfe76e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,8 +61,12 @@ RUN apt-get update && \ git \ ca-certificates \ gosu \ - tini && \ - rm -rf /var/lib/apt/lists/* + tini \ + python3 \ + python3-venv \ + pipx && \ + rm -rf /var/lib/apt/lists/* && \ + PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise # Replace the node base image's default user so we own uid 1000 RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -m kandev diff --git a/apps/backend/internal/notifications/controller/controller.go b/apps/backend/internal/notifications/controller/controller.go index 8307338e86..18b4c59956 100644 --- a/apps/backend/internal/notifications/controller/controller.go +++ b/apps/backend/internal/notifications/controller/controller.go @@ -84,6 +84,10 @@ func (c *Controller) DeleteProvider(ctx context.Context, providerID string) erro return c.service.DeleteProvider(ctx, providerID) } +func (c *Controller) TestProvider(ctx context.Context, providerID string) error { + return c.service.TestProvider(ctx, providerID) +} + func defaultNameForType(providerType string) string { switch providerType { case string(models.ProviderTypeApprise): diff --git a/apps/backend/internal/notifications/handlers/handlers.go b/apps/backend/internal/notifications/handlers/handlers.go index 5d4041e9f0..24f73e26db 100644 --- a/apps/backend/internal/notifications/handlers/handlers.go +++ b/apps/backend/internal/notifications/handlers/handlers.go @@ -1,6 +1,7 @@ package handlers import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -8,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/notifications/controller" "github.com/kandev/kandev/internal/notifications/dto" + "github.com/kandev/kandev/internal/notifications/service" "go.uber.org/zap" ) @@ -26,6 +28,7 @@ func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, log *logger api.POST("/notification-providers", h.httpCreateProvider) api.PATCH("/notification-providers/:id", h.httpUpdateProvider) api.DELETE("/notification-providers/:id", h.httpDeleteProvider) + api.POST("/notification-providers/:id/test", h.httpTestProvider) } func (h *Handlers) httpListProviders(c *gin.Context) { @@ -69,6 +72,20 @@ func (h *Handlers) httpUpdateProvider(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *Handlers) httpTestProvider(c *gin.Context) { + providerID := c.Param("id") + if err := h.controller.TestProvider(c.Request.Context(), providerID); err != nil { + if errors.Is(err, service.ErrProviderNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + h.logger.Error("test notification failed", zap.String("provider_id", providerID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send test notification"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + func (h *Handlers) httpDeleteProvider(c *gin.Context) { providerID := c.Param("id") if err := h.controller.DeleteProvider(c.Request.Context(), providerID); err != nil { diff --git a/apps/backend/internal/notifications/providers/assets/icon.png b/apps/backend/internal/notifications/providers/assets/icon.png new file mode 100644 index 0000000000..1f6a01077c Binary files /dev/null and b/apps/backend/internal/notifications/providers/assets/icon.png differ diff --git a/apps/backend/internal/notifications/providers/system_assets.go b/apps/backend/internal/notifications/providers/system_assets.go index 0cce8ec10c..e1a0cf0bce 100644 --- a/apps/backend/internal/notifications/providers/system_assets.go +++ b/apps/backend/internal/notifications/providers/system_assets.go @@ -8,38 +8,69 @@ import ( "sync" ) -//go:embed assets/toast-notification.ps1 +//go:embed assets/toast-notification.ps1 assets/icon.png var systemAssetsFS embed.FS +const assetDir = "kandev-notifications" + type systemAssets struct { - once sync.Once + scriptOnce sync.Once scriptPath string - err error + scriptErr error + + iconOnce sync.Once + iconPath string + iconErr error } func (a *systemAssets) ensureScript() (string, error) { - a.once.Do(func() { - dir := filepath.Join(os.TempDir(), "kandev-notifications") + a.scriptOnce.Do(func() { + dir := filepath.Join(os.TempDir(), assetDir) if err := os.MkdirAll(dir, 0o755); err != nil { - a.err = fmt.Errorf("create notifications temp dir: %w", err) + a.scriptErr = fmt.Errorf("create notifications temp dir: %w", err) return } - const scriptName = "toast-notification.ps1" - scriptPath := filepath.Join(dir, scriptName) + scriptPath := filepath.Join(dir, "toast-notification.ps1") if _, err := os.Stat(scriptPath); err == nil { a.scriptPath = scriptPath return } data, err := systemAssetsFS.ReadFile("assets/toast-notification.ps1") if err != nil { - a.err = fmt.Errorf("read toast script: %w", err) + a.scriptErr = fmt.Errorf("read toast script: %w", err) return } if err := os.WriteFile(scriptPath, data, 0o644); err != nil { - a.err = fmt.Errorf("write toast script: %w", err) + a.scriptErr = fmt.Errorf("write toast script: %w", err) return } a.scriptPath = scriptPath }) - return a.scriptPath, a.err + return a.scriptPath, a.scriptErr +} + +func (a *systemAssets) ensureIcon() (string, error) { + a.iconOnce.Do(func() { + dir := filepath.Join(os.TempDir(), assetDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + a.iconErr = fmt.Errorf("create notifications temp dir: %w", err) + return + } + iconPath := filepath.Join(dir, "icon.png") + if _, err := os.Stat(iconPath); err == nil { + a.iconPath = iconPath + return + } + data, err := systemAssetsFS.ReadFile("assets/icon.png") + if err != nil { + a.iconErr = fmt.Errorf("read icon: %w", err) + return + } + if err := os.WriteFile(iconPath, data, 0o644); err != nil { + a.iconErr = fmt.Errorf("write icon: %w", err) + return + } + a.iconPath = iconPath + }) + return a.iconPath, a.iconErr } diff --git a/apps/backend/internal/notifications/providers/system_provider.go b/apps/backend/internal/notifications/providers/system_provider.go index 092194d33e..be5d7bc0c8 100644 --- a/apps/backend/internal/notifications/providers/system_provider.go +++ b/apps/backend/internal/notifications/providers/system_provider.go @@ -194,14 +194,19 @@ func parseTimeoutMS(cfg *systemConfig, raw map[string]interface{}) error { } func (p *SystemProvider) sendNotification(ctx context.Context, cfg systemConfig, title, body string) error { + if cfg.IconPath == "" { + if iconPath, err := p.assets.ensureIcon(); err == nil { + cfg.IconPath = iconPath + } + } switch runtime.GOOS { case osDarwin: - return runCommand(ctx, "osascript", "-e", buildAppleScript(title, body)) + return p.sendDarwinNotification(ctx, cfg, title, body) case osLinux: if isWSL() { return p.sendWindowsNotification(ctx, cfg, title, body) } - return sendLinuxNotification(ctx, cfg, title, body) + return p.sendLinuxNotification(ctx, cfg, title, body) case osWindows: return p.sendWindowsNotification(ctx, cfg, title, body) default: @@ -209,6 +214,17 @@ func (p *SystemProvider) sendNotification(ctx context.Context, cfg systemConfig, } } +func (p *SystemProvider) sendDarwinNotification(ctx context.Context, cfg systemConfig, title, body string) error { + if _, err := exec.LookPath("terminal-notifier"); err == nil { + args := []string{"-title", title, "-message", body, "-group", "kandev"} + if cfg.IconPath != "" { + args = append(args, "-contentImage", cfg.IconPath) + } + return runCommand(ctx, "terminal-notifier", args...) + } + return runCommand(ctx, "osascript", "-e", buildAppleScript(title, body)) +} + func (p *SystemProvider) sendWindowsNotification(ctx context.Context, cfg systemConfig, title, body string) error { scriptPath, err := p.assets.ensureScript() if err != nil { @@ -247,13 +263,22 @@ func (p *SystemProvider) sendWindowsNotification(ctx context.Context, cfg system return runCommand(ctx, "powershell.exe", args...) } -func sendLinuxNotification(ctx context.Context, cfg systemConfig, title, body string) error { +func (p *SystemProvider) sendLinuxNotification(ctx context.Context, cfg systemConfig, title, body string) error { if _, err := exec.LookPath("notify-send"); err == nil { - return runCommand(ctx, "notify-send", "-t", strconv.Itoa(cfg.TimeoutMS), title, body) + args := []string{"-t", strconv.Itoa(cfg.TimeoutMS)} + if cfg.IconPath != "" { + args = append(args, "-i", cfg.IconPath) + } + args = append(args, title, body) + return runCommand(ctx, "notify-send", args...) } if _, err := exec.LookPath("zenity"); err == nil { message := strings.TrimSpace(fmt.Sprintf("%s\n%s", title, body)) - return runCommand(ctx, "zenity", "--notification", "--text", message) + args := []string{"--notification", "--text", message} + if cfg.IconPath != "" { + args = append(args, "--window-icon="+cfg.IconPath) + } + return runCommand(ctx, "zenity", args...) } return fmt.Errorf("notify-send or zenity is required for system notifications") } @@ -321,7 +346,7 @@ func runCommand(ctx context.Context, name string, args ...string) error { defer cancel() } cmd := exec.CommandContext(ctx, name, args...) - return cmd.Start() + return cmd.Run() } func isWSL() bool { diff --git a/apps/backend/internal/notifications/service/service.go b/apps/backend/internal/notifications/service/service.go index 0e4e20c53e..362cbd9638 100644 --- a/apps/backend/internal/notifications/service/service.go +++ b/apps/backend/internal/notifications/service/service.go @@ -301,6 +301,23 @@ func (s *Service) ensureSystemProvider(ctx context.Context, userID string) error return s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{EventTaskSessionWaitingForInput}) } +func (s *Service) TestProvider(ctx context.Context, providerID string) error { + provider, err := s.repo.GetProvider(ctx, providerID) + if err != nil { + return ErrProviderNotFound + } + adapter := s.providers[provider.Type] + if adapter == nil { + return fmt.Errorf("unknown provider type: %s", provider.Type) + } + return adapter.Send(ctx, providers.Message{ + EventType: EventTaskSessionWaitingForInput, + Title: "Test notification", + Body: "If you can read this, notifications are working.", + Config: provider.Config, + }) +} + func (s *Service) validateProvider(providerType models.ProviderType, config map[string]interface{}) error { adapter := s.providers[providerType] if adapter == nil { diff --git a/apps/web/components/settings/notifications-settings-actions.ts b/apps/web/components/settings/notifications-settings-actions.ts index fd6ae10de3..24e6ccc051 100644 --- a/apps/web/components/settings/notifications-settings-actions.ts +++ b/apps/web/components/settings/notifications-settings-actions.ts @@ -4,6 +4,7 @@ import { useState } from "react"; import { createNotificationProvider, deleteNotificationProvider, + testNotificationProvider, updateNotificationProvider, } from "@/lib/api"; import { useRequest } from "@/lib/http/use-request"; @@ -346,11 +347,20 @@ export function useNotificationsActions(state: NotificationsState, bumpPermissio }); }; + const handleTestProvider = async (providerId: string) => { + try { + await testNotificationProvider(providerId); + } catch (error) { + console.error("[NotificationsSettings] Test notification failed", error); + } + }; + return { handleToggleEvent, handleRequestPermission, handleRefreshPermission, handleTestNotification, + handleTestProvider, ...appriseActions, }; } diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index 2daf89f8aa..802707f069 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -110,12 +110,14 @@ type NotificationEventsTableProps = { tableProviders: NotificationProvider[]; tableEvents: string[]; onToggleEvent: (provider: NotificationProvider, eventType: string) => void; + onTestProvider: (providerId: string) => Promise; }; function NotificationEventsTable({ tableProviders, tableEvents, onToggleEvent, + onTestProvider, }: NotificationEventsTableProps) { if (tableProviders.length === 0) { return

No providers configured yet.

; @@ -129,7 +131,27 @@ function NotificationEventsTable({ Notification type {tableProviders.map((provider) => ( - {provider.name} +
+ {provider.name} + {provider.type !== "local" && ( + + + + + + Send test notification + + + )} +
))} @@ -165,6 +187,55 @@ function NotificationEventsTable({ ); } +function AppriseProviderCardActions({ + provider, + onOpenForm, + onDeleteProvider, + onTestProvider, +}: { + provider: NotificationProvider; + onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void; + onDeleteProvider: (providerId: string) => void; + onTestProvider: (providerId: string) => Promise; +}) { + return ( +
+ + + + + + Send test notification + + + + +
+ ); +} + function AppriseProviderList({ providers, appriseFormMode, @@ -178,6 +249,7 @@ function AppriseProviderList({ onOpenForm, onCloseForm, onDeleteProvider, + onTestProvider, onTextareaInput, }: { providers: NotificationProvider[]; @@ -192,6 +264,7 @@ function AppriseProviderList({ onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void; onCloseForm: () => void; onDeleteProvider: (providerId: string) => void; + onTestProvider: (providerId: string) => Promise; onTextareaInput: (event: FormEvent) => void; }) { return ( @@ -223,24 +296,12 @@ function AppriseProviderList({
{provider.name}
Apprise
-
- - -
+ )} @@ -279,6 +340,7 @@ type ExternalProvidersSectionProps = { onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void; onCloseForm: () => void; onDeleteProvider: (id: string) => void; + onTestProvider: (id: string) => Promise; onTextareaInput: (e: FormEvent) => void; onCreateAppriseProvider: () => Promise; }; @@ -298,6 +360,7 @@ function ExternalProvidersSection({ onOpenForm, onCloseForm, onDeleteProvider, + onTestProvider, onTextareaInput, onCreateAppriseProvider, }: ExternalProvidersSectionProps) { @@ -339,6 +402,7 @@ function ExternalProvidersSection({ onOpenForm={onOpenForm} onCloseForm={onCloseForm} onDeleteProvider={onDeleteProvider} + onTestProvider={onTestProvider} onTextareaInput={onTextareaInput} /> {appriseAvailable && ( @@ -448,6 +512,7 @@ export function NotificationsSettings() { onOpenForm={actions.openAppriseForm} onCloseForm={actions.closeAppriseForm} onDeleteProvider={actions.handleDeleteProvider} + onTestProvider={actions.handleTestProvider} onTextareaInput={handleTextareaInput} onCreateAppriseProvider={actions.handleCreateAppriseProvider} /> @@ -464,6 +529,7 @@ export function NotificationsSettings() { tableProviders={tableProviders} tableEvents={tableEvents} onToggleEvent={actions.handleToggleEvent} + onTestProvider={actions.handleTestProvider} /> )} diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 1879249ae5..37637cfd1b 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -331,6 +331,13 @@ export async function updateNotificationProvider( }); } +export async function testNotificationProvider(providerId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/notification-providers/${providerId}/test`, { + ...options, + init: { method: "POST", ...(options?.init ?? {}) }, + }); +} + export async function deleteNotificationProvider(providerId: string, options?: ApiRequestOptions) { return fetchJson(`/api/v1/notification-providers/${providerId}`, { ...options,