@freely01/opencode-notify 0.2.0 → 0.4.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/README.md +181 -335
- package/package.json +1 -1
- package/senders/screen-flash/index.ts +12 -7
- package/senders/screen-flash/win32.ts +129 -0
- package/senders/system/darwin.ts +26 -3
- package/senders/system/index.ts +8 -2
- package/senders/system/win32.ts +38 -15
- package/terminator-detect.ts +92 -105
package/README.md
CHANGED
|
@@ -1,35 +1,20 @@
|
|
|
1
1
|
# opencode-notify
|
|
2
2
|
|
|
3
|
-
opencode 通知插件 —
|
|
4
|
-
|
|
5
|
-
> ⚠️
|
|
6
|
-
>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
> - `run_completed` 事件暂未实现(opencode 无直接完成事件)
|
|
16
|
-
> - 屏幕跑马灯效果仅 Linux X11 环境支持(依赖 Python + PyGObject),Wayland/macOS/Windows 不生效
|
|
17
|
-
> - 仅在 Ubuntu 24.04 (X11) 环境下测试并使用,其它平台未验证
|
|
18
|
-
>
|
|
19
|
-
> 如有问题欢迎提 Issue,但不保证及时响应和修复。
|
|
20
|
-
|
|
21
|
-
## 功能特性
|
|
22
|
-
|
|
23
|
-
- 监听 `permission_required` / `input_required` / `run_failed` 等事件
|
|
24
|
-
- 多渠道通知:系统通知、企业微信、飞书、自定义 Webhook(Gotify / Bark / PushDeer 等)
|
|
25
|
-
- YAML 配置文件,每项参数均有详细注释
|
|
26
|
-
- 去重机制:同一事件在时间窗口内不重复发送
|
|
27
|
-
- 会话感知抑制:活跃会话按事件类型智能过滤,不遗漏 `run_failed` 等重要通知
|
|
28
|
-
- 零外部运行时依赖(仅 js-yaml 用于配置解析)
|
|
29
|
-
- **屏幕跑马灯**:通知时屏幕四边高亮闪烁(Linux X11,Python + GTK 内置)
|
|
3
|
+
opencode 通知插件 — 监听会话关键事件,通过多渠道推送通知到手机、群聊或桌面。
|
|
4
|
+
|
|
5
|
+
> ⚠️ **个人项目,按需使用** — 主要在 Ubuntu 24.04 (X11) 环境测试,其他平台可能存在问题。
|
|
6
|
+
> 如有问题欢迎提 [Issue](https://github.com/luyanfeng/opencode-notify/issues),不保证及时响应和修复。
|
|
7
|
+
|
|
8
|
+
## 功能一览
|
|
9
|
+
|
|
10
|
+
- **多渠道通知**:系统通知 + 屏幕跑马灯 + 企业微信 + 飞书 + 自定义 Webhook
|
|
11
|
+
- **会话感知抑制**:活跃会话智能过滤冗余通知,不遗漏 `run_failed` 等重要事件
|
|
12
|
+
- **Terminator 子屏检测**:自动识别子屏最大化遮挡场景,被隐藏的会话强制通知
|
|
13
|
+
- **远程延迟推送**:正常通知发出后,对远程渠道额外延迟补偿,防止错过
|
|
14
|
+
- **去重机制**:同一事件在时间窗口内不重复发送
|
|
30
15
|
- **渠道级事件过滤**:每个渠道可独立配置监听哪些事件,灵活分流
|
|
31
|
-
-
|
|
32
|
-
-
|
|
16
|
+
- **零外部运行时依赖**:仅 `js-yaml` 用于配置解析
|
|
17
|
+
- **诊断 CLI**:验证配置、发送测试通知、调试事件流
|
|
33
18
|
|
|
34
19
|
## 平台支持
|
|
35
20
|
|
|
@@ -37,93 +22,84 @@ opencode 通知插件 — 监听会话中的关键事件,通过多渠道推送
|
|
|
37
22
|
|------|:-----:|:-----:|:-------:|
|
|
38
23
|
| 插件核心(事件监听/路由/分发) | ✅ | ✅ | ✅ |
|
|
39
24
|
| 自定义 Webhook / 企业微信 / 飞书 | ✅ | ✅ | ✅ |
|
|
40
|
-
| 诊断 CLI
|
|
41
|
-
| **系统消息通知** | ✅ `osascript`
|
|
42
|
-
| **屏幕跑马灯** | ❌ | ✅
|
|
43
|
-
|
|
44
|
-
**说明:**
|
|
45
|
-
- **macOS**: 系统通知使用 `osascript`,系统内置,开箱即用
|
|
46
|
-
- **Linux**: 系统通知使用 `notify-send`,来自 `libnotify`。桌面发行版通常预装,如缺失可 `apt install libnotify-bin` / `yum install libnotify`
|
|
47
|
-
- **Windows**: 系统通知使用 PowerShell `New-BurntToastNotification`,需额外安装 [BurntToast](https://github.com/Windos/BurntToast) 模块。Webhook 渠道不受影响
|
|
48
|
-
- **屏幕跑马灯**: 仅 Linux X11 环境。使用 Python + PyGObject(GTK 3),Ubuntu GNOME 桌面内置,无需额外安装。Wayland 暂不支持
|
|
49
|
-
- 非系统通知模块(Webhook 推送、CLI 诊断)均为纯 HTTP/Node API,全平台一致
|
|
25
|
+
| 诊断 CLI | ✅ | ✅ | ✅ |
|
|
26
|
+
| **系统消息通知** | ✅ `osascript` | ⚠️ 需 `libnotify` | ✅ WinRT Native Toast |
|
|
27
|
+
| **屏幕跑马灯** | ❌ | ✅ Ubuntu 24.04 X11 | ✅ PowerShell+WinForms |
|
|
50
28
|
|
|
51
|
-
> **已测试渠道:** 系统通知、企业微信、自定义 Webhook(Gotify
|
|
29
|
+
> **已测试渠道:** 系统通知、企业微信、自定义 Webhook(Gotify)。飞书等渠道理论可用,暂未验证。
|
|
52
30
|
|
|
53
31
|
## 快速开始
|
|
54
32
|
|
|
55
33
|
### 1. 安装
|
|
56
34
|
|
|
57
|
-
将插件添加到 `~/.config/opencode/opencode.json` 的 `plugin` 列表中:
|
|
58
|
-
|
|
59
|
-
**方式一:从 npm 安装(推荐)**
|
|
60
35
|
```bash
|
|
61
36
|
npm install -g @freely01/opencode-notify
|
|
62
37
|
```
|
|
63
38
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
39
|
+
详细安装方式(npm / 本地 / Bun)及平台依赖说明 → [doc/install.md](doc/install.md)
|
|
40
|
+
|
|
41
|
+
### 2. 注册插件
|
|
42
|
+
|
|
43
|
+
编辑 `~/.config/opencode/opencode.json`:
|
|
69
44
|
|
|
70
|
-
**方式二:本地路径(开发调试)**
|
|
71
45
|
```json
|
|
72
46
|
{
|
|
73
|
-
"plugin": [
|
|
74
|
-
"file:///home/<你的用户名>/path/to/opencode-notify/index.ts"
|
|
75
|
-
]
|
|
47
|
+
"plugin": ["@freely01/opencode-notify"]
|
|
76
48
|
}
|
|
77
49
|
```
|
|
78
50
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
### 2. 配置
|
|
51
|
+
### 3. 配置
|
|
82
52
|
|
|
83
|
-
|
|
53
|
+
首次启动自动生成默认配置 `~/.config/opencode/opencode-notify.yaml`,或参考项目中的 `opencode-notify.yaml.example`。
|
|
84
54
|
|
|
85
55
|
```yaml
|
|
86
56
|
channels:
|
|
87
57
|
system_message:
|
|
88
58
|
enabled: true
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
screen_flash:
|
|
59
|
+
wechat_work:
|
|
92
60
|
enabled: true
|
|
93
|
-
|
|
94
|
-
speed: 5.0
|
|
95
|
-
intensity: 0.85
|
|
96
|
-
|
|
61
|
+
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
|
|
97
62
|
custom_webhook:
|
|
98
|
-
enabled:
|
|
63
|
+
enabled: true
|
|
99
64
|
url: "https://gotify.example.com/message"
|
|
100
65
|
headers:
|
|
101
66
|
X-Gotify-Key: "your-app-token"
|
|
102
|
-
template: '{"title":"{{title}}","message":"{{body}}","priority":5}'
|
|
103
|
-
|
|
104
|
-
wechat_work:
|
|
105
|
-
enabled: false
|
|
106
|
-
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
|
|
107
|
-
|
|
108
|
-
feishu:
|
|
109
|
-
enabled: false
|
|
110
|
-
webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
|
|
111
67
|
|
|
112
68
|
events:
|
|
113
69
|
- permission_required
|
|
114
70
|
- input_required
|
|
115
71
|
- run_failed
|
|
116
72
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
73
|
+
suppress_when_active: true
|
|
74
|
+
activity_timeout_ms: 15000
|
|
75
|
+
suppress_events_when_active:
|
|
76
|
+
- permission_required
|
|
77
|
+
- input_required
|
|
78
|
+
|
|
79
|
+
log:
|
|
80
|
+
level: info
|
|
120
81
|
```
|
|
121
82
|
|
|
122
|
-
|
|
83
|
+
完整配置项参考 → [配置参考](#配置参考)
|
|
84
|
+
|
|
85
|
+
### 4. 验证
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# 查看插件加载日志
|
|
89
|
+
tail -f ~/.opencode-notify/plugin.log
|
|
90
|
+
|
|
91
|
+
# 使用诊断 CLI 发送测试通知
|
|
92
|
+
bun run cli.ts --test
|
|
93
|
+
|
|
94
|
+
# 查看配置概览
|
|
95
|
+
bun run cli.ts --config
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
123
99
|
|
|
124
|
-
|
|
100
|
+
## 通知渠道
|
|
125
101
|
|
|
126
|
-
|
|
102
|
+
每个渠道可独立配置监听事件,不填则继承全局 `events`。
|
|
127
103
|
|
|
128
104
|
| 事件值 | 说明 |
|
|
129
105
|
|--------|------|
|
|
@@ -132,70 +108,42 @@ activity_timeout_ms: 30000
|
|
|
132
108
|
| `run_failed` | 任务执行失败 |
|
|
133
109
|
| `run_completed` | 任务执行完成(技术预留,暂未实现) |
|
|
134
110
|
|
|
135
|
-
```yaml
|
|
136
|
-
# 自定义 Webhook 只推送权限请求和错误,不推送等待输入
|
|
137
|
-
custom_webhook:
|
|
138
|
-
enabled: true
|
|
139
|
-
events:
|
|
140
|
-
- permission_required
|
|
141
|
-
- run_failed
|
|
142
|
-
```
|
|
143
|
-
|
|
144
111
|
### 系统消息通知
|
|
145
112
|
|
|
146
|
-
弹出操作系统原生通知横幅。
|
|
147
|
-
|
|
148
113
|
| 平台 | 实现 |
|
|
149
114
|
|------|------|
|
|
150
|
-
| macOS | `osascript
|
|
151
|
-
| Linux | `notify-send
|
|
152
|
-
| Windows |
|
|
115
|
+
| macOS | `osascript`(内置) |
|
|
116
|
+
| Linux | `notify-send`(需 `libnotify`) |
|
|
117
|
+
| Windows | WinRT Native Toast |
|
|
153
118
|
|
|
154
119
|
### 屏幕跑马灯
|
|
155
120
|
|
|
156
|
-
|
|
121
|
+
通知时屏幕四边彩色高亮闪烁,视觉更醒目。
|
|
157
122
|
|
|
158
123
|

|
|
159
124
|
|
|
160
|
-
- 独立渠道,可与系统通知分开启停、分开配置事件过滤
|
|
161
|
-
- 使用 Python + PyGObject(GTK 3) 创建透明覆盖窗口,不干扰当前操作
|
|
162
|
-
- 60fps 动画,彩色灯光沿四边循环运动(红→橙→黄→绿→蓝)
|
|
163
|
-
- 非阻塞执行,不影响通知发送速度
|
|
164
|
-
- 仅 Linux X11 环境,Ubuntu GNOME 桌面内置,无需额外安装
|
|
165
|
-
|
|
166
125
|
```yaml
|
|
167
126
|
screen_flash:
|
|
168
127
|
enabled: true
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
intensity: 0.9 # 不透明度 0.0~1.0(默认 0.9)
|
|
128
|
+
duration: 3.0 # 持续秒数
|
|
129
|
+
speed: 4.0 # 移动速度因子
|
|
130
|
+
intensity: 0.9 # 不透明度 0.0~1.0
|
|
173
131
|
```
|
|
174
132
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
**支持的服务举例:**
|
|
133
|
+
- **Ubuntu 24.04 X11**: Python + PyGObject(GTK 3) 创建透明覆盖窗口,60fps 彩色灯光沿四边循环运动
|
|
134
|
+
- **Windows**: PowerShell + .NET WinForms 创建屏幕四边彩色闪烁边框(8px 宽),中间透明可点击穿透
|
|
135
|
+
- 非阻塞执行,不影响通知发送速度
|
|
180
136
|
|
|
181
|
-
|
|
182
|
-
|------|------|
|
|
183
|
-
| Gotify | [gotify.net](https://gotify.net/) |
|
|
184
|
-
| Bark | [github.com/Finb/Bark](https://github.com/Finb/Bark) |
|
|
185
|
-
| PushDeer | [pushdeer.com](https://pushdeer.com/) |
|
|
186
|
-
| Slack Webhook | [api.slack.com/messaging/webhooks](https://api.slack.com/messaging/webhooks) |
|
|
187
|
-
| Discord Webhook | [support.discord.com](https://support.discord.com/hc/en-us/articles/228383668) |
|
|
137
|
+
### 自定义 Webhook
|
|
188
138
|
|
|
189
|
-
|
|
139
|
+
通用 HTTP 发送器,支持任意 Webhook 服务(Gotify / Bark / PushDeer / Slack / Discord 等)。
|
|
190
140
|
|
|
191
141
|
| 参数 | 说明 |
|
|
192
142
|
|------|------|
|
|
193
143
|
| `url` | Webhook 地址 |
|
|
194
|
-
| `method` |
|
|
195
|
-
| `headers` |
|
|
196
|
-
| `template` |
|
|
197
|
-
|
|
198
|
-
**Gotify 配置示例:**
|
|
144
|
+
| `method` | `POST` / `GET`,默认 `POST` |
|
|
145
|
+
| `headers` | 自定义请求头 |
|
|
146
|
+
| `template` | 消息模板,支持 `{{title}}` `{{body}}` `{{event}}` `{{agent}}` `{{sessionID}}` |
|
|
199
147
|
|
|
200
148
|
```yaml
|
|
201
149
|
custom_webhook:
|
|
@@ -211,110 +159,77 @@ custom_webhook:
|
|
|
211
159
|
|
|
212
160
|
通过群机器人 Webhook 发送 Markdown 消息。
|
|
213
161
|
|
|
214
|
-
**配置步骤:**
|
|
215
|
-
|
|
216
|
-
1. 在企业微信群中添加群机器人
|
|
217
|
-
2. 复制 Webhook URL
|
|
218
|
-
3. 填入配置文件
|
|
219
|
-
|
|
220
162
|
```yaml
|
|
221
163
|
wechat_work:
|
|
222
164
|
enabled: true
|
|
223
165
|
webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
|
|
224
166
|
```
|
|
225
167
|
|
|
226
|
-
消息格式:Markdown,实际发送的请求体:
|
|
227
|
-
|
|
228
|
-
```json
|
|
229
|
-
{
|
|
230
|
-
"msgtype": "markdown",
|
|
231
|
-
"markdown": {
|
|
232
|
-
"content": "**事件标题**\n\n事件详情...\n> 会话: sessionID"
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
```
|
|
236
|
-
|
|
237
168
|
消息包含:标题(加粗)、事件详情、会话 ID。
|
|
238
169
|
|
|
239
170
|
### 飞书
|
|
240
171
|
|
|
241
172
|
通过自定义机器人或流程触发器 Webhook 发送卡片消息。
|
|
242
173
|
|
|
243
|
-
**配置步骤:**
|
|
244
|
-
|
|
245
|
-
1. 在飞书群中添加自定义机器人(或创建流程触发器)
|
|
246
|
-
2. 复制 Webhook URL
|
|
247
|
-
3. 填入配置文件
|
|
248
|
-
|
|
249
174
|
```yaml
|
|
250
175
|
feishu:
|
|
251
176
|
enabled: true
|
|
252
177
|
webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
|
|
253
178
|
```
|
|
254
179
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
```json
|
|
258
|
-
{
|
|
259
|
-
"msg_type": "interactive",
|
|
260
|
-
"card": {
|
|
261
|
-
"header": {
|
|
262
|
-
"title": { "tag": "plain_text", "content": "事件标题" }
|
|
263
|
-
},
|
|
264
|
-
"elements": [
|
|
265
|
-
{ "tag": "markdown", "content": "事件详情..." },
|
|
266
|
-
{ "tag": "hr" },
|
|
267
|
-
{ "tag": "note", "elements": [{ "tag": "plain_text", "content": "会话: sessionID" }] }
|
|
268
|
-
]
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
消息包含:标题头、正文(Markdown)、分割线、脚注(会话 ID)。
|
|
180
|
+
消息格式:interactive 卡片消息,包含标题头、Markdown 正文、分割线、脚注。
|
|
274
181
|
|
|
275
182
|
### 远程延迟推送
|
|
276
183
|
|
|
277
|
-
|
|
278
|
-
用户在延迟期间回到 TUI 操作 → 自动取消该会话所有待发延迟通知。
|
|
279
|
-
|
|
280
|
-
**适用场景:** 用户离开电脑后,系统通知可能一闪而过没看到;延迟推送在用户仍未回来时再次尝试发出。
|
|
184
|
+
正常通知发出后,对指定渠道额外延迟补偿推送。用户在延迟期间回到 TUI 操作 → 自动取消。
|
|
281
185
|
|
|
282
186
|
```yaml
|
|
283
|
-
remote_delay_channels:
|
|
284
|
-
- system_message
|
|
187
|
+
remote_delay_channels:
|
|
285
188
|
- wechat_work
|
|
286
|
-
- feishu
|
|
287
189
|
- custom_webhook
|
|
288
|
-
remote_delay_seconds: 60
|
|
289
|
-
remote_delay_max_count: 3
|
|
190
|
+
remote_delay_seconds: 60
|
|
191
|
+
remote_delay_max_count: 3
|
|
290
192
|
```
|
|
291
193
|
|
|
292
|
-
**注意:**
|
|
293
|
-
- `remote_delay_channels` 仅影响**额外延迟推送**,不影响正常通知(正常通知该发就发)
|
|
294
|
-
- 延迟推送使用渠道自身的事件过滤规则,只有渠道订阅的事件才会延迟推送
|
|
295
|
-
- 用户在该会话中进行任何操作(输入消息、回应权限、执行命令等)都会取消该会话所有待发延迟
|
|
296
|
-
- 达到 `remote_delay_max_count` 次后停止推送
|
|
194
|
+
**注意:** 仅影响额外延迟推送,不影响正常通知(该发就发)。用户在该会话中操作(输入消息、回应权限等)自动取消所有待发延迟。
|
|
297
195
|
|
|
298
|
-
|
|
196
|
+
---
|
|
299
197
|
|
|
300
|
-
|
|
301
|
-
其他子屏幕中的 opencode 会话虽活跃但被遮挡。插件自动检测该场景,
|
|
302
|
-
被遮挡的会话即使活跃也会强制发送通知。
|
|
198
|
+
## Terminator 子屏遮挡检测
|
|
303
199
|
|
|
304
|
-
|
|
305
|
-
环境变量确认在 Terminator 中 → DBus 查询焦点终端 UUID → 与本屏对比
|
|
306
|
-
→ 不一致则强制通知,无需额外配置。
|
|
200
|
+
> 详细设计原理 → [doc/features.md](doc/features.md#4-terminator-子屏幕遮挡检测)
|
|
307
201
|
|
|
308
|
-
|
|
202
|
+
当你在 Terminator 中最大化某个子屏幕时(`Ctrl+Shift+X`),其他子屏被完全遮挡。插件自动检测此场景,被遮挡的会话即使活跃也强制发送通知。
|
|
203
|
+
|
|
204
|
+
**检测策略:**
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
事件被活跃抑制
|
|
208
|
+
↓
|
|
209
|
+
Terminator 窗口是当前 X 活跃窗口? ← xdotool + xprop
|
|
210
|
+
否 → 用户在别的应用中(浏览器/IDE)→ 强制通知
|
|
211
|
+
是 → 本屏可见 → 正常抑制
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**外部依赖**(检测失败自动降级,不影响正常抑制):
|
|
215
|
+
|
|
216
|
+
| 工具 | 用途 | 安装 |
|
|
217
|
+
|------|------|------|
|
|
218
|
+
| `xdotool` | 获取活跃窗口 ID | `apt install xdotool` |
|
|
219
|
+
| `xprop` | 查询窗口 WM_CLASS | 系统自带 |
|
|
220
|
+
|
|
221
|
+
---
|
|
309
222
|
|
|
310
223
|
## 事件映射
|
|
311
224
|
|
|
312
|
-
| 通知事件 | 触发场景 |
|
|
313
|
-
|
|
314
|
-
| `permission_required` | Agent
|
|
315
|
-
| `input_required` | Agent 等待用户输入 | `session.idle` / `session.status
|
|
225
|
+
| 通知事件 | 触发场景 | opencode 事件 |
|
|
226
|
+
|----------|---------|---------------|
|
|
227
|
+
| `permission_required` | Agent 需要授权 | `permission.asked` / `question.asked` |
|
|
228
|
+
| `input_required` | Agent 等待用户输入 | `session.idle` / `session.status(idle)` |
|
|
316
229
|
| `run_failed` | 任务执行失败 | `session.error` |
|
|
317
|
-
| `run_completed` |
|
|
230
|
+
| `run_completed` | 任务完成(预留) | — |
|
|
231
|
+
|
|
232
|
+
---
|
|
318
233
|
|
|
319
234
|
## 配置参考
|
|
320
235
|
|
|
@@ -333,190 +248,121 @@ YAML 文件 > plugin options (opencode.json) > 默认值
|
|
|
333
248
|
```yaml
|
|
334
249
|
channels:
|
|
335
250
|
system_message:
|
|
336
|
-
enabled: true
|
|
337
|
-
events: []
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
duration: 3.5 # 持续秒数
|
|
344
|
-
speed: 5.0 # 移动速度因子
|
|
345
|
-
intensity: 0.85 # 不透明度 0.0~1.0
|
|
346
|
-
|
|
251
|
+
enabled: true
|
|
252
|
+
events: []
|
|
253
|
+
screen_flash:
|
|
254
|
+
enabled: false
|
|
255
|
+
duration: 3.5
|
|
256
|
+
speed: 5.0
|
|
257
|
+
intensity: 0.85
|
|
347
258
|
custom_webhook:
|
|
348
|
-
enabled: false
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
headers: {} # 自定义请求头
|
|
354
|
-
template: "" # 消息模板
|
|
355
|
-
|
|
259
|
+
enabled: false
|
|
260
|
+
url: ""
|
|
261
|
+
method: "POST"
|
|
262
|
+
headers: {}
|
|
263
|
+
template: ""
|
|
356
264
|
wechat_work:
|
|
357
|
-
enabled: false
|
|
358
|
-
|
|
359
|
-
# 可选值: permission_required | input_required | run_completed | run_failed
|
|
360
|
-
webhook_url: "" # 群机器人 Webhook URL
|
|
361
|
-
# 消息格式: Markdown (msgtype=markdown, markdown.content)
|
|
362
|
-
|
|
265
|
+
enabled: false
|
|
266
|
+
webhook_url: ""
|
|
363
267
|
feishu:
|
|
364
|
-
enabled: false
|
|
365
|
-
|
|
366
|
-
# 可选值: permission_required | input_required | run_completed | run_failed
|
|
367
|
-
webhook_url: "" # 机器人/流程触发器 Webhook URL
|
|
368
|
-
# 消息格式: 卡片消息 (msg_type=interactive, card)
|
|
369
|
-
|
|
370
|
-
events: # 订阅的事件列表
|
|
371
|
-
# 可选值: permission_required | input_required | run_completed | run_failed
|
|
372
|
-
|
|
373
|
-
dedupe_seconds: 60 # 去重时间窗口(秒)
|
|
374
|
-
suppress_when_active: true # 会话感知抑制开关(按 suppress_events 列表过滤)
|
|
375
|
-
activity_timeout_ms: 15000 # 会话活跃超时(毫秒),超过此时间无操作视为离开
|
|
376
|
-
suppress_events_when_active: # 活跃时抑制哪些事件(不填=默认列表)
|
|
377
|
-
- permission_required
|
|
378
|
-
- input_required
|
|
379
|
-
# run_failed / run_completed 不在列表中 → 始终通知
|
|
380
|
-
session_stale_timeout_ms: 600000 # 超时会话自动淘汰(毫秒),默认 10 分钟
|
|
381
|
-
remote_delay_channels: [] # 远程延迟推送渠道列表(空=不启用)
|
|
382
|
-
# 可选值: system_message, screen_flash, wechat_work, feishu, custom_webhook
|
|
383
|
-
remote_delay_seconds: 60 # 远程延迟秒数(默认 60)
|
|
384
|
-
remote_delay_max_count: 3 # 远程延迟最多重复次数(默认 3)
|
|
385
|
-
log: # 日志配置
|
|
386
|
-
level: info # 等级: error | warn | info | debug(默认 info)
|
|
387
|
-
# error - 仅记录错误
|
|
388
|
-
# warn - 错误 + 警告
|
|
389
|
-
# info - 错误 + 警告 + 常规信息(推荐)
|
|
390
|
-
# debug - 全部日志(排查时使用)
|
|
391
|
-
# file: "~/.opencode-notify/plugin.log" # 日志文件路径(可选,默认同上)
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
### 活跃抑制
|
|
395
|
-
|
|
396
|
-
当用户正在 opencode TUI 中操作(输入消息、回应权限等),通知可能冗余(屏上已可见)。
|
|
397
|
-
插件通过**会话感知抑制**解决:追踪每个会话的用户操作时间戳,活跃会话按事件类型选择性过滤。
|
|
398
|
-
|
|
399
|
-
**检测的用户操作事件:**
|
|
400
|
-
`message.updated` / `permission.replied` / `question.replied` / `command.executed` / `tui.command.execute`
|
|
401
|
-
|
|
402
|
-
**抑制规则:**
|
|
403
|
-
|
|
404
|
-
| 通知事件 | 活跃时默认行为 | 理由 |
|
|
405
|
-
|---------|:------------:|------|
|
|
406
|
-
| `permission_required` | ✅ 抑制 | 权限弹窗就在屏幕上 |
|
|
407
|
-
| `input_required` | ✅ 抑制 | TUI 明确在等输入 |
|
|
408
|
-
| `run_failed` | ❌ 不抑制 | 异步结果,人可能走开 |
|
|
409
|
-
| `run_completed` | ❌ 不抑制 | 同上 |
|
|
268
|
+
enabled: false
|
|
269
|
+
webhook_url: ""
|
|
410
270
|
|
|
411
|
-
|
|
271
|
+
events: [permission_required, input_required, run_failed]
|
|
272
|
+
dedupe_seconds: 60
|
|
412
273
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
suppress_events_when_active: # 活跃时抑制哪些事件
|
|
274
|
+
suppress_when_active: true
|
|
275
|
+
activity_timeout_ms: 15000
|
|
276
|
+
suppress_events_when_active:
|
|
417
277
|
- permission_required
|
|
418
278
|
- input_required
|
|
419
|
-
session_stale_timeout_ms: 600000
|
|
279
|
+
session_stale_timeout_ms: 600000
|
|
280
|
+
|
|
281
|
+
remote_delay_channels: []
|
|
282
|
+
remote_delay_seconds: 60
|
|
283
|
+
remote_delay_max_count: 3
|
|
284
|
+
|
|
285
|
+
log:
|
|
286
|
+
level: info
|
|
420
287
|
```
|
|
421
288
|
|
|
422
|
-
|
|
289
|
+
### 活跃抑制
|
|
423
290
|
|
|
424
|
-
|
|
291
|
+
当用户在 opencode TUI 中操作时,屏上已可见的通知被智能过滤。追踪的用户操作事件:`message.updated` / `permission.replied` / `question.replied` / `command.executed` / `tui.command.execute`。
|
|
425
292
|
|
|
426
|
-
|
|
293
|
+
| 事件 | 活跃时行为 |
|
|
294
|
+
|------|:---------:|
|
|
295
|
+
| `permission_required` | ✅ 抑制(屏上可见) |
|
|
296
|
+
| `input_required` | ✅ 抑制(TUI 在等输入) |
|
|
297
|
+
| `run_failed` | ❌ 不抑制(异步结果) |
|
|
298
|
+
| `run_completed` | ❌ 不抑制(预留) |
|
|
427
299
|
|
|
428
|
-
|
|
429
|
-
|------|---------|---------|
|
|
430
|
-
| `error` | 仅错误 | 生产环境,只关心失败 |
|
|
431
|
-
| `warn` | 错误 + 警告 | 生产环境,关注潜在问题 |
|
|
432
|
-
| `info` | 错误 + 警告 + 常规信息 | 日常运行(默认) |
|
|
433
|
-
| `debug` | 全部日志(含详细事件流) | 排查问题 |
|
|
300
|
+
---
|
|
434
301
|
|
|
435
|
-
|
|
436
|
-
log:
|
|
437
|
-
level: info # 推荐:日常使用记录所有关键信息
|
|
438
|
-
# level: debug # 排查问题时改为 debug
|
|
439
|
-
# file: "~/.opencode-notify/plugin.log" # 可选自定义路径
|
|
440
|
-
```
|
|
302
|
+
## 日志与故障排查
|
|
441
303
|
|
|
442
|
-
|
|
304
|
+
日志位于 `~/.opencode-notify/plugin.log`(可通过 `log.file` 自定义)。
|
|
443
305
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
306
|
+
| 等级 | 内容 | 用途 |
|
|
307
|
+
|------|------|------|
|
|
308
|
+
| `error` | 渠道失败、异常 | 生产环境 |
|
|
309
|
+
| `warn` | 配置异常、部分失败 | 生产环境 |
|
|
310
|
+
| `info` | 渠道启用、通知分发、抑制决策 | 日常运行(默认) |
|
|
311
|
+
| `debug` | 全部事件流、DBus 调用详情 | 排查问题 |
|
|
449
312
|
|
|
450
313
|
```bash
|
|
451
|
-
# 实时查看日志
|
|
452
314
|
tail -f ~/.opencode-notify/plugin.log
|
|
453
|
-
|
|
454
|
-
# 查看最近的插件加载信息
|
|
455
315
|
grep "插件已加载" ~/.opencode-notify/plugin.log
|
|
456
|
-
|
|
457
|
-
# 只看错误
|
|
458
316
|
grep "\[ERROR\]" ~/.opencode-notify/plugin.log
|
|
459
|
-
|
|
460
|
-
# 只看警告
|
|
461
|
-
grep "\[WARN\]" ~/.opencode-notify/plugin.log
|
|
462
317
|
```
|
|
463
318
|
|
|
464
319
|
### 常见问题
|
|
465
320
|
|
|
466
321
|
**Q: 插件未加载?**
|
|
467
|
-
确认 `opencode.json` 中 `plugin`
|
|
322
|
+
确认 `opencode.json` 中 `plugin` 路径正确,指向 `index.ts`。
|
|
468
323
|
|
|
469
324
|
**Q: 通知没有弹出?**
|
|
470
|
-
|
|
471
|
-
2. 检查渠道是否已 `enabled: true`
|
|
472
|
-
3. 检查 Webhook URL 是否正确
|
|
473
|
-
4. 查看 `~/.opencode-notify/plugin.log` 中是否有错误信息
|
|
325
|
+
检查日志是否有 `[event] type=` 行确认事件被监听到;检查渠道 `enabled: true`;检查 Webhook URL 正确性。
|
|
474
326
|
|
|
475
327
|
**Q: 企业微信/飞书通知失败?**
|
|
476
|
-
确认 Webhook URL
|
|
328
|
+
确认 Webhook URL 有效,网络可达:
|
|
477
329
|
|
|
478
330
|
```bash
|
|
479
|
-
curl -X POST <webhook_url> -H "Content-Type: application/json"
|
|
331
|
+
curl -X POST <webhook_url> -H "Content-Type: application/json" \
|
|
332
|
+
-d '{"msgtype":"markdown","markdown":{"content":"**测试**"}}'
|
|
480
333
|
```
|
|
481
334
|
|
|
335
|
+
---
|
|
336
|
+
|
|
482
337
|
## 项目结构
|
|
483
338
|
|
|
484
339
|
```
|
|
485
340
|
opencode-notify/
|
|
486
|
-
├── index.ts
|
|
487
|
-
├── cli.ts
|
|
488
|
-
├── config.ts
|
|
489
|
-
├── events.ts
|
|
490
|
-
├── log.ts
|
|
491
|
-
├──
|
|
492
|
-
├──
|
|
493
|
-
├──
|
|
494
|
-
├── dispatcher.ts
|
|
495
|
-
├──
|
|
496
|
-
├──
|
|
341
|
+
├── index.ts # 插件入口
|
|
342
|
+
├── cli.ts # 诊断工具
|
|
343
|
+
├── config.ts # 配置解析
|
|
344
|
+
├── events.ts # 事件路由
|
|
345
|
+
├── log.ts # 日志模块
|
|
346
|
+
├── message.ts # 消息模型
|
|
347
|
+
├── session-tracker.ts # 会话感知抑制
|
|
348
|
+
├── terminator-detect.ts # Terminator 子屏遮挡检测
|
|
349
|
+
├── delayed-dispatcher.ts # 远程延迟推送
|
|
350
|
+
├── dispatcher.ts # 去重分发
|
|
351
|
+
├── store.ts # 状态存储
|
|
497
352
|
├── doc/
|
|
498
|
-
│ ├──
|
|
499
|
-
│
|
|
353
|
+
│ ├── install.md # 安装指南
|
|
354
|
+
│ ├── features.md # 功能详解
|
|
355
|
+
│ └── de.png # 跑马灯效果截图
|
|
500
356
|
├── scripts/
|
|
501
|
-
│ └── marquee.py
|
|
357
|
+
│ └── marquee.py # 屏幕跑马灯脚本
|
|
502
358
|
├── senders/
|
|
503
|
-
│ ├── types.ts
|
|
504
|
-
│ ├── system/
|
|
505
|
-
│
|
|
506
|
-
│
|
|
507
|
-
│
|
|
508
|
-
│
|
|
509
|
-
|
|
510
|
-
│ │ ├── index.ts # 入口 + 平台守卫
|
|
511
|
-
│ │ └── linux.ts # Linux 实现 (Python+GTK)
|
|
512
|
-
│ ├── custom-webhook.ts # 自定义 Webhook
|
|
513
|
-
│ ├── wechat-work.ts # 企业微信
|
|
514
|
-
│ └── feishu.ts # 飞书
|
|
515
|
-
├── findings.md # 研究记录
|
|
516
|
-
├── plan.md # 需求与实现计划
|
|
517
|
-
├── task_plan.md # 任务跟踪
|
|
518
|
-
├── progress.md # 进度日志
|
|
359
|
+
│ ├── types.ts # Sender 接口
|
|
360
|
+
│ ├── system/ # 系统通知
|
|
361
|
+
│ ├── screen-flash/ # 屏幕跑马灯
|
|
362
|
+
│ ├── custom-webhook.ts
|
|
363
|
+
│ ├── wechat-work.ts
|
|
364
|
+
│ └── feishu.ts
|
|
365
|
+
├── opencode-notify.yaml.example
|
|
519
366
|
├── package.json
|
|
520
367
|
└── tsconfig.json
|
|
521
368
|
```
|
|
522
|
-
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 屏幕跑马灯发送器
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 通知时在屏幕产生彩色闪烁效果,吸引注意力。
|
|
5
|
+
* - Linux → Python + GTK3 透明覆盖窗口(四边跑马灯动画)
|
|
6
|
+
* - Windows → PowerShell + .NET WinForms 全屏彩色闪烁
|
|
7
|
+
* - macOS / 其他 → 静默忽略
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { Sender } from "../types.js"
|
|
10
11
|
import type { Message } from "../../message.js"
|
|
11
12
|
import type { ScreenFlashChannelConfig } from "../../config.js"
|
|
12
|
-
import { flash } from "./linux.js"
|
|
13
|
+
import { flash as linuxFlash } from "./linux.js"
|
|
14
|
+
import { flash as win32Flash } from "./win32.js"
|
|
13
15
|
|
|
14
16
|
export class ScreenFlashSender implements Sender {
|
|
15
17
|
readonly name = "screen_flash"
|
|
@@ -20,8 +22,11 @@ export class ScreenFlashSender implements Sender {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
async send(_msg: Message): Promise<void> {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
if (process.platform === "linux") {
|
|
26
|
+
await linuxFlash(this.config)
|
|
27
|
+
} else if (process.platform === "win32") {
|
|
28
|
+
await win32Flash(this.config)
|
|
29
|
+
}
|
|
30
|
+
// macOS 及其他平台静默忽略
|
|
26
31
|
}
|
|
27
32
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 屏幕跑马灯
|
|
3
|
+
*
|
|
4
|
+
* 使用 PowerShell + .NET Windows Forms 创建屏幕四边彩色闪烁边框。
|
|
5
|
+
* 将四边分成多个小段,逐帧偏移颜色,产生 LED 跑马灯流动效果。
|
|
6
|
+
*
|
|
7
|
+
* 所有数据通过 $form.Tag(Form 的真实属性)传递,避免事件回调作用域问题。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "node:child_process"
|
|
11
|
+
import type { ScreenFlashChannelConfig } from "../../config.js"
|
|
12
|
+
|
|
13
|
+
export async function flash(config: ScreenFlashChannelConfig): Promise<void> {
|
|
14
|
+
const duration = config.duration ?? 3.0
|
|
15
|
+
const speed = config.speed ?? 4.0
|
|
16
|
+
const borderWidth = 8
|
|
17
|
+
const totalMs = duration * 1000
|
|
18
|
+
const intervalMs = Math.max(20, Math.min(150, Math.round(60 / speed)))
|
|
19
|
+
const segSize = 10
|
|
20
|
+
|
|
21
|
+
const psScript = `
|
|
22
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
23
|
+
Add-Type -AssemblyName System.Drawing
|
|
24
|
+
|
|
25
|
+
$bc = @(
|
|
26
|
+
[System.Drawing.Color]::Red,
|
|
27
|
+
[System.Drawing.Color]::Orange,
|
|
28
|
+
[System.Drawing.Color]::Yellow,
|
|
29
|
+
[System.Drawing.Color]::LimeGreen,
|
|
30
|
+
[System.Drawing.Color]::Blue,
|
|
31
|
+
[System.Drawing.Color]::Purple
|
|
32
|
+
)
|
|
33
|
+
$g = New-Object System.Collections.ArrayList
|
|
34
|
+
for ($b = 0; $b -lt $bc.Length; $b++) {
|
|
35
|
+
$n = ($b + 1) % $bc.Length
|
|
36
|
+
for ($s = 0; $s -lt 8; $s++) {
|
|
37
|
+
$r = [int]($bc[$b].R + ($bc[$n].R - $bc[$b].R) * $s / 8)
|
|
38
|
+
$g_ = [int]($bc[$b].G + ($bc[$n].G - $bc[$b].G) * $s / 8)
|
|
39
|
+
$b_ = [int]($bc[$b].B + ($bc[$n].B - $bc[$b].B) * $s / 8)
|
|
40
|
+
[void]$g.Add([System.Drawing.Color]::FromArgb(255, [Math]::Min(255,$r), [Math]::Min(255,$g_), [Math]::Min(255,$b_)))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$form = New-Object System.Windows.Forms.Form
|
|
45
|
+
$form.WindowState = 'Maximized'
|
|
46
|
+
$form.FormBorderStyle = 'None'
|
|
47
|
+
$form.TopMost = $true
|
|
48
|
+
$form.ShowInTaskbar = $false
|
|
49
|
+
$form.BackColor = [System.Drawing.Color]::Fuchsia
|
|
50
|
+
$form.TransparencyKey = [System.Drawing.Color]::Fuchsia
|
|
51
|
+
# Tag 存哈希表: @{ offset=0; gradient=$g; segSize=${segSize}; bw=${borderWidth} }
|
|
52
|
+
$form.Tag = @{ offset = 0; gradient = $g; segSize = ${segSize}; bw = ${borderWidth} }
|
|
53
|
+
|
|
54
|
+
$form.Add_Paint({
|
|
55
|
+
$d = $form.Tag
|
|
56
|
+
$gfx = $args[1].Graphics
|
|
57
|
+
$w = $form.ClientSize.Width
|
|
58
|
+
$h = $form.ClientSize.Height
|
|
59
|
+
$gr = $d.gradient
|
|
60
|
+
$gl = $gr.Count
|
|
61
|
+
$ss = $d.segSize
|
|
62
|
+
$bw = $d.bw
|
|
63
|
+
$off = $d.offset
|
|
64
|
+
|
|
65
|
+
# 上边 (左→右)
|
|
66
|
+
for ($x = 0; $x -lt $w; $x += $ss) {
|
|
67
|
+
$gi = [int](($x / $ss) + $off) % $gl
|
|
68
|
+
$sw = [Math]::Min($ss, $w - $x)
|
|
69
|
+
$br = [System.Drawing.SolidBrush]::new($gr[$gi])
|
|
70
|
+
$gfx.FillRectangle($br, $x, 0, $sw, $bw)
|
|
71
|
+
$br.Dispose()
|
|
72
|
+
}
|
|
73
|
+
# 右边 (上→下)
|
|
74
|
+
for ($y = 0; $y -lt $h; $y += $ss) {
|
|
75
|
+
$gi = [int](($w / $ss) + ($y / $ss) + $off) % $gl
|
|
76
|
+
$sh = [Math]::Min($ss, $h - $y)
|
|
77
|
+
$br = [System.Drawing.SolidBrush]::new($gr[$gi])
|
|
78
|
+
$gfx.FillRectangle($br, $w - $bw, $y, $bw, $sh)
|
|
79
|
+
$br.Dispose()
|
|
80
|
+
}
|
|
81
|
+
# 下边 (右→左)
|
|
82
|
+
for ($x = 0; $x -lt $w; $x += $ss) {
|
|
83
|
+
$gi = [int](($w / $ss) + ($h / $ss) + ($x / $ss) + $off) % $gl
|
|
84
|
+
$sw = [Math]::Min($ss, $w - $x)
|
|
85
|
+
$br = [System.Drawing.SolidBrush]::new($gr[$gi])
|
|
86
|
+
$gfx.FillRectangle($br, $w - $x - $sw, $h - $bw, $sw, $bw)
|
|
87
|
+
$br.Dispose()
|
|
88
|
+
}
|
|
89
|
+
# 左边 (下→上)
|
|
90
|
+
for ($y = 0; $y -lt $h; $y += $ss) {
|
|
91
|
+
$gi = [int](($w / $ss) * 2 + ($h / $ss) + ($y / $ss) + $off) % $gl
|
|
92
|
+
$sh = [Math]::Min($ss, $h - $y)
|
|
93
|
+
$br = [System.Drawing.SolidBrush]::new($gr[$gi])
|
|
94
|
+
$gfx.FillRectangle($br, 0, $h - $y - $sh, $bw, $sh)
|
|
95
|
+
$br.Dispose()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
$form.Show()
|
|
100
|
+
|
|
101
|
+
# 点击穿透
|
|
102
|
+
$code = @'
|
|
103
|
+
[DllImport("user32.dll")]
|
|
104
|
+
public static extern int SetWindowLong(IntPtr h, int n, int v);
|
|
105
|
+
[DllImport("user32.dll")]
|
|
106
|
+
public static extern int GetWindowLong(IntPtr h, int n);
|
|
107
|
+
'@
|
|
108
|
+
$win32 = Add-Type -MemberDefinition $code -Name W -Namespace W -PassThru
|
|
109
|
+
$style = $win32::GetWindowLong($form.Handle, -20)
|
|
110
|
+
$win32::SetWindowLong($form.Handle, -20, $style -bor 0x20 -bor 0x80)
|
|
111
|
+
|
|
112
|
+
# 跑马灯循环
|
|
113
|
+
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
114
|
+
while ($sw.ElapsedMilliseconds -lt ${totalMs}) {
|
|
115
|
+
$d = $form.Tag
|
|
116
|
+
$d.offset = $d.offset + 1
|
|
117
|
+
$form.Invalidate()
|
|
118
|
+
[System.Windows.Forms.Application]::DoEvents()
|
|
119
|
+
Start-Sleep -Milliseconds ${intervalMs}
|
|
120
|
+
}
|
|
121
|
+
$sw.Stop()
|
|
122
|
+
$form.Close()
|
|
123
|
+
`.trim()
|
|
124
|
+
|
|
125
|
+
execSync(`powershell -NoProfile -Command ${JSON.stringify(psScript)}`, {
|
|
126
|
+
timeout: Math.max(5000, totalMs + 5000),
|
|
127
|
+
stdio: "ignore",
|
|
128
|
+
})
|
|
129
|
+
}
|
package/senders/system/darwin.ts
CHANGED
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* macOS
|
|
2
|
+
* macOS 系统通知(⚠️ 未实际测试)
|
|
3
3
|
*
|
|
4
4
|
* 使用 osascript 调用原生通知中心。
|
|
5
|
-
* - display notification:
|
|
5
|
+
* - display notification: 标题 + 副标题 + 正文 + 声音
|
|
6
6
|
* - 系统内置,无需额外安装
|
|
7
|
+
* - 通知自动进入通知中心
|
|
8
|
+
*
|
|
9
|
+
* AppleScript 双引号使用 `""` 转义(与 shell/JScript 的 `\"` 不同)。
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import { execSync } from "node:child_process"
|
|
10
13
|
|
|
11
14
|
export async function notify(title: string, body: string): Promise<void> {
|
|
12
|
-
|
|
15
|
+
// sanitize() 将 " 转义为 \",但 AppleScript 使用 "" 表示双引号。
|
|
16
|
+
// 此处先还原,再应用 AppleScript 转义。
|
|
17
|
+
const asTitle = aq(title)
|
|
18
|
+
const asBody = aq(body)
|
|
19
|
+
|
|
20
|
+
// 提取 title 中的 [ses_xxx] 前缀作为副标题
|
|
21
|
+
const sessionMatch = title.match(/^(\[[^\]]+\])/)
|
|
22
|
+
const asSubtitle = aq(sessionMatch?.[1] ?? "")
|
|
23
|
+
|
|
24
|
+
const script = `display notification "${asBody}" with title "${asTitle}" subtitle "${asSubtitle}" sound name "default"`
|
|
13
25
|
execSync(`osascript -e ${JSON.stringify(script)}`, {
|
|
14
26
|
timeout: 5000,
|
|
15
27
|
stdio: "ignore",
|
|
16
28
|
})
|
|
17
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 转为 AppleScript 安全字符串
|
|
33
|
+
* - 还原 sanitize() 的 \" 转义
|
|
34
|
+
* - 用 AppleScript 的 "" 方式转义双引号
|
|
35
|
+
*/
|
|
36
|
+
function aq(s: string): string {
|
|
37
|
+
return s
|
|
38
|
+
.replace(/\\"/g, '"') // 还原 sanitize 转义
|
|
39
|
+
.replace(/"/g, '""') // AppleScript 双引号转义
|
|
40
|
+
}
|
package/senders/system/index.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* 根据运行平台自动选择实现:
|
|
5
5
|
* - macOS → darwin.ts (osascript)
|
|
6
6
|
* - Linux → linux.ts (notify-send)
|
|
7
|
-
* - Windows → win32.ts (
|
|
7
|
+
* - Windows → win32.ts (WinRT Native Toast + NotifyIcon 回退)
|
|
8
8
|
* - 其他平台 → 静默忽略
|
|
9
|
+
*
|
|
10
|
+
* Windows 首次调用时自动注册快捷方式,使 toast 出现在通知中心。
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import type { Sender } from "../types.js"
|
|
@@ -39,6 +41,11 @@ export class SystemSender implements Sender {
|
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* 转义标题和正文中的特殊字符,防止 shell 注入
|
|
44
|
+
*
|
|
45
|
+
* 各平台实际使用的逃逸:
|
|
46
|
+
* - Linux: notify-send "${title}" — 只需转义 " $ ` \
|
|
47
|
+
* - macOS: osascript -e JSON 序列化 — 全自动处理
|
|
48
|
+
* - Windows: PowerShell '${title}' — 只需转义单引号(win32.ts 内部处理)
|
|
42
49
|
*/
|
|
43
50
|
function sanitize(
|
|
44
51
|
title: string,
|
|
@@ -53,7 +60,6 @@ function sanitize(
|
|
|
53
60
|
function escape(s: string): string {
|
|
54
61
|
return s
|
|
55
62
|
.replace(/"/g, '\\"')
|
|
56
|
-
.replace(/'/g, "\\'")
|
|
57
63
|
.replace(/`/g, "\\`")
|
|
58
64
|
.replace(/\$/g, "\\$")
|
|
59
65
|
.replace(/\n/g, " ")
|
package/senders/system/win32.ts
CHANGED
|
@@ -1,28 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Windows 系统通知
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 策略:
|
|
5
|
+
* 1. 通过注册表注册 opencode-notify 通知发送方
|
|
6
|
+
* 2. WinRT Native Toast — 使用 PowerShell UUID AppId(确认可弹窗)
|
|
7
|
+
* 3. NotifyIcon BalloonTip — 非阻塞式最终回退
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* 注册表路径: HKCU\SOFTWARE\Classes\AppUserModelId\opencode-notify
|
|
10
|
+
* 此注册使通知出现在 Windows 操作中心。
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { execSync } from "node:child_process"
|
|
12
14
|
|
|
13
15
|
export async function notify(title: string, body: string): Promise<void> {
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
`try {`,
|
|
17
|
-
` New-BurntToastNotification -Text '${title}', '${body}' -ErrorAction Stop`,
|
|
18
|
-
`} catch {`,
|
|
19
|
-
` Add-Type -AssemblyName System.Windows.Forms`,
|
|
20
|
-
` [System.Windows.Forms.MessageBox]::Show('${body}', '${title}')`,
|
|
21
|
-
`}`,
|
|
22
|
-
].join("\n")
|
|
16
|
+
const t = title.replace(/'/g, "''")
|
|
17
|
+
const b = body.replace(/'/g, "''")
|
|
23
18
|
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
const ps = `
|
|
20
|
+
# 注册 opencode-notify 到操作中心
|
|
21
|
+
New-Item -Path 'HKCU:\\SOFTWARE\\Classes\\AppUserModelId\\opencode-notify' -Force -ErrorAction Stop | Out-Null
|
|
22
|
+
New-ItemProperty -Path 'HKCU:\\SOFTWARE\\Classes\\AppUserModelId\\opencode-notify' -Name 'DisplayName' -Value 'opencode-notify' -PropertyType String -Force -ErrorAction Stop | Out-Null
|
|
23
|
+
New-ItemProperty -Path 'HKCU:\\SOFTWARE\\Classes\\AppUserModelId\\opencode-notify' -Name 'ShowInSettings' -Value 1 -PropertyType DWord -Force -ErrorAction Stop | Out-Null
|
|
24
|
+
|
|
25
|
+
# 策略 1: WinRT Native Toast (PowerShell AppId)
|
|
26
|
+
try {
|
|
27
|
+
[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] > \$null
|
|
28
|
+
[Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom.XmlDocument,ContentType=WindowsRuntime] > \$null
|
|
29
|
+
\$t=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent('ToastText02')
|
|
30
|
+
\$t.SelectSingleNode('//text[@id=\"1\"]').InnerText='${t}'
|
|
31
|
+
\$t.SelectSingleNode('//text[@id=\"2\"]').InnerText='${b}'
|
|
32
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show(\$t)
|
|
33
|
+
} catch {
|
|
34
|
+
# 策略 2: NotifyIcon 回退
|
|
35
|
+
try {
|
|
36
|
+
Add-Type -AssemblyName System.Windows.Forms,System.Drawing
|
|
37
|
+
\$n=New-Object System.Windows.Forms.NotifyIcon
|
|
38
|
+
\$n.Icon=[System.Drawing.SystemIcons]::Information
|
|
39
|
+
\$n.Visible=\$true
|
|
40
|
+
\$n.ShowBalloonTip(10000,'${t}','${b}',[System.Windows.Forms.TooltipIcon]::None)
|
|
41
|
+
Start-Sleep -Milliseconds 500
|
|
42
|
+
\$n.Dispose()
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
`.trim()
|
|
46
|
+
|
|
47
|
+
execSync(`powershell -NoProfile -Command ${JSON.stringify(ps)}`, {
|
|
48
|
+
timeout: 15000,
|
|
26
49
|
stdio: "ignore",
|
|
27
50
|
})
|
|
28
51
|
}
|
package/terminator-detect.ts
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Terminator
|
|
2
|
+
* Terminator 子屏幕遮挡检测
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 与本进程的 TERMINATOR_UUID 比较,判定本屏是否被遮挡。
|
|
4
|
+
* 检测当前子屏幕是否被真正遮挡(不可见),用于判定是否需要
|
|
5
|
+
* 强制通知(即使用户在该会话中活跃)。
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* 1.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* 检测策略(两级):
|
|
8
|
+
* 1. X 窗口级别:Terminator 是否是当前活跃窗口(xdotool + xprop)
|
|
9
|
+
* 否 → 用户在别的应用中 → 遮挡
|
|
10
|
+
* 2. 子屏级别:用户当前聚焦的是哪个子屏(DBus get_focused_terminal)
|
|
11
|
+
* 聚焦 != 本屏 → 用户在另一个子屏上 → 遮挡
|
|
12
|
+
*
|
|
13
|
+
* 注意:此版本 Terminator 未暴露 get_maximized_terminal 接口,
|
|
14
|
+
* 无法区分"分屏可见"和"最大化遮挡"。两级结合覆盖核心场景:
|
|
15
|
+
* - Terminator 不活跃(浏览器/IDE):强制通知
|
|
16
|
+
* - 本屏聚焦:不通知
|
|
17
|
+
* - 另一子屏聚焦(分屏或最大化):强制通知
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import { execSync } from "node:child_process"
|
|
16
|
-
import {
|
|
21
|
+
import { debug } from "./log.js"
|
|
17
22
|
|
|
18
23
|
/** 当前进程的 TERMINATOR_UUID */
|
|
19
24
|
const MY_UUID = process.env.TERMINATOR_UUID ?? null
|
|
20
25
|
|
|
21
|
-
/** Terminator DBus
|
|
26
|
+
/** Terminator DBus 信息 */
|
|
22
27
|
const DBUS_NAME = process.env.TERMINATOR_DBUS_NAME ?? null
|
|
23
28
|
const DBUS_PATH = process.env.TERMINATOR_DBUS_PATH ?? null
|
|
24
29
|
|
|
@@ -26,10 +31,10 @@ const DBUS_PATH = process.env.TERMINATOR_DBUS_PATH ?? null
|
|
|
26
31
|
let insideTerminator: boolean | null = null
|
|
27
32
|
|
|
28
33
|
/**
|
|
29
|
-
*
|
|
34
|
+
* 检测当前子屏幕是否被遮挡(不可见)
|
|
30
35
|
*
|
|
31
|
-
* @returns true →
|
|
32
|
-
* false →
|
|
36
|
+
* @returns true → 本子屏幕被遮挡(用户看不到,应强制通知)
|
|
37
|
+
* false → 本子屏幕可见(正常抑制逻辑)
|
|
33
38
|
* null → 无法确定(不在 Terminator 中或检测失败)
|
|
34
39
|
*/
|
|
35
40
|
export function isTerminalOccluded(): boolean | null {
|
|
@@ -40,57 +45,38 @@ export function isTerminalOccluded(): boolean | null {
|
|
|
40
45
|
|
|
41
46
|
insideTerminator = true
|
|
42
47
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return callXdotool()
|
|
47
|
-
}
|
|
48
|
+
// ─── 第一级:X 窗口级别 ────────────────────────────────────────────────
|
|
49
|
+
// Terminator 窗口是否是当前 X 活跃窗口?
|
|
50
|
+
const terminatorActive = isTerminatorWindowActive()
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const occluded = !uuidEqual(focused, MY_UUID)
|
|
54
|
-
debug(`Terminator 焦点检测(busctl): 本屏=${shortId(MY_UUID)} 焦点=${shortId(focused)} 遮挡=${occluded}`)
|
|
55
|
-
return occluded
|
|
56
|
-
}
|
|
57
|
-
} catch (e) {
|
|
58
|
-
debug(`Terminator busctl 失败: ${e instanceof Error ? e.message : String(e)}`)
|
|
52
|
+
if (terminatorActive === false) {
|
|
53
|
+
// 用户在其他应用中(浏览器、IDE 等),本屏不可见
|
|
54
|
+
debug(`Terminator 窗口非活跃(用户在其他应用中),判定为遮挡`)
|
|
55
|
+
return true
|
|
59
56
|
}
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const occluded = !uuidEqual(focused, MY_UUID)
|
|
66
|
-
debug(`Terminator 焦点检测(python): 本屏=${shortId(MY_UUID)} 焦点=${shortId(focused)} 遮挡=${occluded}`)
|
|
67
|
-
return occluded
|
|
68
|
-
}
|
|
69
|
-
} catch (e) {
|
|
70
|
-
debug(`Terminator python-dbus 失败: ${e instanceof Error ? e.message : String(e)}`)
|
|
71
|
-
}
|
|
58
|
+
if (terminatorActive === true) {
|
|
59
|
+
// ─── 第二级:聚焦终端检测 ──────────────────────────────────────────
|
|
60
|
+
// Terminator 窗口活跃,检查用户聚焦的是不是本屏
|
|
61
|
+
const focused = queryFocusedTerminal()
|
|
72
62
|
|
|
73
|
-
// 策略 3: gdbus
|
|
74
|
-
try {
|
|
75
|
-
const focused = callGDBus()
|
|
76
63
|
if (focused !== null) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
64
|
+
if (focused === MY_UUID) {
|
|
65
|
+
debug(`Terminator 本屏聚焦,判定为可见`)
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
// 用户聚焦在另一个子屏上 → 本屏不可见(无论分屏还是最大化)
|
|
69
|
+
debug(`Terminator 用户聚焦在其他子屏(${shortId(focused)}),判定为遮挡`)
|
|
70
|
+
return true
|
|
80
71
|
}
|
|
81
|
-
} catch (e) {
|
|
82
|
-
debug(`Terminator gdbus 失败: ${e instanceof Error ? e.message : String(e)}`)
|
|
83
|
-
}
|
|
84
72
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
debug(`Terminator 窗口检测(xdotool): ${result}`)
|
|
89
|
-
return result
|
|
90
|
-
} catch (e) {
|
|
91
|
-
warn(`Terminator 所有检测策略均失败,最后错误: ${e instanceof Error ? e.message : String(e)}`)
|
|
92
|
-
return null
|
|
73
|
+
// 无法查询焦点状态,保守假设可见
|
|
74
|
+
debug(`Terminator 窗口活跃,无法查询焦点状态,保守假设不遮挡`)
|
|
75
|
+
return false
|
|
93
76
|
}
|
|
77
|
+
|
|
78
|
+
// 无法确定 X 窗口状态
|
|
79
|
+
return null
|
|
94
80
|
}
|
|
95
81
|
|
|
96
82
|
/**
|
|
@@ -104,61 +90,62 @@ export function isInsideTerminator(): boolean {
|
|
|
104
90
|
|
|
105
91
|
// ─── 工具函数 ───────────────────────────────────────────────────────────────
|
|
106
92
|
|
|
107
|
-
/** 比较两个 UUID(忽略 urn:uuid: 前缀) */
|
|
108
|
-
function uuidEqual(a: string, b: string): boolean {
|
|
109
|
-
const strip = (s: string) => s.replace(/^urn:uuid:/i, "")
|
|
110
|
-
return strip(a) === strip(b)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
93
|
/** 取 UUID 前 8 位用于日志 */
|
|
114
94
|
function shortId(uuid: string): string {
|
|
115
95
|
return uuid.replace(/^urn:uuid:/i, "").slice(0, 8)
|
|
116
96
|
}
|
|
117
97
|
|
|
118
|
-
// ───
|
|
119
|
-
|
|
120
|
-
/** 策略 1: busctl(systemd) */
|
|
121
|
-
function callBusctl(): string | null {
|
|
122
|
-
const out = execSync(
|
|
123
|
-
`busctl --user call ${DBUS_NAME} ${DBUS_PATH} ${DBUS_NAME} get_focused_terminal`,
|
|
124
|
-
{ encoding: "utf-8", timeout: 3000 },
|
|
125
|
-
).trim()
|
|
126
|
-
// 输出格式: s "uuid-string"
|
|
127
|
-
const m = out.match(/s\s+"([^"]+)"/)
|
|
128
|
-
return m ? m[1] : null
|
|
129
|
-
}
|
|
98
|
+
// ─── 第一级:X 窗口检测 ──────────────────────────────────────────────────
|
|
130
99
|
|
|
131
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
100
|
+
/**
|
|
101
|
+
* 检测 Terminator 窗口是否是当前 X 活跃窗口
|
|
102
|
+
* @returns true=Terminator 是活跃窗口, false=不是, null=检测失败
|
|
103
|
+
*/
|
|
104
|
+
function isTerminatorWindowActive(): boolean | null {
|
|
105
|
+
try {
|
|
106
|
+
const out = execSync(
|
|
107
|
+
`xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null`,
|
|
108
|
+
{ encoding: "utf-8", timeout: 5000 },
|
|
109
|
+
).trim()
|
|
110
|
+
return out.includes('"Terminator"')
|
|
111
|
+
} catch (e) {
|
|
112
|
+
debug(`xprop/xdotool 检测 Terminator 窗口失败: ${e instanceof Error ? e.message : String(e)}`)
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
144
115
|
}
|
|
145
116
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
117
|
+
// ─── 第二级:聚焦终端检测 ───────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 通过 DBus 查询当前聚焦的子屏 UUID
|
|
121
|
+
* @returns UUID 字符串,或 null(查询失败)
|
|
122
|
+
*/
|
|
123
|
+
function queryFocusedTerminal(): string | null {
|
|
124
|
+
if (!DBUS_NAME || !DBUS_PATH) return null
|
|
125
|
+
|
|
126
|
+
// 策略 1: busctl(无 stderr 输出,避免未知方法报错)
|
|
127
|
+
try {
|
|
128
|
+
const out = execSync(
|
|
129
|
+
`busctl --user call ${DBUS_NAME} ${DBUS_PATH} ${DBUS_NAME} get_focused_terminal 2>/dev/null`,
|
|
130
|
+
{ encoding: "utf-8", timeout: 3000 },
|
|
131
|
+
).trim()
|
|
132
|
+
const m = out.match(/s\s+"([^"]+)"/)
|
|
133
|
+
if (m) return m[1]
|
|
134
|
+
} catch {
|
|
135
|
+
// 静默忽略
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 策略 2: gdbus(备选)
|
|
139
|
+
try {
|
|
140
|
+
const out = execSync(
|
|
141
|
+
`gdbus call --session --dest ${DBUS_NAME} --object-path ${DBUS_PATH} --method ${DBUS_NAME}.get_focused_terminal 2>/dev/null`,
|
|
142
|
+
{ encoding: "utf-8", timeout: 3000 },
|
|
143
|
+
).trim()
|
|
144
|
+
const m = out.match(/\('([^']+)'/)
|
|
145
|
+
if (m) return m[1]
|
|
146
|
+
} catch {
|
|
147
|
+
// 静默忽略
|
|
148
|
+
}
|
|
156
149
|
|
|
157
|
-
|
|
158
|
-
function callXdotool(): boolean | null {
|
|
159
|
-
const cls = execSync(
|
|
160
|
-
`xdotool getactivewindow getwindowclassname`,
|
|
161
|
-
{ encoding: "utf-8", timeout: 3000 },
|
|
162
|
-
).trim()
|
|
163
|
-
return cls === "Terminator" ? true : null
|
|
150
|
+
return null
|
|
164
151
|
}
|