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