@freely01/opencode-notify 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -31
- package/cli.ts +27 -15
- package/config.ts +298 -37
- package/delayed-dispatcher.ts +135 -0
- package/dispatcher.ts +11 -6
- package/events.ts +35 -43
- package/index.ts +115 -62
- package/log.ts +90 -0
- package/message.ts +39 -2
- package/package.json +1 -1
- package/senders/screen-flash/index.ts +27 -0
- package/senders/screen-flash/linux.ts +29 -0
- package/senders/system/darwin.ts +17 -0
- package/senders/system/index.ts +61 -0
- package/senders/system/linux.ts +16 -0
- package/senders/system/win32.ts +28 -0
- package/session-tracker.ts +133 -0
- package/store.ts +5 -4
- package/terminator-detect.ts +164 -0
- package/senders/screen-flash.ts +0 -42
- package/senders/system.ts +0 -88
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
|
-
*
|
|
68
|
-
*
|
|
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
|
-
/**
|
|
75
|
-
|
|
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
|
-
*
|
|
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" | "
|
|
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:
|
|
141
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
}
|