dsh-hooks 0.4.0 → 0.5.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/README.md +25 -6
- package/README.zh.md +25 -6
- package/bin/dsh-hooks.mjs +37 -197
- package/examples/notify-feishu.d.mts +31 -0
- package/lib/client.js +368 -50
- package/lib/feishu-session.d.ts +47 -0
- package/lib/feishu-session.js +94 -0
- package/lib/feishu.d.ts +119 -0
- package/lib/feishu.js +282 -0
- package/lib/history.d.ts +5 -0
- package/lib/history.js +102 -3
- package/lib/index.js +13 -4
- package/lib/server.d.ts +15 -3
- package/lib/server.js +93 -0
- package/package.json +2 -2
package/lib/feishu.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { registerApp } from '@larksuiteoapi/node-sdk';
|
|
2
|
+
/** Feishu config dir: credentials + the stable copy of the notify script. */
|
|
3
|
+
export declare const FEISHU_CONFIG_DIR: string;
|
|
4
|
+
export declare const FEISHU_CONFIG_PATH: string;
|
|
5
|
+
/** Card content truncation length written by setup (notify script default). */
|
|
6
|
+
export declare const FEISHU_RESULT_MAX_CHARS_DEFAULT = 300;
|
|
7
|
+
/** UI-accepted truncation range (characters). */
|
|
8
|
+
export declare const FEISHU_RESULT_MAX_CHARS_MIN = 50;
|
|
9
|
+
export declare const FEISHU_RESULT_MAX_CHARS_MAX = 5000;
|
|
10
|
+
/** Injectable file paths, so tests (and the web routes) stay off the real home. */
|
|
11
|
+
export interface FeishuSetupPaths {
|
|
12
|
+
/** Credential file (default ~/.dsh/dsh-hooks/feishu-config.json). */
|
|
13
|
+
configPath?: string;
|
|
14
|
+
/** Profile patch file (default ~/.dsh/profiles/<profile>/cordis.patch.yml). */
|
|
15
|
+
patchFile?: string;
|
|
16
|
+
/** Stable notify-script location the hooks reference. */
|
|
17
|
+
notifyScript?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface FeishuQRCodeInfo {
|
|
20
|
+
url: string;
|
|
21
|
+
/** Seconds until the QR authorization expires. */
|
|
22
|
+
expireIn: number;
|
|
23
|
+
}
|
|
24
|
+
export interface RunFeishuSetupOptions {
|
|
25
|
+
/** Profile whose cordis.patch.yml receives the dsh-hooks config block. */
|
|
26
|
+
profile?: string;
|
|
27
|
+
/** Overrides the real registerApp (tests). */
|
|
28
|
+
registerAppFn?: typeof registerApp;
|
|
29
|
+
print?: (line: string) => void;
|
|
30
|
+
printErr?: (line: string) => void;
|
|
31
|
+
/** Called when the QR authorization is ready (CLI prints it; web renders it). */
|
|
32
|
+
onQRCodeReady?: (qr: FeishuQRCodeInfo) => void | Promise<void>;
|
|
33
|
+
/** Abort the scan wait (web cancel). */
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
/** Card content truncation length written into the credential file. */
|
|
36
|
+
resultMaxChars?: number;
|
|
37
|
+
paths?: FeishuSetupPaths;
|
|
38
|
+
}
|
|
39
|
+
export interface FeishuSetupResult {
|
|
40
|
+
appId: string;
|
|
41
|
+
ownerOpenId: string;
|
|
42
|
+
}
|
|
43
|
+
/** Profile patch file for a profile name. */
|
|
44
|
+
export declare function patchPath(profile: string): string;
|
|
45
|
+
/** Which hooks the setup installs into the profile. */
|
|
46
|
+
export declare function setupHooks(scriptPath: string): ({
|
|
47
|
+
on: string;
|
|
48
|
+
when: string;
|
|
49
|
+
run: string;
|
|
50
|
+
timeoutMs: number;
|
|
51
|
+
} | {
|
|
52
|
+
when?: undefined;
|
|
53
|
+
on: string;
|
|
54
|
+
run: string;
|
|
55
|
+
timeoutMs: number;
|
|
56
|
+
})[];
|
|
57
|
+
/** Absolute path of the shipped notify script (works from both lib/ and src/). */
|
|
58
|
+
export declare function notifyScriptPath(): string;
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the stable notify-script location hooks should reference. The npx
|
|
61
|
+
* cache (where the CLI often runs from) is ephemeral, so the setup copies the
|
|
62
|
+
* zero-dependency script next to feishu-config.json:
|
|
63
|
+
* ~/.dsh/dsh-hooks/notify-feishu.mjs. Re-copies on every setup so the stable
|
|
64
|
+
* copy tracks the installed version.
|
|
65
|
+
*/
|
|
66
|
+
export declare function stableScriptPath(paths?: FeishuSetupPaths): string;
|
|
67
|
+
/**
|
|
68
|
+
* Write the credential file with 0600 perms (owner-only): secrets stay out of
|
|
69
|
+
* the repo and argv.
|
|
70
|
+
*/
|
|
71
|
+
export declare function writeConfig(configPath: string, { appId, appSecret, targetType, targetId, resultMaxChars }: {
|
|
72
|
+
appId: string;
|
|
73
|
+
appSecret: string;
|
|
74
|
+
targetType?: string;
|
|
75
|
+
targetId: string;
|
|
76
|
+
resultMaxChars?: number;
|
|
77
|
+
}): void;
|
|
78
|
+
/**
|
|
79
|
+
* Merge the dsh-hooks config block into a profile's cordis.patch.yml:
|
|
80
|
+
* existing dsh-hooks entries keep unrelated config and get their hooks
|
|
81
|
+
* replaced with `setupHooks`; other entries stay untouched. Idempotent.
|
|
82
|
+
*/
|
|
83
|
+
export declare function mergePatchYaml(existingText: string, { scriptPath }: {
|
|
84
|
+
scriptPath: string;
|
|
85
|
+
}): string;
|
|
86
|
+
/**
|
|
87
|
+
* Full setup flow: registerApp (QR scan creates the Feishu app), write
|
|
88
|
+
* credentials + the stable notify script, merge the card hooks into the
|
|
89
|
+
* profile patch, and send a welcome card to the scanning user.
|
|
90
|
+
*/
|
|
91
|
+
export declare function runFeishuSetup(options?: RunFeishuSetupOptions): Promise<FeishuSetupResult>;
|
|
92
|
+
export interface RunFeishuTestOptions {
|
|
93
|
+
print?: (line: string) => void;
|
|
94
|
+
paths?: FeishuSetupPaths;
|
|
95
|
+
}
|
|
96
|
+
/** Test the stored credentials and send a test card to the configured target. */
|
|
97
|
+
export declare function runFeishuTest(options?: RunFeishuTestOptions): Promise<string>;
|
|
98
|
+
/** Mask an identifier for display: `cli_a1b2…9012`. Never shows the secret. */
|
|
99
|
+
export declare function maskId(value: string | null): string | null;
|
|
100
|
+
/** Readable connection summary for the settings UI (credentials never leave this module). */
|
|
101
|
+
export interface FeishuSummary {
|
|
102
|
+
configured: boolean;
|
|
103
|
+
appId: string | null;
|
|
104
|
+
targetKind: string | null;
|
|
105
|
+
target: string | null;
|
|
106
|
+
/** Card content truncation length (from the credential file, or the default). */
|
|
107
|
+
resultMaxChars: number;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Inspect the credential file for a display-only summary. The app secret is
|
|
111
|
+
* read for presence only and never enters any returned value.
|
|
112
|
+
*/
|
|
113
|
+
export declare function readFeishuSummary(configPath?: string): FeishuSummary;
|
|
114
|
+
/**
|
|
115
|
+
* Update the card truncation length in an existing credential file, keeping
|
|
116
|
+
* every other field (credentials, target) untouched. Throws a user-facing
|
|
117
|
+
* error for invalid input or a missing/unparsable file.
|
|
118
|
+
*/
|
|
119
|
+
export declare function updateFeishuResultMaxChars(configPath: string | undefined, value: number): number;
|
package/lib/feishu.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Feishu notification setup: the one-shot app-creation flow
|
|
3
|
+
* (`registerApp` + QR scan) plus credential/config plumbing. Used by both
|
|
4
|
+
* the `dsh-hooks feishu-setup` CLI and the web profile's
|
|
5
|
+
* `/dsh-hooks/feishu/*` routes — the CLI is a thin wrapper adding terminal
|
|
6
|
+
* QR printing and the browser opener.
|
|
7
|
+
*
|
|
8
|
+
* The Feishu SDK and the qrcode renderer are never loaded at plugin apply
|
|
9
|
+
* time: the SDK resolves through a dynamic import on first setup, so
|
|
10
|
+
* headless/CLI profiles without a web server pay nothing for the UI path.
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import YAML from 'yaml';
|
|
16
|
+
import { run as notifyRun } from '../examples/notify-feishu.mjs';
|
|
17
|
+
/** Feishu config dir: credentials + the stable copy of the notify script. */
|
|
18
|
+
export const FEISHU_CONFIG_DIR = join(homedir(), '.dsh', 'dsh-hooks');
|
|
19
|
+
export const FEISHU_CONFIG_PATH = join(FEISHU_CONFIG_DIR, 'feishu-config.json');
|
|
20
|
+
/** Card content truncation length written by setup (notify script default). */
|
|
21
|
+
export const FEISHU_RESULT_MAX_CHARS_DEFAULT = 300;
|
|
22
|
+
/** UI-accepted truncation range (characters). */
|
|
23
|
+
export const FEISHU_RESULT_MAX_CHARS_MIN = 50;
|
|
24
|
+
export const FEISHU_RESULT_MAX_CHARS_MAX = 5000;
|
|
25
|
+
/** Profile patch file for a profile name. */
|
|
26
|
+
export function patchPath(profile) {
|
|
27
|
+
return join(homedir(), '.dsh', 'profiles', profile, 'cordis.patch.yml');
|
|
28
|
+
}
|
|
29
|
+
/** Which hooks the setup installs into the profile. */
|
|
30
|
+
export function setupHooks(scriptPath) {
|
|
31
|
+
return [
|
|
32
|
+
{ on: 'turn/end', when: 'completed', run: `node ${JSON.stringify(scriptPath)}`, timeoutMs: 30000 },
|
|
33
|
+
{ on: 'turn/end', when: 'error', run: `node ${JSON.stringify(scriptPath)}`, timeoutMs: 30000 },
|
|
34
|
+
{ on: 'turn/end', when: 'aborted', run: `node ${JSON.stringify(scriptPath)}`, timeoutMs: 30000 },
|
|
35
|
+
{ on: 'approval/asked', run: `node ${JSON.stringify(scriptPath)} --approval`, timeoutMs: 30000 },
|
|
36
|
+
{ on: 'agent/error', run: `node ${JSON.stringify(scriptPath)}`, timeoutMs: 30000 },
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
/** Absolute path of the shipped notify script (works from both lib/ and src/). */
|
|
40
|
+
export function notifyScriptPath() {
|
|
41
|
+
return new URL('../examples/notify-feishu.mjs', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the stable notify-script location hooks should reference. The npx
|
|
45
|
+
* cache (where the CLI often runs from) is ephemeral, so the setup copies the
|
|
46
|
+
* zero-dependency script next to feishu-config.json:
|
|
47
|
+
* ~/.dsh/dsh-hooks/notify-feishu.mjs. Re-copies on every setup so the stable
|
|
48
|
+
* copy tracks the installed version.
|
|
49
|
+
*/
|
|
50
|
+
export function stableScriptPath(paths = {}) {
|
|
51
|
+
return paths.notifyScript ?? join(FEISHU_CONFIG_DIR, 'notify-feishu.mjs');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Write the credential file with 0600 perms (owner-only): secrets stay out of
|
|
55
|
+
* the repo and argv.
|
|
56
|
+
*/
|
|
57
|
+
export function writeConfig(configPath, { appId, appSecret, targetType = 'open_id', targetId, resultMaxChars = 300 }) {
|
|
58
|
+
mkdirSync(join(configPath, '..'), { recursive: true, mode: 0o700 });
|
|
59
|
+
const doc = JSON.stringify({
|
|
60
|
+
app_id: appId,
|
|
61
|
+
app_secret: appSecret,
|
|
62
|
+
target_type: targetType,
|
|
63
|
+
target_id: targetId,
|
|
64
|
+
result_max_chars: resultMaxChars,
|
|
65
|
+
}, null, 2);
|
|
66
|
+
writeFileSync(configPath, doc + '\n', 'utf8');
|
|
67
|
+
try {
|
|
68
|
+
chmodSync(configPath, 0o600);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Windows: ACL-based protection; the file lives under the user profile.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Merge the dsh-hooks config block into a profile's cordis.patch.yml:
|
|
76
|
+
* existing dsh-hooks entries keep unrelated config and get their hooks
|
|
77
|
+
* replaced with `setupHooks`; other entries stay untouched. Idempotent.
|
|
78
|
+
*/
|
|
79
|
+
export function mergePatchYaml(existingText, { scriptPath }) {
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = YAML.parse(existingText || '[]\n');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error('profile 的 cordis.patch.yml 解析失败,请先修复该文件');
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(entries))
|
|
88
|
+
throw new Error('cordis.patch.yml 顶层必须是 YAML 数组');
|
|
89
|
+
const hooks = setupHooks(scriptPath);
|
|
90
|
+
let found = false;
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry && typeof entry === 'object' && entry.id === 'dsh-hooks') {
|
|
93
|
+
;
|
|
94
|
+
entry.name = 'dsh-hooks';
|
|
95
|
+
entry.config = { hooks };
|
|
96
|
+
found = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!found)
|
|
101
|
+
entries.push({ id: 'dsh-hooks', name: 'dsh-hooks', config: { hooks } });
|
|
102
|
+
return YAML.stringify(entries);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Full setup flow: registerApp (QR scan creates the Feishu app), write
|
|
106
|
+
* credentials + the stable notify script, merge the card hooks into the
|
|
107
|
+
* profile patch, and send a welcome card to the scanning user.
|
|
108
|
+
*/
|
|
109
|
+
export async function runFeishuSetup(options = {}) {
|
|
110
|
+
const profile = options.profile ?? 'web';
|
|
111
|
+
const print = options.print ?? console.log;
|
|
112
|
+
const printErr = options.printErr ?? console.error;
|
|
113
|
+
const paths = options.paths ?? {};
|
|
114
|
+
const configPath = paths.configPath ?? FEISHU_CONFIG_PATH;
|
|
115
|
+
const patchFile = paths.patchFile ?? patchPath(profile);
|
|
116
|
+
const notifyScript = stableScriptPath(paths);
|
|
117
|
+
print('dsh-hooks feishu-setup');
|
|
118
|
+
print('1/4 正在生成飞书「一键创建应用」二维码…');
|
|
119
|
+
// The SDK stays out of the module graph until the first setup actually runs.
|
|
120
|
+
const registerAppFn = options.registerAppFn ?? (await import('@larksuiteoapi/node-sdk')).registerApp;
|
|
121
|
+
const result = await registerAppFn({
|
|
122
|
+
source: 'dsh-hooks',
|
|
123
|
+
createOnly: true,
|
|
124
|
+
appPreset: {
|
|
125
|
+
name: 'DSH 通知机器人',
|
|
126
|
+
desc: 'DeepSeek Harness 会话事件通知(dsh-hooks)',
|
|
127
|
+
},
|
|
128
|
+
addons: {
|
|
129
|
+
preset: false,
|
|
130
|
+
scopes: {
|
|
131
|
+
tenant: ['im:message:send_as_bot'],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
signal: options.signal,
|
|
135
|
+
onQRCodeReady: (authorization) => {
|
|
136
|
+
// The SDK fires this once the authorization URL exists; the scan wait
|
|
137
|
+
// is the registerApp promise itself. Caller-side failures must never
|
|
138
|
+
// break the SDK's polling loop.
|
|
139
|
+
void Promise.resolve(options.onQRCodeReady?.(authorization)).catch(() => undefined);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const appId = result.client_id;
|
|
143
|
+
const appSecret = result.client_secret;
|
|
144
|
+
const ownerOpenId = result.user_info?.open_id;
|
|
145
|
+
if (!appId || !appSecret)
|
|
146
|
+
throw new Error('扫码创建未完成,未拿到应用凭证');
|
|
147
|
+
if (!ownerOpenId)
|
|
148
|
+
throw new Error('扫码结果缺少 open_id,请重试');
|
|
149
|
+
print('');
|
|
150
|
+
print(`2/4 应用创建成功:${appId}(机器人将私聊通知你)`);
|
|
151
|
+
writeConfig(configPath, {
|
|
152
|
+
appId,
|
|
153
|
+
appSecret,
|
|
154
|
+
targetType: 'open_id',
|
|
155
|
+
targetId: ownerOpenId,
|
|
156
|
+
resultMaxChars: options.resultMaxChars ?? FEISHU_RESULT_MAX_CHARS_DEFAULT,
|
|
157
|
+
});
|
|
158
|
+
print(`3/4 凭据已写入 ${configPath}(权限 0600,勿提交到仓库)`);
|
|
159
|
+
// Copy the notify script to its stable location so hooks never reference
|
|
160
|
+
// the ephemeral npx cache.
|
|
161
|
+
if (!paths.notifyScript) {
|
|
162
|
+
mkdirSync(FEISHU_CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
163
|
+
writeFileSync(notifyScript, readFileSync(notifyScriptPath(), 'utf8'), 'utf8');
|
|
164
|
+
}
|
|
165
|
+
const existing = existsSync(patchFile) ? readFileSync(patchFile, 'utf8') : '[]\n';
|
|
166
|
+
const merged = mergePatchYaml(existing, { scriptPath: notifyScript });
|
|
167
|
+
writeFileSync(patchFile, merged, 'utf8');
|
|
168
|
+
print(`4/4 hook 配置已写入 ${patchFile}`);
|
|
169
|
+
print('发送欢迎卡片验证…');
|
|
170
|
+
try {
|
|
171
|
+
await notifyRun({
|
|
172
|
+
appId,
|
|
173
|
+
appSecret,
|
|
174
|
+
to: ownerOpenId,
|
|
175
|
+
event: 'agent/created',
|
|
176
|
+
sessionId: 'dsh-hooks-setup',
|
|
177
|
+
cwd: process.cwd(),
|
|
178
|
+
timestamp: new Date().toISOString(),
|
|
179
|
+
});
|
|
180
|
+
print('✅ 欢迎卡片已发送。请重启 dsh web 使 hooks 生效。');
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
printErr(`⚠ 欢迎卡片发送失败(配置已就绪,可稍后用 feishu-test 重试):${error instanceof Error ? error.message : String(error)}`);
|
|
184
|
+
}
|
|
185
|
+
return { appId, ownerOpenId };
|
|
186
|
+
}
|
|
187
|
+
/** Test the stored credentials and send a test card to the configured target. */
|
|
188
|
+
export async function runFeishuTest(options = {}) {
|
|
189
|
+
const print = options.print ?? console.log;
|
|
190
|
+
const configPath = options.paths?.configPath ?? FEISHU_CONFIG_PATH;
|
|
191
|
+
if (!existsSync(configPath)) {
|
|
192
|
+
throw new Error(`未找到配置文件 ${configPath},请先运行 feishu-setup`);
|
|
193
|
+
}
|
|
194
|
+
let file;
|
|
195
|
+
try {
|
|
196
|
+
file = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new Error(`配置文件 ${configPath} 解析失败,请重新运行 feishu-setup`);
|
|
200
|
+
}
|
|
201
|
+
const { app_id: appId, app_secret: appSecret, target_id: targetId } = file;
|
|
202
|
+
if (typeof appId !== 'string' || appId === '' || typeof appSecret !== 'string' || appSecret === '' || typeof targetId !== 'string' || targetId === '') {
|
|
203
|
+
throw new Error('配置文件不完整,请重新运行 feishu-setup');
|
|
204
|
+
}
|
|
205
|
+
await notifyRun({
|
|
206
|
+
appId,
|
|
207
|
+
appSecret,
|
|
208
|
+
to: targetId,
|
|
209
|
+
event: 'agent/status',
|
|
210
|
+
status: 'connected',
|
|
211
|
+
sessionId: 'feishu-test',
|
|
212
|
+
cwd: process.cwd(),
|
|
213
|
+
timestamp: new Date().toISOString(),
|
|
214
|
+
});
|
|
215
|
+
const line = '✅ 测试卡片已发送';
|
|
216
|
+
print(line);
|
|
217
|
+
return line;
|
|
218
|
+
}
|
|
219
|
+
/** Mask an identifier for display: `cli_a1b2…9012`. Never shows the secret. */
|
|
220
|
+
export function maskId(value) {
|
|
221
|
+
if (value === null)
|
|
222
|
+
return null;
|
|
223
|
+
if (value.length <= 12)
|
|
224
|
+
return value;
|
|
225
|
+
return `${value.slice(0, 8)}…${value.slice(-4)}`;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Inspect the credential file for a display-only summary. The app secret is
|
|
229
|
+
* read for presence only and never enters any returned value.
|
|
230
|
+
*/
|
|
231
|
+
export function readFeishuSummary(configPath = FEISHU_CONFIG_PATH) {
|
|
232
|
+
const empty = {
|
|
233
|
+
configured: false,
|
|
234
|
+
appId: null,
|
|
235
|
+
targetKind: null,
|
|
236
|
+
target: null,
|
|
237
|
+
resultMaxChars: FEISHU_RESULT_MAX_CHARS_DEFAULT,
|
|
238
|
+
};
|
|
239
|
+
if (!existsSync(configPath))
|
|
240
|
+
return empty;
|
|
241
|
+
try {
|
|
242
|
+
const file = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
243
|
+
const appId = typeof file.app_id === 'string' && file.app_id !== '' ? file.app_id : null;
|
|
244
|
+
const secret = typeof file.app_secret === 'string' && file.app_secret !== '';
|
|
245
|
+
const targetKind = typeof file.target_type === 'string' && file.target_type !== '' ? file.target_type : null;
|
|
246
|
+
const target = typeof file.target_id === 'string' && file.target_id !== '' ? file.target_id : null;
|
|
247
|
+
const resultMaxChars = typeof file.result_max_chars === 'number' && Number.isFinite(file.result_max_chars) && file.result_max_chars > 0
|
|
248
|
+
? Math.floor(file.result_max_chars)
|
|
249
|
+
: FEISHU_RESULT_MAX_CHARS_DEFAULT;
|
|
250
|
+
if (appId === null || !secret || target === null)
|
|
251
|
+
return { ...empty, resultMaxChars };
|
|
252
|
+
return { configured: true, appId: maskId(appId), targetKind, target: maskId(target), resultMaxChars };
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return empty;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Update the card truncation length in an existing credential file, keeping
|
|
260
|
+
* every other field (credentials, target) untouched. Throws a user-facing
|
|
261
|
+
* error for invalid input or a missing/unparsable file.
|
|
262
|
+
*/
|
|
263
|
+
export function updateFeishuResultMaxChars(configPath = FEISHU_CONFIG_PATH, value) {
|
|
264
|
+
if (!Number.isFinite(value) || value < FEISHU_RESULT_MAX_CHARS_MIN || value > FEISHU_RESULT_MAX_CHARS_MAX) {
|
|
265
|
+
throw new Error(`截断长度必须是 ${FEISHU_RESULT_MAX_CHARS_MIN}–${FEISHU_RESULT_MAX_CHARS_MAX} 之间的数字`);
|
|
266
|
+
}
|
|
267
|
+
const rounded = Math.floor(value);
|
|
268
|
+
if (!existsSync(configPath))
|
|
269
|
+
throw new Error('尚未连接飞书,请先扫码连接');
|
|
270
|
+
let file;
|
|
271
|
+
try {
|
|
272
|
+
file = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
throw new Error('飞书配置文件解析失败,请重新连接');
|
|
276
|
+
}
|
|
277
|
+
if (typeof file !== 'object' || file === null)
|
|
278
|
+
throw new Error('飞书配置文件解析失败,请重新连接');
|
|
279
|
+
file.result_max_chars = rounded;
|
|
280
|
+
writeFileSync(configPath, JSON.stringify(file, null, 2) + '\n', 'utf8');
|
|
281
|
+
return rounded;
|
|
282
|
+
}
|
package/lib/history.d.ts
CHANGED
|
@@ -29,6 +29,11 @@ export interface HistorySink {
|
|
|
29
29
|
record(record: Omit<HookRunRecord, 'ts'>): void;
|
|
30
30
|
/** Most recent records, oldest first. */
|
|
31
31
|
recent(): readonly HookRunRecord[];
|
|
32
|
+
/**
|
|
33
|
+
* Ingest JSONL bytes appended since the last read (startup seed or another
|
|
34
|
+
* process). Idempotent and best-effort: failures leave the buffer as-is.
|
|
35
|
+
*/
|
|
36
|
+
sync(): void;
|
|
32
37
|
dispose(): void;
|
|
33
38
|
}
|
|
34
39
|
export declare function createHistorySink(options?: HistorySinkOptions): HistorySink;
|
package/lib/history.js
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
* Hook execution history: an in-memory ring buffer plus a best-effort
|
|
3
3
|
* JSONL append log under ~/.dsh/dsh-hooks/ (0600, owner-only). History is
|
|
4
4
|
* strictly best-effort — a failed write never breaks a hook.
|
|
5
|
+
*
|
|
6
|
+
* The buffer is not process-private memory only: it seeds from the JSONL at
|
|
7
|
+
* startup and `sync()` incrementally ingests bytes appended since the last
|
|
8
|
+
* read, so records written before a restart (or by another dsh process
|
|
9
|
+
* sharing the file, e.g. a task-board Host) surface in the web GUI instead
|
|
10
|
+
* of vanishing with the process.
|
|
5
11
|
*/
|
|
6
|
-
import { appendFileSync, chmodSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, } from 'node:fs';
|
|
7
13
|
import { homedir } from 'node:os';
|
|
8
14
|
import { dirname, join } from 'node:path';
|
|
9
15
|
export const DEFAULT_HISTORY_PATH = join(homedir(), '.dsh', 'dsh-hooks', 'history.jsonl');
|
|
@@ -15,11 +21,101 @@ export function createHistorySink(options = {}) {
|
|
|
15
21
|
const buffer = [];
|
|
16
22
|
let dirReady = false;
|
|
17
23
|
let chmodded = false;
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
/** Bytes of `file` already ingested into the buffer. */
|
|
25
|
+
let syncedBytes = 0;
|
|
26
|
+
/** Trailing fragment of the last read that did not end with a newline. */
|
|
27
|
+
let pending = '';
|
|
28
|
+
function push(entry) {
|
|
20
29
|
buffer.push(entry);
|
|
21
30
|
if (buffer.length > max)
|
|
22
31
|
buffer.splice(0, buffer.length - max);
|
|
32
|
+
}
|
|
33
|
+
/** Parse complete JSONL lines into the ring buffer; incomplete tails stay pending. */
|
|
34
|
+
function ingest(text) {
|
|
35
|
+
pending += text;
|
|
36
|
+
const lines = pending.split('\n');
|
|
37
|
+
pending = lines.pop() ?? '';
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (line === '')
|
|
40
|
+
continue;
|
|
41
|
+
try {
|
|
42
|
+
const entry = JSON.parse(line);
|
|
43
|
+
if (typeof entry !== 'object' || entry === null || typeof entry.ts !== 'number')
|
|
44
|
+
continue;
|
|
45
|
+
push(entry);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Broken line (mid-write or foreign content): skip, never fail.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Rebuild the buffer from the whole file (startup seed / truncated file). */
|
|
53
|
+
function rebuild() {
|
|
54
|
+
buffer.length = 0;
|
|
55
|
+
pending = '';
|
|
56
|
+
syncedBytes = 0;
|
|
57
|
+
const text = readFileSync(file, 'utf8');
|
|
58
|
+
syncedBytes = Buffer.byteLength(text, 'utf8');
|
|
59
|
+
ingest(text);
|
|
60
|
+
}
|
|
61
|
+
/** Seed the ring buffer from an existing JSONL log (best-effort). */
|
|
62
|
+
function seed() {
|
|
63
|
+
if (!enabled)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
if (!existsSync(file))
|
|
67
|
+
return;
|
|
68
|
+
rebuild();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Seeding is best-effort; recording starts from an empty buffer.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Ingest every byte appended since the last read (own writes included). */
|
|
75
|
+
function sync() {
|
|
76
|
+
if (!enabled)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
if (!existsSync(file))
|
|
80
|
+
return;
|
|
81
|
+
const size = statSync(file).size;
|
|
82
|
+
if (size === syncedBytes)
|
|
83
|
+
return;
|
|
84
|
+
if (size < syncedBytes) {
|
|
85
|
+
// The file shrank (rotation/truncation): rebuild from its tail.
|
|
86
|
+
rebuild();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const deltaBytes = size - syncedBytes;
|
|
90
|
+
const fd = openSync(file, 'r');
|
|
91
|
+
try {
|
|
92
|
+
const chunk = Buffer.allocUnsafe(deltaBytes);
|
|
93
|
+
let total = 0;
|
|
94
|
+
while (total < deltaBytes) {
|
|
95
|
+
const n = readSync(fd, chunk, total, deltaBytes - total, syncedBytes + total);
|
|
96
|
+
if (n <= 0)
|
|
97
|
+
break;
|
|
98
|
+
total += n;
|
|
99
|
+
}
|
|
100
|
+
ingest(chunk.subarray(0, total).toString('utf8'));
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
syncedBytes = size;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Sync is best-effort; the next call retries.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function record(partial) {
|
|
112
|
+
const entry = { ...partial, ts: Date.now() };
|
|
113
|
+
// Ingest other processes' appends BEFORE our own entry so the buffer
|
|
114
|
+
// stays in file order, and `syncedBytes` stays a true prefix of the
|
|
115
|
+
// file (otherwise our own advance would skip the foreign appends).
|
|
116
|
+
if (enabled)
|
|
117
|
+
sync();
|
|
118
|
+
push(entry);
|
|
23
119
|
if (!enabled)
|
|
24
120
|
return;
|
|
25
121
|
try {
|
|
@@ -28,6 +124,7 @@ export function createHistorySink(options = {}) {
|
|
|
28
124
|
dirReady = true;
|
|
29
125
|
}
|
|
30
126
|
appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
|
|
127
|
+
syncedBytes = statSync(file).size;
|
|
31
128
|
if (!chmodded) {
|
|
32
129
|
try {
|
|
33
130
|
chmodSync(file, 0o600);
|
|
@@ -42,9 +139,11 @@ export function createHistorySink(options = {}) {
|
|
|
42
139
|
// History is best-effort: a failed write never breaks a hook.
|
|
43
140
|
}
|
|
44
141
|
}
|
|
142
|
+
seed();
|
|
45
143
|
return {
|
|
46
144
|
record,
|
|
47
145
|
recent: () => buffer,
|
|
146
|
+
sync,
|
|
48
147
|
dispose: () => { },
|
|
49
148
|
};
|
|
50
149
|
}
|
package/lib/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { eventLabel } from './context.js';
|
|
|
5
5
|
import { createHookRunner } from './runner.js';
|
|
6
6
|
import { fireNotify } from './notify.js';
|
|
7
7
|
import { createHistorySink } from './history.js';
|
|
8
|
+
import { createFeishuSetupManager } from './feishu-session.js';
|
|
8
9
|
import { registerHookRoutes } from './server.js';
|
|
9
10
|
export const name = 'dsh-hooks';
|
|
10
11
|
// Dependency on the session service: `session/event` only exists once a
|
|
@@ -22,12 +23,20 @@ export function apply(ctx, config = {}) {
|
|
|
22
23
|
const hooks = config.hooks ?? [];
|
|
23
24
|
const history = createHistorySink(config.history ?? undefined);
|
|
24
25
|
const runner = createHookRunner((line) => ctx.logger?.info(line), (record) => history.record(record));
|
|
25
|
-
// Web-profile extras: /dsh-hooks routes
|
|
26
|
-
// services are optional — CLI/headless
|
|
27
|
-
// plugin keeps working there untouched.
|
|
26
|
+
// Web-profile extras: /dsh-hooks routes (incl. the Feishu connect flow)
|
|
27
|
+
// and the agent announcement. Both services are optional — CLI/headless
|
|
28
|
+
// profiles provide neither, and the plugin keeps working there untouched.
|
|
28
29
|
const webServer = ctx.get('webServer', false);
|
|
29
30
|
if (webServer !== undefined) {
|
|
30
|
-
|
|
31
|
+
const feishu = createFeishuSetupManager();
|
|
32
|
+
ctx.effect(() => {
|
|
33
|
+
const unregister = registerHookRoutes(webServer, { hooks, history, feishu: { manager: feishu } });
|
|
34
|
+
return () => {
|
|
35
|
+
unregister();
|
|
36
|
+
// Abort an in-flight QR scan so it never outlives the plugin.
|
|
37
|
+
feishu.dispose();
|
|
38
|
+
};
|
|
39
|
+
}, 'dsh-hooks: /dsh-hooks routes');
|
|
31
40
|
}
|
|
32
41
|
const systemPrompt = ctx.get('systemPrompt', false);
|
|
33
42
|
if (systemPrompt !== undefined) {
|
package/lib/server.d.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* /dsh-hooks/* HTTP routes for the web profile: status, execution history,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* a dry-run-style test trigger, and the Feishu connect flow (QR setup /
|
|
4
|
+
* cancel / test card). Registered only when the shared webserver service
|
|
5
|
+
* exists (web profile) — CLI/headless environments never see them.
|
|
6
|
+
* Loopback-only with JSON envelopes; POSTs require an explicit
|
|
6
7
|
* application/json content-type (CSRF hardening, same posture as
|
|
7
8
|
* dsh-aionui-panel).
|
|
8
9
|
*/
|
|
9
10
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
10
11
|
import type { HookSpec } from './config.js';
|
|
11
12
|
import type { HistorySink } from './history.js';
|
|
13
|
+
import { type FeishuSetupManager } from './feishu-session.js';
|
|
14
|
+
import { runFeishuTest } from './feishu.js';
|
|
12
15
|
/** Minimal structural shape of the shared web server (dsh-host-webserver). */
|
|
13
16
|
export interface WebServerLike {
|
|
14
17
|
register(spec: {
|
|
@@ -21,10 +24,19 @@ export interface WebServerLike {
|
|
|
21
24
|
export declare function pluginVersion(): string;
|
|
22
25
|
/** Loopback fence: never let a LAN client reach /dsh-hooks operations. */
|
|
23
26
|
export declare function isLoopbackRequest(req: IncomingMessage): boolean;
|
|
27
|
+
export interface FeishuRouteDeps {
|
|
28
|
+
/** QR-scan session manager (one in-flight flow at a time). */
|
|
29
|
+
manager: FeishuSetupManager;
|
|
30
|
+
/** Test-card sender, injectable for tests. */
|
|
31
|
+
runTest?: typeof runFeishuTest;
|
|
32
|
+
/** Credential file the status route summarizes. */
|
|
33
|
+
configPath?: string;
|
|
34
|
+
}
|
|
24
35
|
export interface HookRoutesOptions {
|
|
25
36
|
hooks: readonly HookSpec[];
|
|
26
37
|
history: HistorySink;
|
|
27
38
|
version?: string;
|
|
39
|
+
feishu?: FeishuRouteDeps;
|
|
28
40
|
}
|
|
29
41
|
/** Create the /dsh-hooks route handler (exported for tests). */
|
|
30
42
|
export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|