@freely01/opencode-notify 0.4.0 → 0.4.2
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/cli.ts +9 -7
- package/config.ts +62 -30
- package/delayed-dispatcher.ts +59 -2
- package/events.ts +26 -7
- package/index.ts +64 -42
- package/message.ts +40 -8
- package/package.json +1 -1
- package/senders/feishu.ts +0 -12
- package/senders/wechat-work.ts +1 -2
- package/session-tracker.ts +36 -2
package/cli.ts
CHANGED
|
@@ -105,7 +105,7 @@ function cmdCheck() {
|
|
|
105
105
|
let hasError = false
|
|
106
106
|
|
|
107
107
|
for (const [name, ch] of Object.entries(channels)) {
|
|
108
|
-
if (!ch || !ch.
|
|
108
|
+
if (!ch || !ch.mode || ch.mode === "none") continue
|
|
109
109
|
|
|
110
110
|
switch (name) {
|
|
111
111
|
case "system":
|
|
@@ -177,23 +177,25 @@ async function cmdTest(channel?: string) {
|
|
|
177
177
|
allChannels.push([name, enabled, sender])
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
const modeOk = (m: string | undefined): boolean => m !== undefined && m !== "none"
|
|
181
|
+
|
|
182
|
+
add("system_message", modeOk(channels.system_message?.mode), async () => {
|
|
181
183
|
await new SystemSender().send(SAMPLE_MSG)
|
|
182
184
|
})
|
|
183
185
|
|
|
184
|
-
if (channels.custom_webhook?.
|
|
186
|
+
if (modeOk(channels.custom_webhook?.mode) && channels.custom_webhook?.url) {
|
|
185
187
|
add("custom_webhook", true, async () => {
|
|
186
188
|
await new CustomWebhookSender(channels.custom_webhook!).send(SAMPLE_MSG)
|
|
187
189
|
})
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
if (channels.wechat_work?.
|
|
192
|
+
if (modeOk(channels.wechat_work?.mode) && channels.wechat_work?.webhook_url) {
|
|
191
193
|
add("wechat_work", true, async () => {
|
|
192
194
|
await new WechatWorkSender(channels.wechat_work!).send(SAMPLE_MSG)
|
|
193
195
|
})
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
if (channels.feishu?.
|
|
198
|
+
if (modeOk(channels.feishu?.mode) && channels.feishu?.webhook_url) {
|
|
197
199
|
add("feishu", true, async () => {
|
|
198
200
|
await new FeishuSender(channels.feishu!).send(SAMPLE_MSG)
|
|
199
201
|
})
|
|
@@ -303,13 +305,13 @@ function cmdInfo() {
|
|
|
303
305
|
console.log(`\n 🔔 通知渠道:`)
|
|
304
306
|
const channelNames: [string, any, string][] = [
|
|
305
307
|
["系统消息", ch.system_message, ""],
|
|
306
|
-
["屏幕跑马灯", ch.screen_flash, ch.screen_flash?.
|
|
308
|
+
["屏幕跑马灯", ch.screen_flash, ch.screen_flash?.mode !== "none" ? `强度${ch.screen_flash?.intensity ?? 0.9}` : ""],
|
|
307
309
|
["自定义 Webhook", ch.custom_webhook, ch.custom_webhook?.url ?? ""],
|
|
308
310
|
["企业微信", ch.wechat_work, ch.wechat_work?.webhook_url ? `${ch.wechat_work.webhook_url.slice(0, 40)}...` : ""],
|
|
309
311
|
["飞书", ch.feishu, ch.feishu?.webhook_url ? `${ch.feishu.webhook_url.slice(0, 40)}...` : ""],
|
|
310
312
|
]
|
|
311
313
|
for (const [label, config, url] of channelNames) {
|
|
312
|
-
if (config?.
|
|
314
|
+
if (config?.mode !== "none") {
|
|
313
315
|
const urlInfo = url ? ` ${url}` : ""
|
|
314
316
|
console.log(` ✅ ${label}${urlInfo}`)
|
|
315
317
|
} else {
|
package/config.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 渠道模式
|
|
3
|
+
* - "all" → 即时通知 + 延迟推送都启用
|
|
4
|
+
* - "delay_only" → 仅用于延迟推送(不弹即时通知)
|
|
5
|
+
* - "none" → 禁用
|
|
6
|
+
*/
|
|
7
|
+
export type ChannelMode = "all" | "delay_only" | "none"
|
|
8
|
+
|
|
1
9
|
/** 通知渠道配置 */
|
|
2
10
|
export interface ChannelConfig {
|
|
3
|
-
|
|
11
|
+
mode: ChannelMode
|
|
4
12
|
/**
|
|
5
13
|
* 渠道级事件过滤 — 仅这些事件触发本渠道通知
|
|
6
14
|
* 不填或留空则继承全局 events
|
|
@@ -67,6 +75,7 @@ export interface PluginConfig {
|
|
|
67
75
|
* input_required - Agent 等待用户输入
|
|
68
76
|
* run_completed - 任务执行完成(技术预留,暂未实现)
|
|
69
77
|
* run_failed - 任务执行失败
|
|
78
|
+
* run_cancelled - 用户主动中断任务
|
|
70
79
|
*/
|
|
71
80
|
events?: string[]
|
|
72
81
|
/** 去重时间窗口(秒),默认 60 */
|
|
@@ -171,9 +180,17 @@ channels:
|
|
|
171
180
|
# Linux - 使用 notify-send (需 libnotify 包,桌面版通常预装)
|
|
172
181
|
# Windows - 使用 PowerShell New-BurntToastNotification
|
|
173
182
|
# (需额外安装 BurntToast 模块)
|
|
183
|
+
#
|
|
184
|
+
# mode 可选值:
|
|
185
|
+
# all → 启用即时通知(是否延迟推送由下方 remote_delay_channels 独立控制)
|
|
186
|
+
# delay_only → 仅用于远程延迟推送,不弹即时通知
|
|
187
|
+
# none → 禁用
|
|
188
|
+
#
|
|
189
|
+
# 纯即时通知(不延迟推送):mode: all 且不要加入 remote_delay_channels 即可。
|
|
190
|
+
# 纯延迟推送(不即时通知):mode: delay_only 并加入 remote_delay_channels。
|
|
174
191
|
# ---------------------------------------------------------------------------
|
|
175
192
|
system_message:
|
|
176
|
-
|
|
193
|
+
mode: all # all | delay_only | none
|
|
177
194
|
|
|
178
195
|
# ---------------------------------------------------------------------------
|
|
179
196
|
# 屏幕跑马灯 (Linux X11 专用)
|
|
@@ -184,7 +201,7 @@ channels:
|
|
|
184
201
|
# 取消下方注释启用:
|
|
185
202
|
# ---------------------------------------------------------------------------
|
|
186
203
|
# screen_flash:
|
|
187
|
-
#
|
|
204
|
+
# mode: all # all | delay_only | none
|
|
188
205
|
# duration: 3.5 # 持续秒数
|
|
189
206
|
# speed: 5.0 # 移动速度因子
|
|
190
207
|
# intensity: 0.85 # 不透明度 0.0~1.0
|
|
@@ -198,7 +215,7 @@ channels:
|
|
|
198
215
|
# 取消下方注释并填入 webhook_url 启用:
|
|
199
216
|
# ---------------------------------------------------------------------------
|
|
200
217
|
# wechat_work:
|
|
201
|
-
#
|
|
218
|
+
# mode: all # all | delay_only | none
|
|
202
219
|
# webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
|
|
203
220
|
|
|
204
221
|
# ---------------------------------------------------------------------------
|
|
@@ -210,7 +227,7 @@ channels:
|
|
|
210
227
|
# 取消下方注释并填入 webhook_url 启用:
|
|
211
228
|
# ---------------------------------------------------------------------------
|
|
212
229
|
# feishu:
|
|
213
|
-
#
|
|
230
|
+
# mode: all # all | delay_only | none
|
|
214
231
|
# webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
|
|
215
232
|
|
|
216
233
|
# ---------------------------------------------------------------------------
|
|
@@ -231,7 +248,7 @@ channels:
|
|
|
231
248
|
# 取消下方注释并配置 url 启用:
|
|
232
249
|
# ---------------------------------------------------------------------------
|
|
233
250
|
# custom_webhook:
|
|
234
|
-
#
|
|
251
|
+
# mode: all # all | delay_only | none
|
|
235
252
|
# url: ""
|
|
236
253
|
# method: "POST" # 请求方法: "POST" | "GET"
|
|
237
254
|
# headers: {} # 自定义请求头
|
|
@@ -243,7 +260,12 @@ channels:
|
|
|
243
260
|
# =============================================================================
|
|
244
261
|
# 只订阅你关心的事件类型,减少不必要通知。
|
|
245
262
|
#
|
|
246
|
-
#
|
|
263
|
+
# ⚠️ 全局 events 是主闸门:只有在此列表中的事件才会生成通知消息。
|
|
264
|
+
# 渠道级 events(channels.xxx.events)只能从全局列表中进一步收窄,
|
|
265
|
+
# 无法新增全局列表之外的事件。例如全局无 run_cancelled 时,
|
|
266
|
+
# 即使渠道配了 run_cancelled 也不会收到。
|
|
267
|
+
#
|
|
268
|
+
# 可选事件(各渠道也可单独配置 events,从全局列表中进一步筛选):
|
|
247
269
|
# permission_required - Agent 需要用户授权(如执行命令、读写文件)
|
|
248
270
|
# 触发: permission.asked / question.asked
|
|
249
271
|
# input_required - Agent 等待用户输入
|
|
@@ -251,11 +273,14 @@ channels:
|
|
|
251
273
|
# run_completed - 任务执行完成(技术预留,暂未实现)
|
|
252
274
|
# run_failed - 任务执行失败
|
|
253
275
|
# 触发: session.error
|
|
276
|
+
# run_cancelled - 用户主动中断任务(Ctrl+C 或点击中断按钮)
|
|
277
|
+
# 触发: session.error (MessageAbortedError)
|
|
254
278
|
# ---------------------------------------------------------------------------
|
|
255
279
|
events:
|
|
256
280
|
- permission_required # 权限请求通知(推荐开启)
|
|
257
281
|
- input_required # 等待输入通知(推荐开启)
|
|
258
282
|
- run_failed # 任务失败通知
|
|
283
|
+
- run_cancelled # 用户取消通知(推荐开启)
|
|
259
284
|
|
|
260
285
|
|
|
261
286
|
# =============================================================================
|
|
@@ -284,7 +309,7 @@ dedupe_seconds: 60 # 去重时间窗口(秒),0 或负数
|
|
|
284
309
|
#
|
|
285
310
|
# 抑制规则:
|
|
286
311
|
# permission_required / input_required: 活跃时抑制(屏上可见)
|
|
287
|
-
# run_failed / run_completed: 始终通知(异步结果,人可能走开)
|
|
312
|
+
# run_failed / run_completed / run_cancelled: 始终通知(异步结果,人可能走开)
|
|
288
313
|
# ---------------------------------------------------------------------------
|
|
289
314
|
suppress_when_active: true # true=开启会话感知抑制, false=不抑制
|
|
290
315
|
activity_timeout_ms: 15000 # 会话操作超时(毫秒)
|
|
@@ -293,7 +318,7 @@ activity_timeout_ms: 15000 # 会话操作超时(毫秒)
|
|
|
293
318
|
suppress_events_when_active: # 活跃时抑制哪些事件(不填继承默认)
|
|
294
319
|
- permission_required
|
|
295
320
|
- input_required
|
|
296
|
-
# run_failed / run_completed 不在列表中 → 始终通知
|
|
321
|
+
# run_failed / run_completed / run_cancelled 不在列表中 → 始终通知
|
|
297
322
|
session_stale_timeout_ms: 600000 # 超时会话自动淘汰(毫秒)
|
|
298
323
|
# 10 分钟无任何活动的会话从追踪 Map 移除
|
|
299
324
|
# 防止长期运行导致内存泄漏
|
|
@@ -305,6 +330,12 @@ session_stale_timeout_ms: 600000 # 超时会话自动淘汰(毫秒)
|
|
|
305
330
|
# 正常通知发出后,如果用户长时间未操作,针对指定渠道额外再推送一次。
|
|
306
331
|
# 用户回到 opencode TUI 操作 → 自动取消所有待发延迟通知。
|
|
307
332
|
#
|
|
333
|
+
# 只影响此列表中的渠道,不在列表中的渠道为"纯即时通知"。
|
|
334
|
+
# 结合 mode 使用:
|
|
335
|
+
# mode: all + 在此列表中 → 即时 + 延迟
|
|
336
|
+
# mode: all + 不在列表中 → 纯即时(不延迟推送)
|
|
337
|
+
# mode: delay_only + 在此列表中 → 纯延迟(不弹即时通知)
|
|
338
|
+
#
|
|
308
339
|
# 适用场景:用户离开电脑后,系统通知可能一闪而过没看到,
|
|
309
340
|
# 延迟推送在用户仍未回来时再次尝试发出。
|
|
310
341
|
#
|
|
@@ -383,17 +414,18 @@ export function mergeConfig(base: PluginConfig, overrides: PluginConfig): Plugin
|
|
|
383
414
|
/** 默认配置 */
|
|
384
415
|
const DEFAULT_CONFIG: Required<Pick<PluginConfig, "suppress_when_active" | "activity_timeout_ms" | "suppress_events_when_active" | "session_stale_timeout_ms" | "remote_delay_seconds" | "remote_delay_max_count">> & PluginConfig = {
|
|
385
416
|
channels: {
|
|
386
|
-
system_message: {
|
|
387
|
-
screen_flash: {
|
|
388
|
-
wechat_work: {
|
|
389
|
-
feishu: {
|
|
390
|
-
custom_webhook: {
|
|
417
|
+
system_message: { mode: "all" },
|
|
418
|
+
screen_flash: { mode: "none" },
|
|
419
|
+
wechat_work: { mode: "none" },
|
|
420
|
+
feishu: { mode: "none" },
|
|
421
|
+
custom_webhook: { mode: "none" },
|
|
391
422
|
},
|
|
392
423
|
events: [
|
|
393
424
|
"permission_required",
|
|
394
425
|
"input_required",
|
|
395
426
|
"run_completed",
|
|
396
427
|
"run_failed",
|
|
428
|
+
"run_cancelled",
|
|
397
429
|
],
|
|
398
430
|
dedupe_seconds: 60,
|
|
399
431
|
suppress_when_active: true,
|
|
@@ -421,39 +453,39 @@ export function resolveConfig(options: PluginConfig): PluginConfig {
|
|
|
421
453
|
return {
|
|
422
454
|
channels: {
|
|
423
455
|
system_message: {
|
|
424
|
-
|
|
425
|
-
options.channels?.system_message?.
|
|
426
|
-
DEFAULT_CONFIG.channels!.system_message!.
|
|
456
|
+
mode:
|
|
457
|
+
options.channels?.system_message?.mode ??
|
|
458
|
+
DEFAULT_CONFIG.channels!.system_message!.mode,
|
|
427
459
|
events: chEvents(options.channels?.system_message),
|
|
428
460
|
},
|
|
429
461
|
screen_flash: {
|
|
430
|
-
|
|
431
|
-
options.channels?.screen_flash?.
|
|
432
|
-
DEFAULT_CONFIG.channels!.screen_flash!.
|
|
462
|
+
mode:
|
|
463
|
+
options.channels?.screen_flash?.mode ??
|
|
464
|
+
DEFAULT_CONFIG.channels!.screen_flash!.mode,
|
|
433
465
|
duration: options.channels?.screen_flash?.duration,
|
|
434
466
|
speed: options.channels?.screen_flash?.speed,
|
|
435
467
|
intensity: options.channels?.screen_flash?.intensity,
|
|
436
468
|
events: chEvents(options.channels?.screen_flash),
|
|
437
469
|
},
|
|
438
470
|
wechat_work: {
|
|
439
|
-
|
|
440
|
-
options.channels?.wechat_work?.
|
|
441
|
-
DEFAULT_CONFIG.channels!.wechat_work!.
|
|
471
|
+
mode:
|
|
472
|
+
options.channels?.wechat_work?.mode ??
|
|
473
|
+
DEFAULT_CONFIG.channels!.wechat_work!.mode,
|
|
442
474
|
webhook_url: options.channels?.wechat_work?.webhook_url || undefined,
|
|
443
475
|
events: chEvents(options.channels?.wechat_work),
|
|
444
476
|
},
|
|
445
477
|
feishu: {
|
|
446
|
-
|
|
447
|
-
options.channels?.feishu?.
|
|
448
|
-
DEFAULT_CONFIG.channels!.feishu!.
|
|
478
|
+
mode:
|
|
479
|
+
options.channels?.feishu?.mode ??
|
|
480
|
+
DEFAULT_CONFIG.channels!.feishu!.mode,
|
|
449
481
|
webhook_url: options.channels?.feishu?.webhook_url || undefined,
|
|
450
482
|
events: chEvents(options.channels?.feishu),
|
|
451
483
|
},
|
|
452
484
|
custom_webhook: {
|
|
453
|
-
|
|
454
|
-
options.channels?.custom_webhook?.
|
|
455
|
-
DEFAULT_CONFIG.channels?.custom_webhook?.
|
|
456
|
-
|
|
485
|
+
mode:
|
|
486
|
+
options.channels?.custom_webhook?.mode ??
|
|
487
|
+
DEFAULT_CONFIG.channels?.custom_webhook?.mode ??
|
|
488
|
+
"none",
|
|
457
489
|
url: options.channels?.custom_webhook?.url || undefined,
|
|
458
490
|
method: options.channels?.custom_webhook?.method ?? "POST",
|
|
459
491
|
headers: options.channels?.custom_webhook?.headers,
|
package/delayed-dispatcher.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Message } from "./message.js"
|
|
2
2
|
import type { Sender } from "./senders/types.js"
|
|
3
3
|
import { error, warn, info, debug } from "./log.js"
|
|
4
|
+
import { isTerminalOccluded } from "./terminator-detect.js"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* 远程延迟通知调度器
|
|
@@ -96,6 +97,48 @@ export class DelayedDispatcher {
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
/**
|
|
101
|
+
* 将毫秒转换为人类可读的间隔描述
|
|
102
|
+
* 例: 30_000 → "30秒", 120_000 → "2分钟", 3_600_000 → "60分钟"
|
|
103
|
+
*/
|
|
104
|
+
private formatInterval(ms: number): string {
|
|
105
|
+
const sec = Math.round(ms / 1000)
|
|
106
|
+
if (sec < 60) return `${sec}秒`
|
|
107
|
+
const min = Math.round(sec / 60)
|
|
108
|
+
return `${min}分钟`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 在通知正文追加或替换延迟推送标记
|
|
113
|
+
*
|
|
114
|
+
* 清除正文末尾已有的旧标记行,追加最新标记。
|
|
115
|
+
* 标记格式:
|
|
116
|
+
* ─────────────────
|
|
117
|
+
* ⚠️ 延迟 第2/3次(下次约 15:31:00 / 2分钟后)
|
|
118
|
+
*/
|
|
119
|
+
private markDelayBody(body: string, current: number, total: number, nextDelayMs: number): string {
|
|
120
|
+
// 移除旧标记(从末尾 ─── 分隔线到最后)
|
|
121
|
+
const clean = body.replace(/\n─{3,}[\s\S]*$/, "")
|
|
122
|
+
if (current < total) {
|
|
123
|
+
const next = new Date(Date.now() + nextDelayMs)
|
|
124
|
+
const t = `${String(next.getHours()).padStart(2, "0")}:${String(next.getMinutes()).padStart(2, "0")}:${String(next.getSeconds()).padStart(2, "0")}`
|
|
125
|
+
return `${clean}\n─────────────────\n⚠️ 延迟 第${current}/${total}次(下次约 ${t} / ${this.formatInterval(nextDelayMs)}后)`
|
|
126
|
+
}
|
|
127
|
+
// 最后一次推送,不显示下次时间
|
|
128
|
+
return `${clean}\n─────────────────\n⚠️ 延迟 第${current}/${total}次(最终)`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 根据已发送次数计算本次延迟
|
|
133
|
+
*
|
|
134
|
+
* 指数退避:base × 2^count,上限 10 分钟
|
|
135
|
+
* count=0 → base, count=1 → base×2, count=2 → base×4, ...
|
|
136
|
+
*/
|
|
137
|
+
private getDelayMs(count: number): number {
|
|
138
|
+
const max = 600_000 // 10 分钟上限
|
|
139
|
+
return Math.min(this.delayMs * Math.pow(2, count), max)
|
|
140
|
+
}
|
|
141
|
+
|
|
99
142
|
/**
|
|
100
143
|
* 调度单次延迟发送
|
|
101
144
|
*/
|
|
@@ -105,9 +148,23 @@ export class DelayedDispatcher {
|
|
|
105
148
|
const entry = chMap.get(ch)
|
|
106
149
|
if (!entry) return
|
|
107
150
|
|
|
151
|
+
const currentDelay = this.getDelayMs(entry.count)
|
|
152
|
+
|
|
108
153
|
entry.timeoutId = setTimeout(() => {
|
|
109
154
|
entry.timeoutId = null
|
|
110
155
|
|
|
156
|
+
// 发送前检查:如果终端子屏可见(用户已回到电脑前),取消本会话的所有待发延迟
|
|
157
|
+
if (isTerminalOccluded() === false) {
|
|
158
|
+
info(`远程延迟: 用户已回到终端,取消会话=${sid} 的延迟推送`)
|
|
159
|
+
this.cancelForSession(sid)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 在正文追加延迟标记(第几次 / 共几次 / 下次时间)
|
|
164
|
+
const sendCount = entry.count + 1 // 1-based
|
|
165
|
+
const nextDelay = this.getDelayMs(entry.count + 1)
|
|
166
|
+
msg.body = this.markDelayBody(msg.body, sendCount, this.maxCount, nextDelay)
|
|
167
|
+
|
|
111
168
|
// 发送延迟通知
|
|
112
169
|
const sender = this.senders.get(ch)
|
|
113
170
|
if (sender) {
|
|
@@ -121,7 +178,7 @@ export class DelayedDispatcher {
|
|
|
121
178
|
entry.count++
|
|
122
179
|
if (entry.count < this.maxCount) {
|
|
123
180
|
this.scheduleOne(sid, ch, msg)
|
|
124
|
-
debug(`远程延迟: 重试 ${entry.count}/${this.maxCount} 会话=${sid} 渠道=${ch}`)
|
|
181
|
+
debug(`远程延迟: 重试 ${entry.count}/${this.maxCount} 会话=${sid} 渠道=${ch} 下次延迟=${this.getDelayMs(entry.count)}ms`)
|
|
125
182
|
} else {
|
|
126
183
|
// 达到最大次数,清理
|
|
127
184
|
chMap.delete(ch)
|
|
@@ -130,6 +187,6 @@ export class DelayedDispatcher {
|
|
|
130
187
|
}
|
|
131
188
|
info(`远程延迟: 已完成 会话=${sid} 渠道=${ch} (推送${entry.count}次)`)
|
|
132
189
|
}
|
|
133
|
-
},
|
|
190
|
+
}, currentDelay)
|
|
134
191
|
}
|
|
135
192
|
}
|
package/events.ts
CHANGED
|
@@ -71,14 +71,33 @@ export function route(
|
|
|
71
71
|
)
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
// 会话错误 → run_failed
|
|
75
|
-
|
|
74
|
+
// 会话错误 → run_cancelled / run_failed
|
|
75
|
+
// MessageAbortedError = 用户主动中断(Ctrl+C 或点击中断按钮)
|
|
76
|
+
// 其他错误类型 = 真实失败
|
|
77
|
+
if (type === "session.error") {
|
|
76
78
|
const err = properties.error
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
|
|
80
|
+
// 用户主动中断
|
|
81
|
+
if (err?.name === "MessageAbortedError") {
|
|
82
|
+
if (enabled.has("run_cancelled")) {
|
|
83
|
+
const msg = err?.data?.message ?? ""
|
|
84
|
+
return makeMsg(
|
|
85
|
+
"run_cancelled",
|
|
86
|
+
msg ? `用户中断: ${truncate(msg, 200)}` : defaultBody("run_cancelled"),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
// run_cancelled 未启用 → 跳过,不降级为 run_failed
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 真实失败
|
|
94
|
+
if (enabled.has("run_failed")) {
|
|
95
|
+
const errMsg = err?.data?.message ?? err?.name ?? defaultBody("run_failed")
|
|
96
|
+
return makeMsg(
|
|
97
|
+
"run_failed",
|
|
98
|
+
`错误: ${truncate(String(errMsg), 200)}`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
// 会话空闲 → input_required
|
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin"
|
|
|
2
2
|
import type { PluginConfig } from "./config.js"
|
|
3
3
|
import { resolveConfig, loadYamlConfig, mergeConfig, ensureConfigFile } from "./config.js"
|
|
4
4
|
import { route } from "./events.js"
|
|
5
|
+
import { enrich } from "./message.js"
|
|
5
6
|
import { Dispatcher } from "./dispatcher.js"
|
|
6
7
|
import { FileStore } from "./store.js"
|
|
7
8
|
import { SystemSender } from "./senders/system/index.js"
|
|
@@ -85,8 +86,14 @@ const plugin: Plugin = async (_input, options) => {
|
|
|
85
86
|
|
|
86
87
|
// 会话生命周期事件
|
|
87
88
|
if (type === "session.created") {
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
const userPrompt = properties.info?.title
|
|
90
|
+
tracker.register(sessionID, userPrompt)
|
|
91
|
+
debug(`→ 会话已创建, 会话=${sessionID}${userPrompt ? ` userPrompt="${userPrompt}"` : ""}`)
|
|
92
|
+
}
|
|
93
|
+
if (type === "session.updated") {
|
|
94
|
+
const topic = properties.info?.title
|
|
95
|
+
tracker.updateTopic(sessionID, topic)
|
|
96
|
+
debug(`→ 会话已更新, 会话=${sessionID}${topic ? ` topic="${topic}"` : ""}`)
|
|
90
97
|
}
|
|
91
98
|
if (type === "session.deleted") {
|
|
92
99
|
tracker.remove(sessionID)
|
|
@@ -102,7 +109,12 @@ const plugin: Plugin = async (_input, options) => {
|
|
|
102
109
|
const msg = route(event, cfg.events)
|
|
103
110
|
if (!msg) return // 不关心的事件
|
|
104
111
|
|
|
105
|
-
|
|
112
|
+
// 注入会话主题和任务描述,增强通知内容
|
|
113
|
+
const userPrompt = tracker.getUserPrompt(sessionID)
|
|
114
|
+
const sessionTopic = tracker.getSessionTopic(sessionID)
|
|
115
|
+
enrich(msg, userPrompt, sessionTopic)
|
|
116
|
+
|
|
117
|
+
debug(`→ 匹配通知: ${msg.event} sessionTopic="${sessionTopic ?? ""}" userPrompt="${userPrompt ?? ""}"`)
|
|
106
118
|
|
|
107
119
|
// 会话感知抑制判定
|
|
108
120
|
let shouldSuppress = cfg.suppress_when_active && suppressEvents.includes(msg.event)
|
|
@@ -121,7 +133,10 @@ const plugin: Plugin = async (_input, options) => {
|
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
if (shouldSuppress) {
|
|
124
|
-
info(`→ 会话 ${sessionID}
|
|
136
|
+
info(`→ 会话 ${sessionID} 活跃中,跳过即时通知 (${msg.event})`)
|
|
137
|
+
// 仍调度延迟推送:用户可能在电脑前屏上可见所以抑制,
|
|
138
|
+
// 但万一用户已离开电脑,延迟推送能在用户未回来时再次提醒
|
|
139
|
+
delayedDispatcher?.schedule(msg)
|
|
125
140
|
return
|
|
126
141
|
}
|
|
127
142
|
|
|
@@ -140,50 +155,57 @@ interface BuildSendersResult {
|
|
|
140
155
|
senderMap: Map<string, import("./senders/types.js").Sender>
|
|
141
156
|
}
|
|
142
157
|
|
|
143
|
-
function addSender(
|
|
144
|
-
senders: import("./senders/types.js").Sender[],
|
|
145
|
-
sender: import("./senders/types.js").Sender,
|
|
146
|
-
events: string[],
|
|
147
|
-
label: string,
|
|
148
|
-
extra?: string,
|
|
149
|
-
): import("./senders/types.js").Sender {
|
|
150
|
-
const filtered = new FilteredSender(sender, events)
|
|
151
|
-
senders.push(filtered)
|
|
152
|
-
const evt = events.length < 6 ? `events=${JSON.stringify(events)}` : `events=${events.length}个`
|
|
153
|
-
info(`${label}已启用 (${evt})${extra ? `, ${extra}` : ""}`)
|
|
154
|
-
return filtered
|
|
155
|
-
}
|
|
156
|
-
|
|
157
158
|
function buildSenders(cfg: PluginConfig): BuildSendersResult {
|
|
158
159
|
const senders: import("./senders/types.js").Sender[] = []
|
|
159
160
|
const senderMap = new Map<string, import("./senders/types.js").Sender>()
|
|
160
161
|
const globalEvents = cfg.events ?? []
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
163
|
+
/**
|
|
164
|
+
* 注册渠道发送器
|
|
165
|
+
* @param key 渠道键名
|
|
166
|
+
* @param mode 渠道模式
|
|
167
|
+
* @param chEvents 渠道级事件过滤
|
|
168
|
+
* @param create 创建原始 Sender 的回调
|
|
169
|
+
* @param label 日志标签
|
|
170
|
+
*/
|
|
171
|
+
function register(
|
|
172
|
+
key: string,
|
|
173
|
+
mode: string | undefined,
|
|
174
|
+
chEvents: string[] | undefined,
|
|
175
|
+
create: () => import("./senders/types.js").Sender,
|
|
176
|
+
label: string,
|
|
177
|
+
): void {
|
|
178
|
+
if (mode === "none" || !mode) return // 禁用
|
|
179
|
+
|
|
180
|
+
const evts = chEvents ?? globalEvents
|
|
181
|
+
const raw = create()
|
|
182
|
+
const filtered = new FilteredSender(raw, evts)
|
|
183
|
+
|
|
184
|
+
if (mode === "delay_only") {
|
|
185
|
+
// 仅延迟推送:不进 senders[],只入 senderMap
|
|
186
|
+
senderMap.set(key, filtered)
|
|
187
|
+
info(`${label}已启用 (delay_only, 仅延迟推送)`)
|
|
188
|
+
} else {
|
|
189
|
+
// all:即时通知 + 延迟推送
|
|
190
|
+
senders.push(filtered)
|
|
191
|
+
senderMap.set(key, filtered)
|
|
192
|
+
const evtStr = evts.length < 6 ? `events=${JSON.stringify(evts)}` : `events=${evts.length}个`
|
|
193
|
+
info(`${label}已启用 (${evtStr})`)
|
|
194
|
+
}
|
|
186
195
|
}
|
|
196
|
+
|
|
197
|
+
const ch = cfg.channels
|
|
198
|
+
register("system_message", ch?.system_message?.mode, ch?.system_message?.events,
|
|
199
|
+
() => new SystemSender(), "系统通知")
|
|
200
|
+
register("screen_flash", ch?.screen_flash?.mode, ch?.screen_flash?.events,
|
|
201
|
+
() => new ScreenFlashSender(ch?.screen_flash!), "屏幕跑马灯")
|
|
202
|
+
register("custom_webhook", ch?.custom_webhook?.mode, ch?.custom_webhook?.events,
|
|
203
|
+
() => new CustomWebhookSender(ch?.custom_webhook!), "自定义 Webhook")
|
|
204
|
+
register("wechat_work", ch?.wechat_work?.mode, ch?.wechat_work?.events,
|
|
205
|
+
() => new WechatWorkSender(ch?.wechat_work!), "企业微信")
|
|
206
|
+
register("feishu", ch?.feishu?.mode, ch?.feishu?.events,
|
|
207
|
+
() => new FeishuSender(ch?.feishu!), "飞书")
|
|
208
|
+
|
|
187
209
|
return { senders, senderMap }
|
|
188
210
|
}
|
|
189
211
|
|
package/message.ts
CHANGED
|
@@ -20,6 +20,7 @@ const EVENT_LABELS: Record<string, string> = {
|
|
|
20
20
|
input_required: "等待输入",
|
|
21
21
|
run_completed: "任务完成",
|
|
22
22
|
run_failed: "任务失败",
|
|
23
|
+
run_cancelled: "用户取消",
|
|
23
24
|
session_idle: "会话空闲",
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -28,9 +29,7 @@ const EVENT_LABELS: Record<string, string> = {
|
|
|
28
29
|
*/
|
|
29
30
|
function shortSession(sessionID: string): string {
|
|
30
31
|
if (!sessionID || sessionID === "unknown") return "未知"
|
|
31
|
-
return sessionID
|
|
32
|
-
? sessionID.slice(0, 11) + "…"
|
|
33
|
-
: sessionID
|
|
32
|
+
return sessionID
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
/**
|
|
@@ -38,12 +37,9 @@ function shortSession(sessionID: string): string {
|
|
|
38
37
|
* @param event 事件类型
|
|
39
38
|
* @param sessionID 会话 ID(可选,传入后在标题前加会话标签)
|
|
40
39
|
*/
|
|
41
|
-
export function formatTitle(event: string,
|
|
40
|
+
export function formatTitle(event: string, _sessionID?: string): string {
|
|
42
41
|
const label = EVENT_LABELS[event] ?? event
|
|
43
|
-
|
|
44
|
-
? `[${shortSession(sessionID)}] `
|
|
45
|
-
: ""
|
|
46
|
-
return `${prefix}opencode - ${label}`
|
|
42
|
+
return `opencode - ${label}`
|
|
47
43
|
}
|
|
48
44
|
|
|
49
45
|
/**
|
|
@@ -59,6 +55,8 @@ export function defaultBody(event: string): string {
|
|
|
59
55
|
return "任务执行完成"
|
|
60
56
|
case "run_failed":
|
|
61
57
|
return "任务执行失败"
|
|
58
|
+
case "run_cancelled":
|
|
59
|
+
return "用户主动中断了任务"
|
|
62
60
|
default:
|
|
63
61
|
return `事件: ${event}`
|
|
64
62
|
}
|
|
@@ -85,3 +83,37 @@ export function formatBody(msg: Message): string {
|
|
|
85
83
|
`时间:${time}`,
|
|
86
84
|
].join("\n")
|
|
87
85
|
}
|
|
86
|
+
|
|
87
|
+
/** 截断标题到指定长度 */
|
|
88
|
+
function shortTitle(title: string, maxLen = 20): string {
|
|
89
|
+
if (title.length <= maxLen) return title
|
|
90
|
+
return title.slice(0, maxLen - 1) + "…"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 增强通知消息:注入会话上下文
|
|
95
|
+
*
|
|
96
|
+
* - 标题:`[主题] 事件标签`(替换 `opencode - 事件标签`)
|
|
97
|
+
* - 正文:用 `主题` / `任务` 行替换 `详情` 行
|
|
98
|
+
*
|
|
99
|
+
* @param msg 原始通知消息
|
|
100
|
+
* @param userPrompt 用户输入的问题/任务描述(创建时捕获)
|
|
101
|
+
* @param sessionTopic opencode 自动生成的会话主题
|
|
102
|
+
* @returns 增强后的消息(原地修改并返回)
|
|
103
|
+
*/
|
|
104
|
+
export function enrich(msg: Message, userPrompt?: string, sessionTopic?: string): Message {
|
|
105
|
+
if (!userPrompt && !sessionTopic) return msg
|
|
106
|
+
|
|
107
|
+
// 标题优先用主题(更简洁),无主题则用用户提示词
|
|
108
|
+
const title = sessionTopic ?? userPrompt
|
|
109
|
+
const label = EVENT_LABELS[msg.event] ?? msg.event
|
|
110
|
+
msg.title = `[${shortTitle(title!)}] ${label}`
|
|
111
|
+
|
|
112
|
+
// 替换"详情"行,按可用字段生成上下文行
|
|
113
|
+
const lines: string[] = []
|
|
114
|
+
if (sessionTopic) lines.push(`主题:${sessionTopic}`)
|
|
115
|
+
if (userPrompt) lines.push(`任务:${userPrompt}`)
|
|
116
|
+
// 如果两者都为空(前面 return 了)不可能到这里
|
|
117
|
+
msg.body = msg.body.replace(/^详情:.*$/m, lines.join("\n"))
|
|
118
|
+
return msg
|
|
119
|
+
}
|
package/package.json
CHANGED
package/senders/feishu.ts
CHANGED
|
@@ -47,18 +47,6 @@ export class FeishuSender implements Sender {
|
|
|
47
47
|
tag: "markdown",
|
|
48
48
|
content: msg.body,
|
|
49
49
|
},
|
|
50
|
-
{
|
|
51
|
-
tag: "hr",
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
tag: "note",
|
|
55
|
-
elements: [
|
|
56
|
-
{
|
|
57
|
-
tag: "plain_text",
|
|
58
|
-
content: `会话: ${msg.sessionID}`,
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
},
|
|
62
50
|
],
|
|
63
51
|
},
|
|
64
52
|
})
|
package/senders/wechat-work.ts
CHANGED
|
@@ -33,12 +33,11 @@ export class WechatWorkSender implements Sender {
|
|
|
33
33
|
throw new Error("wechat_work: webhook_url not configured")
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// 构造 markdown
|
|
36
|
+
// 构造 markdown 内容(msg.body 已包含事件/会话/详情/时间/延迟标记)
|
|
37
37
|
const content = [
|
|
38
38
|
`**${msg.title}**`,
|
|
39
39
|
"",
|
|
40
40
|
msg.body,
|
|
41
|
-
`> 会话: ${msg.sessionID}`,
|
|
42
41
|
].join("\n")
|
|
43
42
|
|
|
44
43
|
const body = JSON.stringify({
|
package/session-tracker.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface SessionInfo {
|
|
|
20
20
|
lastActivity: number
|
|
21
21
|
/** 会话创建时间 */
|
|
22
22
|
createdAt: number
|
|
23
|
+
/** 用户输入的问题/任务描述(创建时捕获) */
|
|
24
|
+
userPrompt?: string
|
|
25
|
+
/** opencode 自动生成的会话主题(session.updated 时更新) */
|
|
26
|
+
sessionTopic?: string
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // 5 分钟
|
|
@@ -69,17 +73,47 @@ export class SessionTracker {
|
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
/** 注册新会话 */
|
|
72
|
-
register(sessionID: string): void {
|
|
76
|
+
register(sessionID: string, userPrompt?: string): void {
|
|
73
77
|
if (sessionID === "unknown") return
|
|
74
|
-
|
|
78
|
+
const existing = this.sessions.get(sessionID)
|
|
79
|
+
if (existing) {
|
|
80
|
+
if (userPrompt) existing.userPrompt = userPrompt
|
|
81
|
+
} else {
|
|
82
|
+
this.sessions.set(sessionID, {
|
|
83
|
+
sessionID,
|
|
84
|
+
lastActivity: Date.now(),
|
|
85
|
+
createdAt: Date.now(),
|
|
86
|
+
userPrompt,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 更新会话主题(opencode 自动生成,来自 session.updated) */
|
|
92
|
+
updateTopic(sessionID: string, topic: string): void {
|
|
93
|
+
if (sessionID === "unknown" || !topic) return
|
|
94
|
+
const existing = this.sessions.get(sessionID)
|
|
95
|
+
if (existing) {
|
|
96
|
+
existing.sessionTopic = topic
|
|
97
|
+
} else {
|
|
75
98
|
this.sessions.set(sessionID, {
|
|
76
99
|
sessionID,
|
|
77
100
|
lastActivity: Date.now(),
|
|
78
101
|
createdAt: Date.now(),
|
|
102
|
+
sessionTopic: topic,
|
|
79
103
|
})
|
|
80
104
|
}
|
|
81
105
|
}
|
|
82
106
|
|
|
107
|
+
/** 获取用户提示词 */
|
|
108
|
+
getUserPrompt(sessionID: string): string | undefined {
|
|
109
|
+
return this.sessions.get(sessionID)?.userPrompt
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 获取会话主题 */
|
|
113
|
+
getSessionTopic(sessionID: string): string | undefined {
|
|
114
|
+
return this.sessions.get(sessionID)?.sessionTopic
|
|
115
|
+
}
|
|
116
|
+
|
|
83
117
|
/** 移除会话 */
|
|
84
118
|
remove(sessionID: string): void {
|
|
85
119
|
this.sessions.delete(sessionID)
|