dsh-hooks 0.2.1
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 +125 -0
- package/README.zh.md +125 -0
- package/bin/dsh-hooks.mjs +312 -0
- package/cordis.patch.yml +7 -0
- package/examples/notify-feishu.mjs +395 -0
- package/lib/config.d.ts +29 -0
- package/lib/config.js +34 -0
- package/lib/context.d.ts +31 -0
- package/lib/context.js +48 -0
- package/lib/events.d.ts +64 -0
- package/lib/events.js +213 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +56 -0
- package/lib/runner.d.ts +29 -0
- package/lib/runner.js +70 -0
- package/lib/types.d.ts +38 -0
- package/lib/types.js +8 -0
- package/package.json +76 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Feishu card notification for dsh-hooks — Card JSON 2.0 with a single
|
|
4
|
+
* markdown body component: colored header, time/dir/session meta lines, and
|
|
5
|
+
* the turn content rendered with full markdown (headings, tables, images).
|
|
6
|
+
* Truncation lengths follow the feishu-notify conventions.
|
|
7
|
+
*
|
|
8
|
+
* Reads the hook context from DSH_HOOK_* environment variables and posts an
|
|
9
|
+
* interactive card through the Feishu app API (im/v1/messages). Works without
|
|
10
|
+
* a group custom bot — any user/chat the app bot can reach is a valid target.
|
|
11
|
+
*
|
|
12
|
+
* Required environment (set in the dsh process environment, NOT in config):
|
|
13
|
+
* DSH_HOOKS_FEISHU_APP_ID Feishu open platform app id (cli_...)
|
|
14
|
+
* DSH_HOOKS_FEISHU_APP_SECRET Feishu open platform app secret
|
|
15
|
+
* DSH_HOOKS_FEISHU_TO Target open_id / chat_id to notify
|
|
16
|
+
*
|
|
17
|
+
* Usage (from a dsh-hooks config):
|
|
18
|
+
* - on: 'turn/end'
|
|
19
|
+
* when: 'completed'
|
|
20
|
+
* run: 'node examples/notify-feishu.mjs'
|
|
21
|
+
* - on: 'approval/asked'
|
|
22
|
+
* run: 'node examples/notify-feishu.mjs --approval'
|
|
23
|
+
*
|
|
24
|
+
* Optional flags:
|
|
25
|
+
* --text send plain text instead of a card
|
|
26
|
+
* --header <color> card header color (default depends on event)
|
|
27
|
+
* --title <title> card title (default depends on event)
|
|
28
|
+
* --note <note> extra meta line at the bottom
|
|
29
|
+
* -q quiet: suppress success output (hooks parse stdout)
|
|
30
|
+
*
|
|
31
|
+
* Card body: `turn/end` shows the turn's final assistant text directly
|
|
32
|
+
* (failure detail first when the turn errored); other events carry their
|
|
33
|
+
* own one-line summaries.
|
|
34
|
+
*
|
|
35
|
+
* Zero npm dependencies: fetch is global in Node 18+.
|
|
36
|
+
* The module exports its helpers for testing; it only executes when invoked
|
|
37
|
+
* directly (node notify-feishu.mjs), not when imported.
|
|
38
|
+
*/
|
|
39
|
+
import { pathToFileURL } from 'node:url'
|
|
40
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
41
|
+
import { homedir } from 'node:os'
|
|
42
|
+
import { join } from 'node:path'
|
|
43
|
+
|
|
44
|
+
/** Default config file written by `dsh-hooks feishu-setup` (feishu-notify parity). */
|
|
45
|
+
export const DEFAULT_CONFIG_PATH = join(homedir(), '.dsh', 'dsh-hooks', 'feishu-config.json')
|
|
46
|
+
|
|
47
|
+
/** Common Feishu API error codes with Chinese hints (feishu-notify parity). */
|
|
48
|
+
const ERROR_HINTS = {
|
|
49
|
+
99991663: '应用缺少发送消息权限,请在开放平台「权限管理」开通 im:message:send_as_bot(以应用身份发消息)并发布新版本',
|
|
50
|
+
99991667: '目标用户不在应用的可用范围内,请在管理后台「应用 → 可用范围」中添加该用户',
|
|
51
|
+
99991668: '应用未发布或被停用,请在开放平台发布应用新版本',
|
|
52
|
+
99991669: '应用凭证无效,请检查 app_id / app_secret',
|
|
53
|
+
10003: 'app_secret 不正确',
|
|
54
|
+
230002: '机器人不在该群聊中,请先把机器人拉入群聊',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Card header colors (feishu-notify parity). */
|
|
58
|
+
const CARD_HEADERS = new Set([
|
|
59
|
+
'blue', 'wathet', 'turquoise', 'green', 'yellow', 'orange',
|
|
60
|
+
'red', 'carmine', 'violet', 'purple', 'indigo', 'grey',
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
let tokenCache = new Map() // appId -> { value, expireAt }; tenant tokens are per-app
|
|
64
|
+
|
|
65
|
+
export function readEnv(env = process.env) {
|
|
66
|
+
return {
|
|
67
|
+
appId: env.DSH_HOOKS_FEISHU_APP_ID,
|
|
68
|
+
appSecret: env.DSH_HOOKS_FEISHU_APP_SECRET,
|
|
69
|
+
to: env.DSH_HOOKS_FEISHU_TO,
|
|
70
|
+
event: env.DSH_HOOK_EVENT ?? '',
|
|
71
|
+
sessionId: env.DSH_HOOK_SESSION_ID ?? '',
|
|
72
|
+
sessionName: env.DSH_HOOK_SESSION_NAME ?? '',
|
|
73
|
+
cwd: env.DSH_HOOK_CWD ?? '',
|
|
74
|
+
turn: env.DSH_HOOK_TURN ?? '',
|
|
75
|
+
reason: env.DSH_HOOK_REASON ?? '',
|
|
76
|
+
tool: env.DSH_HOOK_TOOL ?? '',
|
|
77
|
+
callId: env.DSH_HOOK_CALL_ID ?? '',
|
|
78
|
+
durationMs: env.DSH_HOOK_DURATION_MS ?? '',
|
|
79
|
+
status: env.DSH_HOOK_STATUS ?? '',
|
|
80
|
+
error: env.DSH_HOOK_ERROR ?? '',
|
|
81
|
+
content: env.DSH_HOOK_CONTENT ?? '',
|
|
82
|
+
timestamp: env.DSH_HOOK_TIMESTAMP ?? '',
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Load credentials from the local feishu-config.json (written by
|
|
88
|
+
* `dsh-hooks feishu-setup`) and fill any missing credential fields —
|
|
89
|
+
* environment variables always win. Also picks up `result_max_chars`,
|
|
90
|
+
* the content truncation length (default 300); invalid values fall back.
|
|
91
|
+
* Returns a merged context.
|
|
92
|
+
*/
|
|
93
|
+
export function mergeConfig(ctx, configPath = DEFAULT_CONFIG_PATH) {
|
|
94
|
+
if (!existsSync(configPath)) return ctx
|
|
95
|
+
let file
|
|
96
|
+
try {
|
|
97
|
+
file = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
98
|
+
} catch {
|
|
99
|
+
return ctx
|
|
100
|
+
}
|
|
101
|
+
if (typeof file !== 'object' || file === null) return ctx
|
|
102
|
+
const fromFile =
|
|
103
|
+
Number.isFinite(file.result_max_chars) && file.result_max_chars > 0
|
|
104
|
+
? Math.floor(file.result_max_chars)
|
|
105
|
+
: undefined
|
|
106
|
+
return {
|
|
107
|
+
...ctx,
|
|
108
|
+
appId: ctx.appId || file.app_id || '',
|
|
109
|
+
appSecret: ctx.appSecret || file.app_secret || '',
|
|
110
|
+
to: ctx.to || file.target_id || '',
|
|
111
|
+
// target_type=chat_id targets need a different receive_id_type; the
|
|
112
|
+
// default pipeline posts to open_id, so a chat_id target must be sent
|
|
113
|
+
// with receive_id_type=chat_id. readTargetType surfaces that choice.
|
|
114
|
+
receiveIdType: ctx.to ? 'open_id' : file.target_type === 'chat_id' ? 'chat_id' : 'open_id',
|
|
115
|
+
resultMaxChars: ctx.resultMaxChars ?? fromFile,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Clean and truncate text for the card body: normalize newlines, strip
|
|
121
|
+
* trailing whitespace, collapse blank lines, and cut at a line boundary
|
|
122
|
+
* near the limit with an ellipsis (feishu-notify truncateText parity).
|
|
123
|
+
*/
|
|
124
|
+
export function truncateText(text, max) {
|
|
125
|
+
const t = String(text)
|
|
126
|
+
.replace(/\r\n/g, '\n')
|
|
127
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
128
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
129
|
+
.trim()
|
|
130
|
+
if (!t) return null
|
|
131
|
+
if (t.length <= max) return t
|
|
132
|
+
const cut = t.slice(0, max)
|
|
133
|
+
const lastNl = cut.lastIndexOf('\n')
|
|
134
|
+
return (lastNl > max / 2 ? cut.slice(0, lastNl) : cut) + '…'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `2026/8/13 00:28:12` style timestamps (feishu-notify parity). */
|
|
138
|
+
export function fmtTime(d) {
|
|
139
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
140
|
+
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatDuration(ms) {
|
|
144
|
+
if (!Number.isFinite(ms) || ms < 0) return String(ms)
|
|
145
|
+
const seconds = Math.round(ms / 1000)
|
|
146
|
+
if (seconds < 60) return `${seconds} 秒`
|
|
147
|
+
const minutes = Math.floor(seconds / 60)
|
|
148
|
+
const rest = seconds % 60
|
|
149
|
+
return rest === 0 ? `${minutes} 分钟` : `${minutes} 分 ${rest} 秒`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Per-event card presentation (feishu-notify style: colored header + title). */
|
|
153
|
+
export function eventPresentation(ctx) {
|
|
154
|
+
const { event, reason } = ctx
|
|
155
|
+
switch (event) {
|
|
156
|
+
case 'turn/end':
|
|
157
|
+
if (reason === 'completed') return { header: 'green', title: '✅ 任务已完成' }
|
|
158
|
+
if (reason === 'aborted' || reason === 'interrupted' || reason === 'blocked') {
|
|
159
|
+
return { header: 'orange', title: '⏸ 任务中断' }
|
|
160
|
+
}
|
|
161
|
+
return { header: 'red', title: '❌ 任务失败' }
|
|
162
|
+
case 'approval/asked':
|
|
163
|
+
return { header: 'orange', title: '⏳ 需要审批' }
|
|
164
|
+
case 'agent/error':
|
|
165
|
+
return { header: 'red', title: '⚠️ Agent 出错' }
|
|
166
|
+
case 'agent/status':
|
|
167
|
+
return { header: 'blue', title: '🤖 Agent 状态' }
|
|
168
|
+
case 'turn/start':
|
|
169
|
+
return { header: 'blue', title: '▶️ 任务开始' }
|
|
170
|
+
case 'agent/created':
|
|
171
|
+
return { header: 'green', title: '✨ 会话已创建' }
|
|
172
|
+
case 'agent/disposed':
|
|
173
|
+
return { header: 'grey', title: '🏁 会话已结束' }
|
|
174
|
+
default:
|
|
175
|
+
return { header: 'blue', title: '🔔 DSH 通知' }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Body text for the event, respecting feishu-notify truncation lengths. */
|
|
180
|
+
export function buildBody(ctx) {
|
|
181
|
+
const { event, reason, tool, error, status, sessionId, content, turn } = ctx
|
|
182
|
+
const resultMaxChars = ctx.resultMaxChars ?? 300
|
|
183
|
+
const lines = []
|
|
184
|
+
if (event === 'turn/end') {
|
|
185
|
+
// The turn's own words are the story: show the final assistant text
|
|
186
|
+
// directly, with the failure detail first when the turn errored.
|
|
187
|
+
if (error) lines.push(`详情:${truncateText(error, 200) ?? error}`)
|
|
188
|
+
if (content) lines.push(truncateText(content, resultMaxChars) ?? content)
|
|
189
|
+
} else if (event === 'approval/asked') {
|
|
190
|
+
lines.push('有一个操作等你批准')
|
|
191
|
+
if (tool) lines.push(`工具:${tool}`)
|
|
192
|
+
if (reason) lines.push(`原因:${truncateText(reason, 200) ?? reason}`)
|
|
193
|
+
} else if (event === 'agent/error') {
|
|
194
|
+
lines.push('Agent 循环报告了错误')
|
|
195
|
+
if (error) lines.push(`详情:${truncateText(error, 200) ?? error}`)
|
|
196
|
+
} else if (event === 'agent/status') {
|
|
197
|
+
lines.push(`状态:${status || '未知'}`)
|
|
198
|
+
} else if (event === 'turn/start') {
|
|
199
|
+
lines.push(`开始回合:#${turn ?? ''}`)
|
|
200
|
+
} else {
|
|
201
|
+
if (sessionId) lines.push(`会话:${sessionId}`)
|
|
202
|
+
}
|
|
203
|
+
const body = lines.join('\n')
|
|
204
|
+
// Overall body cap: the truncated 内容/详情 lines stay intact
|
|
205
|
+
// (feishu-notify caps the result text itself, not the whole card).
|
|
206
|
+
return truncateText(body, 1200) ?? body
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Card JSON 2.0: a single `markdown` body component carries the meta lines
|
|
211
|
+
* plus an hr-separated body, so the turn's content renders with full
|
|
212
|
+
* markdown (headings, tables, images, code blocks). The colored header
|
|
213
|
+
* template is preserved.
|
|
214
|
+
*/
|
|
215
|
+
export function buildCard(ctx, { header, title, note, body, now = new Date() } = {}) {
|
|
216
|
+
const metaLines = [`🕐 ${fmtTime(now)}`]
|
|
217
|
+
if (ctx.cwd) metaLines.push(`📁 ${truncateText(ctx.cwd, 200) ?? ctx.cwd}`)
|
|
218
|
+
if (ctx.sessionName || ctx.sessionId) {
|
|
219
|
+
metaLines.push(`🗒 会话 ${truncateText(ctx.sessionName || ctx.sessionId, 80) ?? (ctx.sessionName || ctx.sessionId)}`)
|
|
220
|
+
}
|
|
221
|
+
if (note) metaLines.push(`📝 ${note}`)
|
|
222
|
+
// CommonMark: two trailing spaces make a hard line break, so the meta
|
|
223
|
+
// lines stack compactly instead of collapsing into one line. The blank
|
|
224
|
+
// line before `---` keeps it a thematic break — directly after text it
|
|
225
|
+
// would be parsed as a setext heading underline and disappear.
|
|
226
|
+
const meta = metaLines.join(' \n')
|
|
227
|
+
const content = body ? `${meta}\n\n---\n\n${body}` : meta
|
|
228
|
+
return {
|
|
229
|
+
schema: '2.0',
|
|
230
|
+
header: { template: header, title: { tag: 'plain_text', content: title } },
|
|
231
|
+
body: { elements: [{ tag: 'markdown', content }] },
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read one Feishu API response body. A non-2xx status fails first (surfacing
|
|
237
|
+
* the API's own message when it answered JSON); a 2xx body that is not JSON
|
|
238
|
+
* fails with a parse error instead of a confusing TypeError.
|
|
239
|
+
*/
|
|
240
|
+
async function readApiBody(response) {
|
|
241
|
+
if (!response.ok) {
|
|
242
|
+
let apiMsg = ''
|
|
243
|
+
try {
|
|
244
|
+
const body = await response.json()
|
|
245
|
+
if (body && typeof body.msg === 'string' && body.msg) apiMsg = ` msg=${body.msg}`
|
|
246
|
+
} catch {
|
|
247
|
+
// Non-JSON error page (proxy/HTML) — the status alone is the message.
|
|
248
|
+
}
|
|
249
|
+
throw new Error(`飞书接口 HTTP ${response.status}${apiMsg}`)
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return await response.json()
|
|
253
|
+
} catch {
|
|
254
|
+
throw new Error('飞书接口返回了无法解析的响应')
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** POST to the Feishu API with network-failure translation. */
|
|
259
|
+
async function postApi(url, init) {
|
|
260
|
+
let response
|
|
261
|
+
try {
|
|
262
|
+
response = await fetch(url, init)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
throw new Error(`飞书接口请求失败: ${error instanceof Error ? error.message : String(error)}`)
|
|
265
|
+
}
|
|
266
|
+
return readApiBody(response)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function getToken(appId, appSecret, now = Date.now()) {
|
|
270
|
+
const cached = tokenCache.get(appId)
|
|
271
|
+
if (cached && now < cached.expireAt) return cached.value
|
|
272
|
+
const body = await postApi('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: { 'content-type': 'application/json' },
|
|
275
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
276
|
+
})
|
|
277
|
+
if (body.code !== 0) {
|
|
278
|
+
const hint = ERROR_HINTS[body.code]
|
|
279
|
+
throw new Error(`飞书接口错误 code=${body.code} msg=${body.msg}${hint ? `\n → ${hint}` : ''}`)
|
|
280
|
+
}
|
|
281
|
+
const entry = {
|
|
282
|
+
value: body.tenant_access_token,
|
|
283
|
+
expireAt: now + (body.expire - 300) * 1000, // 提前 5 分钟过期
|
|
284
|
+
}
|
|
285
|
+
tokenCache.set(appId, entry)
|
|
286
|
+
return entry.value
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function sendCard(token, to, card, receiveIdType = 'open_id') {
|
|
290
|
+
const body = await postApi(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${encodeURIComponent(receiveIdType)}`, {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
headers: {
|
|
293
|
+
'content-type': 'application/json',
|
|
294
|
+
authorization: `Bearer ${token}`,
|
|
295
|
+
},
|
|
296
|
+
body: JSON.stringify({
|
|
297
|
+
receive_id: to,
|
|
298
|
+
msg_type: 'interactive',
|
|
299
|
+
content: JSON.stringify(card),
|
|
300
|
+
}),
|
|
301
|
+
})
|
|
302
|
+
if (body.code !== 0) {
|
|
303
|
+
const hint = ERROR_HINTS[body.code]
|
|
304
|
+
throw new Error(`飞书接口错误 code=${body.code} msg=${body.msg}${hint ? `\n → ${hint}` : ''}`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function sendText(token, to, text, receiveIdType = 'open_id') {
|
|
309
|
+
const body = await postApi(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${encodeURIComponent(receiveIdType)}`, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: {
|
|
312
|
+
'content-type': 'application/json',
|
|
313
|
+
authorization: `Bearer ${token}`,
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify({
|
|
316
|
+
receive_id: to,
|
|
317
|
+
msg_type: 'text',
|
|
318
|
+
content: JSON.stringify({ text }),
|
|
319
|
+
}),
|
|
320
|
+
})
|
|
321
|
+
if (body.code !== 0) {
|
|
322
|
+
const hint = ERROR_HINTS[body.code]
|
|
323
|
+
throw new Error(`飞书接口错误 code=${body.code} msg=${body.msg}${hint ? `\n → ${hint}` : ''}`)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Parse the optional CLI flags; positional args join into a body override. */
|
|
328
|
+
export function parseArgs(args) {
|
|
329
|
+
const opts = { textMode: false, header: '', title: '', note: '', quiet: false, bodyParts: [] }
|
|
330
|
+
for (let i = 0; i < args.length; i++) {
|
|
331
|
+
const a = args[i]
|
|
332
|
+
if (a === '--text') opts.textMode = true
|
|
333
|
+
else if (a === '--header') opts.header = args[++i]
|
|
334
|
+
else if (a === '--title') opts.title = args[++i]
|
|
335
|
+
else if (a === '--note') opts.note = args[++i]
|
|
336
|
+
else if (a === '--approval') { /* approval events already build their own body */ }
|
|
337
|
+
else if (a === '-q') opts.quiet = true
|
|
338
|
+
else opts.bodyParts.push(a)
|
|
339
|
+
}
|
|
340
|
+
opts.body = opts.bodyParts.join(' ').trim()
|
|
341
|
+
return opts
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Full pipeline for one hook event. Exported for tests and CLI use. */
|
|
345
|
+
export async function run(ctx, args = [], configPath = DEFAULT_CONFIG_PATH) {
|
|
346
|
+
const merged = mergeConfig(ctx, configPath)
|
|
347
|
+
if (!merged.appId || !merged.appSecret) throw new Error('缺少 DSH_HOOKS_FEISHU_APP_ID / DSH_HOOKS_FEISHU_APP_SECRET')
|
|
348
|
+
if (!merged.to) throw new Error('缺少 DSH_HOOKS_FEISHU_TO(接收者 open_id 或 chat_id)')
|
|
349
|
+
if (!merged.event) throw new Error('缺少 DSH_HOOK_EVENT(请通过 dsh-hooks 触发,不要直接运行)')
|
|
350
|
+
const opts = parseArgs(args)
|
|
351
|
+
const presentation = eventPresentation(merged)
|
|
352
|
+
const header = opts.header || presentation.header
|
|
353
|
+
const title = opts.title || presentation.title
|
|
354
|
+
if (!CARD_HEADERS.has(header)) {
|
|
355
|
+
throw new Error(`无效的卡片配色: ${header},可选: ${[...CARD_HEADERS].join(', ')}`)
|
|
356
|
+
}
|
|
357
|
+
const token = await getToken(merged.appId, merged.appSecret)
|
|
358
|
+
const receiveIdType = merged.receiveIdType ?? 'open_id'
|
|
359
|
+
if (opts.textMode) {
|
|
360
|
+
const text = opts.body || buildBody(merged)
|
|
361
|
+
await sendText(token, merged.to, text, receiveIdType)
|
|
362
|
+
return { kind: 'text', text }
|
|
363
|
+
}
|
|
364
|
+
const card = buildCard(merged, {
|
|
365
|
+
header,
|
|
366
|
+
title,
|
|
367
|
+
note: opts.note || undefined,
|
|
368
|
+
body: opts.body || buildBody(merged),
|
|
369
|
+
})
|
|
370
|
+
await sendCard(token, merged.to, card, receiveIdType)
|
|
371
|
+
return { kind: 'card', card }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function isDirectRun() {
|
|
375
|
+
try {
|
|
376
|
+
return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
377
|
+
} catch {
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (isDirectRun()) {
|
|
383
|
+
run(readEnv(), process.argv.slice(2))
|
|
384
|
+
.then((result) => {
|
|
385
|
+
if (!parseArgs(process.argv.slice(2)).quiet) {
|
|
386
|
+
if (result.kind === 'card') console.log(`已发送卡片: ${result.card.header.title.content}`)
|
|
387
|
+
else console.log(`已发送文本: ${result.text.length > 50 ? result.text.slice(0, 50) + '…' : result.text}`)
|
|
388
|
+
}
|
|
389
|
+
process.exit(0)
|
|
390
|
+
})
|
|
391
|
+
.catch((error) => {
|
|
392
|
+
console.warn(`[dsh-hooks/notify-feishu] ${error instanceof Error ? error.message : String(error)}`)
|
|
393
|
+
process.exit(1)
|
|
394
|
+
})
|
|
395
|
+
}
|
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
|
|
2
|
+
export declare const HOOK_EVENTS: readonly ["turn/start", "turn/end", "approval/asked", "agent/created", "agent/disposed", "agent/error", "agent/status"];
|
|
3
|
+
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
4
|
+
/** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
|
|
5
|
+
export declare const TURN_END_REASONS: readonly ["completed", "error", "aborted", "blocked", "max-tokens", "interrupted"];
|
|
6
|
+
export type TurnEndReasonKind = (typeof TURN_END_REASONS)[number];
|
|
7
|
+
/** One declared hook: a matching event runs `run` through the platform shell. */
|
|
8
|
+
export interface HookSpec {
|
|
9
|
+
/** Event that triggers the hook. */
|
|
10
|
+
on: HookEvent;
|
|
11
|
+
/**
|
|
12
|
+
* Optional filter. For `turn/end` it matches the reason kind
|
|
13
|
+
* (`completed`, `error`, …). Ignored for other events.
|
|
14
|
+
*/
|
|
15
|
+
when?: TurnEndReasonKind;
|
|
16
|
+
/** Command to spawn through the platform shell. */
|
|
17
|
+
run: string;
|
|
18
|
+
/** Per-hook timeout in milliseconds. Defaults to 10000. */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface Config {
|
|
22
|
+
hooks?: HookSpec[];
|
|
23
|
+
}
|
|
24
|
+
export declare const Config: {
|
|
25
|
+
(data?: Config | null): Config;
|
|
26
|
+
meta: {
|
|
27
|
+
description?: string | Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
};
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
2
|
+
/** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
|
|
3
|
+
export const HOOK_EVENTS = [
|
|
4
|
+
'turn/start',
|
|
5
|
+
'turn/end',
|
|
6
|
+
'approval/asked',
|
|
7
|
+
'agent/created',
|
|
8
|
+
'agent/disposed',
|
|
9
|
+
'agent/error',
|
|
10
|
+
'agent/status',
|
|
11
|
+
];
|
|
12
|
+
/** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
|
|
13
|
+
export const TURN_END_REASONS = [
|
|
14
|
+
'completed',
|
|
15
|
+
'error',
|
|
16
|
+
'aborted',
|
|
17
|
+
'blocked',
|
|
18
|
+
'max-tokens',
|
|
19
|
+
'interrupted',
|
|
20
|
+
];
|
|
21
|
+
// Explicit structural annotation: the inferred Schema type names the
|
|
22
|
+
// cosmokit transitive dependency, which is not portable across pnpm
|
|
23
|
+
// installations. Annotating with the schemastery callable shape keeps the
|
|
24
|
+
// declaration self-contained.
|
|
25
|
+
export const Config = Schema.object({
|
|
26
|
+
hooks: Schema.array(Schema.object({
|
|
27
|
+
on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | approval/asked | agent/created | agent/disposed | agent/error | agent/status'),
|
|
28
|
+
when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
|
|
29
|
+
run: Schema.string().required().description('触发时通过系统 shell 执行的命令'),
|
|
30
|
+
timeoutMs: Schema.number().default(10000).description('单次执行超时(毫秒)'),
|
|
31
|
+
}).description('一个事件 → 命令的 hook 声明'))
|
|
32
|
+
.default([])
|
|
33
|
+
.description('事件触发时执行的外部命令列表;按声明顺序触发'),
|
|
34
|
+
}).description('dsh-hooks 配置:声明式生命周期 hooks');
|
package/lib/context.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook execution context: a flat string map rendered into environment
|
|
3
|
+
* variables and `{{var}}` placeholders. Never carries non-string values —
|
|
4
|
+
* the runner owns serialization boundaries.
|
|
5
|
+
*/
|
|
6
|
+
export interface HookContext {
|
|
7
|
+
event: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Readable session title (the latest `session/title` log event, or a
|
|
11
|
+
* first-prompt fallback), when the session log offers one.
|
|
12
|
+
*/
|
|
13
|
+
sessionName?: string;
|
|
14
|
+
/** Absolute working directory of the session, when known. */
|
|
15
|
+
cwd?: string;
|
|
16
|
+
turn?: number;
|
|
17
|
+
reason?: string;
|
|
18
|
+
tool?: string;
|
|
19
|
+
callId?: string;
|
|
20
|
+
durationMs?: number;
|
|
21
|
+
status?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
/** Turn content snapshot, e.g. the turn's final assistant text. */
|
|
24
|
+
content?: string;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function toEnv(ctx: HookContext): Record<string, string>;
|
|
28
|
+
/** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
|
|
29
|
+
export declare function renderTemplate(template: string, ctx: HookContext): string;
|
|
30
|
+
/** Human-readable label for an event, used in runner logs. */
|
|
31
|
+
export declare function eventLabel(ctx: HookContext): string;
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook execution context: a flat string map rendered into environment
|
|
3
|
+
* variables and `{{var}}` placeholders. Never carries non-string values —
|
|
4
|
+
* the runner owns serialization boundaries.
|
|
5
|
+
*/
|
|
6
|
+
export function toEnv(ctx) {
|
|
7
|
+
const env = { DSH_HOOK_EVENT: ctx.event, DSH_HOOK_TIMESTAMP: ctx.timestamp };
|
|
8
|
+
if (ctx.sessionId !== undefined)
|
|
9
|
+
env.DSH_HOOK_SESSION_ID = ctx.sessionId;
|
|
10
|
+
if (ctx.sessionName !== undefined)
|
|
11
|
+
env.DSH_HOOK_SESSION_NAME = ctx.sessionName;
|
|
12
|
+
if (ctx.cwd !== undefined)
|
|
13
|
+
env.DSH_HOOK_CWD = ctx.cwd;
|
|
14
|
+
if (ctx.turn !== undefined)
|
|
15
|
+
env.DSH_HOOK_TURN = String(ctx.turn);
|
|
16
|
+
if (ctx.reason !== undefined)
|
|
17
|
+
env.DSH_HOOK_REASON = ctx.reason;
|
|
18
|
+
if (ctx.tool !== undefined)
|
|
19
|
+
env.DSH_HOOK_TOOL = ctx.tool;
|
|
20
|
+
if (ctx.callId !== undefined)
|
|
21
|
+
env.DSH_HOOK_CALL_ID = ctx.callId;
|
|
22
|
+
if (ctx.durationMs !== undefined)
|
|
23
|
+
env.DSH_HOOK_DURATION_MS = String(ctx.durationMs);
|
|
24
|
+
if (ctx.status !== undefined)
|
|
25
|
+
env.DSH_HOOK_STATUS = ctx.status;
|
|
26
|
+
if (ctx.error !== undefined)
|
|
27
|
+
env.DSH_HOOK_ERROR = ctx.error;
|
|
28
|
+
if (ctx.content !== undefined)
|
|
29
|
+
env.DSH_HOOK_CONTENT = ctx.content;
|
|
30
|
+
return env;
|
|
31
|
+
}
|
|
32
|
+
/** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
|
|
33
|
+
export function renderTemplate(template, ctx) {
|
|
34
|
+
const env = toEnv(ctx);
|
|
35
|
+
return template.replace(/\{\{(\w+)\}\}/g, (whole, key) => Object.prototype.hasOwnProperty.call(env, key) ? env[key] : whole);
|
|
36
|
+
}
|
|
37
|
+
/** Human-readable label for an event, used in runner logs. */
|
|
38
|
+
export function eventLabel(ctx) {
|
|
39
|
+
const session = ctx.sessionId ? ` · 会话 ${ctx.sessionName ?? ctx.sessionId}` : '';
|
|
40
|
+
const extra = ctx.reason !== undefined
|
|
41
|
+
? ` · ${ctx.reason}`
|
|
42
|
+
: ctx.tool !== undefined
|
|
43
|
+
? ` · 工具 ${ctx.tool}`
|
|
44
|
+
: ctx.status !== undefined
|
|
45
|
+
? ` · ${ctx.status}`
|
|
46
|
+
: '';
|
|
47
|
+
return `${ctx.event}${extra}${session}`;
|
|
48
|
+
}
|
package/lib/events.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session';
|
|
2
|
+
import type { HookContext } from './context.js';
|
|
3
|
+
import type { HookSpec, TurnEndReasonKind } from './config.js';
|
|
4
|
+
import type { AgentLike } from './types.js';
|
|
5
|
+
/** `approval/asked` payload (merge-extensible, declared by dsh-user-approval). */
|
|
6
|
+
export interface ApprovalAskedData {
|
|
7
|
+
id: string;
|
|
8
|
+
toolName: string;
|
|
9
|
+
callId?: string;
|
|
10
|
+
reason?: string;
|
|
11
|
+
}
|
|
12
|
+
declare module '@deepseek-ai/dsh-session/types' {
|
|
13
|
+
interface SessionEventMap {
|
|
14
|
+
'approval/asked': ApprovalAskedData;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Agent lifecycle payloads (structural; emitted by dsh-agent's AgentService). */
|
|
18
|
+
export interface AgentCreatedPayload {
|
|
19
|
+
agent: AgentLike;
|
|
20
|
+
}
|
|
21
|
+
export interface AgentDisposedPayload {
|
|
22
|
+
agent: AgentLike;
|
|
23
|
+
}
|
|
24
|
+
export interface AgentErrorPayload {
|
|
25
|
+
agent: AgentLike;
|
|
26
|
+
turn?: number;
|
|
27
|
+
step?: number;
|
|
28
|
+
error?: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface AgentStatusPayload {
|
|
31
|
+
agent: AgentLike;
|
|
32
|
+
status?: unknown;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Readable session title for notification cards. Mirrors the harness
|
|
36
|
+
* session-title conventions without depending on the title service:
|
|
37
|
+
* prefer the latest `session/title` log event (explicit rename, LLM title, or
|
|
38
|
+
* deterministic fallback), otherwise derive one from the first direct human
|
|
39
|
+
* prompt, as `dsh-session-title`'s fallback does.
|
|
40
|
+
*/
|
|
41
|
+
export declare function sessionTitle(session: Session): string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* The turn's final assistant text, from the last `assistant/message` of that
|
|
44
|
+
* turn. Capped so the environment snapshot stays small — card builders apply
|
|
45
|
+
* their own display truncation.
|
|
46
|
+
*/
|
|
47
|
+
export declare function turnContent(session: Session, turn: number): string | undefined;
|
|
48
|
+
export declare function rememberTurnStart(session: Session): void;
|
|
49
|
+
export declare function clearTurnTracking(session: Session): void;
|
|
50
|
+
/** Does a declared hook match this event (type + optional `when` filter)? */
|
|
51
|
+
export declare function hookMatches(spec: HookSpec, event: string, reasonKind?: TurnEndReasonKind): boolean;
|
|
52
|
+
export declare function turnEndContext(session: Session, turn: number, reason: TurnEndReason | string): HookContext;
|
|
53
|
+
export declare function turnStartContext(session: Session, turn: number): HookContext;
|
|
54
|
+
export declare function approvalContext(session: Session, data: ApprovalAskedData): HookContext;
|
|
55
|
+
export declare function agentCreatedContext(agent: AgentLike): HookContext;
|
|
56
|
+
export declare function agentDisposedContext(agent: AgentLike): HookContext;
|
|
57
|
+
export declare function agentErrorContext(agent: AgentLike, turn: number | undefined, error: unknown): HookContext;
|
|
58
|
+
export declare function agentStatusContext(agent: AgentLike, status: unknown): HookContext;
|
|
59
|
+
/** Classify a session event into a hook context, or undefined when unmapped. */
|
|
60
|
+
export declare function classifySessionEvent(session: Session, event: SessionEvent): HookContext | undefined;
|
|
61
|
+
/** Best-effort error text from an arbitrary thrown value. */
|
|
62
|
+
export declare function errorText(error: unknown): string;
|
|
63
|
+
/** Best-effort status text from an agent status payload. */
|
|
64
|
+
export declare function statusText(status: unknown): string;
|