@freely01/opencode-notify 0.1.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/cli.ts ADDED
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * opencode-notify 诊断与调试 CLI
4
+ *
5
+ * 用法:
6
+ * bun cli.ts check 检查配置文件的正确性
7
+ * bun cli.ts test 测试所有已启用的通知渠道
8
+ * bun cli.ts test <channel> 测试指定渠道 (system|wechat_work|feishu|custom_webhook)
9
+ * bun cli.ts log [lines] 查看最近 N 行插件日志 (默认 20)
10
+ * bun cli.ts info 显示插件版本、配置、渠道状态等综合信息
11
+ * bun cli.ts help 显示帮助
12
+ */
13
+
14
+ import { loadYamlConfig, resolveConfig, mergeConfig } from "./config.js"
15
+ import type { PluginConfig, ChannelsConfig } from "./config.js"
16
+ import type { Message } from "./message.js"
17
+ import { SystemSender } from "./senders/system.js"
18
+ import { CustomWebhookSender } from "./senders/custom-webhook.js"
19
+ import { WechatWorkSender } from "./senders/wechat-work.js"
20
+ import { FeishuSender } from "./senders/feishu.js"
21
+ import { readFileSync, existsSync, statSync } from "node:fs"
22
+ import { homedir } from "node:os"
23
+ import { join } from "node:path"
24
+
25
+ // ─── 常量 ───────────────────────────────────────────────────────────────────
26
+
27
+ const PKG = JSON.parse(readFileSync(join(import.meta.dirname, "package.json"), "utf-8"))
28
+ const LOG_FILE = join(homedir(), ".opencode-notify", "plugin.log")
29
+ const YAML_PATH = process.env.OPENCODE_NOTIFY_CONFIG ?? join(homedir(), ".config", "opencode", "opencode-notify.yaml")
30
+
31
+ const SAMPLE_MSG: Message = {
32
+ agent: "opencode",
33
+ event: "permission_required",
34
+ sessionID: "diagnose-test",
35
+ title: "opencode - 诊断测试",
36
+ body: "这是一条来自诊断工具的测试通知,如果收到说明渠道配置正确",
37
+ }
38
+
39
+ // ─── main ───────────────────────────────────────────────────────────────────
40
+
41
+ async function main() {
42
+ const args = process.argv.slice(2)
43
+ const cmd = args[0] ?? "help"
44
+
45
+ switch (cmd) {
46
+ case "check":
47
+ cmdCheck()
48
+ break
49
+ case "test":
50
+ await cmdTest(args[1])
51
+ break
52
+ case "log":
53
+ cmdLog(args[1] ? parseInt(args[1], 10) : 20)
54
+ break
55
+ case "info":
56
+ cmdInfo()
57
+ break
58
+ case "help":
59
+ default:
60
+ cmdHelp()
61
+ break
62
+ }
63
+ }
64
+
65
+ // ─── 命令: check ─────────────────────────────────────────────────────────────
66
+
67
+ function cmdCheck() {
68
+ console.log("═".repeat(50))
69
+ console.log(" 🔍 配置检查")
70
+ console.log("═".repeat(50))
71
+
72
+ // 1. 检查配置文件是否存在
73
+ if (!existsSync(YAML_PATH)) {
74
+ console.log(`\n❌ 配置文件不存在: ${YAML_PATH}`)
75
+ console.log(" 请创建配置文件,或设置 OPENCODE_NOTIFY_CONFIG 环境变量指向正确的路径")
76
+ process.exit(1)
77
+ }
78
+ console.log(`\n✅ 配置文件: ${YAML_PATH}`)
79
+
80
+ // 2. 尝试解析 YAML
81
+ let yamlCfg: PluginConfig | null = null
82
+ try {
83
+ yamlCfg = loadYamlConfig()
84
+ console.log("✅ YAML 解析成功")
85
+ } catch (e: any) {
86
+ console.log(`\n❌ YAML 解析失败: ${e.message}`)
87
+ process.exit(1)
88
+ }
89
+ if (!yamlCfg) {
90
+ console.log("\n❌ YAML 解析返回空")
91
+ process.exit(1)
92
+ }
93
+
94
+ // 3. resolveConfig 合并
95
+ const cfg = resolveConfig(mergeConfig(yamlCfg, {}))
96
+
97
+ // 4. 检查渠道配置
98
+ const channels = cfg.channels ?? {}
99
+ let hasError = false
100
+
101
+ for (const [name, ch] of Object.entries(channels)) {
102
+ if (!ch || !ch.enabled) continue
103
+
104
+ switch (name) {
105
+ case "system":
106
+ console.log(`\n✅ 系统通知: 已启用`)
107
+ break
108
+ case "custom_webhook": {
109
+ const c = ch as any
110
+ if (!c.url) {
111
+ console.log(`\n❌ 自定义 Webhook: 已启用但未配置 url`)
112
+ hasError = true
113
+ } else {
114
+ console.log(`\n✅ 自定义 Webhook: url=${c.url}`)
115
+ }
116
+ break
117
+ }
118
+ case "wechat_work": {
119
+ const c = ch as any
120
+ if (!c.webhook_url) {
121
+ console.log(`\n❌ 企业微信: 已启用但未配置 webhook_url`)
122
+ hasError = true
123
+ } else {
124
+ console.log(`\n✅ 企业微信: webhook_url=${c.webhook_url.slice(0, 60)}...`)
125
+ }
126
+ break
127
+ }
128
+ case "feishu": {
129
+ const c = ch as any
130
+ if (!c.webhook_url) {
131
+ console.log(`\n❌ 飞书: 已启用但未配置 webhook_url`)
132
+ hasError = true
133
+ } else {
134
+ console.log(`\n✅ 飞书: webhook_url=${c.webhook_url.slice(0, 60)}...`)
135
+ }
136
+ break
137
+ }
138
+ }
139
+ }
140
+
141
+ // 5. 检查订阅事件
142
+ const events = cfg.events ?? []
143
+ console.log(`\n📋 订阅事件: ${events.length > 0 ? events.join(", ") : "(无)"}`)
144
+
145
+ // 6. 检查活跃抑制
146
+ console.log(`\n🔇 活跃抑制: ${cfg.suppress_when_active ? "开启" : "关闭"} (超时 ${cfg.activity_timeout_ms}ms)`)
147
+
148
+ // 7. 检查去重
149
+ console.log(`\n🔄 去重窗口: ${cfg.dedupe_seconds} 秒`)
150
+
151
+ if (hasError) {
152
+ console.log(`\n⚠️ 检查完成,存在配置错误,请修正后重试`)
153
+ process.exit(1)
154
+ }
155
+ console.log(`\n✅ 配置检查通过!`)
156
+ }
157
+
158
+ // ─── 命令: test ──────────────────────────────────────────────────────────────
159
+
160
+ async function cmdTest(channel?: string) {
161
+ const yamlCfg = loadYamlConfig() ?? {}
162
+ const cfg = resolveConfig(mergeConfig(yamlCfg, {}))
163
+ const channels = cfg.channels ?? {}
164
+ const allChannels: [string, boolean, () => Promise<void>][] = []
165
+
166
+ // 收集要测试的渠道
167
+ function add(name: string, enabled: boolean, sender: () => Promise<void>) {
168
+ allChannels.push([name, enabled, sender])
169
+ }
170
+
171
+ add("system_message", !!(channels.system_message?.enabled), async () => {
172
+ await new SystemSender().send(SAMPLE_MSG)
173
+ })
174
+
175
+ if (channels.custom_webhook?.enabled && channels.custom_webhook.url) {
176
+ add("custom_webhook", true, async () => {
177
+ await new CustomWebhookSender(channels.custom_webhook!).send(SAMPLE_MSG)
178
+ })
179
+ }
180
+
181
+ if (channels.wechat_work?.enabled && channels.wechat_work.webhook_url) {
182
+ add("wechat_work", true, async () => {
183
+ await new WechatWorkSender(channels.wechat_work!).send(SAMPLE_MSG)
184
+ })
185
+ }
186
+
187
+ if (channels.feishu?.enabled && channels.feishu.webhook_url) {
188
+ add("feishu", true, async () => {
189
+ await new FeishuSender(channels.feishu!).send(SAMPLE_MSG)
190
+ })
191
+ }
192
+
193
+ // 过滤
194
+ const targets = channel
195
+ ? allChannels.filter(([n]) => n === channel)
196
+ : allChannels
197
+
198
+ if (targets.length === 0) {
199
+ console.log(`没有匹配的渠道。${channel ? `渠道 "${channel}" 不存在或未启用。` : "请在配置文件中启用至少一个渠道。"}`)
200
+ console.log("可用渠道: system, custom_webhook, wechat_work, feishu")
201
+ process.exit(1)
202
+ }
203
+
204
+ console.log(`开始测试 ${targets.length} 个渠道...\n`)
205
+
206
+ for (const [name, enabled, send] of targets) {
207
+ process.stdout.write(` ${enabled ? "▶" : "⏭"} ${name} ... `)
208
+ try {
209
+ await send()
210
+ console.log("✅ 成功")
211
+ } catch (e: any) {
212
+ console.log(`❌ 失败: ${e.message}`)
213
+ }
214
+ }
215
+
216
+ console.log("\n测试完成。")
217
+ }
218
+
219
+ // ─── 命令: log ───────────────────────────────────────────────────────────────
220
+
221
+ function cmdLog(lines: number) {
222
+ if (!existsSync(LOG_FILE)) {
223
+ console.log(`日志文件不存在: ${LOG_FILE}`)
224
+ console.log("插件尚未运行过,或日志已被清理。")
225
+ process.exit(1)
226
+ }
227
+
228
+ const stat = statSync(LOG_FILE)
229
+ const all = readFileSync(LOG_FILE, "utf-8").trimEnd()
230
+ const entries = all.split("\n")
231
+
232
+ console.log(`📄 ${LOG_FILE} (${(stat.size / 1024).toFixed(1)} KB, ${entries.length} 行)\n`)
233
+
234
+ const tail = entries.slice(-lines)
235
+ for (const line of tail) {
236
+ // 着色: 错误行标红,成功行标绿
237
+ if (line.includes("失败") || line.includes("error") || line.includes("Error")) {
238
+ console.log(` ${line}`)
239
+ } else if (line.includes("成功") || line.includes("✅")) {
240
+ console.log(` ${line}`)
241
+ } else {
242
+ console.log(` ${line}`)
243
+ }
244
+ }
245
+
246
+ if (entries.length > lines) {
247
+ console.log(`\n... 共 ${entries.length} 行,显示最后 ${lines} 行`)
248
+ }
249
+ }
250
+
251
+ // ─── 命令: info ──────────────────────────────────────────────────────────────
252
+
253
+ function cmdInfo() {
254
+ console.log("═".repeat(50))
255
+ console.log(" 📋 opencode-notify 插件信息")
256
+ console.log("═".repeat(50))
257
+
258
+ // 版本
259
+ console.log(`\n 📦 版本: ${PKG.version}`)
260
+ console.log(` 📂 项目路径: ${import.meta.dirname}`)
261
+
262
+ // 配置文件
263
+ console.log(`\n 📄 配置文件:`)
264
+ if (existsSync(YAML_PATH)) {
265
+ const stat = statSync(YAML_PATH)
266
+ console.log(` 路径: ${YAML_PATH}`)
267
+ console.log(` 大小: ${(stat.size / 1024).toFixed(1)} KB`)
268
+ } else {
269
+ console.log(` 路径: ${YAML_PATH} (不存在,使用默认配置)`)
270
+ }
271
+
272
+ // 日志文件
273
+ console.log(`\n 📝 日志文件:`)
274
+ if (existsSync(LOG_FILE)) {
275
+ const stat = statSync(LOG_FILE)
276
+ const lines = readFileSync(LOG_FILE, "utf-8").split("\n").length
277
+ const recent = readFileSync(LOG_FILE, "utf-8").trimEnd().split("\n").slice(-1)[0] ?? ""
278
+ console.log(` 路径: ${LOG_FILE}`)
279
+ console.log(` 大小: ${(stat.size / 1024).toFixed(1)} KB, ${lines} 行`)
280
+ console.log(` 最新: ${recent}`)
281
+ } else {
282
+ console.log(` 路径: ${LOG_FILE} (暂无日志)`)
283
+ }
284
+
285
+ // 配置详情
286
+ const yamlCfg = loadYamlConfig()
287
+ const cfg = yamlCfg ? resolveConfig(mergeConfig(yamlCfg, {})) : null
288
+
289
+ if (cfg) {
290
+ const ch = cfg.channels ?? {}
291
+
292
+ console.log(`\n 🔔 通知渠道:`)
293
+ const channelNames: [string, any, string][] = [
294
+ ["系统消息", ch.system_message, ""],
295
+ ["屏幕跑马灯", ch.screen_flash, ch.screen_flash?.enabled ? `强度${ch.screen_flash.intensity ?? 0.9}` : ""],
296
+ ["自定义 Webhook", ch.custom_webhook, ch.custom_webhook?.url ?? ""],
297
+ ["企业微信", ch.wechat_work, ch.wechat_work?.webhook_url ? `${ch.wechat_work.webhook_url.slice(0, 40)}...` : ""],
298
+ ["飞书", ch.feishu, ch.feishu?.webhook_url ? `${ch.feishu.webhook_url.slice(0, 40)}...` : ""],
299
+ ]
300
+ for (const [label, config, url] of channelNames) {
301
+ if (config?.enabled) {
302
+ const urlInfo = url ? ` ${url}` : ""
303
+ console.log(` ✅ ${label}${urlInfo}`)
304
+ } else {
305
+ console.log(` ⏸ ${label} (未启用)`)
306
+ }
307
+ }
308
+
309
+ console.log(`\n 📋 订阅事件: ${cfg.events?.join(", ") ?? "(无)"}`)
310
+ console.log(` 🔇 活跃抑制: ${cfg.suppress_when_active ? "开启" : "关闭"}`)
311
+ console.log(` ⏱ 去重窗口: ${cfg.dedupe_seconds} 秒`)
312
+ } else {
313
+ console.log(`\n ⚠️ 未加载到配置`)
314
+ }
315
+
316
+ console.log(`\n 🔧 诊断命令:`)
317
+ console.log(` bun cli.ts check 检查配置`)
318
+ console.log(` bun cli.ts test 测试所有渠道`)
319
+ console.log(` bun cli.ts log 查看日志`)
320
+ }
321
+
322
+ // ─── 命令: help ──────────────────────────────────────────────────────────────
323
+
324
+ function cmdHelp() {
325
+ console.log("opencode-notify 诊断工具")
326
+ console.log("")
327
+ console.log("用法:")
328
+ console.log(" bun cli.ts check 检查配置文件")
329
+ console.log(" bun cli.ts test 测试所有通知渠道")
330
+ console.log(" bun cli.ts test <channel> 测试指定渠道")
331
+ console.log(" bun cli.ts log [lines] 查看插件日志 (默认 20 行)")
332
+ console.log(" bun cli.ts info 显示插件综合信息")
333
+ console.log(" bun cli.ts help 显示此帮助")
334
+ console.log("")
335
+ console.log("渠道名称:")
336
+ console.log(" system 系统通知")
337
+ console.log(" wechat_work 企业微信")
338
+ console.log(" feishu 飞书")
339
+ console.log(" custom_webhook 自定义 Webhook")
340
+ console.log("")
341
+ console.log("示例:")
342
+ console.log(" bun cli.ts check")
343
+ console.log(" bun cli.ts test wechat_work")
344
+ console.log(" bun cli.ts test feishu")
345
+ console.log(" bun cli.ts log 50")
346
+ console.log(" bun cli.ts info")
347
+ }
348
+
349
+ // ─── 启动 ────────────────────────────────────────────────────────────────────
350
+
351
+ main().catch((e) => {
352
+ console.error("诊断工具执行出错:", e.message)
353
+ process.exit(1)
354
+ })
package/config.ts ADDED
@@ -0,0 +1,217 @@
1
+ /** 通知渠道配置 */
2
+ export interface ChannelConfig {
3
+ enabled: boolean
4
+ /**
5
+ * 渠道级事件过滤 — 仅这些事件触发本渠道通知
6
+ * 不填或留空则继承全局 events
7
+ */
8
+ events?: string[]
9
+ }
10
+
11
+ /** 系统消息通知配置 */
12
+ export interface SystemMessageChannelConfig extends ChannelConfig {}
13
+
14
+ /** 屏幕跑马灯渠道配置 */
15
+ export interface ScreenFlashChannelConfig extends ChannelConfig {
16
+ /** 持续秒数,默认 3.0 */
17
+ duration?: number
18
+ /** 移动速度因子,默认 4.0 */
19
+ speed?: number
20
+ /** 不透明度 0.0–1.0,默认 0.9 */
21
+ intensity?: number
22
+ }
23
+
24
+ /** 企业微信通知配置 */
25
+ export interface WechatWorkChannelConfig extends ChannelConfig {
26
+ webhook_url?: string
27
+ }
28
+
29
+ /** 飞书通知配置 */
30
+ export interface FeishuChannelConfig extends ChannelConfig {
31
+ webhook_url?: string
32
+ }
33
+
34
+ /** 自定义 Webhook 配置 */
35
+ export interface CustomWebhookChannelConfig extends ChannelConfig {
36
+ url?: string
37
+ method?: "POST" | "GET"
38
+ headers?: Record<string, string>
39
+ /** 消息模板,支持占位符 {{title}} {{body}} {{event}} */
40
+ template?: string
41
+ }
42
+
43
+ /** 渠道配置集合 */
44
+ export interface ChannelsConfig {
45
+ system_message?: SystemMessageChannelConfig
46
+ screen_flash?: ScreenFlashChannelConfig
47
+ wechat_work?: WechatWorkChannelConfig
48
+ feishu?: FeishuChannelConfig
49
+ custom_webhook?: CustomWebhookChannelConfig
50
+ }
51
+
52
+ /** 插件配置 */
53
+ export interface PluginConfig {
54
+ channels?: ChannelsConfig
55
+ /**
56
+ * 需要通知的事件列表
57
+ * 可选值:
58
+ * permission_required - Agent 需要用户授权(执行命令、读写文件等)
59
+ * input_required - Agent 等待用户输入
60
+ * run_completed - 任务执行完成(技术预留,暂未实现)
61
+ * run_failed - 任务执行失败
62
+ */
63
+ events?: string[]
64
+ /** 去重时间窗口(秒),默认 60 */
65
+ dedupe_seconds?: number
66
+ /**
67
+ * 当用户活跃(正在操作 TUI)时抑制通知
68
+ * 检测的事件:message.updated / permission.replied / question.replied / command.executed / tui.command.execute
69
+ * @default true
70
+ */
71
+ suppress_when_active?: boolean
72
+ /** 用户活跃超时时间(毫秒),超过此时间无操作视为离开 */
73
+ activity_timeout_ms?: number
74
+ /** 写入 ~/.opencode-notify/plugin.log 调试日志,默认 false */
75
+ debug_log?: boolean
76
+ }
77
+
78
+ import { readFileSync, existsSync } from "node:fs"
79
+ import { homedir } from "node:os"
80
+ import { join } from "node:path"
81
+ import yaml from "js-yaml"
82
+
83
+ /**
84
+ * 加载 YAML 配置文件
85
+ *
86
+ * 文件路径优先级:
87
+ * 1. OPENCODE_NOTIFY_CONFIG 环境变量
88
+ * 2. ~/.config/opencode/opencode-notify.yaml
89
+ *
90
+ * 文件不存在时返回 null,插件使用默认配置 + plugin options
91
+ */
92
+ export function loadYamlConfig(): PluginConfig | null {
93
+ const configPath =
94
+ process.env.OPENCODE_NOTIFY_CONFIG ??
95
+ join(homedir(), ".config", "opencode", "opencode-notify.yaml")
96
+
97
+ if (!existsSync(configPath)) return null
98
+
99
+ const raw = readFileSync(configPath, "utf-8")
100
+ return yaml.load(raw) as PluginConfig
101
+ }
102
+
103
+ /**
104
+ * 浅层合并配置:overrides 覆盖 base 的对应字段
105
+ * 用于 YAML 配置 + plugin options 的合并
106
+ */
107
+ export function mergeConfig(base: PluginConfig, overrides: PluginConfig): PluginConfig {
108
+ return {
109
+ ...base,
110
+ ...overrides,
111
+ channels: {
112
+ ...(base.channels ?? {}),
113
+ ...(overrides.channels ?? {}),
114
+ system_message: { ...(base.channels?.system_message ?? {}), ...(overrides.channels?.system_message ?? {}) } as any,
115
+ screen_flash: { ...(base.channels?.screen_flash ?? {}), ...(overrides.channels?.screen_flash ?? {}) } as any,
116
+ wechat_work: { ...(base.channels?.wechat_work ?? {}), ...(overrides.channels?.wechat_work ?? {}) } as any,
117
+ feishu: { ...(base.channels?.feishu ?? {}), ...(overrides.channels?.feishu ?? {}) } as any,
118
+ custom_webhook: { ...(base.channels?.custom_webhook ?? {}), ...(overrides.channels?.custom_webhook ?? {}) } as any,
119
+ },
120
+ }
121
+ }
122
+
123
+ /** 默认配置 */
124
+ const DEFAULT_CONFIG: Required<Pick<PluginConfig, "suppress_when_active" | "activity_timeout_ms" | "debug_log">> & PluginConfig = {
125
+ channels: {
126
+ system_message: { enabled: true },
127
+ screen_flash: { enabled: false },
128
+ wechat_work: { enabled: false },
129
+ feishu: { enabled: false },
130
+ custom_webhook: { enabled: false },
131
+ },
132
+ events: [
133
+ "permission_required",
134
+ "input_required",
135
+ "run_completed",
136
+ "run_failed",
137
+ ],
138
+ dedupe_seconds: 60,
139
+ suppress_when_active: true,
140
+ activity_timeout_ms: 30_000,
141
+ debug_log: false,
142
+ }
143
+
144
+ /**
145
+ * 合并配置:options → 环境变量 → 默认值
146
+ */
147
+ 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
+ // 全局 events,各渠道继承此值
161
+ const globalEvents = options.events ?? DEFAULT_CONFIG.events
162
+
163
+ // 渠道级 events:有则用渠道的,否则继承全局
164
+ function chEvents(ch: ChannelConfig | undefined): string[] | undefined {
165
+ return ch?.events?.length ? ch.events : undefined
166
+ }
167
+
168
+ return {
169
+ channels: {
170
+ system_message: {
171
+ enabled:
172
+ options.channels?.system_message?.enabled ??
173
+ DEFAULT_CONFIG.channels!.system_message!.enabled,
174
+ events: chEvents(options.channels?.system_message),
175
+ },
176
+ screen_flash: {
177
+ enabled:
178
+ options.channels?.screen_flash?.enabled ??
179
+ DEFAULT_CONFIG.channels!.screen_flash!.enabled,
180
+ duration: options.channels?.screen_flash?.duration,
181
+ speed: options.channels?.screen_flash?.speed,
182
+ intensity: options.channels?.screen_flash?.intensity,
183
+ events: chEvents(options.channels?.screen_flash),
184
+ },
185
+ wechat_work: {
186
+ enabled:
187
+ options.channels?.wechat_work?.enabled ??
188
+ DEFAULT_CONFIG.channels!.wechat_work!.enabled,
189
+ webhook_url: wechatWebhook || undefined,
190
+ events: chEvents(options.channels?.wechat_work),
191
+ },
192
+ feishu: {
193
+ enabled:
194
+ options.channels?.feishu?.enabled ??
195
+ DEFAULT_CONFIG.channels!.feishu!.enabled,
196
+ webhook_url: feishuWebhook || undefined,
197
+ events: chEvents(options.channels?.feishu),
198
+ },
199
+ custom_webhook: {
200
+ enabled:
201
+ options.channels?.custom_webhook?.enabled ??
202
+ DEFAULT_CONFIG.channels?.custom_webhook?.enabled ??
203
+ false,
204
+ url: customWebhookUrl || undefined,
205
+ method: options.channels?.custom_webhook?.method ?? "POST",
206
+ headers: options.channels?.custom_webhook?.headers,
207
+ template: options.channels?.custom_webhook?.template,
208
+ events: chEvents(options.channels?.custom_webhook),
209
+ },
210
+ },
211
+ events: globalEvents,
212
+ dedupe_seconds: options.dedupe_seconds ?? DEFAULT_CONFIG.dedupe_seconds,
213
+ suppress_when_active: options.suppress_when_active ?? DEFAULT_CONFIG.suppress_when_active,
214
+ activity_timeout_ms: options.activity_timeout_ms ?? DEFAULT_CONFIG.activity_timeout_ms,
215
+ debug_log: options.debug_log ?? DEFAULT_CONFIG.debug_log,
216
+ }
217
+ }
package/dispatcher.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { Message } from "./message.js"
2
+ import type { Sender } from "./senders/types.js"
3
+ import { FileStore } from "./store.js"
4
+
5
+ /**
6
+ * 通知分发器
7
+ *
8
+ * 职责:
9
+ * 1. 去重检查(通过 Store)
10
+ * 2. 遍历所有已启用的 Sender 发送通知
11
+ * 3. 发送成功后标记状态
12
+ */
13
+ export class Dispatcher {
14
+ private store: FileStore
15
+ private windowSec: number
16
+ private senders: Sender[]
17
+
18
+ constructor(
19
+ store: FileStore,
20
+ windowSec: number,
21
+ senders: Sender[],
22
+ ) {
23
+ this.store = store
24
+ this.windowSec = windowSec
25
+ this.senders = senders
26
+ }
27
+
28
+ async dispatch(msg: Message): Promise<void> {
29
+ if (this.senders.length === 0) {
30
+ return
31
+ }
32
+
33
+ const now = Date.now()
34
+ const key = this.store.buildKey(msg.agent, msg.event, msg.sessionID)
35
+
36
+ // 去重检查 + 预留发送时隙
37
+ const allowed = this.store.reserveSend(key, this.windowSec, now)
38
+ if (!allowed) {
39
+ return // 去重命中,跳过
40
+ }
41
+
42
+ // 并发发送到所有渠道
43
+ const results = await Promise.allSettled(
44
+ this.senders.map((s) => s.send(msg)),
45
+ )
46
+
47
+ // 所有都成功 → 标记已发送
48
+ const allSuccess = results.every(
49
+ (r) => r.status === "fulfilled",
50
+ )
51
+
52
+ if (allSuccess) {
53
+ this.store.markSent(key, now)
54
+ } else {
55
+ // 有失败 → 释放预留
56
+ this.store.clearReservation(key)
57
+ // 记录失败(但不抛异常避免影响主流程)
58
+ for (let i = 0; i < results.length; i++) {
59
+ const r = results[i]
60
+ if (r.status === "rejected") {
61
+ console.error(
62
+ `[opencode-notify] sender ${this.senders[i].name} failed:`,
63
+ r.reason,
64
+ )
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }