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);
+ }
+}