Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions docs/NOTIFICATION_CATEGORIZATION.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions src/OpenClaw.Shared/NotificationCategorizer.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
53 changes: 16 additions & 37 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -611,16 +611,16 @@ private void HandleChatEvent(JsonElement root)

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)
Expand Down Expand Up @@ -875,44 +875,23 @@ private void ParseUsage(JsonElement usage)

// --- 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 ---
Expand Down
Loading
Loading