@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/events.ts ADDED
@@ -0,0 +1,110 @@
1
+ // Event 类型来自 @opencode-ai/sdk/gen/types.gen.d.ts
2
+ // 但通过 event hook 收到时是 { type: string; properties: any }
3
+ // 这里直接使用 any 避免对 SDK 内部类型的依赖
4
+ import type { Message } from "./message.js"
5
+ import { formatTitle, defaultBody } from "./message.js"
6
+
7
+ /**
8
+ * 将 opencode event hook 收到的 Event 映射为内部通知 Message
9
+ *
10
+ * 实际事件类型 (from @opencode-ai/sdk gen types):
11
+ * - "permission.updated" → 新权限请求
12
+ * - "permission.replied" → 权限已回复
13
+ * - "session.idle" → 会话空闲(等待输入)
14
+ * - "session.status" → 会话状态变化(含 idle/busy/retry)
15
+ * - "session.error" → 会话错误
16
+ * - "session.created" → 新会话
17
+ *
18
+ * @returns Message | null — 不关心的事件返回 null
19
+ */
20
+ export function route(
21
+ event: { type: string; properties: Record<string, any> },
22
+ enabledEvents?: string[],
23
+ ): Message | null {
24
+ const enabled = new Set(enabledEvents ?? [])
25
+ const { type, properties } = event
26
+
27
+ // question.asked — 通用问题询问(权限/确认等均走此事件)
28
+ // properties 结构待实测确认
29
+ if (type === "question.asked" && enabled.has("permission_required")) {
30
+ const text = properties.text ?? properties.message ?? ""
31
+ return {
32
+ agent: "opencode",
33
+ event: "permission_required",
34
+ sessionID: properties.sessionID ?? "unknown",
35
+ title: formatTitle("permission_required"),
36
+ body: text ? `需要确认: ${truncate(text, 200)}` : defaultBody("permission_required"),
37
+ }
38
+ }
39
+
40
+ // permission.asked — 工具权限请求
41
+ if (type === "permission.asked" && enabled.has("permission_required")) {
42
+ // properties: { id, sessionID, permission, patterns, metadata, always, tool }
43
+ // tool 可能是对象 { name, ... } 或字符串
44
+ const toolName = typeof properties.tool === "object" && properties.tool
45
+ ? (properties.tool.name ?? properties.tool.type ?? "")
46
+ : (properties.tool ?? "")
47
+ const permission = properties.permission ?? ""
48
+ const desc = [toolName, permission].filter(Boolean).join(" - ")
49
+ return {
50
+ agent: "opencode",
51
+ event: "permission_required",
52
+ sessionID: properties.sessionID ?? "unknown",
53
+ title: formatTitle("permission_required"),
54
+ body: desc
55
+ ? `操作「${desc}」需要您的授权许可`
56
+ : defaultBody("permission_required"),
57
+ }
58
+ }
59
+
60
+ // 会话错误 → run_failed
61
+ if (type === "session.error" && enabled.has("run_failed")) {
62
+ const err = properties.error
63
+ const errMsg = err?.message ?? err?.name ?? defaultBody("run_failed")
64
+ return {
65
+ agent: "opencode",
66
+ event: "run_failed",
67
+ sessionID: properties.sessionID ?? "unknown",
68
+ title: formatTitle("run_failed"),
69
+ body: `错误: ${truncate(String(errMsg), 200)}`,
70
+ }
71
+ }
72
+
73
+ // 会话空闲 → input_required
74
+ if (type === "session.idle" && enabled.has("input_required")) {
75
+ return {
76
+ agent: "opencode",
77
+ event: "input_required",
78
+ sessionID: properties.sessionID ?? "unknown",
79
+ title: formatTitle("input_required"),
80
+ body: defaultBody("input_required"),
81
+ }
82
+ }
83
+
84
+ // session.status 也可能包含 idle 状态
85
+ if (type === "session.status" && enabled.has("input_required")) {
86
+ const status = properties.status
87
+ if (status?.type === "idle") {
88
+ return {
89
+ agent: "opencode",
90
+ event: "input_required",
91
+ sessionID: properties.sessionID ?? "unknown",
92
+ title: formatTitle("input_required"),
93
+ body: defaultBody("input_required"),
94
+ }
95
+ }
96
+ }
97
+
98
+ // 新会话创建 → 可跟踪任务开始
99
+ // 当前暂不直接通知,留作 run_completed 检测的基础
100
+ if (type === "session.created") {
101
+ // 预留: 可在这里记录会话开始时间,用于后续推断任务完成
102
+ return null
103
+ }
104
+
105
+ return null
106
+ }
107
+
108
+ function truncate(s: string, maxLen: number): string {
109
+ return s.length > maxLen ? s.slice(0, maxLen - 3) + "..." : s
110
+ }
package/index.ts ADDED
@@ -0,0 +1,137 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import type { PluginConfig } from "./config.js"
3
+ import { resolveConfig, loadYamlConfig, mergeConfig } from "./config.js"
4
+ import { route } from "./events.js"
5
+ import { Dispatcher } from "./dispatcher.js"
6
+ import { FileStore } from "./store.js"
7
+ import { SystemSender } from "./senders/system.js"
8
+ import { ScreenFlashSender } from "./senders/screen-flash.js"
9
+ import { CustomWebhookSender } from "./senders/custom-webhook.js"
10
+ import { WechatWorkSender } from "./senders/wechat-work.js"
11
+ import { FeishuSender } from "./senders/feishu.js"
12
+ import { FilteredSender } from "./senders/types.js"
13
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs"
14
+ import { homedir } from "node:os"
15
+ import { join, dirname } from "node:path"
16
+ import { fileURLToPath } from "node:url"
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const MARQUEE_SCRIPT = join(__dirname, "scripts", "marquee.py")
20
+
21
+ const LOG_DIR = join(homedir(), ".opencode-notify")
22
+ const LOG_FILE = join(LOG_DIR, "plugin.log")
23
+
24
+ let debugEnabled = false
25
+
26
+ function enableDebug() {
27
+ debugEnabled = true
28
+ }
29
+
30
+ function log(msg: string) {
31
+ if (!debugEnabled) return
32
+ try {
33
+ if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
34
+ writeFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`, { flag: "a" })
35
+ } catch {
36
+ // 日志写入失败不影响主流程
37
+ }
38
+ }
39
+
40
+ // 用户活跃事件类型
41
+ const USER_ACTIVITY_EVENTS = new Set([
42
+ "message.updated",
43
+ "permission.replied",
44
+ "question.replied",
45
+ "command.executed",
46
+ "tui.command.execute",
47
+ ])
48
+
49
+ const plugin: Plugin = async (_input, options) => {
50
+ // 加载 YAML 配置 + 合并 plugin options
51
+ const yamlCfg = loadYamlConfig() ?? {}
52
+ const merged = mergeConfig(yamlCfg, options as PluginConfig ?? {})
53
+ const cfg = resolveConfig(merged)
54
+ if (cfg.debug_log) enableDebug()
55
+ const store = new FileStore()
56
+ const senders = buildSenders(cfg)
57
+ const dispatcher = new Dispatcher(store, cfg.dedupe_seconds ?? 60, senders)
58
+ // 用户活跃追踪
59
+ let lastActivity = Date.now()
60
+ const suppressActive = cfg.suppress_when_active ?? true
61
+ const activityTimeout = cfg.activity_timeout_ms ?? 30_000
62
+
63
+ log(`插件已加载, debug_log=${cfg.debug_log}, events=${JSON.stringify(cfg.events)}, suppressActive=${suppressActive}, timeout=${activityTimeout}ms`)
64
+
65
+ return {
66
+ // event 总线 — 所有事件通过此钩子
67
+ event: async ({ event }) => {
68
+ const { type, properties } = event as any
69
+ const propKeys = properties ? Object.keys(properties).join(",") : ""
70
+
71
+ // 调试日志:记录所有事件(随时可关闭)
72
+ log(`[event] type=${type} keys=${propKeys}`)
73
+
74
+ // 用户活跃事件追踪
75
+ if (USER_ACTIVITY_EVENTS.has(type)) {
76
+ lastActivity = Date.now()
77
+ log(`→ 用户活跃事件, 重置活跃时间`)
78
+ }
79
+
80
+ // 活跃抑制检查
81
+ if (suppressActive) {
82
+ const idleMs = Date.now() - lastActivity
83
+ if (idleMs < activityTimeout) {
84
+ // 用户活跃中,跳过通知
85
+ return
86
+ }
87
+ }
88
+
89
+ const msg = route(event, cfg.events)
90
+ if (msg) {
91
+ log(`→ 匹配通知: ${msg.event}`)
92
+ await dispatcher.dispatch(msg)
93
+ }
94
+ },
95
+ }
96
+ }
97
+
98
+ function addSender(
99
+ senders: import("./senders/types.js").Sender[],
100
+ sender: import("./senders/types.js").Sender,
101
+ events: string[],
102
+ label: string,
103
+ extra?: string,
104
+ ) {
105
+ senders.push(new FilteredSender(sender, events))
106
+ const evt = events.length < 6 ? `events=${JSON.stringify(events)}` : `events=${events.length}个`
107
+ log(`${label}已启用 (${evt})${extra ? `, ${extra}` : ""}`)
108
+ }
109
+
110
+ function buildSenders(cfg: PluginConfig) {
111
+ const senders: import("./senders/types.js").Sender[] = []
112
+ const globalEvents = cfg.events ?? []
113
+
114
+ if (cfg.channels?.system_message?.enabled) {
115
+ const events = cfg.channels.system_message.events ?? globalEvents
116
+ addSender(senders, new SystemSender(), events, "系统通知")
117
+ }
118
+ if (cfg.channels?.screen_flash?.enabled) {
119
+ const events = cfg.channels.screen_flash.events ?? globalEvents
120
+ addSender(senders, new ScreenFlashSender(cfg.channels.screen_flash), events, "屏幕跑马灯")
121
+ }
122
+ if (cfg.channels?.custom_webhook?.enabled && cfg.channels.custom_webhook.url) {
123
+ const events = cfg.channels.custom_webhook.events ?? globalEvents
124
+ addSender(senders, new CustomWebhookSender(cfg.channels.custom_webhook), events, "自定义 Webhook")
125
+ }
126
+ if (cfg.channels?.wechat_work?.enabled && cfg.channels.wechat_work.webhook_url) {
127
+ const events = cfg.channels.wechat_work.events ?? globalEvents
128
+ addSender(senders, new WechatWorkSender(cfg.channels.wechat_work), events, "企业微信")
129
+ }
130
+ if (cfg.channels?.feishu?.enabled && cfg.channels.feishu.webhook_url) {
131
+ const events = cfg.channels.feishu.events ?? globalEvents
132
+ addSender(senders, new FeishuSender(cfg.channels.feishu), events, "飞书")
133
+ }
134
+ return senders
135
+ }
136
+
137
+ export default plugin
package/message.ts ADDED
@@ -0,0 +1,50 @@
1
+ /** 内部通知消息 */
2
+ export interface Message {
3
+ /** 触发事件的 agent 名称 */
4
+ agent: string
5
+ /** 通知事件类型 */
6
+ event: string
7
+ /** 会话 ID,用于去重 */
8
+ sessionID: string
9
+ /** 通知标题 */
10
+ title: string
11
+ /** 通知正文 */
12
+ body: string
13
+ /** 工作目录 */
14
+ workspace?: string
15
+ }
16
+
17
+ /** 事件中文标签映射 */
18
+ const EVENT_LABELS: Record<string, string> = {
19
+ permission_required: "需要授权",
20
+ input_required: "等待输入",
21
+ run_completed: "任务完成",
22
+ run_failed: "任务失败",
23
+ session_idle: "会话空闲",
24
+ }
25
+
26
+ /**
27
+ * 格式化通知标题
28
+ */
29
+ export function formatTitle(event: string): string {
30
+ const label = EVENT_LABELS[event] ?? event
31
+ return `opencode - ${label}`
32
+ }
33
+
34
+ /**
35
+ * 创建默认通知正文
36
+ */
37
+ export function defaultBody(event: string): string {
38
+ switch (event) {
39
+ case "permission_required":
40
+ return "Agent 需要您的授权许可"
41
+ case "input_required":
42
+ return "Agent 正在等待您的输入"
43
+ case "run_completed":
44
+ return "任务执行完成"
45
+ case "run_failed":
46
+ return "任务执行失败"
47
+ default:
48
+ return `事件: ${event}`
49
+ }
50
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@freely01/opencode-notify",
3
+ "version": "0.1.0",
4
+ "description": "opencode 通知插件 - 监听会话事件并通过多渠道推送通知",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "exports": {
8
+ ".": "./index.ts",
9
+ "./cli": "./cli.ts"
10
+ },
11
+ "bin": {
12
+ "opencode-notify": "cli.ts"
13
+ },
14
+ "files": [
15
+ "*.ts",
16
+ "senders/",
17
+ "scripts/marquee.py",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "keywords": [
22
+ "opencode",
23
+ "notification",
24
+ "plugin",
25
+ "notify"
26
+ ],
27
+ "author": "luyanfeng",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/luyanfeng/opencode-notify.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/luyanfeng/opencode-notify/issues"
35
+ },
36
+ "homepage": "https://github.com/luyanfeng/opencode-notify#readme",
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "devDependencies": {
41
+ "@opencode-ai/plugin": "latest",
42
+ "@types/js-yaml": "^4.0.9",
43
+ "@types/node": "^25.9.1",
44
+ "typescript": "^6.0.3"
45
+ },
46
+ "dependencies": {
47
+ "js-yaml": "^4.1.1"
48
+ }
49
+ }
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 屏幕四边跑马灯效果 — GTK 透明覆盖层
4
+
5
+ 用法:
6
+ python3 marquee.py [duration] [speed] [intensity]
7
+
8
+ 参数:
9
+ duration 持续秒数 (默认 3.0)
10
+ speed 移动速度因子 (默认 4.0)
11
+ intensity 不透明度 0.0–1.0 (默认 0.9)
12
+
13
+ 依赖: Python 3 + PyGObject (Ubuntu GNOME 内置)
14
+ """
15
+
16
+ import gi
17
+ import sys
18
+ import time
19
+ import signal
20
+
21
+ gi.require_version("Gtk", "3.0")
22
+ from gi.repository import Gtk, GLib, Gdk
23
+ import cairo as _cairo
24
+
25
+ # 跑马灯颜色序列:红 → 橙 → 黄 → 绿 → 蓝
26
+ MARQUEE_COLORS = [
27
+ (1.0, 0.2, 0.1),
28
+ (1.0, 0.6, 0.0),
29
+ (1.0, 1.0, 0.0),
30
+ (0.0, 1.0, 0.0),
31
+ (0.0, 0.6, 1.0),
32
+ ]
33
+
34
+
35
+ class MarqueeWindow(Gtk.Window):
36
+ def __init__(self, duration=3.0, speed=4.0, intensity=0.9):
37
+ super().__init__(type=Gtk.WindowType.POPUP)
38
+ self.set_title("opencode-marquee")
39
+ self.set_decorated(False)
40
+ self.set_app_paintable(True)
41
+ self.set_keep_above(True)
42
+ self.set_accept_focus(False)
43
+ self.set_skip_taskbar_hint(True)
44
+ # 覆盖所有工作区
45
+ self.stick()
46
+
47
+ # 获取主显示器尺寸
48
+ screen = Gdk.Screen.get_default()
49
+ display = screen.get_display()
50
+ monitor = display.get_primary_monitor() if display else screen.get_monitor(0)
51
+ geo = monitor.get_geometry()
52
+ self.screen_w = geo.width
53
+ self.screen_h = geo.height
54
+ self.set_default_size(self.screen_w, self.screen_h)
55
+ self.move(geo.x, geo.y)
56
+
57
+ self.border_w = 6 # 边框厚度 (px)
58
+ self.light_w = 28 # 单个灯宽度 (px)
59
+ self.light_gap = 12 # 灯间距 (px)
60
+ self.speed = speed
61
+ self.intensity = min(max(intensity, 0.0), 1.0)
62
+ self.duration = duration
63
+ self.start_time = time.monotonic()
64
+ self.t = 0.0
65
+
66
+ # 设置透明背景
67
+ self._setup_transparency()
68
+
69
+ self.connect("realize", self._on_realize)
70
+ self.connect("draw", self.on_draw)
71
+ self.connect("screen-changed", self.on_screen_changed)
72
+
73
+ # 60fps 动画循环
74
+ GLib.timeout_add(16, self.on_tick)
75
+
76
+ def _setup_transparency(self):
77
+ """配置 RGBA 透明支持"""
78
+ visual = self.get_screen().get_rgba_visual()
79
+ if visual:
80
+ self.set_visual(visual)
81
+ else:
82
+ # 没有 RGBA 支持时回退为整体窗口透明度
83
+ self.set_opacity(self.intensity)
84
+
85
+ def _on_realize(self, widget):
86
+ """窗口实时化后,设置空的输入区域 → 点击穿透"""
87
+ surf = _cairo.ImageSurface(_cairo.Format.A1, 1, 1)
88
+ empty = Gdk.cairo_region_create_from_surface(surf)
89
+ if empty:
90
+ widget.input_shape_combine_region(empty)
91
+
92
+ def on_screen_changed(self, widget, old_screen):
93
+ self._setup_transparency()
94
+
95
+ def on_draw(self, widget, cr):
96
+ w = self.screen_w
97
+ h = self.screen_h
98
+ bw = self.border_w
99
+ t = self.t
100
+ step = self.light_w + self.light_gap
101
+
102
+ # 清空为全透明
103
+ cr.set_source_rgba(0, 0, 0, 0)
104
+ cr.set_operator(_cairo.Operator.SOURCE)
105
+ cr.paint()
106
+ cr.set_operator(_cairo.Operator.OVER)
107
+
108
+ # 构造所有灯光的 (x, y, width, height, side_index)
109
+ lights = []
110
+
111
+ # 上边:从左向右
112
+ offset = (t * self.speed * step) % (w + self.light_w * 2)
113
+ for i in range(0, w + step, step):
114
+ x = (i + offset) % (w + self.light_w * 4) - self.light_w * 2
115
+ lights.append((x, 0, self.light_w, bw, 0))
116
+
117
+ # 右边:从上向下
118
+ offset = (t * self.speed * step) % (h + self.light_w * 2)
119
+ for i in range(0, h + step, step):
120
+ y = (i + offset) % (h + self.light_w * 4) - self.light_w * 2
121
+ lights.append((w - bw, y, bw, self.light_w, 1))
122
+
123
+ # 下边:从右向左
124
+ offset = (t * self.speed * step) % (w + self.light_w * 2)
125
+ for i in range(0, w + step, step):
126
+ x = (w + self.light_w * 4 - (i + offset)) % (w + self.light_w * 4) - self.light_w * 2
127
+ lights.append((x, h - bw, self.light_w, bw, 2))
128
+
129
+ # 左边:从下向上
130
+ offset = (t * self.speed * step) % (h + self.light_w * 2)
131
+ for i in range(0, h + step, step):
132
+ y = (h + self.light_w * 4 - (i + offset)) % (h + self.light_w * 4) - self.light_w * 2
133
+ lights.append((0, y, bw, self.light_w, 3))
134
+
135
+ # 绘制灯光
136
+ nc = len(MARQUEE_COLORS)
137
+ for idx, (lx, ly, lw, lh, side) in enumerate(lights):
138
+ r, g, b = MARQUEE_COLORS[(idx + side) % nc]
139
+ fade = 0.5 + 0.5 * (1.0 - abs(float(idx % 5) / 5.0 - 0.5) * 2)
140
+ cr.set_source_rgba(r, g, b, fade * self.intensity)
141
+ cr.rectangle(lx, ly, lw, lh)
142
+ cr.fill()
143
+
144
+ return True
145
+
146
+ def on_tick(self):
147
+ elapsed = time.monotonic() - self.start_time
148
+ if elapsed >= self.duration:
149
+ Gtk.main_quit()
150
+ return False
151
+ self.t = elapsed
152
+ self.queue_draw()
153
+ return True
154
+
155
+
156
+ def main():
157
+ # 解析命令行参数
158
+ duration = float(sys.argv[1]) if len(sys.argv) > 1 else 3.0
159
+ speed = float(sys.argv[2]) if len(sys.argv) > 2 else 4.0
160
+ intensity = float(sys.argv[3]) if len(sys.argv) > 3 else 0.9
161
+
162
+ # 检查 DISPLAY
163
+ import os
164
+ if not os.environ.get("DISPLAY"):
165
+ print("marquee: DISPLAY 未设置,跳过", file=sys.stderr)
166
+ sys.exit(1)
167
+
168
+ win = MarqueeWindow(duration=duration, speed=speed, intensity=intensity)
169
+ win.show_all()
170
+
171
+ # 优雅退出
172
+ signal.signal(signal.SIGINT, lambda s, f: Gtk.main_quit())
173
+ signal.signal(signal.SIGTERM, lambda s, f: Gtk.main_quit())
174
+
175
+ Gtk.main()
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()
@@ -0,0 +1,87 @@
1
+ import type { Sender } from "./types.js"
2
+ import type { Message } from "../message.js"
3
+ import type { CustomWebhookChannelConfig } from "../config.js"
4
+
5
+ /**
6
+ * 自定义 Webhook 发送器
7
+ *
8
+ * 支持任意 HTTP Webhook 服务,通过模板配置请求体。
9
+ * 模板占位符: {{title}} {{body}} {{event}} {{agent}} {{sessionID}}
10
+ *
11
+ * Gotify 配置示例:
12
+ * ```json
13
+ * {
14
+ * "enabled": true,
15
+ * "url": "https://gotify.example.com/message",
16
+ * "method": "POST",
17
+ * "headers": { "X-Gotify-Key": "YOUR_APP_TOKEN" },
18
+ * "template": "{\"title\":\"{{title}}\",\"message\":\"{{body}}\",\"priority\":5}"
19
+ * }
20
+ * ```
21
+ */
22
+ export class CustomWebhookSender implements Sender {
23
+ readonly name = "custom_webhook"
24
+ private config: CustomWebhookChannelConfig
25
+
26
+ constructor(config: CustomWebhookChannelConfig) {
27
+ this.config = config
28
+ }
29
+
30
+ async send(msg: Message): Promise<void> {
31
+ const { url, method = "POST", headers = {}, template } = this.config
32
+
33
+ if (!url) {
34
+ throw new Error("custom_webhook: url not configured")
35
+ }
36
+
37
+ // 构造请求体
38
+ let body: string | undefined
39
+ if (template) {
40
+ body = this.interpolate(template, msg)
41
+ } else {
42
+ // 默认 JSON 格式
43
+ body = JSON.stringify({
44
+ title: msg.title,
45
+ message: msg.body,
46
+ event: msg.event,
47
+ agent: msg.agent,
48
+ })
49
+ }
50
+
51
+ const response = await fetch(url, {
52
+ method,
53
+ headers: {
54
+ "Content-Type": "application/json",
55
+ ...headers,
56
+ },
57
+ body: method === "POST" ? body : undefined,
58
+ })
59
+
60
+ if (!response.ok) {
61
+ const text = await response.text().catch(() => "")
62
+ throw new Error(
63
+ `custom_webhook returned ${response.status}${text ? `: ${text.slice(0, 500)}` : ""}`,
64
+ )
65
+ }
66
+ }
67
+
68
+ /** 模板插值 */
69
+ private interpolate(tpl: string, msg: Message): string {
70
+ return tpl
71
+ .replace(/\{\{title\}\}/g, this.escapeJson(msg.title))
72
+ .replace(/\{\{body\}\}/g, this.escapeJson(msg.body))
73
+ .replace(/\{\{event\}\}/g, this.escapeJson(msg.event))
74
+ .replace(/\{\{agent\}\}/g, this.escapeJson(msg.agent))
75
+ .replace(/\{\{sessionID\}\}/g, this.escapeJson(msg.sessionID))
76
+ }
77
+
78
+ /** 转义模板值中的特殊字符(防止破坏 JSON) */
79
+ private escapeJson(s: string): string {
80
+ return s
81
+ .replace(/\\/g, "\\\\")
82
+ .replace(/"/g, '\\"')
83
+ .replace(/\n/g, "\\n")
84
+ .replace(/\r/g, "\\r")
85
+ .replace(/\t/g, "\\t")
86
+ }
87
+ }
@@ -0,0 +1,79 @@
1
+ import type { Sender } from "./types.js"
2
+ import type { Message } from "../message.js"
3
+ import type { FeishuChannelConfig } from "../config.js"
4
+
5
+ /**
6
+ * 飞书 自定义机器人 Webhook 发送器
7
+ *
8
+ * 文档: https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
9
+ *
10
+ * 配置示例:
11
+ * ```json
12
+ * {
13
+ * "enabled": true,
14
+ * "webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
15
+ * }
16
+ * ```
17
+ *
18
+ * 消息格式: 卡片消息 (interactive)
19
+ * 包含标题、正文、分割线、脚注
20
+ */
21
+ export class FeishuSender implements Sender {
22
+ readonly name = "feishu"
23
+ private config: FeishuChannelConfig
24
+
25
+ constructor(config: FeishuChannelConfig) {
26
+ this.config = config
27
+ }
28
+
29
+ async send(msg: Message): Promise<void> {
30
+ const { webhook_url } = this.config
31
+
32
+ if (!webhook_url) {
33
+ throw new Error("feishu: webhook_url not configured")
34
+ }
35
+
36
+ const body = JSON.stringify({
37
+ msg_type: "interactive",
38
+ card: {
39
+ header: {
40
+ title: {
41
+ tag: "plain_text",
42
+ content: msg.title,
43
+ },
44
+ },
45
+ elements: [
46
+ {
47
+ tag: "markdown",
48
+ content: msg.body,
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
+ ],
63
+ },
64
+ })
65
+
66
+ const response = await fetch(webhook_url, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body,
70
+ })
71
+
72
+ if (!response.ok) {
73
+ const text = await response.text().catch(() => "")
74
+ throw new Error(
75
+ `feishu returned ${response.status}${text ? `: ${text.slice(0, 500)}` : ""}`,
76
+ )
77
+ }
78
+ }
79
+ }