@freely01/opencode-notify 0.1.1 → 0.3.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/config.ts CHANGED
@@ -49,6 +49,14 @@ export interface ChannelsConfig {
49
49
  custom_webhook?: CustomWebhookChannelConfig
50
50
  }
51
51
 
52
+ /** 日志配置 */
53
+ export interface LogConfig {
54
+ /** 日志等级:error | warn | info | debug,默认 info */
55
+ level?: string
56
+ /** 日志文件路径,默认 ~/.opencode-notify/plugin.log */
57
+ file?: string
58
+ }
59
+
52
60
  /** 插件配置 */
53
61
  export interface PluginConfig {
54
62
  channels?: ChannelsConfig
@@ -64,35 +72,287 @@ export interface PluginConfig {
64
72
  /** 去重时间窗口(秒),默认 60 */
65
73
  dedupe_seconds?: number
66
74
  /**
67
- * 当用户活跃(正在操作 TUI)时抑制通知
68
- * 检测的事件:message.updated / permission.replied / question.replied / command.executed / tui.command.execute
75
+ * 会话感知抑制开关
76
+ * true → 会话活跃时按 suppress_events_when_active 列表过滤通知
77
+ * false → 不抑制(旧行为,所有事件都通知)
69
78
  * @default true
70
79
  */
71
80
  suppress_when_active?: boolean
72
- /** 用户活跃超时时间(毫秒),超过此时间无操作视为离开 */
81
+ /**
82
+ * 会话操作超时(毫秒)
83
+ * 超过此时间无操作 → 视为不活跃,不再抑制通知
84
+ * @default 15000
85
+ */
73
86
  activity_timeout_ms?: number
74
- /** 写入 ~/.opencode-notify/plugin.log 调试日志,默认 false */
75
- debug_log?: boolean
87
+ /**
88
+ * 活跃时抑制哪些事件
89
+ * 空数组表示不抑制任何事件(仅跟踪会话,不影响通知)
90
+ * @default ["permission_required", "input_required"]
91
+ */
92
+ suppress_events_when_active?: string[]
93
+ /**
94
+ * 超时会话自动淘汰(毫秒)
95
+ * 会话超过此时间无任何活动 → 从追踪 Map 移除(防内存泄漏)
96
+ * @default 600000 (10 分钟)
97
+ */
98
+ session_stale_timeout_ms?: number
99
+ /**
100
+ * 远程延迟推送渠道列表
101
+ *
102
+ * 这些渠道在正常通知发出后,还会额外进行一次延迟推送。
103
+ * 正常通知不受影响(该发就发),延迟推送是额外补偿。
104
+ *
105
+ * 适用场景:用户不在电脑前时,正常通知可能没看到,
106
+ * 延迟推送在用户仍未操作时再次通知。
107
+ *
108
+ * 用户回到 opencode TUI 操作 → 取消该会话所有待发延迟通知。
109
+ *
110
+ * @default [] (不启用延迟推送)
111
+ */
112
+ remote_delay_channels?: string[]
113
+ /**
114
+ * 远程延迟秒数
115
+ * 正常通知发出后等待此秒数,用户仍无操作则再次通知
116
+ * @default 60
117
+ */
118
+ remote_delay_seconds?: number
119
+ /**
120
+ * 远程延迟最多重复次数
121
+ * @default 3
122
+ */
123
+ remote_delay_max_count?: number
124
+ /**
125
+ * 日志配置
126
+ *
127
+ * level: error | warn | info | debug
128
+ * - error → 仅记录错误
129
+ * - warn → 错误 + 警告
130
+ * - info → 错误 + 警告 + 常规信息(默认)
131
+ * - debug → 全部日志(相当于旧版的 debug_log: true)
132
+ *
133
+ * file: 日志文件路径,默认 ~/.opencode-notify/plugin.log
134
+ */
135
+ log?: LogConfig
76
136
  }
77
137
 
78
- import { readFileSync, existsSync } from "node:fs"
138
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs"
79
139
  import { homedir } from "node:os"
80
- import { join } from "node:path"
140
+ import { join, dirname } from "node:path"
81
141
  import yaml from "js-yaml"
82
142
 
143
+ /** 默认配置文件路径 */
144
+ export function defaultConfigPath(): string {
145
+ return join(homedir(), ".config", "opencode", "opencode-notify.yaml")
146
+ }
147
+
148
+ /** 默认配置模板内容(仅启用系统通知) */
149
+ const DEFAULT_CONFIG_TEMPLATE = `# =============================================================================
150
+ # opencode-notify 配置文件
151
+ # =============================================================================
152
+ # 文件位置: ~/.config/opencode/opencode-notify.yaml
153
+ #
154
+ # 配置优先级: YAML 文件 > plugin options (opencode.json tuple) > 默认值
155
+ #
156
+ # 首次运行自动生成,仅启用了系统通知渠道,开箱即用。
157
+ # 其他渠道按需取消注释即可启用。
158
+ # =============================================================================
159
+
160
+
161
+ # =============================================================================
162
+ # 通知渠道
163
+ # =============================================================================
164
+ channels:
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # 系统消息通知 (macOS / Linux / Windows)
168
+ # ---------------------------------------------------------------------------
169
+ # 弹出 OS 原生通知横幅,开箱即用,无需额外配置。
170
+ # macOS - 使用 osascript (display notification)
171
+ # Linux - 使用 notify-send (需 libnotify 包,桌面版通常预装)
172
+ # Windows - 使用 PowerShell New-BurntToastNotification
173
+ # (需额外安装 BurntToast 模块)
174
+ # ---------------------------------------------------------------------------
175
+ system_message:
176
+ enabled: true # true=启用, false=禁用
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # 屏幕跑马灯 (Linux X11 专用)
180
+ # ---------------------------------------------------------------------------
181
+ # 屏幕四边彩色高亮闪烁,作为系统通知之外的视觉辅助。
182
+ # 使用 Python + PyGObject(GTK 3),Ubuntu GNOME 桌面内置。
183
+ # 支持独立配置事件过滤和持续时间/速度/不透明度。
184
+ # 取消下方注释启用:
185
+ # ---------------------------------------------------------------------------
186
+ # screen_flash:
187
+ # enabled: true
188
+ # duration: 3.5 # 持续秒数
189
+ # speed: 5.0 # 移动速度因子
190
+ # intensity: 0.85 # 不透明度 0.0~1.0
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # 企业微信 群机器人 Webhook
194
+ # ---------------------------------------------------------------------------
195
+ # 发送 Markdown 消息到企业微信群聊。
196
+ # 使用前提:在企业微信群中添加群机器人,获取 Webhook URL。
197
+ # 文档: https://developer.work.weixin.qq.com/document/path/99110
198
+ # 取消下方注释并填入 webhook_url 启用:
199
+ # ---------------------------------------------------------------------------
200
+ # wechat_work:
201
+ # enabled: true
202
+ # webhook_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # 飞书 自定义机器人 / 流程触发器 Webhook
206
+ # ---------------------------------------------------------------------------
207
+ # 发送卡片消息到飞书群聊。
208
+ # 使用前提:在飞书群中添加自定义机器人,获取 Webhook URL。
209
+ # 文档: https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
210
+ # 取消下方注释并填入 webhook_url 启用:
211
+ # ---------------------------------------------------------------------------
212
+ # feishu:
213
+ # enabled: true
214
+ # webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # 自定义 Webhook (通用 HTTP POST)
218
+ # ---------------------------------------------------------------------------
219
+ # 发送 HTTP 请求到任意 Webhook 服务。
220
+ # 支持模板插值自动填充消息内容。
221
+ # 适用服务: Gotify, Bark, PushDeer, Slack Webhook, Discord Webhook 等
222
+ #
223
+ # Gotify 配置示例:
224
+ # url: "https://gotify.example.com/message"
225
+ # method: "POST"
226
+ # headers:
227
+ # X-Gotify-Key: "your-app-token"
228
+ # template: '{"title":"{{title}}","message":"{{body}}","priority":5}'
229
+ #
230
+ # 模板占位符: {{title}} {{body}} {{event}} {{agent}} {{sessionID}}
231
+ # 取消下方注释并配置 url 启用:
232
+ # ---------------------------------------------------------------------------
233
+ # custom_webhook:
234
+ # enabled: true
235
+ # url: ""
236
+ # method: "POST" # 请求方法: "POST" | "GET"
237
+ # headers: {} # 自定义请求头
238
+ # template: "" # 消息模板(JSON 字符串)
239
+
240
+
241
+ # =============================================================================
242
+ # 通知事件订阅
243
+ # =============================================================================
244
+ # 只订阅你关心的事件类型,减少不必要通知。
245
+ #
246
+ # 可选事件(各渠道也可单独配置 events 覆盖此全局列表):
247
+ # permission_required - Agent 需要用户授权(如执行命令、读写文件)
248
+ # 触发: permission.asked / question.asked
249
+ # input_required - Agent 等待用户输入
250
+ # 触发: session.idle / session.status(idle)
251
+ # run_completed - 任务执行完成(技术预留,暂未实现)
252
+ # run_failed - 任务执行失败
253
+ # 触发: session.error
254
+ # ---------------------------------------------------------------------------
255
+ events:
256
+ - permission_required # 权限请求通知(推荐开启)
257
+ - input_required # 等待输入通知(推荐开启)
258
+ - run_failed # 任务失败通知
259
+
260
+
261
+ # =============================================================================
262
+ # 去重设置
263
+ # =============================================================================
264
+ # 同一事件在时间窗口内只发送一次,避免重复骚扰。
265
+ # 去重 key: agent:event:sessionID
266
+ # 例如 60 秒内同一个会话的权限请求不会重复弹通知。
267
+ # ---------------------------------------------------------------------------
268
+ dedupe_seconds: 60 # 去重时间窗口(秒),0 或负数=不限制
269
+
270
+
271
+ # =============================================================================
272
+ # 会话感知抑制
273
+ # =============================================================================
274
+ # 当用户在 opencode TUI 中操作(输入消息、回应权限等),
275
+ # 部分通知可能冗余(屏上已可见)。插件追踪每个会话的操作时间戳,
276
+ # 只对活跃会话按事件类型选择性过滤。
277
+ #
278
+ # 检测的用户活跃事件:
279
+ # message.updated - 用户发送了消息
280
+ # permission.replied - 用户回应了授权
281
+ # question.replied - 用户回答了问题
282
+ # command.executed - 用户执行了命令
283
+ # tui.command.execute - 用户按键操作 TUI
284
+ #
285
+ # 抑制规则:
286
+ # permission_required / input_required: 活跃时抑制(屏上可见)
287
+ # run_failed / run_completed: 始终通知(异步结果,人可能走开)
288
+ # ---------------------------------------------------------------------------
289
+ suppress_when_active: true # true=开启会话感知抑制, false=不抑制
290
+ activity_timeout_ms: 15000 # 会话操作超时(毫秒)
291
+ # 超过此时间该会话无操作 → 视为不活跃
292
+ # 推荐值: 10000~30000
293
+ suppress_events_when_active: # 活跃时抑制哪些事件(不填继承默认)
294
+ - permission_required
295
+ - input_required
296
+ # run_failed / run_completed 不在列表中 → 始终通知
297
+ session_stale_timeout_ms: 600000 # 超时会话自动淘汰(毫秒)
298
+ # 10 分钟无任何活动的会话从追踪 Map 移除
299
+ # 防止长期运行导致内存泄漏
300
+
301
+
302
+ # =============================================================================
303
+ # 远程延迟推送
304
+ # =============================================================================
305
+ # 正常通知发出后,如果用户长时间未操作,针对指定渠道额外再推送一次。
306
+ # 用户回到 opencode TUI 操作 → 自动取消所有待发延迟通知。
307
+ #
308
+ # 适用场景:用户离开电脑后,系统通知可能一闪而过没看到,
309
+ # 延迟推送在用户仍未回来时再次尝试发出。
310
+ #
311
+ # remote_delay_seconds 和 remote_delay_max_count 仅在
312
+ # remote_delay_channels 非空时生效。
313
+ # ---------------------------------------------------------------------------
314
+ remote_delay_channels: [] # 启用的延迟推送渠道列表
315
+ # 可选: system_message, screen_flash,
316
+ # wechat_work, feishu, custom_webhook
317
+ # 空 = 不启用延迟推送
318
+ # remote_delay_seconds: 60 # 延迟秒数(默认 60)
319
+ # remote_delay_max_count: 3 # 最多重复次数(默认 3)
320
+
321
+
322
+ # =============================================================================
323
+ # 日志配置
324
+ # =============================================================================
325
+ # 所有通知失败、警告、运行信息均写入日志文件。
326
+ # 日志等级控制输出详细程度,日常使用 info 即可。
327
+ # ---------------------------------------------------------------------------
328
+ log:
329
+ level: info # 日志等级: error | warn | info | debug
330
+ # error - 仅记录错误
331
+ # warn - 错误 + 警告
332
+ # info - 错误 + 警告 + 常规信息(推荐)
333
+ # debug - 全部日志(排查问题时使用)
334
+ # file: "~/.opencode-notify/plugin.log" # 日志文件路径(可选,默认同上)
335
+ `
336
+
83
337
  /**
84
- * 加载 YAML 配置文件
85
- *
86
- * 文件路径优先级:
87
- * 1. OPENCODE_NOTIFY_CONFIG 环境变量
88
- * 2. ~/.config/opencode/opencode-notify.yaml
89
- *
90
- * 文件不存在时返回 null,插件使用默认配置 + plugin options
338
+ * 确保配置文件存在,不存在则生成默认模板
91
339
  */
340
+ export function ensureConfigFile(): void {
341
+ const configPath = join(homedir(), ".config", "opencode", "opencode-notify.yaml")
342
+ if (existsSync(configPath)) return // 已存在
343
+
344
+ try {
345
+ const configDir = dirname(configPath)
346
+ if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true })
347
+ writeFileSync(configPath, DEFAULT_CONFIG_TEMPLATE, "utf-8")
348
+ console.error(`[opencode-notify] 已生成默认配置文件: ${configPath}`)
349
+ } catch {
350
+ // 生成失败不影响插件加载(使用内置默认配置)
351
+ }
352
+ }
353
+
92
354
  export function loadYamlConfig(): PluginConfig | null {
93
- const configPath =
94
- process.env.OPENCODE_NOTIFY_CONFIG ??
95
- join(homedir(), ".config", "opencode", "opencode-notify.yaml")
355
+ const configPath = join(homedir(), ".config", "opencode", "opencode-notify.yaml")
96
356
 
97
357
  if (!existsSync(configPath)) return null
98
358
 
@@ -121,7 +381,7 @@ export function mergeConfig(base: PluginConfig, overrides: PluginConfig): Plugin
121
381
  }
122
382
 
123
383
  /** 默认配置 */
124
- const DEFAULT_CONFIG: Required<Pick<PluginConfig, "suppress_when_active" | "activity_timeout_ms" | "debug_log">> & PluginConfig = {
384
+ 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 = {
125
385
  channels: {
126
386
  system_message: { enabled: true },
127
387
  screen_flash: { enabled: false },
@@ -137,26 +397,19 @@ const DEFAULT_CONFIG: Required<Pick<PluginConfig, "suppress_when_active" | "acti
137
397
  ],
138
398
  dedupe_seconds: 60,
139
399
  suppress_when_active: true,
140
- activity_timeout_ms: 30_000,
141
- debug_log: false,
400
+ activity_timeout_ms: 15_000,
401
+ suppress_events_when_active: ["permission_required", "input_required"],
402
+ session_stale_timeout_ms: 600_000,
403
+ remote_delay_channels: [],
404
+ remote_delay_seconds: 60,
405
+ remote_delay_max_count: 3,
406
+ log: { level: "info", file: undefined },
142
407
  }
143
408
 
144
409
  /**
145
- * 合并配置:options → 环境变量 → 默认值
410
+ * 合并配置:options → 默认值
146
411
  */
147
412
  export function resolveConfig(options: PluginConfig): PluginConfig {
148
- const wechatWebhook =
149
- options.channels?.wechat_work?.webhook_url ||
150
- process.env.OPENCODE_NOTIFY_WECHAT_WEBHOOK
151
-
152
- const feishuWebhook =
153
- options.channels?.feishu?.webhook_url ||
154
- process.env.OPENCODE_NOTIFY_FEISHU_WEBHOOK
155
-
156
- const customWebhookUrl =
157
- options.channels?.custom_webhook?.url ||
158
- process.env.OPENCODE_NOTIFY_CUSTOM_WEBHOOK_URL
159
-
160
413
  // 全局 events,各渠道继承此值
161
414
  const globalEvents = options.events ?? DEFAULT_CONFIG.events
162
415
 
@@ -186,14 +439,14 @@ export function resolveConfig(options: PluginConfig): PluginConfig {
186
439
  enabled:
187
440
  options.channels?.wechat_work?.enabled ??
188
441
  DEFAULT_CONFIG.channels!.wechat_work!.enabled,
189
- webhook_url: wechatWebhook || undefined,
442
+ webhook_url: options.channels?.wechat_work?.webhook_url || undefined,
190
443
  events: chEvents(options.channels?.wechat_work),
191
444
  },
192
445
  feishu: {
193
446
  enabled:
194
447
  options.channels?.feishu?.enabled ??
195
448
  DEFAULT_CONFIG.channels!.feishu!.enabled,
196
- webhook_url: feishuWebhook || undefined,
449
+ webhook_url: options.channels?.feishu?.webhook_url || undefined,
197
450
  events: chEvents(options.channels?.feishu),
198
451
  },
199
452
  custom_webhook: {
@@ -201,7 +454,7 @@ export function resolveConfig(options: PluginConfig): PluginConfig {
201
454
  options.channels?.custom_webhook?.enabled ??
202
455
  DEFAULT_CONFIG.channels?.custom_webhook?.enabled ??
203
456
  false,
204
- url: customWebhookUrl || undefined,
457
+ url: options.channels?.custom_webhook?.url || undefined,
205
458
  method: options.channels?.custom_webhook?.method ?? "POST",
206
459
  headers: options.channels?.custom_webhook?.headers,
207
460
  template: options.channels?.custom_webhook?.template,
@@ -212,6 +465,14 @@ export function resolveConfig(options: PluginConfig): PluginConfig {
212
465
  dedupe_seconds: options.dedupe_seconds ?? DEFAULT_CONFIG.dedupe_seconds,
213
466
  suppress_when_active: options.suppress_when_active ?? DEFAULT_CONFIG.suppress_when_active,
214
467
  activity_timeout_ms: options.activity_timeout_ms ?? DEFAULT_CONFIG.activity_timeout_ms,
215
- debug_log: options.debug_log ?? DEFAULT_CONFIG.debug_log,
468
+ suppress_events_when_active: options.suppress_events_when_active ?? DEFAULT_CONFIG.suppress_events_when_active,
469
+ session_stale_timeout_ms: options.session_stale_timeout_ms ?? DEFAULT_CONFIG.session_stale_timeout_ms,
470
+ remote_delay_channels: options.remote_delay_channels ?? DEFAULT_CONFIG.remote_delay_channels,
471
+ remote_delay_seconds: options.remote_delay_seconds ?? DEFAULT_CONFIG.remote_delay_seconds,
472
+ remote_delay_max_count: options.remote_delay_max_count ?? DEFAULT_CONFIG.remote_delay_max_count,
473
+ log: {
474
+ level: options.log?.level ?? DEFAULT_CONFIG.log?.level ?? "info",
475
+ file: options.log?.file,
476
+ },
216
477
  }
217
478
  }
@@ -0,0 +1,135 @@
1
+ import type { Message } from "./message.js"
2
+ import type { Sender } from "./senders/types.js"
3
+ import { error, warn, info, debug } from "./log.js"
4
+
5
+ /**
6
+ * 远程延迟通知调度器
7
+ *
8
+ * 职责:
9
+ * 1. 正常通知发出后,为指定渠道额外调度延迟推送
10
+ * 2. 延迟期内用户活跃 → 取消该会话所有待发延迟通知
11
+ * 3. 超时后发送,最多重复 remote_delay_max_count 次
12
+ */
13
+
14
+ interface PendingEntry {
15
+ /** 当前已发送次数 */
16
+ count: number
17
+ /** 定时器 ID,用于取消 */
18
+ timeoutId: ReturnType<typeof setTimeout> | null
19
+ }
20
+
21
+ export class DelayedDispatcher {
22
+ /**
23
+ * 待发延迟通知
24
+ * Map<sessionID, Map<channelName, PendingEntry>>
25
+ */
26
+ private pending: Map<string, Map<string, PendingEntry>> = new Map()
27
+
28
+ constructor(
29
+ /** 延迟毫秒数 */
30
+ private delayMs: number,
31
+ /** 最大重复次数 */
32
+ private maxCount: number,
33
+ /** 需要延迟推送的渠道名列表 */
34
+ private channelNames: string[],
35
+ /** 渠道名 → Sender 映射(已包含事件过滤) */
36
+ private senders: Map<string, Sender>,
37
+ ) {}
38
+
39
+ /**
40
+ * 调度延迟通知
41
+ *
42
+ * 在正常通知发出后调用,为 remote_delay_channels 中的渠道
43
+ * 逐个调度延迟推送。同一会话同一渠道已有待发任务则跳过(不重复调度)。
44
+ */
45
+ schedule(msg: Message): void {
46
+ if (this.channelNames.length === 0) return
47
+
48
+ const sid = msg.sessionID
49
+ if (!this.pending.has(sid)) {
50
+ this.pending.set(sid, new Map())
51
+ }
52
+
53
+ const chMap = this.pending.get(sid)!
54
+ for (const ch of this.channelNames) {
55
+ // 该渠道已有待发延迟 → 跳过(不重复调度)
56
+ if (chMap.has(ch)) continue
57
+
58
+ chMap.set(ch, { count: 0, timeoutId: null })
59
+ this.scheduleOne(sid, ch, msg)
60
+ info(`远程延迟: 已调度 会话=${sid} 渠道=${ch} 延迟=${this.delayMs}ms`)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 取消指定会话的所有待发延迟通知
66
+ *
67
+ * 在用户活跃事件或会话删除时调用。
68
+ */
69
+ cancelForSession(sessionID: string): void {
70
+ const chMap = this.pending.get(sessionID)
71
+ if (!chMap) return
72
+
73
+ for (const [ch, entry] of chMap) {
74
+ if (entry.timeoutId !== null) {
75
+ clearTimeout(entry.timeoutId)
76
+ }
77
+ }
78
+ this.pending.delete(sessionID)
79
+ info(`远程延迟: 已取消 会话=${sessionID}`)
80
+ }
81
+
82
+ /**
83
+ * 等待指定会话的待发延迟任务数量
84
+ * 用于测试/诊断
85
+ */
86
+ pendingCount(sessionID: string): number {
87
+ return this.pending.get(sessionID)?.size ?? 0
88
+ }
89
+
90
+ /**
91
+ * 清理所有待发任务(插件卸载时调用)
92
+ */
93
+ destroy(): void {
94
+ for (const sessionID of this.pending.keys()) {
95
+ this.cancelForSession(sessionID)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 调度单次延迟发送
101
+ */
102
+ private scheduleOne(sid: string, ch: string, msg: Message): void {
103
+ const chMap = this.pending.get(sid)
104
+ if (!chMap) return
105
+ const entry = chMap.get(ch)
106
+ if (!entry) return
107
+
108
+ entry.timeoutId = setTimeout(() => {
109
+ entry.timeoutId = null
110
+
111
+ // 发送延迟通知
112
+ const sender = this.senders.get(ch)
113
+ if (sender) {
114
+ sender.send(msg).catch((err) => {
115
+ error(`远程延迟推送失败 会话=${sid} 渠道=${ch}: ${err}`)
116
+ })
117
+ info(`远程延迟: 已推送 会话=${sid} 渠道=${ch}`)
118
+ }
119
+
120
+ // 检查是否需要继续重试
121
+ entry.count++
122
+ if (entry.count < this.maxCount) {
123
+ this.scheduleOne(sid, ch, msg)
124
+ debug(`远程延迟: 重试 ${entry.count}/${this.maxCount} 会话=${sid} 渠道=${ch}`)
125
+ } else {
126
+ // 达到最大次数,清理
127
+ chMap.delete(ch)
128
+ if (chMap.size === 0) {
129
+ this.pending.delete(sid)
130
+ }
131
+ info(`远程延迟: 已完成 会话=${sid} 渠道=${ch} (推送${entry.count}次)`)
132
+ }
133
+ }, this.delayMs)
134
+ }
135
+ }
package/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 { FileStore } from "./store.js"
4
+ import { error, warn, info, debug } from "./log.js"
4
5
 
5
6
  /**
6
7
  * 通知分发器
@@ -27,6 +28,7 @@ export class Dispatcher {
27
28
 
28
29
  async dispatch(msg: Message): Promise<void> {
29
30
  if (this.senders.length === 0) {
31
+ warn(`没有启用的通知渠道,跳过: ${msg.event} 会话=${msg.sessionID}`)
30
32
  return
31
33
  }
32
34
 
@@ -36,9 +38,12 @@ export class Dispatcher {
36
38
  // 去重检查 + 预留发送时隙
37
39
  const allowed = this.store.reserveSend(key, this.windowSec, now)
38
40
  if (!allowed) {
39
- return // 去重命中,跳过
41
+ debug(`去重命中,跳过: ${key}`)
42
+ return
40
43
  }
41
44
 
45
+ info(`分发通知: agent=${msg.agent} event=${msg.event} 会话=${msg.sessionID} 渠道数=${this.senders.length}`)
46
+
42
47
  // 并发发送到所有渠道
43
48
  const results = await Promise.allSettled(
44
49
  this.senders.map((s) => s.send(msg)),
@@ -51,19 +56,19 @@ export class Dispatcher {
51
56
 
52
57
  if (allSuccess) {
53
58
  this.store.markSent(key, now)
59
+ debug(`通知发送成功: ${key}`)
54
60
  } else {
55
61
  // 有失败 → 释放预留
56
62
  this.store.clearReservation(key)
57
- // 记录失败(但不抛异常避免影响主流程)
63
+ let failCount = 0
58
64
  for (let i = 0; i < results.length; i++) {
59
65
  const r = results[i]
60
66
  if (r.status === "rejected") {
61
- console.error(
62
- `[opencode-notify] sender ${this.senders[i].name} failed:`,
63
- r.reason,
64
- )
67
+ failCount++
68
+ error(`渠道 ${this.senders[i].name} 发送失败: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`)
65
69
  }
66
70
  }
71
+ warn(`通知部分失败: ${key} (${failCount}/${this.senders.length} 个渠道失败)`)
67
72
  }
68
73
  }
69
74
  }