feat: enhanced notification categorization pipeline (#12) (#15)

Replace keyword-only classification with a layered pipeline:
structured metadata (intent/channel) > user rules > keyword fallback.

- Add Channel, Agent, Intent, Tags fields to OpenClawNotification
- Extract NotificationCategorizer class with layered pipeline
- Add NotifyChatResponses toggle to suppress chat toasts
- Add UserNotificationRule model for custom regex/keyword rules
- Map error notifications to urgent setting
- Add 30+ unit tests for categorizer pipeline
- Document categorization system in docs/
This commit is contained in:
Scott Hanselman 2026-02-07 17:24:11 -08:00 committed by GitHub
parent 5d95042ff3
commit 199f855729
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 661 additions and 39 deletions

View File

@ -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

View File

@ -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.

View File

@ -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
}
/// <summary>
/// A user-defined notification categorization rule.
/// </summary>
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

View File

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace OpenClaw.Shared;
/// <summary>
/// Layered notification categorization pipeline.
/// Order: structured metadata → user rules → keyword fallback → default.
/// </summary>
public class NotificationCategorizer
{
private static readonly Dictionary<string, (string title, string type)> 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<string, (string title, string type)> 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<string, string> 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",
};
/// <summary>
/// Classify a notification using the layered pipeline.
/// </summary>
public (string title, string type) Classify(OpenClawNotification notification, IReadOnlyList<UserNotificationRule>? 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);
}
/// <summary>
/// Legacy keyword-based classification (backward compatible).
/// </summary>
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);
}
}

View File

@ -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 ---

View File

@ -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
};
}

View File

@ -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<OpenClaw.Shared.UserNotificationRule> 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<OpenClaw.Shared.UserNotificationRule>? UserRules { get; set; }
}
}

View File

@ -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<UserNotificationRule>
{
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<UserNotificationRule>
{
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<UserNotificationRule>
{
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<UserNotificationRule>
{
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<UserNotificationRule>
{
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<UserNotificationRule>
{
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<UserNotificationRule>
{
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<UserNotificationRule>
{
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<UserNotificationRule>
{
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<UserNotificationRule>()).type);
}
[Fact]
public void EmptyPattern_RuleIsSkipped()
{
var rules = new List<UserNotificationRule>
{
new() { Pattern = "", Category = "urgent", Enabled = true }
};
var notification = new OpenClawNotification { Message = "Hello world" };
Assert.Equal("info", _categorizer.Classify(notification, rules).type);
}
}