@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/LICENSE +21 -0
- package/README.md +434 -0
- package/cli.ts +354 -0
- package/config.ts +217 -0
- package/dispatcher.ts +69 -0
- package/events.ts +110 -0
- package/index.ts +137 -0
- package/message.ts +50 -0
- package/package.json +49 -0
- package/scripts/marquee.py +179 -0
- package/senders/custom-webhook.ts +87 -0
- package/senders/feishu.ts +79 -0
- package/senders/screen-flash.ts +42 -0
- package/senders/system.ts +88 -0
- package/senders/types.ts +32 -0
- package/senders/wechat-work.ts +62 -0
- package/store.ts +111 -0
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
|
+
}
|