@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 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.enabled) continue
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
- add("system_message", !!(channels.system_message?.enabled), async () => {
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?.enabled && channels.custom_webhook.url) {
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?.enabled && channels.wechat_work.webhook_url) {
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?.enabled && channels.feishu.webhook_url) {
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?.enabled ? `强度${ch.screen_flash.intensity ?? 0.9}` : ""],
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?.enabled) {
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
- enabled: boolean
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
- enabled: true # true=启用, false=禁用
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
- # enabled: true
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
- # enabled: true
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
- # enabled: true
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
- # enabled: true
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
- # 可选事件(各渠道也可单独配置 events 覆盖此全局列表):
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: { enabled: true },
387
- screen_flash: { enabled: false },
388
- wechat_work: { enabled: false },
389
- feishu: { enabled: false },
390
- custom_webhook: { enabled: false },
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
- enabled:
425
- options.channels?.system_message?.enabled ??
426
- DEFAULT_CONFIG.channels!.system_message!.enabled,
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
- enabled:
431
- options.channels?.screen_flash?.enabled ??
432
- DEFAULT_CONFIG.channels!.screen_flash!.enabled,
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
- enabled:
440
- options.channels?.wechat_work?.enabled ??
441
- DEFAULT_CONFIG.channels!.wechat_work!.enabled,
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
- enabled:
447
- options.channels?.feishu?.enabled ??
448
- DEFAULT_CONFIG.channels!.feishu!.enabled,
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
- enabled:
454
- options.channels?.custom_webhook?.enabled ??
455
- DEFAULT_CONFIG.channels?.custom_webhook?.enabled ??
456
- false,
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,
@@ -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
- }, this.delayMs)
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
- if (type === "session.error" && enabled.has("run_failed")) {
74
+ // 会话错误 → run_cancelled / run_failed
75
+ // MessageAbortedError = 用户主动中断(Ctrl+C 或点击中断按钮)
76
+ // 其他错误类型 = 真实失败
77
+ if (type === "session.error") {
76
78
  const err = properties.error
77
- const errMsg = err?.message ?? err?.name ?? defaultBody("run_failed")
78
- return makeMsg(
79
- "run_failed",
80
- `错误: ${truncate(String(errMsg), 200)}`,
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
- tracker.register(sessionID)
89
- debug(`→ 会话已创建, 会话=${sessionID}`)
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
- debug(`→ 匹配通知: ${msg.event}`)
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} 活跃中,跳过通知 (${msg.event})`)
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
- if (cfg.channels?.system_message?.enabled) {
163
- const events = cfg.channels.system_message.events ?? globalEvents
164
- const s = addSender(senders, new SystemSender(), events, "系统通知")
165
- senderMap.set("system_message", s)
166
- }
167
- if (cfg.channels?.screen_flash?.enabled) {
168
- const events = cfg.channels.screen_flash.events ?? globalEvents
169
- const s = addSender(senders, new ScreenFlashSender(cfg.channels.screen_flash), events, "屏幕跑马灯")
170
- senderMap.set("screen_flash", s)
171
- }
172
- if (cfg.channels?.custom_webhook?.enabled && cfg.channels.custom_webhook.url) {
173
- const events = cfg.channels.custom_webhook.events ?? globalEvents
174
- const s = addSender(senders, new CustomWebhookSender(cfg.channels.custom_webhook), events, "自定义 Webhook")
175
- senderMap.set("custom_webhook", s)
176
- }
177
- if (cfg.channels?.wechat_work?.enabled && cfg.channels.wechat_work.webhook_url) {
178
- const events = cfg.channels.wechat_work.events ?? globalEvents
179
- const s = addSender(senders, new WechatWorkSender(cfg.channels.wechat_work), events, "企业微信")
180
- senderMap.set("wechat_work", s)
181
- }
182
- if (cfg.channels?.feishu?.enabled && cfg.channels.feishu.webhook_url) {
183
- const events = cfg.channels.feishu.events ?? globalEvents
184
- const s = addSender(senders, new FeishuSender(cfg.channels.feishu), events, "飞书")
185
- senderMap.set("feishu", s)
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.length > 11
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, sessionID?: string): string {
40
+ export function formatTitle(event: string, _sessionID?: string): string {
42
41
  const label = EVENT_LABELS[event] ?? event
43
- const prefix = sessionID && sessionID !== "unknown"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freely01/opencode-notify",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "opencode 通知插件 - 监听会话事件并通过多渠道推送通知",
5
5
  "type": "module",
6
6
  "main": "index.ts",
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
  })
@@ -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({
@@ -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
- if (!this.sessions.has(sessionID)) {
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)