@freely01/opencode-notify 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/cli.ts +354 -0
- package/config.ts +217 -0
- package/dispatcher.ts +69 -0
- package/events.ts +110 -0
- package/index.ts +137 -0
- package/message.ts +50 -0
- package/package.json +49 -0
- package/scripts/marquee.py +179 -0
- package/senders/custom-webhook.ts +87 -0
- package/senders/feishu.ts +79 -0
- package/senders/screen-flash.ts +42 -0
- package/senders/system.ts +88 -0
- package/senders/types.ts +32 -0
- package/senders/wechat-work.ts +62 -0
- package/store.ts +111 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 屏幕跑马灯发送器(Linux X11 专用)
|
|
3
|
+
*
|
|
4
|
+
* 通知时在屏幕四边生成彩色高亮闪烁效果。
|
|
5
|
+
* 使用 Python + PyGObject(GTK 3) 创建透明覆盖窗口。
|
|
6
|
+
* Ubuntu GNOME 桌面内置依赖,无需额外安装。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process"
|
|
10
|
+
import { join, dirname } from "node:path"
|
|
11
|
+
import { fileURLToPath } from "node:url"
|
|
12
|
+
import type { Message } from "../message.js"
|
|
13
|
+
import type { Sender } from "./types.js"
|
|
14
|
+
import type { ScreenFlashChannelConfig } from "../config.js"
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const MARQUEE_SCRIPT = join(__dirname, "..", "scripts", "marquee.py")
|
|
18
|
+
|
|
19
|
+
export class ScreenFlashSender implements Sender {
|
|
20
|
+
readonly name = "screen_flash"
|
|
21
|
+
private config: ScreenFlashChannelConfig
|
|
22
|
+
|
|
23
|
+
constructor(config: ScreenFlashChannelConfig) {
|
|
24
|
+
this.config = config
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async send(_msg: Message): Promise<void> {
|
|
28
|
+
if (process.platform !== "linux") return
|
|
29
|
+
|
|
30
|
+
const args = [
|
|
31
|
+
MARQUEE_SCRIPT,
|
|
32
|
+
String(this.config.duration ?? 3.0),
|
|
33
|
+
String(this.config.speed ?? 4.0),
|
|
34
|
+
String(this.config.intensity ?? 0.9),
|
|
35
|
+
]
|
|
36
|
+
const child = spawn("python3", args, {
|
|
37
|
+
stdio: "ignore",
|
|
38
|
+
detached: true,
|
|
39
|
+
})
|
|
40
|
+
child.unref()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统通知发送器
|
|
3
|
+
*
|
|
4
|
+
* 平台适配:
|
|
5
|
+
* - macOS: osascript(内置,无需额外安装)
|
|
6
|
+
* - Linux: notify-send(需 libnotify)
|
|
7
|
+
* - Windows: PowerShell Toast(需 BurntToast 模块)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "node:child_process"
|
|
11
|
+
import type { Message } from "../message.js"
|
|
12
|
+
import type { Sender } from "./types.js"
|
|
13
|
+
|
|
14
|
+
export class SystemSender implements Sender {
|
|
15
|
+
readonly name = "system_message"
|
|
16
|
+
|
|
17
|
+
async send(msg: Message): Promise<void> {
|
|
18
|
+
const { title, body } = escapeForShell(msg.title, msg.body)
|
|
19
|
+
const platform = process.platform
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
if (platform === "darwin") {
|
|
23
|
+
this.sendMacOS(title, body)
|
|
24
|
+
} else if (platform === "linux") {
|
|
25
|
+
this.sendLinux(title, body)
|
|
26
|
+
} else if (platform === "win32") {
|
|
27
|
+
this.sendWindows(title, body)
|
|
28
|
+
}
|
|
29
|
+
// 其他平台静默忽略
|
|
30
|
+
} catch (err) {
|
|
31
|
+
throw new Error(`系统通知失败: ${err}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private sendMacOS(title: string, body: string): void {
|
|
36
|
+
// 使用 osascript 显示原生通知
|
|
37
|
+
const script = `display notification "${body}" with title "${title}" sound name "default"`
|
|
38
|
+
execSync(`osascript -e ${JSON.stringify(script)}`, {
|
|
39
|
+
timeout: 5000,
|
|
40
|
+
stdio: "ignore",
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private sendLinux(title: string, body: string): void {
|
|
45
|
+
execSync(`notify-send "${title}" "${body}"`, {
|
|
46
|
+
timeout: 5000,
|
|
47
|
+
stdio: "ignore",
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private sendWindows(title: string, body: string): void {
|
|
52
|
+
// 尝试使用 BurntToast,失败则用简单 MessageBox
|
|
53
|
+
const psScript = `
|
|
54
|
+
try {
|
|
55
|
+
New-BurntToastNotification -Text '${title}', '${body}' -ErrorAction Stop
|
|
56
|
+
} catch {
|
|
57
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
58
|
+
[System.Windows.Forms.MessageBox]::Show('${body}', '${title}')
|
|
59
|
+
}`
|
|
60
|
+
execSync(`powershell -NoProfile -Command ${JSON.stringify(psScript)}`, {
|
|
61
|
+
timeout: 10000,
|
|
62
|
+
stdio: "ignore",
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 转义标题和正文中的特殊字符,防止 shell 注入
|
|
69
|
+
*/
|
|
70
|
+
function escapeForShell(
|
|
71
|
+
title: string,
|
|
72
|
+
body: string,
|
|
73
|
+
): { title: string; body: string } {
|
|
74
|
+
return {
|
|
75
|
+
title: sanitize(title),
|
|
76
|
+
body: sanitize(body),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sanitize(s: string): string {
|
|
81
|
+
return s
|
|
82
|
+
.replace(/"/g, '\\"')
|
|
83
|
+
.replace(/'/g, "\\'")
|
|
84
|
+
.replace(/`/g, "\\`")
|
|
85
|
+
.replace(/\$/g, "\\$")
|
|
86
|
+
.replace(/\n/g, " ")
|
|
87
|
+
.replace(/\r/g, "")
|
|
88
|
+
}
|
package/senders/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Message } from "../message.js"
|
|
2
|
+
|
|
3
|
+
/** 通知发送器接口 */
|
|
4
|
+
export interface Sender {
|
|
5
|
+
readonly name: string
|
|
6
|
+
send(msg: Message): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 带事件过滤的 Sender 包装器
|
|
11
|
+
*
|
|
12
|
+
* 包装一个 Sender,只有 msg.event 在 events 列表中时才转发。
|
|
13
|
+
* events 为 undefined/空时继承全局(不过滤)。
|
|
14
|
+
*/
|
|
15
|
+
export class FilteredSender implements Sender {
|
|
16
|
+
readonly name: string
|
|
17
|
+
private sender: Sender
|
|
18
|
+
private events: Set<string> | undefined
|
|
19
|
+
|
|
20
|
+
constructor(sender: Sender, events?: string[]) {
|
|
21
|
+
this.name = `${sender.name}[filtered]`
|
|
22
|
+
this.sender = sender
|
|
23
|
+
this.events = events && events.length > 0 ? new Set(events) : undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async send(msg: Message): Promise<void> {
|
|
27
|
+
if (this.events && !this.events.has(msg.event)) {
|
|
28
|
+
return // 事件不在该渠道订阅列表中,跳过
|
|
29
|
+
}
|
|
30
|
+
return this.sender.send(msg)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Sender } from "./types.js"
|
|
2
|
+
import type { Message } from "../message.js"
|
|
3
|
+
import type { WechatWorkChannelConfig } from "../config.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 企业微信 群机器人 Webhook 发送器
|
|
7
|
+
*
|
|
8
|
+
* 文档: https://developer.work.weixin.qq.com/document/path/99110
|
|
9
|
+
*
|
|
10
|
+
* 配置示例:
|
|
11
|
+
* ```json
|
|
12
|
+
* {
|
|
13
|
+
* "enabled": true,
|
|
14
|
+
* "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* 消息格式: Markdown
|
|
19
|
+
* 支持: # 标题、**粗体**、[链接](url)、> 引用、- 列表
|
|
20
|
+
*/
|
|
21
|
+
export class WechatWorkSender implements Sender {
|
|
22
|
+
readonly name = "wechat_work"
|
|
23
|
+
private config: WechatWorkChannelConfig
|
|
24
|
+
|
|
25
|
+
constructor(config: WechatWorkChannelConfig) {
|
|
26
|
+
this.config = config
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async send(msg: Message): Promise<void> {
|
|
30
|
+
const { webhook_url } = this.config
|
|
31
|
+
|
|
32
|
+
if (!webhook_url) {
|
|
33
|
+
throw new Error("wechat_work: webhook_url not configured")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 构造 markdown 内容
|
|
37
|
+
const content = [
|
|
38
|
+
`**${msg.title}**`,
|
|
39
|
+
"",
|
|
40
|
+
msg.body,
|
|
41
|
+
`> 会话: ${msg.sessionID}`,
|
|
42
|
+
].join("\n")
|
|
43
|
+
|
|
44
|
+
const body = JSON.stringify({
|
|
45
|
+
msgtype: "markdown",
|
|
46
|
+
markdown: { content },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const response = await fetch(webhook_url, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const text = await response.text().catch(() => "")
|
|
57
|
+
throw new Error(
|
|
58
|
+
`wechat_work returned ${response.status}${text ? `: ${text.slice(0, 500)}` : ""}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/store.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 去重状态存储
|
|
3
|
+
*
|
|
4
|
+
* 基于内存 Map + 可选 JSON 文件持久化。
|
|
5
|
+
* 使用 "预留发送" 机制:预占发送时隙,发送成功后才标记为已发送,
|
|
6
|
+
* 发送失败则释放预留,允许后续重试。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
10
|
+
import { homedir } from "node:os"
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
|
|
13
|
+
interface StoreData {
|
|
14
|
+
lastSent: Record<string, number> // key → unix timestamp (seconds)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class FileStore {
|
|
18
|
+
private lastSent: Record<string, number> = {}
|
|
19
|
+
private reservations: Record<string, number> = {}
|
|
20
|
+
private path: string
|
|
21
|
+
|
|
22
|
+
constructor(path?: string) {
|
|
23
|
+
this.path =
|
|
24
|
+
path ?? join(homedir(), ".opencode-notify", "state.json")
|
|
25
|
+
this.load()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 检查是否可以发送(去重检查)
|
|
30
|
+
* 返回 true 表示允许发送
|
|
31
|
+
*/
|
|
32
|
+
shouldSend(key: string, windowSec: number, now: number = Date.now()): boolean {
|
|
33
|
+
// 检查已发送记录
|
|
34
|
+
const last = this.lastSent[key]
|
|
35
|
+
if (last && now - last < windowSec * 1000) {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
// 检查是否已有预留
|
|
39
|
+
const reserved = this.reservations[key]
|
|
40
|
+
if (reserved && now - reserved < windowSec * 1000) {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 预留发送时隙
|
|
48
|
+
* 返回 true 表示预留成功,可以发送
|
|
49
|
+
*/
|
|
50
|
+
reserveSend(key: string, windowSec: number, now: number = Date.now()): boolean {
|
|
51
|
+
// 检查已发送记录
|
|
52
|
+
const last = this.lastSent[key]
|
|
53
|
+
if (last && now - last < windowSec * 1000) {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
// 检查已有预留
|
|
57
|
+
const reserved = this.reservations[key]
|
|
58
|
+
if (reserved && now - reserved < windowSec * 1000) {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
// 创建新预留
|
|
62
|
+
this.reservations[key] = now
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 标记为已发送
|
|
68
|
+
*/
|
|
69
|
+
markSent(key: string, now: number = Date.now()): void {
|
|
70
|
+
this.lastSent[key] = now
|
|
71
|
+
delete this.reservations[key]
|
|
72
|
+
this.save()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 清除预留(发送失败时调用,允许重试)
|
|
77
|
+
*/
|
|
78
|
+
clearReservation(key: string): void {
|
|
79
|
+
delete this.reservations[key]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 构建去重 key */
|
|
83
|
+
buildKey(agent: string, event: string, sessionID: string): string {
|
|
84
|
+
return `${agent}:${event}:${sessionID}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private load(): void {
|
|
88
|
+
try {
|
|
89
|
+
if (!existsSync(this.path)) return
|
|
90
|
+
const raw = readFileSync(this.path, "utf-8")
|
|
91
|
+
const data = JSON.parse(raw) as StoreData
|
|
92
|
+
this.lastSent = data.lastSent ?? {}
|
|
93
|
+
} catch {
|
|
94
|
+
// 文件损坏时使用空状态
|
|
95
|
+
this.lastSent = {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private save(): void {
|
|
100
|
+
try {
|
|
101
|
+
const dir = this.path.substring(0, this.path.lastIndexOf("/"))
|
|
102
|
+
if (!existsSync(dir)) {
|
|
103
|
+
mkdirSync(dir, { recursive: true })
|
|
104
|
+
}
|
|
105
|
+
const data: StoreData = { lastSent: this.lastSent }
|
|
106
|
+
writeFileSync(this.path, JSON.stringify(data, null, 2), "utf-8")
|
|
107
|
+
} catch {
|
|
108
|
+
// 持久化失败不应影响主流程
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|