@freely01/opencode-notify 0.1.0 → 0.2.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/events.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // 但通过 event hook 收到时是 { type: string; properties: any }
3
3
  // 这里直接使用 any 避免对 SDK 内部类型的依赖
4
4
  import type { Message } from "./message.js"
5
- import { formatTitle, defaultBody } from "./message.js"
5
+ import { formatTitle, formatBody, defaultBody } from "./message.js"
6
6
 
7
7
  /**
8
8
  * 将 opencode event hook 收到的 Event 映射为内部通知 Message
@@ -15,6 +15,8 @@ import { formatTitle, defaultBody } from "./message.js"
15
15
  * - "session.error" → 会话错误
16
16
  * - "session.created" → 新会话
17
17
  *
18
+ * @param event opencode 事件对象
19
+ * @param enabledEvents 启用的事件列表,用于过滤
18
20
  * @returns Message | null — 不关心的事件返回 null
19
21
  */
20
22
  export function route(
@@ -23,18 +25,33 @@ export function route(
23
25
  ): Message | null {
24
26
  const enabled = new Set(enabledEvents ?? [])
25
27
  const { type, properties } = event
28
+ const sessionID = properties.sessionID ?? "unknown"
29
+
30
+ /**
31
+ * 构造 Message 的辅助函数
32
+ * 统一处理 sessionID、title(带会话前缀)、body(结构化格式)
33
+ */
34
+ function makeMsg(evt: string, detail: string): Message {
35
+ const msg: Message = {
36
+ agent: "opencode",
37
+ event: evt,
38
+ sessionID,
39
+ title: formatTitle(evt, sessionID),
40
+ body: detail,
41
+ }
42
+ // 将 body 格式化为结构化通知正文
43
+ msg.body = formatBody(msg)
44
+ return msg
45
+ }
26
46
 
27
47
  // question.asked — 通用问题询问(权限/确认等均走此事件)
28
48
  // properties 结构待实测确认
29
49
  if (type === "question.asked" && enabled.has("permission_required")) {
30
50
  const text = properties.text ?? properties.message ?? ""
31
- return {
32
- agent: "opencode",
33
- event: "permission_required",
34
- sessionID: properties.sessionID ?? "unknown",
35
- title: formatTitle("permission_required"),
36
- body: text ? `需要确认: ${truncate(text, 200)}` : defaultBody("permission_required"),
37
- }
51
+ return makeMsg(
52
+ "permission_required",
53
+ text ? `需要确认: ${truncate(text, 200)}` : defaultBody("permission_required"),
54
+ )
38
55
  }
39
56
 
40
57
  // permission.asked — 工具权限请求
@@ -46,62 +63,37 @@ export function route(
46
63
  : (properties.tool ?? "")
47
64
  const permission = properties.permission ?? ""
48
65
  const desc = [toolName, permission].filter(Boolean).join(" - ")
49
- return {
50
- agent: "opencode",
51
- event: "permission_required",
52
- sessionID: properties.sessionID ?? "unknown",
53
- title: formatTitle("permission_required"),
54
- body: desc
66
+ return makeMsg(
67
+ "permission_required",
68
+ desc
55
69
  ? `操作「${desc}」需要您的授权许可`
56
70
  : defaultBody("permission_required"),
57
- }
71
+ )
58
72
  }
59
73
 
60
74
  // 会话错误 → run_failed
61
75
  if (type === "session.error" && enabled.has("run_failed")) {
62
76
  const err = properties.error
63
77
  const errMsg = err?.message ?? err?.name ?? defaultBody("run_failed")
64
- return {
65
- agent: "opencode",
66
- event: "run_failed",
67
- sessionID: properties.sessionID ?? "unknown",
68
- title: formatTitle("run_failed"),
69
- body: `错误: ${truncate(String(errMsg), 200)}`,
70
- }
78
+ return makeMsg(
79
+ "run_failed",
80
+ `错误: ${truncate(String(errMsg), 200)}`,
81
+ )
71
82
  }
72
83
 
73
84
  // 会话空闲 → input_required
74
85
  if (type === "session.idle" && enabled.has("input_required")) {
75
- return {
76
- agent: "opencode",
77
- event: "input_required",
78
- sessionID: properties.sessionID ?? "unknown",
79
- title: formatTitle("input_required"),
80
- body: defaultBody("input_required"),
81
- }
86
+ return makeMsg("input_required", defaultBody("input_required"))
82
87
  }
83
88
 
84
89
  // session.status 也可能包含 idle 状态
85
90
  if (type === "session.status" && enabled.has("input_required")) {
86
91
  const status = properties.status
87
92
  if (status?.type === "idle") {
88
- return {
89
- agent: "opencode",
90
- event: "input_required",
91
- sessionID: properties.sessionID ?? "unknown",
92
- title: formatTitle("input_required"),
93
- body: defaultBody("input_required"),
94
- }
93
+ return makeMsg("input_required", defaultBody("input_required"))
95
94
  }
96
95
  }
97
96
 
98
- // 新会话创建 → 可跟踪任务开始
99
- // 当前暂不直接通知,留作 run_completed 检测的基础
100
- if (type === "session.created") {
101
- // 预留: 可在这里记录会话开始时间,用于后续推断任务完成
102
- return null
103
- }
104
-
105
97
  return null
106
98
  }
107
99
 
package/index.ts CHANGED
@@ -1,43 +1,21 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import type { PluginConfig } from "./config.js"
3
- import { resolveConfig, loadYamlConfig, mergeConfig } from "./config.js"
3
+ import { resolveConfig, loadYamlConfig, mergeConfig, ensureConfigFile } from "./config.js"
4
4
  import { route } from "./events.js"
5
5
  import { Dispatcher } from "./dispatcher.js"
6
6
  import { FileStore } from "./store.js"
7
- import { SystemSender } from "./senders/system.js"
8
- import { ScreenFlashSender } from "./senders/screen-flash.js"
7
+ import { SystemSender } from "./senders/system/index.js"
8
+ import { ScreenFlashSender } from "./senders/screen-flash/index.js"
9
9
  import { CustomWebhookSender } from "./senders/custom-webhook.js"
10
10
  import { WechatWorkSender } from "./senders/wechat-work.js"
11
11
  import { FeishuSender } from "./senders/feishu.js"
12
12
  import { FilteredSender } from "./senders/types.js"
13
- import { writeFileSync, mkdirSync, existsSync } from "node:fs"
14
- import { homedir } from "node:os"
15
- import { join, dirname } from "node:path"
16
- import { fileURLToPath } from "node:url"
13
+ import { SessionTracker } from "./session-tracker.js"
14
+ import { configureLog, error, warn, info, debug } from "./log.js"
15
+ import { DelayedDispatcher } from "./delayed-dispatcher.js"
16
+ import { isTerminalOccluded } from "./terminator-detect.js"
17
17
 
18
- const __dirname = dirname(fileURLToPath(import.meta.url))
19
- const MARQUEE_SCRIPT = join(__dirname, "scripts", "marquee.py")
20
-
21
- const LOG_DIR = join(homedir(), ".opencode-notify")
22
- const LOG_FILE = join(LOG_DIR, "plugin.log")
23
-
24
- let debugEnabled = false
25
-
26
- function enableDebug() {
27
- debugEnabled = true
28
- }
29
-
30
- function log(msg: string) {
31
- if (!debugEnabled) return
32
- try {
33
- if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
34
- writeFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`, { flag: "a" })
35
- } catch {
36
- // 日志写入失败不影响主流程
37
- }
38
- }
39
-
40
- // 用户活跃事件类型
18
+ // 用户活跃事件类型(这些事件表明用户正在操作 opencode 的某个会话)
41
19
  const USER_ACTIVITY_EVENTS = new Set([
42
20
  "message.updated",
43
21
  "permission.replied",
@@ -46,21 +24,44 @@ const USER_ACTIVITY_EVENTS = new Set([
46
24
  "tui.command.execute",
47
25
  ])
48
26
 
27
+ // 应追踪会话生命周期的事件(不产生通知,仅更新会话状态)
28
+ // 直接在内联 if 中判断,无需常量
29
+
30
+
49
31
  const plugin: Plugin = async (_input, options) => {
32
+ // 确保配置文件存在(不存在则生成默认模板)
33
+ ensureConfigFile()
34
+
50
35
  // 加载 YAML 配置 + 合并 plugin options
51
36
  const yamlCfg = loadYamlConfig() ?? {}
52
37
  const merged = mergeConfig(yamlCfg, options as PluginConfig ?? {})
53
38
  const cfg = resolveConfig(merged)
54
- if (cfg.debug_log) enableDebug()
39
+ configureLog(cfg.log?.level as any ?? "info", cfg.log?.file)
55
40
  const store = new FileStore()
56
- const senders = buildSenders(cfg)
41
+
42
+ // 构建发送器
43
+ const { senders, senderMap } = buildSenders(cfg)
57
44
  const dispatcher = new Dispatcher(store, cfg.dedupe_seconds ?? 60, senders)
58
- // 用户活跃追踪
59
- let lastActivity = Date.now()
60
- const suppressActive = cfg.suppress_when_active ?? true
61
- const activityTimeout = cfg.activity_timeout_ms ?? 30_000
62
45
 
63
- log(`插件已加载, debug_log=${cfg.debug_log}, events=${JSON.stringify(cfg.events)}, suppressActive=${suppressActive}, timeout=${activityTimeout}ms`)
46
+ // 远程延迟推送
47
+ const delayedChannels = cfg.remote_delay_channels ?? []
48
+ const delayedDispatcher = delayedChannels.length > 0
49
+ ? new DelayedDispatcher(
50
+ (cfg.remote_delay_seconds ?? 60) * 1000,
51
+ cfg.remote_delay_max_count ?? 3,
52
+ delayedChannels,
53
+ senderMap,
54
+ )
55
+ : undefined
56
+
57
+ // 会话感知抑制
58
+ const tracker = new SessionTracker(cfg.session_stale_timeout_ms)
59
+
60
+ info(`插件已加载, log_level=${cfg.log?.level}, events=${JSON.stringify(cfg.events)}, `
61
+ + `suppressActive=${cfg.suppress_when_active}, timeout=${cfg.activity_timeout_ms}ms, `
62
+ + `suppressEvents=${JSON.stringify(cfg.suppress_events_when_active)}, `
63
+ + `remote_channels=${JSON.stringify(delayedChannels)}, `
64
+ + `terminator_detect=${!!process.env.TERMINATOR_UUID}`)
64
65
 
65
66
  return {
66
67
  // event 总线 — 所有事件通过此钩子
@@ -68,70 +69,122 @@ const plugin: Plugin = async (_input, options) => {
68
69
  const { type, properties } = event as any
69
70
  const propKeys = properties ? Object.keys(properties).join(",") : ""
70
71
 
71
- // 调试日志:记录所有事件(随时可关闭)
72
- log(`[event] type=${type} keys=${propKeys}`)
72
+ // 调试日志:记录所有事件
73
+ debug(`[event] type=${type} keys=${propKeys}`)
74
+
75
+ const sessionID = properties?.sessionID ?? "unknown"
73
76
 
74
- // 用户活跃事件追踪
77
+ // === 更新会话追踪状态 ===
78
+
79
+ // 用户操作事件 → 标记该会话活跃 + 取消延迟通知
75
80
  if (USER_ACTIVITY_EVENTS.has(type)) {
76
- lastActivity = Date.now()
77
- log(`→ 用户活跃事件, 重置活跃时间`)
81
+ tracker.markActivity(sessionID)
82
+ delayedDispatcher?.cancelForSession(sessionID)
83
+ debug(`→ 用户活跃事件, 会话=${sessionID}`)
78
84
  }
79
85
 
80
- // 活跃抑制检查
81
- if (suppressActive) {
82
- const idleMs = Date.now() - lastActivity
83
- if (idleMs < activityTimeout) {
84
- // 用户活跃中,跳过通知
85
- return
86
- }
86
+ // 会话生命周期事件
87
+ if (type === "session.created") {
88
+ tracker.register(sessionID)
89
+ debug(`→ 会话已创建, 会话=${sessionID}`)
90
+ }
91
+ if (type === "session.deleted") {
92
+ tracker.remove(sessionID)
93
+ delayedDispatcher?.cancelForSession(sessionID)
94
+ debug(`→ 会话已删除, 会话=${sessionID}`)
87
95
  }
88
96
 
97
+ // === 通知判定 ===
98
+
99
+ const suppressEvents = cfg.suppress_events_when_active ?? []
100
+
101
+ // 先路由事件,看是否匹配通知
89
102
  const msg = route(event, cfg.events)
90
- if (msg) {
91
- log(`→ 匹配通知: ${msg.event}`)
92
- await dispatcher.dispatch(msg)
103
+ if (!msg) return // 不关心的事件
104
+
105
+ debug(`→ 匹配通知: ${msg.event}`)
106
+
107
+ // 会话感知抑制判定
108
+ let shouldSuppress = cfg.suppress_when_active && suppressEvents.includes(msg.event)
109
+ && tracker.isSessionActive(sessionID, cfg.activity_timeout_ms ?? 15000)
110
+
111
+ // Terminator 子屏遮挡覆盖:会话活跃但如果本屏被遮挡 → 强制通知
112
+ if (shouldSuppress) {
113
+ const occluded = isTerminalOccluded()
114
+ if (occluded === true) {
115
+ info(`→ 会话 ${sessionID} 活跃但 Terminator 子屏被遮挡,强制通知 (${msg.event})`)
116
+ shouldSuppress = false
117
+ } else if (occluded === false) {
118
+ debug(`→ Terminator 子屏未遮挡,正常抑制`)
119
+ }
120
+ // null = 不在 Terminator 或检测失败,不处理
93
121
  }
122
+
123
+ if (shouldSuppress) {
124
+ info(`→ 会话 ${sessionID} 活跃中,跳过通知 (${msg.event})`)
125
+ return
126
+ }
127
+
128
+ // 调度发送(正常立即通知)
129
+ await dispatcher.dispatch(msg)
130
+
131
+ // 正常通知已发出 → 调度远程延迟推送(如果启用)
132
+ delayedDispatcher?.schedule(msg)
94
133
  },
95
134
  }
96
135
  }
97
136
 
137
+ /** buildSenders 返回值 */
138
+ interface BuildSendersResult {
139
+ senders: import("./senders/types.js").Sender[]
140
+ senderMap: Map<string, import("./senders/types.js").Sender>
141
+ }
142
+
98
143
  function addSender(
99
144
  senders: import("./senders/types.js").Sender[],
100
145
  sender: import("./senders/types.js").Sender,
101
146
  events: string[],
102
147
  label: string,
103
148
  extra?: string,
104
- ) {
105
- senders.push(new FilteredSender(sender, events))
149
+ ): import("./senders/types.js").Sender {
150
+ const filtered = new FilteredSender(sender, events)
151
+ senders.push(filtered)
106
152
  const evt = events.length < 6 ? `events=${JSON.stringify(events)}` : `events=${events.length}个`
107
- log(`${label}已启用 (${evt})${extra ? `, ${extra}` : ""}`)
153
+ info(`${label}已启用 (${evt})${extra ? `, ${extra}` : ""}`)
154
+ return filtered
108
155
  }
109
156
 
110
- function buildSenders(cfg: PluginConfig) {
157
+ function buildSenders(cfg: PluginConfig): BuildSendersResult {
111
158
  const senders: import("./senders/types.js").Sender[] = []
159
+ const senderMap = new Map<string, import("./senders/types.js").Sender>()
112
160
  const globalEvents = cfg.events ?? []
113
161
 
114
162
  if (cfg.channels?.system_message?.enabled) {
115
163
  const events = cfg.channels.system_message.events ?? globalEvents
116
- addSender(senders, new SystemSender(), events, "系统通知")
164
+ const s = addSender(senders, new SystemSender(), events, "系统通知")
165
+ senderMap.set("system_message", s)
117
166
  }
118
167
  if (cfg.channels?.screen_flash?.enabled) {
119
168
  const events = cfg.channels.screen_flash.events ?? globalEvents
120
- addSender(senders, new ScreenFlashSender(cfg.channels.screen_flash), events, "屏幕跑马灯")
169
+ const s = addSender(senders, new ScreenFlashSender(cfg.channels.screen_flash), events, "屏幕跑马灯")
170
+ senderMap.set("screen_flash", s)
121
171
  }
122
172
  if (cfg.channels?.custom_webhook?.enabled && cfg.channels.custom_webhook.url) {
123
173
  const events = cfg.channels.custom_webhook.events ?? globalEvents
124
- addSender(senders, new CustomWebhookSender(cfg.channels.custom_webhook), events, "自定义 Webhook")
174
+ const s = addSender(senders, new CustomWebhookSender(cfg.channels.custom_webhook), events, "自定义 Webhook")
175
+ senderMap.set("custom_webhook", s)
125
176
  }
126
177
  if (cfg.channels?.wechat_work?.enabled && cfg.channels.wechat_work.webhook_url) {
127
178
  const events = cfg.channels.wechat_work.events ?? globalEvents
128
- addSender(senders, new WechatWorkSender(cfg.channels.wechat_work), events, "企业微信")
179
+ const s = addSender(senders, new WechatWorkSender(cfg.channels.wechat_work), events, "企业微信")
180
+ senderMap.set("wechat_work", s)
129
181
  }
130
182
  if (cfg.channels?.feishu?.enabled && cfg.channels.feishu.webhook_url) {
131
183
  const events = cfg.channels.feishu.events ?? globalEvents
132
- addSender(senders, new FeishuSender(cfg.channels.feishu), events, "飞书")
184
+ const s = addSender(senders, new FeishuSender(cfg.channels.feishu), events, "飞书")
185
+ senderMap.set("feishu", s)
133
186
  }
134
- return senders
187
+ return { senders, senderMap }
135
188
  }
136
189
 
137
190
  export default plugin
package/log.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join, dirname } from "node:path"
4
+
5
+ /**
6
+ * 日志等级(按优先级从高到低)
7
+ */
8
+ export type LogLevel = "error" | "warn" | "info" | "debug"
9
+
10
+ const LEVEL_PRIORITY: Record<LogLevel, number> = {
11
+ error: 0,
12
+ warn: 1,
13
+ info: 2,
14
+ debug: 3,
15
+ }
16
+
17
+ interface LogState {
18
+ level: LogLevel
19
+ file: string
20
+ }
21
+
22
+ const state: LogState = {
23
+ level: "info",
24
+ file: join(homedir(), ".opencode-notify", "plugin.log"),
25
+ }
26
+
27
+ /**
28
+ * 配置日志系统
29
+ *
30
+ * @param level 日志等级(仅输出 >= 此等级的消息)
31
+ * @param file 日志文件路径(可选,不传保持当前值)
32
+ */
33
+ export function configureLog(level: LogLevel, file?: string): void {
34
+ state.level = level
35
+ if (file) state.file = file
36
+ ensureDir()
37
+ }
38
+
39
+ /** 获取当前日志文件路径 */
40
+ export function getLogFile(): string {
41
+ return state.file
42
+ }
43
+
44
+ /** 获取当前日志等级 */
45
+ export function getLogLevel(): LogLevel {
46
+ return state.level
47
+ }
48
+
49
+ function ensureDir(): void {
50
+ try {
51
+ const dir = dirname(state.file)
52
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
53
+ } catch {
54
+ // 创建目录失败不影响主流程
55
+ }
56
+ }
57
+
58
+ function writeLog(level: LogLevel, msg: string): void {
59
+ if (LEVEL_PRIORITY[level] > LEVEL_PRIORITY[state.level]) return
60
+ try {
61
+ ensureDir()
62
+ writeFileSync(
63
+ state.file,
64
+ `[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}\n`,
65
+ { flag: "a" },
66
+ )
67
+ } catch {
68
+ // 日志写入失败不影响主流程
69
+ }
70
+ }
71
+
72
+ /** 错误日志 — 系统无法正常运行或功能不可用 */
73
+ export function error(msg: string): void {
74
+ writeLog("error", msg)
75
+ }
76
+
77
+ /** 警告日志 — 潜在问题,但不影响核心功能 */
78
+ export function warn(msg: string): void {
79
+ writeLog("warn", msg)
80
+ }
81
+
82
+ /** 信息日志 — 正常运行状态变化 */
83
+ export function info(msg: string): void {
84
+ writeLog("info", msg)
85
+ }
86
+
87
+ /** 调试日志 — 详细事件流,仅排查问题时开启 */
88
+ export function debug(msg: string): void {
89
+ writeLog("debug", msg)
90
+ }
package/message.ts CHANGED
@@ -23,12 +23,27 @@ const EVENT_LABELS: Record<string, string> = {
23
23
  session_idle: "会话空闲",
24
24
  }
25
25
 
26
+ /**
27
+ * 截短会话 ID 便于阅读
28
+ */
29
+ function shortSession(sessionID: string): string {
30
+ if (!sessionID || sessionID === "unknown") return "未知"
31
+ return sessionID.length > 11
32
+ ? sessionID.slice(0, 11) + "…"
33
+ : sessionID
34
+ }
35
+
26
36
  /**
27
37
  * 格式化通知标题
38
+ * @param event 事件类型
39
+ * @param sessionID 会话 ID(可选,传入后在标题前加会话标签)
28
40
  */
29
- export function formatTitle(event: string): string {
41
+ export function formatTitle(event: string, sessionID?: string): string {
30
42
  const label = EVENT_LABELS[event] ?? event
31
- return `opencode - ${label}`
43
+ const prefix = sessionID && sessionID !== "unknown"
44
+ ? `[${shortSession(sessionID)}] `
45
+ : ""
46
+ return `${prefix}opencode - ${label}`
32
47
  }
33
48
 
34
49
  /**
@@ -48,3 +63,25 @@ export function defaultBody(event: string): string {
48
63
  return `事件: ${event}`
49
64
  }
50
65
  }
66
+
67
+ /**
68
+ * 格式化结构化通知正文
69
+ *
70
+ * 输出格式:
71
+ * 事件:权限请求
72
+ * 会话:ses_abc1234
73
+ * 详情:Agent 需要授权
74
+ * 时间:2026-05-31 15:30:00
75
+ */
76
+ export function formatBody(msg: Message): string {
77
+ const now = new Date()
78
+ const time = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
79
+ const eventLabel = EVENT_LABELS[msg.event] ?? msg.event
80
+
81
+ return [
82
+ `事件:${eventLabel}`,
83
+ `会话:${shortSession(msg.sessionID)}`,
84
+ `详情:${msg.body}`,
85
+ `时间:${time}`,
86
+ ].join("\n")
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freely01/opencode-notify",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "opencode 通知插件 - 监听会话事件并通过多渠道推送通知",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 屏幕跑马灯发送器
3
+ *
4
+ * 通知时在屏幕四边产生彩色跑马灯效果。
5
+ * 当前仅 Linux X11 支持(Python + GTK 透明覆盖窗口)。
6
+ * 其他平台静默忽略。
7
+ */
8
+
9
+ import type { Sender } from "../types.js"
10
+ import type { Message } from "../../message.js"
11
+ import type { ScreenFlashChannelConfig } from "../../config.js"
12
+ import { flash } from "./linux.js"
13
+
14
+ export class ScreenFlashSender implements Sender {
15
+ readonly name = "screen_flash"
16
+ private config: ScreenFlashChannelConfig
17
+
18
+ constructor(config: ScreenFlashChannelConfig) {
19
+ this.config = config
20
+ }
21
+
22
+ async send(_msg: Message): Promise<void> {
23
+ // 仅 Linux 支持跑马灯效果
24
+ if (process.platform !== "linux") return
25
+ await flash(this.config)
26
+ }
27
+ }
@@ -0,0 +1,29 @@
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 { ScreenFlashChannelConfig } from "../../config.js"
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url))
15
+ const MARQUEE_SCRIPT = join(__dirname, "..", "..", "scripts", "marquee.py")
16
+
17
+ export async function flash(config: ScreenFlashChannelConfig): Promise<void> {
18
+ const args = [
19
+ MARQUEE_SCRIPT,
20
+ String(config.duration ?? 3.0),
21
+ String(config.speed ?? 4.0),
22
+ String(config.intensity ?? 0.9),
23
+ ]
24
+ const child = spawn("python3", args, {
25
+ stdio: "ignore",
26
+ detached: true,
27
+ })
28
+ child.unref()
29
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * macOS 系统通知
3
+ *
4
+ * 使用 osascript 调用原生通知中心。
5
+ * - display notification: 弹窗 + 声音
6
+ * - 系统内置,无需额外安装
7
+ */
8
+
9
+ import { execSync } from "node:child_process"
10
+
11
+ export async function notify(title: string, body: string): Promise<void> {
12
+ const script = `display notification "${body}" with title "${title}" sound name "default"`
13
+ execSync(`osascript -e ${JSON.stringify(script)}`, {
14
+ timeout: 5000,
15
+ stdio: "ignore",
16
+ })
17
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 系统通知发送器
3
+ *
4
+ * 根据运行平台自动选择实现:
5
+ * - macOS → darwin.ts (osascript)
6
+ * - Linux → linux.ts (notify-send)
7
+ * - Windows → win32.ts (PowerShell BurntToast / MessageBox)
8
+ * - 其他平台 → 静默忽略
9
+ */
10
+
11
+ import type { Sender } from "../types.js"
12
+ import type { Message } from "../../message.js"
13
+ import { notify as darwinNotify } from "./darwin.js"
14
+ import { notify as linuxNotify } from "./linux.js"
15
+ import { notify as win32Notify } from "./win32.js"
16
+
17
+ /** 平台通知函数注册表 */
18
+ const notifiers: Record<string, (title: string, body: string) => Promise<void>> = {
19
+ darwin: darwinNotify,
20
+ linux: linuxNotify,
21
+ win32: win32Notify,
22
+ }
23
+
24
+ export class SystemSender implements Sender {
25
+ readonly name = "system_message"
26
+
27
+ async send(msg: Message): Promise<void> {
28
+ const fn = notifiers[process.platform]
29
+ if (!fn) return // 其他平台静默忽略
30
+
31
+ const { title, body } = sanitize(msg.title, msg.body)
32
+ try {
33
+ await fn(title, body)
34
+ } catch (err) {
35
+ throw new Error(`系统通知失败: ${err}`)
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 转义标题和正文中的特殊字符,防止 shell 注入
42
+ */
43
+ function sanitize(
44
+ title: string,
45
+ body: string,
46
+ ): { title: string; body: string } {
47
+ return {
48
+ title: escape(title),
49
+ body: escape(body),
50
+ }
51
+ }
52
+
53
+ function escape(s: string): string {
54
+ return s
55
+ .replace(/"/g, '\\"')
56
+ .replace(/'/g, "\\'")
57
+ .replace(/`/g, "\\`")
58
+ .replace(/\$/g, "\\$")
59
+ .replace(/\n/g, " ")
60
+ .replace(/\r/g, "")
61
+ }