dsh-hooks 0.3.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 +38 -7
- package/README.zh.md +37 -6
- package/bin/dsh-hooks.mjs +37 -197
- package/examples/notify-feishu.d.mts +31 -0
- package/lib/client.js +683 -0
- 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 +26 -6
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu QR-scan session manager for the web routes: one in-flight
|
|
3
|
+
* `registerApp` flow at a time, polled by the settings card. The start
|
|
4
|
+
* promise resolves as soon as the QR authorization is ready (so the UI can
|
|
5
|
+
* render the code immediately), while the scan wait and file writes finish
|
|
6
|
+
* in the background and surface through `status()`.
|
|
7
|
+
*/
|
|
8
|
+
import { runFeishuSetup, type FeishuSetupPaths } from './feishu.js';
|
|
9
|
+
export type FeishuSetupStatus = 'pending' | 'succeeded' | 'failed';
|
|
10
|
+
/** Display-only snapshot; credentials never enter any field. */
|
|
11
|
+
export interface FeishuSetupSnapshot {
|
|
12
|
+
status: FeishuSetupStatus;
|
|
13
|
+
/** Epoch ms when the flow started (server clock). */
|
|
14
|
+
startedAt: number;
|
|
15
|
+
/** Epoch ms when the QR authorization expires (pending only). */
|
|
16
|
+
expiresAtMs?: number;
|
|
17
|
+
/** Feishu authorization URL (pending only). */
|
|
18
|
+
qrUrl?: string;
|
|
19
|
+
/** PNG data URL of the QR code (pending only; best-effort). */
|
|
20
|
+
qrDataUrl?: string;
|
|
21
|
+
/** Created app id (succeeded only, unmasked — it is not a secret). */
|
|
22
|
+
appId?: string;
|
|
23
|
+
/** Failure message (failed only). */
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const FEISHU_SETUP_BUSY = "\u5DF2\u6709\u8FDB\u884C\u4E2D\u7684\u626B\u7801\u4F1A\u8BDD\uFF0C\u8BF7\u5148\u53D6\u6D88\u6216\u7B49\u5F85\u5B8C\u6210";
|
|
27
|
+
/** Render the QR as a PNG data URL (the qrcode package loads lazily). */
|
|
28
|
+
export declare function renderFeishuQr(url: string): Promise<string>;
|
|
29
|
+
export interface FeishuSetupManagerDeps {
|
|
30
|
+
runSetup?: typeof runFeishuSetup;
|
|
31
|
+
renderQr?: (url: string) => Promise<string>;
|
|
32
|
+
/** Clock override for tests. */
|
|
33
|
+
now?: () => number;
|
|
34
|
+
paths?: FeishuSetupPaths;
|
|
35
|
+
}
|
|
36
|
+
export interface FeishuSetupManager {
|
|
37
|
+
/** Start one scan flow; rejects when another flow is still pending. */
|
|
38
|
+
start(profile?: string, options?: {
|
|
39
|
+
resultMaxChars?: number;
|
|
40
|
+
}): Promise<FeishuSetupSnapshot>;
|
|
41
|
+
/** Current snapshot, or null when idle. */
|
|
42
|
+
status(): FeishuSetupSnapshot | null;
|
|
43
|
+
/** Abort the pending flow. Returns false when nothing was pending. */
|
|
44
|
+
cancel(): boolean;
|
|
45
|
+
dispose(): void;
|
|
46
|
+
}
|
|
47
|
+
export declare function createFeishuSetupManager(deps?: FeishuSetupManagerDeps): FeishuSetupManager;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu QR-scan session manager for the web routes: one in-flight
|
|
3
|
+
* `registerApp` flow at a time, polled by the settings card. The start
|
|
4
|
+
* promise resolves as soon as the QR authorization is ready (so the UI can
|
|
5
|
+
* render the code immediately), while the scan wait and file writes finish
|
|
6
|
+
* in the background and surface through `status()`.
|
|
7
|
+
*/
|
|
8
|
+
import { runFeishuSetup } from './feishu.js';
|
|
9
|
+
export const FEISHU_SETUP_BUSY = '已有进行中的扫码会话,请先取消或等待完成';
|
|
10
|
+
/** Render the QR as a PNG data URL (the qrcode package loads lazily). */
|
|
11
|
+
export async function renderFeishuQr(url) {
|
|
12
|
+
const { default: QRCode } = await import('qrcode');
|
|
13
|
+
return QRCode.toDataURL(url, { width: 320, margin: 1 });
|
|
14
|
+
}
|
|
15
|
+
export function createFeishuSetupManager(deps = {}) {
|
|
16
|
+
const runSetup = deps.runSetup ?? runFeishuSetup;
|
|
17
|
+
const renderQr = deps.renderQr ?? renderFeishuQr;
|
|
18
|
+
const now = deps.now ?? Date.now;
|
|
19
|
+
const paths = deps.paths;
|
|
20
|
+
let current = null;
|
|
21
|
+
let controller = null;
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
async function start(profile = 'web', options = {}) {
|
|
24
|
+
if (current?.status === 'pending')
|
|
25
|
+
throw new Error(FEISHU_SETUP_BUSY);
|
|
26
|
+
cancelled = false;
|
|
27
|
+
const ac = new AbortController();
|
|
28
|
+
controller = ac;
|
|
29
|
+
const signal = ac.signal;
|
|
30
|
+
const startedAt = now();
|
|
31
|
+
const snapshot = { status: 'pending', startedAt };
|
|
32
|
+
current = snapshot;
|
|
33
|
+
let resolveReady;
|
|
34
|
+
const ready = new Promise((resolve) => {
|
|
35
|
+
resolveReady = resolve;
|
|
36
|
+
});
|
|
37
|
+
const task = (async () => {
|
|
38
|
+
try {
|
|
39
|
+
const result = await runSetup({
|
|
40
|
+
profile,
|
|
41
|
+
signal,
|
|
42
|
+
paths,
|
|
43
|
+
resultMaxChars: options.resultMaxChars,
|
|
44
|
+
onQRCodeReady: async (qr) => {
|
|
45
|
+
snapshot.qrUrl = qr.url;
|
|
46
|
+
snapshot.expiresAtMs = startedAt + qr.expireIn * 1000;
|
|
47
|
+
try {
|
|
48
|
+
snapshot.qrDataUrl = await renderQr(qr.url);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// QR 图像渲染失败不阻塞扫码:UI 回退为授权链接。
|
|
52
|
+
}
|
|
53
|
+
resolveReady({ ...snapshot });
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
if (cancelled)
|
|
57
|
+
return;
|
|
58
|
+
current = { status: 'succeeded', startedAt, appId: result.appId };
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (cancelled)
|
|
62
|
+
return;
|
|
63
|
+
current = {
|
|
64
|
+
status: 'failed',
|
|
65
|
+
startedAt,
|
|
66
|
+
error: error instanceof Error ? error.message : String(error),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
// Resolve when the QR is ready; if the flow settles first (no QR callback
|
|
71
|
+
// or an immediate failure), report the terminal snapshot instead.
|
|
72
|
+
const cancelledOutcome = { status: 'failed', startedAt, error: '已取消' };
|
|
73
|
+
return await Promise.race([
|
|
74
|
+
ready,
|
|
75
|
+
task.then(() => current ?? cancelledOutcome),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
function status() {
|
|
79
|
+
return current;
|
|
80
|
+
}
|
|
81
|
+
function cancel() {
|
|
82
|
+
if (current?.status !== 'pending')
|
|
83
|
+
return false;
|
|
84
|
+
cancelled = true;
|
|
85
|
+
controller?.abort();
|
|
86
|
+
controller = null;
|
|
87
|
+
current = null;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
function dispose() {
|
|
91
|
+
cancel();
|
|
92
|
+
}
|
|
93
|
+
return { start, status, cancel, dispose };
|
|
94
|
+
}
|
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;
|