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:
parent
5d95042ff3
commit
199f855729
@ -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
|
||||
|
||||
137
docs/NOTIFICATION_CATEGORIZATION.md
Normal file
137
docs/NOTIFICATION_CATEGORIZATION.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
138
src/OpenClaw.Shared/NotificationCategorizer.cs
Normal file
138
src/OpenClaw.Shared/NotificationCategorizer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 ---
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
331
tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
Normal file
331
tests/OpenClaw.Shared.Tests/NotificationCategorizerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user