diff --git a/README.md b/README.md index 4b1ad4b..29c1e6e 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Modern Windows 11-style system tray companion that connects to your local OpenCl - 🔄 **Auto-updates** - Automatic updates from GitHub Releases - 🌐 **Web Chat** - Embedded chat window with WebView2 - 📊 **Live Status** - Real-time sessions, channels, and usage display -- 🔔 **Toast Notifications** - Clickable Windows notifications with filters +- 🔔 **Toast Notifications** - Clickable Windows notifications with [smart categorization](docs/NOTIFICATION_CATEGORIZATION.md) - 📡 **Channel Control** - Start/stop Telegram & WhatsApp from the menu - ⏱ **Cron Jobs** - Quick access to scheduled tasks - 🚀 **Auto-start** - Launch with Windows diff --git a/docs/NOTIFICATION_CATEGORIZATION.md b/docs/NOTIFICATION_CATEGORIZATION.md new file mode 100644 index 0000000..a445ae4 --- /dev/null +++ b/docs/NOTIFICATION_CATEGORIZATION.md @@ -0,0 +1,137 @@ +# Notification Categorization + +The tray app categorizes incoming notifications to apply per-category filters, display appropriate icons, and let users control which notifications they see. + +## How It Works + +Notifications flow through a **layered pipeline** — the first layer that matches wins: + +``` +Structured Metadata → User Rules → Keyword Matching → Default (info) +``` + +### 1. Structured Metadata (highest priority) + +If the gateway sends metadata on the notification, it is used directly: + +- **Intent** (e.g. `reminder`, `build`, `alert`) — maps to a category +- **Channel** (e.g. `email`, `calendar`, `ci`) — maps to a category + +This eliminates misclassification. A chat response that mentions "email" won't be categorized as email — the gateway knows the actual source. + +> **Note:** The gateway does not send structured metadata yet. When it does, categorization will automatically improve with no client changes needed. + +### 2. User-Defined Rules + +Custom regex or keyword rules, evaluated in order. Configure these in `%APPDATA%\OpenClawTray\settings.json`: + +```json +{ + "UserRules": [ + { + "Pattern": "invoice|receipt", + "IsRegex": true, + "Category": "email", + "Enabled": true + }, + { + "Pattern": "deploy to prod", + "IsRegex": false, + "Category": "urgent", + "Enabled": true + } + ] +} +``` + +Rules match against both the notification title and message (case-insensitive). Invalid regex patterns are silently skipped. + +### 3. Keyword Matching (legacy fallback) + +The original keyword-based system, preserved for backward compatibility: + +| Category | Keywords | Icon | +|----------|----------|------| +| `health` | blood sugar, glucose, cgm, mg/dl | 🩸 | +| `urgent` | urgent, critical, emergency | 🚨 | +| `reminder` | reminder | ⏰ | +| `stock` | stock, in stock, available now | 📦 | +| `email` | email, inbox, gmail | 📧 | +| `calendar` | calendar, meeting, event | 📅 | +| `error` | error, failed, exception | ⚠️ | +| `build` | build, ci, deploy | 🔨 | +| `info` | *(everything else)* | 🤖 | + +### 4. Default + +If nothing matches, the notification is categorized as `info`. + +## Chat Response Toggle + +Notifications are either **chat responses** (replies from an AI agent) or **system notifications** (alerts, reminders, build status, etc.). The `NotifyChatResponses` setting controls whether chat responses generate Windows toasts: + +| Setting | Chat Responses | System Notifications | +|---------|----------------|----------------------| +| `true` (default) | ✅ Shown | ✅ Shown | +| `false` | ❌ Suppressed | ✅ Shown | + +This is useful when you're having a conversation through another device and don't want every reply popping up as a toast on your desktop. + +## Settings + +All notification settings are in `%APPDATA%\OpenClawTray\settings.json`: + +```json +{ + "ShowNotifications": true, + "NotificationSound": "Default", + + "NotifyHealth": true, + "NotifyUrgent": true, + "NotifyReminder": true, + "NotifyEmail": true, + "NotifyCalendar": true, + "NotifyBuild": true, + "NotifyStock": true, + "NotifyInfo": true, + + "NotifyChatResponses": true, + "PreferStructuredCategories": true, + "UserRules": [] +} +``` + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `ShowNotifications` | bool | `true` | Master toggle for all notifications | +| `NotifyHealth` | bool | `true` | Show health/glucose alerts | +| `NotifyUrgent` | bool | `true` | Show urgent alerts (also covers `error` type) | +| `NotifyReminder` | bool | `true` | Show reminders | +| `NotifyEmail` | bool | `true` | Show email notifications | +| `NotifyCalendar` | bool | `true` | Show calendar events | +| `NotifyBuild` | bool | `true` | Show build/CI/deploy notifications | +| `NotifyStock` | bool | `true` | Show stock alerts | +| `NotifyInfo` | bool | `true` | Show general info notifications | +| `NotifyChatResponses` | bool | `true` | Show chat response toasts | +| `PreferStructuredCategories` | bool | `true` | Use gateway metadata over keywords | +| `UserRules` | array | `[]` | Custom categorization rules (see above) | + +## Channel and Agent Mapping + +When structured metadata is available, channels and agents map to categories: + +**Channel → Category:** +| Channel | Category | +|---------|----------| +| `calendar` | calendar | +| `email` | email | +| `ci`, `build` | build | +| `stock`, `inventory` | stock | +| `health` | health | +| `alerts` | urgent | + +**Agent mapping** is also supported — per-agent category defaults can be added to the channel map in `NotificationCategorizer.cs`. + +## Architecture + +The categorization logic lives in `OpenClaw.Shared.NotificationCategorizer`, making it available to both the WinUI tray app and any other consumers of the shared library. The gateway client (`OpenClawGatewayClient`) calls the categorizer when emitting notifications, and the tray app's `ShouldShowNotification` method applies the per-category and chat-toggle filters before showing a toast. diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index c0ec729..10773f8 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -78,6 +78,23 @@ public class OpenClawNotification public string Message { get; set; } = ""; public string Type { get; set; } = ""; public bool IsChat { get; set; } = false; // True if from chat response + + // Structured metadata (populated by gateway when available) + public string? Channel { get; set; } // e.g. telegram, email, chat + public string? Agent { get; set; } // agent name/identifier + public string? Intent { get; set; } // normalized intent (reminder, build, alert) + public string[]? Tags { get; set; } // free-form routing tags +} + +/// +/// A user-defined notification categorization rule. +/// +public class UserNotificationRule +{ + public string Pattern { get; set; } = ""; + public bool IsRegex { get; set; } + public string Category { get; set; } = "info"; + public bool Enabled { get; set; } = true; } public class ChannelHealth diff --git a/src/OpenClaw.Shared/NotificationCategorizer.cs b/src/OpenClaw.Shared/NotificationCategorizer.cs new file mode 100644 index 0000000..affa0e1 --- /dev/null +++ b/src/OpenClaw.Shared/NotificationCategorizer.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace OpenClaw.Shared; + +/// +/// Layered notification categorization pipeline. +/// Order: structured metadata → user rules → keyword fallback → default. +/// +public class NotificationCategorizer +{ + private static readonly Dictionary ChannelMap = new(StringComparer.OrdinalIgnoreCase) + { + ["calendar"] = ("📅 Calendar", "calendar"), + ["email"] = ("📧 Email", "email"), + ["ci"] = ("🔨 Build", "build"), + ["build"] = ("🔨 Build", "build"), + ["inventory"] = ("📦 Stock Alert", "stock"), + ["stock"] = ("📦 Stock Alert", "stock"), + ["health"] = ("🩸 Blood Sugar Alert", "health"), + ["alerts"] = ("🚨 Urgent Alert", "urgent"), + }; + + private static readonly Dictionary IntentMap = new(StringComparer.OrdinalIgnoreCase) + { + ["health"] = ("🩸 Blood Sugar Alert", "health"), + ["urgent"] = ("🚨 Urgent Alert", "urgent"), + ["alert"] = ("🚨 Urgent Alert", "urgent"), + ["reminder"] = ("⏰ Reminder", "reminder"), + ["email"] = ("📧 Email", "email"), + ["calendar"] = ("📅 Calendar", "calendar"), + ["build"] = ("🔨 Build", "build"), + ["stock"] = ("📦 Stock Alert", "stock"), + ["error"] = ("⚠️ Error", "error"), + }; + + private static readonly Dictionary CategoryTitles = new(StringComparer.OrdinalIgnoreCase) + { + ["health"] = "🩸 Blood Sugar Alert", + ["urgent"] = "🚨 Urgent Alert", + ["reminder"] = "⏰ Reminder", + ["stock"] = "📦 Stock Alert", + ["email"] = "📧 Email", + ["calendar"] = "📅 Calendar", + ["error"] = "⚠️ Error", + ["build"] = "🔨 Build", + ["info"] = "🤖 OpenClaw", + }; + + /// + /// Classify a notification using the layered pipeline. + /// + public (string title, string type) Classify(OpenClawNotification notification, IReadOnlyList? userRules = null) + { + // 1. Structured metadata: Intent + if (!string.IsNullOrEmpty(notification.Intent) && IntentMap.TryGetValue(notification.Intent, out var intentResult)) + return intentResult; + + // 2. Structured metadata: Channel + if (!string.IsNullOrEmpty(notification.Channel) && ChannelMap.TryGetValue(notification.Channel, out var channelResult)) + return channelResult; + + // 3. User-defined rules (pattern match on title + message) + if (userRules is { Count: > 0 }) + { + var searchText = $"{notification.Title} {notification.Message}"; + foreach (var rule in userRules) + { + if (!rule.Enabled) continue; + if (MatchesRule(searchText, rule)) + { + var cat = rule.Category.ToLowerInvariant(); + var title = CategoryTitles.GetValueOrDefault(cat, "🤖 OpenClaw"); + return (title, cat); + } + } + } + + // 4. Legacy keyword fallback + return ClassifyByKeywords(notification.Message); + } + + /// + /// Legacy keyword-based classification (backward compatible). + /// + public static (string title, string type) ClassifyByKeywords(string text) + { + var lower = text.ToLowerInvariant(); + if (lower.Contains("blood sugar") || lower.Contains("glucose") || + lower.Contains("cgm") || lower.Contains("mg/dl")) + return ("🩸 Blood Sugar Alert", "health"); + if (lower.Contains("urgent") || lower.Contains("critical") || + lower.Contains("emergency")) + return ("🚨 Urgent Alert", "urgent"); + if (lower.Contains("reminder")) + return ("⏰ Reminder", "reminder"); + if (lower.Contains("stock") || lower.Contains("in stock") || + lower.Contains("available now")) + return ("📦 Stock Alert", "stock"); + if (lower.Contains("email") || lower.Contains("inbox") || + lower.Contains("gmail")) + return ("📧 Email", "email"); + if (lower.Contains("calendar") || lower.Contains("meeting") || + lower.Contains("event")) + return ("📅 Calendar", "calendar"); + if (lower.Contains("error") || lower.Contains("failed") || + lower.Contains("exception")) + return ("⚠️ Error", "error"); + if (lower.Contains("build") || lower.Contains("ci ") || + lower.Contains("deploy")) + return ("🔨 Build", "build"); + return ("🤖 OpenClaw", "info"); + } + + private static bool MatchesRule(string text, UserNotificationRule rule) + { + if (string.IsNullOrEmpty(rule.Pattern)) return false; + + if (rule.IsRegex) + { + try + { + return Regex.IsMatch(text, rule.Pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100)); + } + catch (RegexParseException) + { + return false; + } + catch (RegexMatchTimeoutException) + { + return false; + } + } + + return text.Contains(rule.Pattern, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 4ba9556..a735589 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -611,16 +611,16 @@ public class OpenClawGatewayClient : IDisposable private void EmitChatNotification(string text) { - var (title, type) = ClassifyNotification(text); - // Truncate long messages but always notify var displayText = text.Length > 200 ? text[..200] + "…" : text; - NotificationReceived?.Invoke(this, new OpenClawNotification + var notification = new OpenClawNotification { - Title = title, Message = displayText, - Type = type, IsChat = true - }); + }; + var (title, type) = _categorizer.Classify(notification); + notification.Title = title; + notification.Type = type; + NotificationReceived?.Invoke(this, notification); } private void HandleSessionEvent(JsonElement root) @@ -875,44 +875,23 @@ public class OpenClawGatewayClient : IDisposable // --- Notification classification --- + private static readonly NotificationCategorizer _categorizer = new(); + private void EmitNotification(string text) { - var (title, type) = ClassifyNotification(text); - NotificationReceived?.Invoke(this, new OpenClawNotification + var notification = new OpenClawNotification { - Title = title, - Message = text.Length > 200 ? text[..200] + "…" : text, - Type = type - }); + Message = text.Length > 200 ? text[..200] + "…" : text + }; + var (title, type) = _categorizer.Classify(notification); + notification.Title = title; + notification.Type = type; + NotificationReceived?.Invoke(this, notification); } private static (string title, string type) ClassifyNotification(string text) { - var lower = text.ToLowerInvariant(); - if (lower.Contains("blood sugar") || lower.Contains("glucose") || - lower.Contains("cgm") || lower.Contains("mg/dl")) - return ("🩸 Blood Sugar Alert", "health"); - if (lower.Contains("urgent") || lower.Contains("critical") || - lower.Contains("emergency")) - return ("🚨 Urgent Alert", "urgent"); - if (lower.Contains("reminder")) - return ("⏰ Reminder", "reminder"); - if (lower.Contains("stock") || lower.Contains("in stock") || - lower.Contains("available now")) - return ("📦 Stock Alert", "stock"); - if (lower.Contains("email") || lower.Contains("inbox") || - lower.Contains("gmail")) - return ("📧 Email", "email"); - if (lower.Contains("calendar") || lower.Contains("meeting") || - lower.Contains("event")) - return ("📅 Calendar", "calendar"); - if (lower.Contains("error") || lower.Contains("failed") || - lower.Contains("exception")) - return ("⚠️ Error", "error"); - if (lower.Contains("build") || lower.Contains("ci ") || - lower.Contains("deploy")) - return ("🔨 Build", "build"); - return ("🤖 OpenClaw", "info"); + return NotificationCategorizer.ClassifyByKeywords(text); } // --- Utility --- diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index b9b94c0..077cb80 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1069,6 +1069,10 @@ public partial class App : Application { if (_settings == null) return true; + // Chat toggle: suppress all chat responses if disabled + if (notification.IsChat && !_settings.NotifyChatResponses) + return false; + return notification.Type?.ToLowerInvariant() switch { "health" => _settings.NotifyHealth, @@ -1079,6 +1083,7 @@ public partial class App : Application "build" => _settings.NotifyBuild, "stock" => _settings.NotifyStock, "info" => _settings.NotifyInfo, + "error" => _settings.NotifyUrgent, // errors follow urgent setting _ => true }; } diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index ee66837..faa6a0a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -36,6 +36,11 @@ public class SettingsManager public bool NotifyBuild { get; set; } = true; public bool NotifyStock { get; set; } = true; public bool NotifyInfo { get; set; } = true; + + // Enhanced categorization + public bool NotifyChatResponses { get; set; } = true; + public bool PreferStructuredCategories { get; set; } = true; + public List UserRules { get; set; } = new(); // Node mode (enables Windows as a node, not just operator) public bool EnableNodeMode { get; set; } = false; @@ -70,6 +75,10 @@ public class SettingsManager NotifyStock = loaded.NotifyStock; NotifyInfo = loaded.NotifyInfo; EnableNodeMode = loaded.EnableNodeMode; + NotifyChatResponses = loaded.NotifyChatResponses; + PreferStructuredCategories = loaded.PreferStructuredCategories; + if (loaded.UserRules != null) + UserRules = loaded.UserRules; } } } @@ -101,7 +110,10 @@ public class SettingsManager NotifyBuild = NotifyBuild, NotifyStock = NotifyStock, NotifyInfo = NotifyInfo, - EnableNodeMode = EnableNodeMode + EnableNodeMode = EnableNodeMode, + NotifyChatResponses = NotifyChatResponses, + PreferStructuredCategories = PreferStructuredCategories, + UserRules = UserRules }; var options = new JsonSerializerOptions { WriteIndented = true }; @@ -133,5 +145,8 @@ public class SettingsManager public bool NotifyStock { get; set; } = true; public bool NotifyInfo { get; set; } = true; public bool EnableNodeMode { get; set; } = false; + public bool NotifyChatResponses { get; set; } = true; + public bool PreferStructuredCategories { get; set; } = true; + public List? UserRules { get; set; } } } diff --git a/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs b/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs new file mode 100644 index 0000000..bb2354c --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs @@ -0,0 +1,331 @@ +using System.Collections.Generic; +using Xunit; +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Tests; + +public class NotificationCategorizerTests +{ + private readonly NotificationCategorizer _categorizer = new(); + + // --- Keyword fallback (backward compatibility) --- + + [Theory] + [InlineData("Your blood sugar is high", "health")] + [InlineData("Glucose level: 180 mg/dl", "health")] + [InlineData("CGM reading available", "health")] + [InlineData("URGENT: Action required", "urgent")] + [InlineData("This is critical", "urgent")] + [InlineData("Emergency situation", "urgent")] + [InlineData("Reminder: Meeting at 3pm", "reminder")] + [InlineData("Item is in stock", "stock")] + [InlineData("Available now!", "stock")] + [InlineData("New email in inbox", "email")] + [InlineData("Gmail notification", "email")] + [InlineData("Meeting starting soon", "calendar")] + [InlineData("Calendar event: Team standup", "calendar")] + [InlineData("Build failed", "error")] + [InlineData("Exception occurred", "error")] + [InlineData("Build succeeded", "build")] + [InlineData("CI pipeline completed", "build")] + [InlineData("Deploy finished", "build")] + [InlineData("Hello world", "info")] + public void KeywordFallback_BackwardCompatible(string message, string expectedType) + { + var notification = new OpenClawNotification { Message = message }; + var (_, type) = _categorizer.Classify(notification); + Assert.Equal(expectedType, type); + } + + [Fact] + public void KeywordFallback_IsCaseInsensitive() + { + var notification = new OpenClawNotification { Message = "URGENT: test" }; + Assert.Equal("urgent", _categorizer.Classify(notification).type); + + notification = new OpenClawNotification { Message = "urgent: test" }; + Assert.Equal("urgent", _categorizer.Classify(notification).type); + } + + // --- Structured metadata takes priority --- + + [Fact] + public void Intent_TakesPriority_OverKeywords() + { + // Message says "email" but intent says "build" + var notification = new OpenClawNotification + { + Message = "New email notification", + Intent = "build" + }; + var (_, type) = _categorizer.Classify(notification); + Assert.Equal("build", type); + } + + [Theory] + [InlineData("health", "health")] + [InlineData("urgent", "urgent")] + [InlineData("alert", "urgent")] + [InlineData("reminder", "reminder")] + [InlineData("email", "email")] + [InlineData("calendar", "calendar")] + [InlineData("build", "build")] + [InlineData("stock", "stock")] + [InlineData("error", "error")] + public void Intent_MapsCorrectly(string intent, string expectedType) + { + var notification = new OpenClawNotification { Message = "test", Intent = intent }; + Assert.Equal(expectedType, _categorizer.Classify(notification).type); + } + + [Fact] + public void Channel_TakesPriority_OverKeywords() + { + // Message says "email" but channel is "calendar" + var notification = new OpenClawNotification + { + Message = "Check your email", + Channel = "calendar" + }; + Assert.Equal("calendar", _categorizer.Classify(notification).type); + } + + [Theory] + [InlineData("calendar", "calendar")] + [InlineData("email", "email")] + [InlineData("ci", "build")] + [InlineData("build", "build")] + [InlineData("stock", "stock")] + [InlineData("inventory", "stock")] + [InlineData("health", "health")] + [InlineData("alerts", "urgent")] + public void Channel_MapsCorrectly(string channel, string expectedType) + { + var notification = new OpenClawNotification { Message = "test", Channel = channel }; + Assert.Equal(expectedType, _categorizer.Classify(notification).type); + } + + [Fact] + public void Intent_TakesPriority_OverChannel() + { + var notification = new OpenClawNotification + { + Message = "test", + Channel = "email", + Intent = "build" + }; + Assert.Equal("build", _categorizer.Classify(notification).type); + } + + [Fact] + public void UnknownChannel_FallsThrough_ToKeywords() + { + var notification = new OpenClawNotification + { + Message = "Your blood sugar is high", + Channel = "unknown-channel" + }; + Assert.Equal("health", _categorizer.Classify(notification).type); + } + + [Fact] + public void UnknownIntent_FallsThrough_ToChannel() + { + var notification = new OpenClawNotification + { + Message = "test", + Intent = "unknown-intent", + Channel = "email" + }; + Assert.Equal("email", _categorizer.Classify(notification).type); + } + + // --- User-defined rules --- + + [Fact] + public void UserRule_KeywordMatch_Categorizes() + { + var rules = new List + { + new() { Pattern = "invoice", Category = "email", Enabled = true } + }; + var notification = new OpenClawNotification { Message = "New invoice received" }; + Assert.Equal("email", _categorizer.Classify(notification, rules).type); + } + + [Fact] + public void UserRule_RegexMatch_Categorizes() + { + var rules = new List + { + new() { Pattern = @"PR\s*#\d+", IsRegex = true, Category = "build", Enabled = true } + }; + var notification = new OpenClawNotification { Message = "PR #42 merged" }; + Assert.Equal("build", _categorizer.Classify(notification, rules).type); + } + + [Fact] + public void UserRule_DisabledRule_IsSkipped() + { + var rules = new List + { + new() { Pattern = "invoice", Category = "email", Enabled = false } + }; + var notification = new OpenClawNotification { Message = "New invoice received" }; + // Falls through to keyword "info" since no keyword matches + Assert.Equal("info", _categorizer.Classify(notification, rules).type); + } + + [Fact] + public void UserRule_InvalidRegex_IsSkipped() + { + var rules = new List + { + new() { Pattern = "[invalid", IsRegex = true, Category = "build", Enabled = true } + }; + var notification = new OpenClawNotification { Message = "Hello world" }; + Assert.Equal("info", _categorizer.Classify(notification, rules).type); + } + + [Fact] + public void UserRule_TakesPriority_OverKeywords() + { + // Message contains "email" keyword, but user rule maps "inbox" to "stock" + var rules = new List + { + new() { Pattern = "inbox", Category = "stock", Enabled = true } + }; + var notification = new OpenClawNotification { Message = "New items in your inbox" }; + Assert.Equal("stock", _categorizer.Classify(notification, rules).type); + } + + [Fact] + public void UserRule_StructuredMetadata_TakesPriority_OverUserRules() + { + var rules = new List + { + new() { Pattern = "anything", Category = "stock", Enabled = true } + }; + var notification = new OpenClawNotification + { + Message = "anything here", + Intent = "build" + }; + Assert.Equal("build", _categorizer.Classify(notification, rules).type); + } + + [Fact] + public void UserRule_FirstMatchWins() + { + var rules = new List + { + new() { Pattern = "report", Category = "email", Enabled = true }, + new() { Pattern = "report", Category = "build", Enabled = true } + }; + var notification = new OpenClawNotification { Message = "Daily report ready" }; + Assert.Equal("email", _categorizer.Classify(notification, rules).type); + } + + [Fact] + public void UserRule_MatchesAgainstTitleAndMessage() + { + var rules = new List + { + new() { Pattern = "special-title", Category = "urgent", Enabled = true } + }; + var notification = new OpenClawNotification + { + Title = "special-title", + Message = "generic message" + }; + Assert.Equal("urgent", _categorizer.Classify(notification, rules).type); + } + + // --- Pipeline order verification --- + + [Fact] + public void PipelineOrder_Intent_Channel_UserRules_Keywords() + { + var rules = new List + { + new() { Pattern = "test", Category = "stock", Enabled = true } + }; + + // All layers match — intent wins + var notification = new OpenClawNotification + { + Message = "test email urgent blood sugar", + Intent = "calendar", + Channel = "email" + }; + Assert.Equal("calendar", _categorizer.Classify(notification, rules).type); + + // Remove intent — channel wins + notification.Intent = null; + Assert.Equal("email", _categorizer.Classify(notification, rules).type); + + // Remove channel — user rule wins + notification.Channel = null; + Assert.Equal("stock", _categorizer.Classify(notification, rules).type); + + // Remove user rules — keyword wins + Assert.Equal("health", _categorizer.Classify(notification).type); + } + + // --- ClassifyByKeywords static method --- + + [Fact] + public void ClassifyByKeywords_DefaultsToInfo() + { + var (title, type) = NotificationCategorizer.ClassifyByKeywords("Hello world"); + Assert.Equal("info", type); + Assert.Equal("🤖 OpenClaw", title); + } + + [Fact] + public void ClassifyByKeywords_ReturnsCorrectTitles() + { + Assert.Equal("🩸 Blood Sugar Alert", NotificationCategorizer.ClassifyByKeywords("blood sugar high").title); + Assert.Equal("🚨 Urgent Alert", NotificationCategorizer.ClassifyByKeywords("urgent message").title); + Assert.Equal("⏰ Reminder", NotificationCategorizer.ClassifyByKeywords("reminder").title); + Assert.Equal("📦 Stock Alert", NotificationCategorizer.ClassifyByKeywords("in stock").title); + Assert.Equal("📧 Email", NotificationCategorizer.ClassifyByKeywords("email notification").title); + Assert.Equal("📅 Calendar", NotificationCategorizer.ClassifyByKeywords("calendar event").title); + Assert.Equal("⚠️ Error", NotificationCategorizer.ClassifyByKeywords("error occurred").title); + Assert.Equal("🔨 Build", NotificationCategorizer.ClassifyByKeywords("deploy finished").title); + } + + // --- Empty/null edge cases --- + + [Fact] + public void EmptyMessage_DefaultsToInfo() + { + var notification = new OpenClawNotification { Message = "" }; + Assert.Equal("info", _categorizer.Classify(notification).type); + } + + [Fact] + public void NullUserRules_FallsToKeywords() + { + var notification = new OpenClawNotification { Message = "urgent alert" }; + Assert.Equal("urgent", _categorizer.Classify(notification, null).type); + } + + [Fact] + public void EmptyUserRules_FallsToKeywords() + { + var notification = new OpenClawNotification { Message = "urgent alert" }; + Assert.Equal("urgent", _categorizer.Classify(notification, new List()).type); + } + + [Fact] + public void EmptyPattern_RuleIsSkipped() + { + var rules = new List + { + new() { Pattern = "", Category = "urgent", Enabled = true } + }; + var notification = new OpenClawNotification { Message = "Hello world" }; + Assert.Equal("info", _categorizer.Classify(notification, rules).type); + } +}