@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/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
|
+
}
|