dsh-lark-agent 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 +6 -3
- package/dist/bridge.d.ts +2 -1
- package/dist/bridge.js +23 -13
- package/dist/client/AgentSettings.d.ts +12 -0
- package/dist/client/ConnectionCard.d.ts +5 -1
- package/dist/client/ManagerPanel.d.ts +3 -0
- package/dist/client/api.d.ts +14 -2
- package/dist/client/index.d.ts +1 -1
- package/dist/client/manager-styles.d.ts +2 -0
- package/dist/client/use-connection.d.ts +2 -2
- package/dist/client.cjs +727 -258
- package/dist/config.d.ts +4 -0
- package/dist/config.js +8 -0
- package/dist/dsh-runtime.d.ts +3 -0
- package/dist/dsh-runtime.js +44 -4
- package/dist/lark-cli.d.ts +7 -0
- package/dist/lark-cli.js +105 -1
- package/dist/media-input.d.ts +28 -0
- package/dist/media-input.js +127 -0
- package/dist/media-scopes.d.ts +5 -0
- package/dist/media-scopes.js +6 -0
- package/dist/message.d.ts +7 -0
- package/dist/message.js +27 -3
- package/dist/onboarding/agents.d.ts +9 -0
- package/dist/onboarding/agents.js +100 -0
- package/dist/onboarding/controller.d.ts +15 -2
- package/dist/onboarding/controller.js +155 -14
- package/dist/onboarding/host.d.ts +14 -2
- package/dist/onboarding/host.js +45 -16
- package/dist/onboarding/lark-platform.d.ts +6 -0
- package/dist/onboarding/lark-platform.js +15 -4
- package/dist/onboarding/manager.d.ts +32 -0
- package/dist/onboarding/manager.js +129 -0
- package/dist/onboarding/protocol.d.ts +51 -1
- package/dist/onboarding/settings-store.d.ts +11 -0
- package/dist/onboarding/settings-store.js +67 -0
- package/dist/session-store.d.ts +5 -1
- package/dist/session-store.js +32 -4
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { SetupError } from './controller.js';
|
|
4
|
+
import { SettingsStore } from './settings-store.js';
|
|
5
|
+
import { ffmpegAvailable } from '../media-input.js';
|
|
6
|
+
const controls = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
|
7
|
+
export const defaultAgentSettings = { label: '', agentPreset: '', workspace: '', imageInput: true, voiceInput: false };
|
|
8
|
+
export function parseAgentSettings(raw) {
|
|
9
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
10
|
+
throw new Error('Invalid agent settings');
|
|
11
|
+
const data = raw;
|
|
12
|
+
for (const [key, limit] of [['label', 80], ['agentPreset', 200], ['workspace', 4096]]) {
|
|
13
|
+
const value = data[key];
|
|
14
|
+
if (typeof value !== 'string' || value.length > limit || value !== value.trim() || controls.test(value))
|
|
15
|
+
throw new Error('Invalid agent settings');
|
|
16
|
+
}
|
|
17
|
+
if (typeof data.imageInput !== 'boolean' || typeof data.voiceInput !== 'boolean'
|
|
18
|
+
|| (data.workspace && !isAbsolute(data.workspace)))
|
|
19
|
+
throw new Error('Invalid agent settings');
|
|
20
|
+
return { label: data.label, agentPreset: data.agentPreset, workspace: data.workspace,
|
|
21
|
+
imageInput: data.imageInput, voiceInput: data.voiceInput };
|
|
22
|
+
}
|
|
23
|
+
export function agentSettingsStore(directory) {
|
|
24
|
+
return new SettingsStore(directory, 'agent.json', parseAgentSettings);
|
|
25
|
+
}
|
|
26
|
+
/** A metadata-only catalog; no plugin definitions, paths, provider endpoints or secrets. */
|
|
27
|
+
export async function agentCatalog(ctx) {
|
|
28
|
+
const service = ctx.get('agentPresets');
|
|
29
|
+
const available = typeof service?.list === 'function' && typeof service.resolve === 'function' && typeof service.mount === 'function';
|
|
30
|
+
const catalog = { presets: [], presetsAvailable: available, model: '跟随宿主模型',
|
|
31
|
+
imagesAvailable: typeof ctx.get('attachments')?.saveImages === 'function',
|
|
32
|
+
voiceAvailable: await ffmpegAvailable() };
|
|
33
|
+
const display = (value, limit) => typeof value === 'string' && !controls.test(value) ? value.slice(0, limit) : '';
|
|
34
|
+
if (available) {
|
|
35
|
+
const rows = await service.list();
|
|
36
|
+
if (!Array.isArray(rows) || rows.length > 200)
|
|
37
|
+
throw new SetupError('Agent 预设列表暂时不可用,请检查宿主设置。');
|
|
38
|
+
catalog.presets = rows.filter(row => typeof row.id === 'string' && row.id.length > 0 && row.id.length <= 200 && !controls.test(row.id))
|
|
39
|
+
.map(row => ({ id: row.id, name: display(row.name, 200) || row.id,
|
|
40
|
+
...(display(row.description, 600) ? { description: display(row.description, 600) } : {}), available: !row.broken }));
|
|
41
|
+
try {
|
|
42
|
+
catalog.defaultPreset = (await service.resolve()).id;
|
|
43
|
+
}
|
|
44
|
+
catch { /* An explicit working preset is still selectable. */ }
|
|
45
|
+
}
|
|
46
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
47
|
+
if (display(selection?.model, 200))
|
|
48
|
+
catalog.model = display(selection?.model, 200);
|
|
49
|
+
catalog.modelImages = 'unknown';
|
|
50
|
+
const llm = ctx.get('llm');
|
|
51
|
+
if (selection?.provider && selection.model && typeof llm?.resolveModelInfo === 'function') {
|
|
52
|
+
const aborter = new AbortController();
|
|
53
|
+
let timer;
|
|
54
|
+
try {
|
|
55
|
+
// Adapter metadata only. No test message, Agent creation or tool execution.
|
|
56
|
+
const info = await Promise.race([
|
|
57
|
+
llm.resolveModelInfo(selection.provider, selection.model, aborter.signal),
|
|
58
|
+
new Promise(resolve => { timer = setTimeout(() => { aborter.abort(); resolve(undefined); }, 3000); }),
|
|
59
|
+
]);
|
|
60
|
+
if (Array.isArray(info?.inputModalities))
|
|
61
|
+
catalog.modelImages = info.inputModalities.includes('image') ? 'supported' : 'unsupported';
|
|
62
|
+
}
|
|
63
|
+
catch { /* Missing metadata is shown as unknown, and checked again for the actual chat model at admission. */ }
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
aborter.abort();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return catalog;
|
|
70
|
+
}
|
|
71
|
+
export async function validateAgentSettings(ctx, settings) {
|
|
72
|
+
const next = parseAgentSettings(settings);
|
|
73
|
+
if (next.agentPreset) {
|
|
74
|
+
const presets = ctx.get('agentPresets');
|
|
75
|
+
if (typeof presets?.resolve !== 'function' || typeof presets.mount !== 'function')
|
|
76
|
+
throw new SetupError('当前宿主不支持选择 Agent 预设。');
|
|
77
|
+
try {
|
|
78
|
+
const resolved = await presets.resolve(next.agentPreset);
|
|
79
|
+
if (resolved.id !== next.agentPreset || resolved.broken)
|
|
80
|
+
throw new Error();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new SetupError('这个 Agent 预设已不可用,请刷新后重新选择。');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (next.workspace) {
|
|
87
|
+
try {
|
|
88
|
+
next.workspace = await realpath(next.workspace);
|
|
89
|
+
if (!(await stat(next.workspace)).isDirectory())
|
|
90
|
+
throw new Error();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new SetupError('工作区必须是这台设备上已有的文件夹。');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (next.voiceInput && !await ffmpegAvailable())
|
|
97
|
+
throw new SetupError('请先在宿主设备安装 FFmpeg,再启用语音输入。');
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=agents.js.map
|
|
@@ -3,9 +3,9 @@ import { type BotService } from '../bot-service.js';
|
|
|
3
3
|
import { LarkPlatform } from './lark-platform.js';
|
|
4
4
|
import { ConnectionStore } from './store.js';
|
|
5
5
|
import { waitForPairing } from './pairing.js';
|
|
6
|
-
import type { ConnectionEndpoint, ConnectionProfile, ConnectionSnapshot } from './protocol.js';
|
|
6
|
+
import type { AgentSettings, ConnectionEndpoint, ConnectionProfile, ConnectionSnapshot } from './protocol.js';
|
|
7
7
|
export interface OnboardingDependencies {
|
|
8
|
-
platform: Pick<LarkPlatform, 'listProfiles' | 'registerNew' | 'saveNewProfile' | 'inspect' | 'configureAndPublish'> & Partial<Pick<LarkPlatform, 'enableDocuments'>>;
|
|
8
|
+
platform: Pick<LarkPlatform, 'listProfiles' | 'registerNew' | 'saveNewProfile' | 'inspect' | 'configureAndPublish'> & Partial<Pick<LarkPlatform, 'enableDocuments' | 'enableInput'>>;
|
|
9
9
|
store: ConnectionStore;
|
|
10
10
|
checkRuntime(): void;
|
|
11
11
|
start(config: BotConfig, signal: AbortSignal, onHealth: (ready: boolean) => void, pairedChat?: {
|
|
@@ -19,6 +19,10 @@ export interface OnboardingDependencies {
|
|
|
19
19
|
executable?: string;
|
|
20
20
|
/** Delay before automatic restore attempt `attempt` (0-based) of an enabled connection. */
|
|
21
21
|
retryDelay?: (attempt: number) => number;
|
|
22
|
+
validateAgent?: (settings: AgentSettings) => Promise<AgentSettings>;
|
|
23
|
+
reserveApp?: (appId: string) => void;
|
|
24
|
+
/** Release former reservations only after the replacement record is durable. */
|
|
25
|
+
commitApp?: (appId: string) => void;
|
|
22
26
|
}
|
|
23
27
|
export declare class SetupError extends Error {
|
|
24
28
|
readonly retryable: boolean;
|
|
@@ -39,20 +43,27 @@ export declare class ConnectionController {
|
|
|
39
43
|
private unsafeStop;
|
|
40
44
|
private menuReady;
|
|
41
45
|
private documentsReady;
|
|
46
|
+
private imagesReady;
|
|
47
|
+
private voiceReady;
|
|
42
48
|
/** Menu entries the published app lacks; the owner adds them in the developer console. */
|
|
43
49
|
private menuMissing;
|
|
44
50
|
private documentSetup?;
|
|
45
51
|
private documentJob?;
|
|
52
|
+
private inputSetup?;
|
|
46
53
|
private documentAborter?;
|
|
47
54
|
private candidate?;
|
|
48
55
|
private commands;
|
|
49
56
|
private retryTimer?;
|
|
50
57
|
private retryAttempts;
|
|
58
|
+
private settings;
|
|
59
|
+
private readonly settingsStore;
|
|
60
|
+
private settingsFailed;
|
|
51
61
|
constructor(deps: OnboardingDependencies);
|
|
52
62
|
snapshot(): ConnectionSnapshot;
|
|
53
63
|
private phase;
|
|
54
64
|
private action;
|
|
55
65
|
private consoleUrl;
|
|
66
|
+
private inspectCapabilities;
|
|
56
67
|
private assertCurrent;
|
|
57
68
|
private save;
|
|
58
69
|
private launch;
|
|
@@ -68,6 +79,8 @@ export declare class ConnectionController {
|
|
|
68
79
|
command(endpoint: Exclude<ConnectionEndpoint, 'profiles' | 'status'>, payload: unknown): Promise<ConnectionSnapshot>;
|
|
69
80
|
private create;
|
|
70
81
|
private check;
|
|
82
|
+
private botDirectory;
|
|
83
|
+
private defaultWorkspace;
|
|
71
84
|
private config;
|
|
72
85
|
private bind;
|
|
73
86
|
private start;
|
|
@@ -5,11 +5,14 @@ import QRCode from 'qrcode';
|
|
|
5
5
|
import { parseConfig } from '../config.js';
|
|
6
6
|
import { BotServiceStartError } from '../bot-service.js';
|
|
7
7
|
import { LarkCli } from '../lark-cli.js';
|
|
8
|
-
import { LockHeldError } from '../lock.js';
|
|
8
|
+
import { acquireLock, LockHeldError } from '../lock.js';
|
|
9
9
|
import { BOT_SCOPES, CARD_SCOPE, LarkPlatformError } from './lark-platform.js';
|
|
10
10
|
import { createChallenge, PairingError, waitForPairing } from './pairing.js';
|
|
11
11
|
import { defaultScopesReady } from '../rich-scopes.js';
|
|
12
12
|
import { missingBotMenuEntries } from '../bot-menu.js';
|
|
13
|
+
import { agentSettingsStore, defaultAgentSettings, parseAgentSettings } from './agents.js';
|
|
14
|
+
import { SessionStore } from '../session-store.js';
|
|
15
|
+
import { MEDIA_RESOURCE_SCOPE, mediaScopes } from '../media-scopes.js';
|
|
13
16
|
const restoreDelay = (attempt) => Math.min(10 * 60_000, 5_000 * 2 ** Math.min(attempt, 10));
|
|
14
17
|
const messages = {
|
|
15
18
|
idle: '把助手带到飞书,先用飞书确认创建。',
|
|
@@ -76,27 +79,37 @@ export class ConnectionController {
|
|
|
76
79
|
unsafeStop = false;
|
|
77
80
|
menuReady = false;
|
|
78
81
|
documentsReady = false;
|
|
82
|
+
imagesReady = false;
|
|
83
|
+
voiceReady = false;
|
|
79
84
|
/** Menu entries the published app lacks; the owner adds them in the developer console. */
|
|
80
85
|
menuMissing = [];
|
|
81
86
|
documentSetup;
|
|
82
87
|
documentJob;
|
|
88
|
+
inputSetup;
|
|
83
89
|
documentAborter;
|
|
84
90
|
candidate;
|
|
85
91
|
commands = Promise.resolve();
|
|
86
92
|
retryTimer;
|
|
87
93
|
retryAttempts = 0;
|
|
94
|
+
settings = { ...defaultAgentSettings };
|
|
95
|
+
settingsStore;
|
|
96
|
+
settingsFailed = false;
|
|
88
97
|
constructor(deps) {
|
|
89
98
|
this.deps = deps;
|
|
99
|
+
this.settingsStore = agentSettingsStore(deps.store.directory);
|
|
90
100
|
}
|
|
91
101
|
snapshot() {
|
|
92
102
|
return structuredClone({ ...this.state,
|
|
93
|
-
|
|
103
|
+
agent: { ...this.settings, workspaceDisplay: this.settings.workspace || this.defaultWorkspace() || '连接后自动创建独立工作区',
|
|
104
|
+
editable: !this.closed && !this.service && !this.job && !this.documentJob && !this.record?.enabled && !this.unsafeStop && !this.settingsFailed },
|
|
105
|
+
capabilities: { ...this.state.capabilities, documents: this.documentsReady, images: this.imagesReady, voice: this.voiceReady }, ...(this.documentSetup ? { documentSetup: this.documentSetup } : {}),
|
|
106
|
+
...(this.inputSetup ? { inputSetup: this.inputSetup } : {}),
|
|
94
107
|
...(this.record && this.menuMissing.length ? { menu: { missing: this.menuMissing, url: this.consoleUrl() } } : {}) });
|
|
95
108
|
}
|
|
96
109
|
phase(phase, message = messages[phase]) {
|
|
97
110
|
this.state = { phase, message, busy: ['creating', 'configuring', 'starting'].includes(phase),
|
|
98
111
|
capabilities: { cards: this.record?.cards ?? true, loading: this.record?.loading ?? true },
|
|
99
|
-
...(this.record ? { bot: { name: '我的 dsh 助手', appId: this.record.appId,
|
|
112
|
+
...(this.record ? { bot: { name: this.settings.label || '我的 dsh 助手', appId: this.record.appId,
|
|
100
113
|
url: `https://applink.${this.record.brand === 'lark' ? 'larksuite.com' : 'feishu.cn'}/client/bot/open?appId=${this.record.appId}` } } : {}) };
|
|
101
114
|
}
|
|
102
115
|
action(message = messages.action_required) {
|
|
@@ -107,9 +120,19 @@ export class ConnectionController {
|
|
|
107
120
|
consoleUrl() {
|
|
108
121
|
return `https://open.${this.record?.brand === 'lark' ? 'larksuite.com' : 'feishu.cn'}/app/${this.record.appId}`;
|
|
109
122
|
}
|
|
123
|
+
inspectCapabilities(inspection) {
|
|
124
|
+
this.documentsReady = inspection.scopeStatus === 'verified' && defaultScopesReady(inspection.grantedScopes);
|
|
125
|
+
this.imagesReady = inspection.scopeStatus === 'verified' && inspection.grantedScopes.includes(MEDIA_RESOURCE_SCOPE);
|
|
126
|
+
this.voiceReady = inspection.scopeStatus === 'verified' && mediaScopes('voice').every(scope => inspection.grantedScopes.includes(scope));
|
|
127
|
+
this.menuMissing = missingBotMenuEntries(inspection.menuEventKeys ?? []);
|
|
128
|
+
}
|
|
110
129
|
assertCurrent(signal) { signal.throwIfAborted(); if (this.closed)
|
|
111
130
|
throw new SetupError('连接设置已关闭。'); }
|
|
112
|
-
async save(record) {
|
|
131
|
+
async save(record) {
|
|
132
|
+
await this.deps.store.write(record);
|
|
133
|
+
this.record = record;
|
|
134
|
+
this.deps.commitApp?.(record.appId);
|
|
135
|
+
}
|
|
113
136
|
launch(work) {
|
|
114
137
|
this.clearRetry();
|
|
115
138
|
const generation = ++this.generation;
|
|
@@ -156,9 +179,11 @@ export class ConnectionController {
|
|
|
156
179
|
}
|
|
157
180
|
clearRetry() { clearTimeout(this.retryTimer); this.retryTimer = undefined; }
|
|
158
181
|
async initialize() {
|
|
182
|
+
this.settings = await this.settingsStore.read() ?? { ...defaultAgentSettings };
|
|
159
183
|
this.record = await this.deps.store.read(); // Corrupt durable state fails closed.
|
|
160
184
|
if (!this.record)
|
|
161
185
|
return;
|
|
186
|
+
this.deps.reserveApp?.(this.record.appId);
|
|
162
187
|
if (this.record.enabled && this.record.ownerOpenId) {
|
|
163
188
|
this.phase('configuring');
|
|
164
189
|
this.launch(signal => this.check(signal, false));
|
|
@@ -171,8 +196,7 @@ export class ConnectionController {
|
|
|
171
196
|
this.documentJob = this.deps.platform.inspect(record.profile, aborter.signal).then(inspection => {
|
|
172
197
|
if (!aborter.signal.aborted && !this.closed && this.record?.appId === record.appId) {
|
|
173
198
|
if (inspection.appId === record.appId && (!record.botOpenId || inspection.botOpenId === record.botOpenId)) {
|
|
174
|
-
this.
|
|
175
|
-
this.menuMissing = missingBotMenuEntries(inspection.menuEventKeys ?? []);
|
|
199
|
+
this.inspectCapabilities(inspection);
|
|
176
200
|
}
|
|
177
201
|
}
|
|
178
202
|
}).catch(() => { })
|
|
@@ -192,20 +216,112 @@ export class ConnectionController {
|
|
|
192
216
|
const result = this.commands.then(async () => {
|
|
193
217
|
if (this.closed)
|
|
194
218
|
throw new SetupError('连接设置已关闭。');
|
|
219
|
+
if (this.settingsFailed)
|
|
220
|
+
throw new SetupError('Agent 设置保存结果未能确认,请重启宿主后重新读取。');
|
|
195
221
|
if (this.unsafeStop)
|
|
196
222
|
throw new SetupError('连接未能安全停止,请检查并关闭原实例。为防止重复处理,当前连接锁已保留。');
|
|
197
223
|
const data = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
|
|
198
|
-
if (endpoint === '
|
|
224
|
+
if (endpoint === 'update-agent') {
|
|
225
|
+
if (this.service || this.job || this.documentJob || this.record?.enabled)
|
|
226
|
+
throw new SetupError('请先暂停这个机器人的连接,再修改 Agent 设置。');
|
|
227
|
+
let next;
|
|
228
|
+
try {
|
|
229
|
+
next = parseAgentSettings(data);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new SetupError('Agent 设置格式不正确,请检查名称、预设和工作区。');
|
|
233
|
+
}
|
|
234
|
+
if (next.voiceInput && !this.settings.voiceInput && !this.voiceReady)
|
|
235
|
+
throw new SetupError('请先开通并检查这个机器人的飞书语音权限,再启用语音转文字。');
|
|
236
|
+
if (next.imageInput && !this.settings.imageInput && !this.imagesReady)
|
|
237
|
+
throw new SetupError('请先开通并检查这个机器人的飞书图片权限,再启用图片识别。');
|
|
238
|
+
if (this.deps.validateAgent)
|
|
239
|
+
next = await this.deps.validateAgent(next);
|
|
240
|
+
// Existing native logs keep their original workspace even when new topics move.
|
|
241
|
+
const oldWorkspace = this.settings.workspace || this.defaultWorkspace();
|
|
242
|
+
if (oldWorkspace && next.workspace !== this.settings.workspace) {
|
|
243
|
+
const stateDir = join(this.botDirectory(), 'state');
|
|
244
|
+
const release = await acquireLock(stateDir);
|
|
245
|
+
try {
|
|
246
|
+
await (await SessionStore.open(stateDir)).pinWorkspace(oldWorkspace);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
await release();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
await this.settingsStore.write(next);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
this.settingsFailed = true;
|
|
257
|
+
throw new SetupError('Agent 设置未能可靠保存,请重启宿主后重新读取。');
|
|
258
|
+
}
|
|
259
|
+
this.settings = next;
|
|
260
|
+
if (this.state.bot)
|
|
261
|
+
this.state.bot.name = next.label || '我的 dsh 助手';
|
|
262
|
+
return this.snapshot();
|
|
263
|
+
}
|
|
264
|
+
if (endpoint === 'cancel-documents' || endpoint === 'cancel-input') {
|
|
199
265
|
await this.cancelDocuments();
|
|
200
266
|
return this.snapshot();
|
|
201
267
|
}
|
|
268
|
+
if (endpoint === 'enable-input') {
|
|
269
|
+
if (data.kind !== 'images' && data.kind !== 'voice')
|
|
270
|
+
throw new SetupError('不支持的消息输入类型。');
|
|
271
|
+
const kind = data.kind;
|
|
272
|
+
if (!this.record?.verified || !this.record.ownerOpenId || this.job || !this.deps.platform.enableInput)
|
|
273
|
+
throw new SetupError('请先连接并确认本人,再开通消息输入权限。');
|
|
274
|
+
if (kind === 'images' ? this.imagesReady : this.voiceReady)
|
|
275
|
+
return this.snapshot();
|
|
276
|
+
if (this.documentJob) {
|
|
277
|
+
if (this.inputSetup?.kind === kind)
|
|
278
|
+
return this.snapshot();
|
|
279
|
+
throw new SetupError('请先完成或取消当前的权限检查与授权。');
|
|
280
|
+
}
|
|
281
|
+
const record = this.record, aborter = this.documentAborter = new AbortController();
|
|
282
|
+
const feature = kind === 'images' ? '图片' : '语音';
|
|
283
|
+
this.inputSetup = { kind, phase: 'preparing', message: `正在准备${feature}权限授权。` };
|
|
284
|
+
this.documentJob = this.deps.platform.enableInput(record.profile, kind, info => {
|
|
285
|
+
const url = officialDestination(info.url), expiresAt = Date.now() + info.expireIn * 1000;
|
|
286
|
+
void (this.deps.qr ?? (url => QRCode.toDataURL(url, { width: 256, margin: 2 })))(url).then(image => {
|
|
287
|
+
if (aborter.signal.aborted || this.closed || this.documentAborter !== aborter)
|
|
288
|
+
return;
|
|
289
|
+
this.inputSetup = { kind, phase: 'scanning', message: `用飞书扫码为这个机器人开通${feature}权限,完成后再勾选并保存输入设置。`, qr: { url, image, expiresAt } };
|
|
290
|
+
}).catch(() => { if (!aborter.signal.aborted) {
|
|
291
|
+
this.inputSetup = { kind, phase: 'error', message: '二维码未能生成,请重试。' };
|
|
292
|
+
aborter.abort();
|
|
293
|
+
} });
|
|
294
|
+
}, aborter.signal, record.appId).then(async () => {
|
|
295
|
+
if (aborter.signal.aborted || this.closed)
|
|
296
|
+
return;
|
|
297
|
+
const inspection = await this.deps.platform.inspect(record.profile, aborter.signal);
|
|
298
|
+
if (aborter.signal.aborted || this.closed || this.record?.appId !== record.appId)
|
|
299
|
+
return;
|
|
300
|
+
if (inspection.appId !== record.appId || inspection.botOpenId !== record.botOpenId)
|
|
301
|
+
throw new SetupError('机器人身份发生变化,请断开后重新绑定。', false);
|
|
302
|
+
this.inspectCapabilities(inspection);
|
|
303
|
+
if (!(kind === 'images' ? this.imagesReady : this.voiceReady))
|
|
304
|
+
throw new SetupError('飞书权限尚未生效,请确认审批和发布完成后重新检查连接。');
|
|
305
|
+
this.inputSetup = undefined;
|
|
306
|
+
}).catch(error => {
|
|
307
|
+
if (!aborter.signal.aborted && !this.closed)
|
|
308
|
+
this.inputSetup = { kind, phase: 'error', message: errorMessage(error) };
|
|
309
|
+
}).finally(() => { if (this.documentAborter === aborter) {
|
|
310
|
+
this.documentJob = undefined;
|
|
311
|
+
this.documentAborter = undefined;
|
|
312
|
+
} });
|
|
313
|
+
return this.snapshot();
|
|
314
|
+
}
|
|
202
315
|
if (endpoint === 'enable-documents') {
|
|
203
316
|
if (!this.record?.verified || !this.record.ownerOpenId || this.job || !this.deps.platform.enableDocuments)
|
|
204
317
|
throw new SetupError('请先连接并确认本人,再补充默认权限。');
|
|
205
318
|
if (this.documentsReady)
|
|
206
319
|
return this.snapshot();
|
|
207
|
-
if (this.documentJob)
|
|
320
|
+
if (this.documentJob) {
|
|
321
|
+
if (this.inputSetup)
|
|
322
|
+
throw new SetupError('请先完成或取消当前的消息输入授权。');
|
|
208
323
|
return this.snapshot();
|
|
324
|
+
}
|
|
209
325
|
const record = this.record, aborter = this.documentAborter = new AbortController();
|
|
210
326
|
this.documentSetup = { phase: 'preparing', message: '正在准备默认权限授权,聊天连接继续保持。' };
|
|
211
327
|
this.documentJob = this.deps.platform.enableDocuments(record.profile, info => {
|
|
@@ -277,6 +393,7 @@ export class ConnectionController {
|
|
|
277
393
|
if (!profile)
|
|
278
394
|
throw new SetupError('没有找到这个机器人,请刷新列表。');
|
|
279
395
|
this.assertCurrent(signal);
|
|
396
|
+
this.deps.reserveApp?.(profile.appId);
|
|
280
397
|
const previous = this.record?.profile === name && this.record.appId === profile.appId
|
|
281
398
|
&& this.record.brand === profile.brand ? this.record : undefined;
|
|
282
399
|
await this.save({ schema: 1, profile: name, appId: profile.appId, brand: profile.brand,
|
|
@@ -311,9 +428,23 @@ export class ConnectionController {
|
|
|
311
428
|
if (this.service) {
|
|
312
429
|
if (!this.record.verified || !this.record.enabled)
|
|
313
430
|
throw new SetupError('本人验证尚未完成,请先安全停止连接后重新配对。');
|
|
431
|
+
if (endpoint === 'recheck' && !this.documentJob) {
|
|
432
|
+
const inspection = await this.deps.platform.inspect(this.record.profile, new AbortController().signal);
|
|
433
|
+
if (inspection.appId !== this.record.appId || inspection.botOpenId !== this.record.botOpenId)
|
|
434
|
+
throw new SetupError('机器人身份发生变化,请断开后重新绑定。', false);
|
|
435
|
+
this.inspectCapabilities(inspection);
|
|
436
|
+
}
|
|
314
437
|
this.phase(this.service.ready() ? 'connected' : 'starting', this.service.ready() ? messages.connected : '正在重新连接飞书。');
|
|
315
438
|
return this.snapshot();
|
|
316
439
|
}
|
|
440
|
+
if (endpoint === 'recheck' && this.record.verified && !this.record.enabled) {
|
|
441
|
+
const inspection = await this.deps.platform.inspect(this.record.profile, new AbortController().signal);
|
|
442
|
+
if (inspection.appId !== this.record.appId || inspection.botOpenId !== this.record.botOpenId)
|
|
443
|
+
throw new SetupError('机器人身份发生变化,请断开后重新绑定。', false);
|
|
444
|
+
this.inspectCapabilities(inspection);
|
|
445
|
+
this.phase('paused');
|
|
446
|
+
return this.snapshot();
|
|
447
|
+
}
|
|
317
448
|
this.deps.checkRuntime();
|
|
318
449
|
this.retryAttempts = 0;
|
|
319
450
|
this.phase('configuring');
|
|
@@ -345,6 +476,7 @@ export class ConnectionController {
|
|
|
345
476
|
} });
|
|
346
477
|
this.assertCurrent(signal);
|
|
347
478
|
this.phase('configuring');
|
|
479
|
+
this.deps.reserveApp?.(registration.appId);
|
|
348
480
|
await this.deps.platform.saveNewProfile(profile, registration, signal);
|
|
349
481
|
this.assertCurrent(signal);
|
|
350
482
|
await this.save({ schema: 1, profile, appId: registration.appId, brand: registration.brand,
|
|
@@ -357,8 +489,7 @@ export class ConnectionController {
|
|
|
357
489
|
this.assertCurrent(signal);
|
|
358
490
|
let record = this.record;
|
|
359
491
|
let inspection = await this.deps.platform.inspect(record.profile, signal);
|
|
360
|
-
this.
|
|
361
|
-
this.menuMissing = missingBotMenuEntries(inspection.menuEventKeys ?? []);
|
|
492
|
+
this.inspectCapabilities(inspection);
|
|
362
493
|
this.assertCurrent(signal);
|
|
363
494
|
if (inspection.appId !== record.appId || (record.botOpenId && inspection.botOpenId && record.botOpenId !== inspection.botOpenId)) {
|
|
364
495
|
throw new SetupError('机器人身份发生变化,请断开后重新绑定。', false);
|
|
@@ -395,6 +526,7 @@ export class ConnectionController {
|
|
|
395
526
|
if (inspection.appId !== record.appId || (record.botOpenId && inspection.botOpenId && record.botOpenId !== inspection.botOpenId)) {
|
|
396
527
|
throw new SetupError('机器人身份发生变化,请断开后重新绑定。', false);
|
|
397
528
|
}
|
|
529
|
+
this.inspectCapabilities(inspection);
|
|
398
530
|
await this.save({ ...this.record, botOpenId: inspection.botOpenId ?? this.record.botOpenId,
|
|
399
531
|
creatorOpenId: this.record.creatorOpenId ?? inspection.creatorOpenId });
|
|
400
532
|
}
|
|
@@ -439,17 +571,25 @@ export class ConnectionController {
|
|
|
439
571
|
this.phase('confirming_owner');
|
|
440
572
|
this.state.confirmation = { id: this.candidate.id, label: '已收到配对消息,请确认这是你刚发送的私聊。' };
|
|
441
573
|
}
|
|
574
|
+
botDirectory() {
|
|
575
|
+
if (!this.record?.ownerOpenId)
|
|
576
|
+
return;
|
|
577
|
+
const identity = createHash('sha256').update(JSON.stringify([this.record.appId, this.record.ownerOpenId])).digest('hex').slice(0, 24);
|
|
578
|
+
return join(this.deps.store.directory, 'bots', identity);
|
|
579
|
+
}
|
|
580
|
+
defaultWorkspace() { const directory = this.botDirectory(); return directory ? join(directory, 'workspace') : ''; }
|
|
442
581
|
async config() {
|
|
443
582
|
const record = this.record;
|
|
444
583
|
if (!record.ownerOpenId || !record.botOpenId)
|
|
445
584
|
throw new SetupError('请先确认你的飞书身份。', false);
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
const cwd = join(directory, 'workspace');
|
|
585
|
+
const directory = this.botDirectory();
|
|
586
|
+
const cwd = this.settings.workspace || this.defaultWorkspace();
|
|
449
587
|
await mkdir(cwd, { recursive: true, mode: 0o700 });
|
|
450
588
|
return parseConfig({ profile: record.profile, botOpenId: record.botOpenId, stateDir: join(directory, 'state'),
|
|
451
589
|
executable: this.deps.executable ?? 'lark-cli', users: [{ openId: record.ownerOpenId, direct: true, directMode: 'harness',
|
|
452
|
-
groupChats: [], cwd, allowedTools: []
|
|
590
|
+
groupChats: [], cwd, allowedTools: [], ...(this.settings.agentPreset ? { agentPreset: this.settings.agentPreset } : {}) }],
|
|
591
|
+
imageInput: this.settings.imageInput, voiceInput: this.settings.voiceInput,
|
|
592
|
+
cardInteractions: record.cards, loadingReaction: record.loading, botMenu: this.menuReady,
|
|
453
593
|
// Without the CardKit scope a live card cannot be created and replies fall back to ordinary messages.
|
|
454
594
|
streamingCards: true }, directory);
|
|
455
595
|
}
|
|
@@ -545,6 +685,7 @@ export class ConnectionController {
|
|
|
545
685
|
this.documentJob = undefined;
|
|
546
686
|
this.documentAborter = undefined;
|
|
547
687
|
this.documentSetup = undefined;
|
|
688
|
+
this.inputSetup = undefined;
|
|
548
689
|
}
|
|
549
690
|
async dispose() {
|
|
550
691
|
this.closed = true;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import { ConnectionController } from './controller.js';
|
|
3
|
-
import type { ConnectionResult } from './protocol.js';
|
|
3
|
+
import type { ConnectionEndpoint, ConnectionResult, ConnectionSnapshot, AgentCatalog, ConnectionList } from './protocol.js';
|
|
4
4
|
import { type Release } from './updates.js';
|
|
5
5
|
interface Peer {
|
|
6
6
|
principal?: {
|
|
@@ -21,7 +21,19 @@ export interface OwnerFetchRoutes {
|
|
|
21
21
|
fetch(request: Request): Promise<Response>;
|
|
22
22
|
}): () => void | Promise<void>;
|
|
23
23
|
}
|
|
24
|
-
type ConnectionControl =
|
|
24
|
+
type ConnectionControl = {
|
|
25
|
+
snapshot(id?: string): ConnectionSnapshot;
|
|
26
|
+
profiles(signal?: AbortSignal, id?: string): ReturnType<ConnectionController['profiles']>;
|
|
27
|
+
command(endpoint: Exclude<ConnectionEndpoint, 'status' | 'profiles'>, payload: unknown, id?: string): Promise<ConnectionSnapshot>;
|
|
28
|
+
list?(): ConnectionList;
|
|
29
|
+
add?(): Promise<ConnectionList & {
|
|
30
|
+
selectedId: string;
|
|
31
|
+
}>;
|
|
32
|
+
remove?(id: string): Promise<ConnectionList & {
|
|
33
|
+
selectedId: string;
|
|
34
|
+
}>;
|
|
35
|
+
catalog?(): Promise<AgentCatalog>;
|
|
36
|
+
};
|
|
25
37
|
/** Settings stay readable, but inert, while another running host owns this connection directory. */
|
|
26
38
|
export declare function lockedConnectionControl(): ConnectionControl;
|
|
27
39
|
/** Legacy channels pin loopback; modern exact routes inherit the host's authenticated owner gate. */
|
package/dist/onboarding/host.js
CHANGED
|
@@ -6,13 +6,16 @@ import { delimiter, join, resolve } from 'node:path';
|
|
|
6
6
|
import { createBotService, BotServiceStartError } from '../bot-service.js';
|
|
7
7
|
import { checkRuntimeCompatibility } from '../dsh-runtime.js';
|
|
8
8
|
import { acquireLock, LockHeldError } from '../lock.js';
|
|
9
|
-
import {
|
|
10
|
-
import { ConnectionStore } from './store.js';
|
|
9
|
+
import { SetupError } from './controller.js';
|
|
11
10
|
import { LarkPlatform } from './lark-platform.js';
|
|
11
|
+
import { ConnectionManager, validConnectionId } from './manager.js';
|
|
12
|
+
import { agentCatalog, parseAgentSettings, validateAgentSettings } from './agents.js';
|
|
12
13
|
import { npmLatest, UpdateCheck } from './updates.js';
|
|
13
14
|
import { checkConnectionModel } from './model-check.js';
|
|
14
15
|
const endpoints = new Set(['status', 'profiles', 'create', 'use-profile', 'recheck', 'cancel',
|
|
15
|
-
'renew-pairing', 'confirm-owner', 'pause', 'resume', 'disconnect', 'enable-documents', 'cancel-documents']);
|
|
16
|
+
'renew-pairing', 'confirm-owner', 'pause', 'resume', 'disconnect', 'enable-documents', 'cancel-documents', 'enable-input', 'cancel-input', 'update-agent']);
|
|
17
|
+
const managerEndpoints = ['connections', 'add-connection', 'remove-connection', 'agent-catalog'];
|
|
18
|
+
const allEndpoints = [...endpoints, ...managerEndpoints];
|
|
16
19
|
const failure = (message) => ({ ok: false, error: { code: 'internal', message, details: {} } });
|
|
17
20
|
class ConnectionRegistrationCleanupError extends Error {
|
|
18
21
|
}
|
|
@@ -30,24 +33,48 @@ export function registerConnectionRpc(rpc, controller, ownerRoutes) {
|
|
|
30
33
|
if (!legacy && rpc.handle.length !== 2)
|
|
31
34
|
throw new SetupError('此 dsh 版本的连接接口尚未验证,请更新到支持的版本。');
|
|
32
35
|
const dispatch = async (endpoint, payload, signal) => {
|
|
33
|
-
if (!
|
|
36
|
+
if (!allEndpoints.includes(endpoint))
|
|
34
37
|
return failure('不支持的连接操作。');
|
|
35
38
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
36
39
|
return failure('连接请求格式不正确。');
|
|
37
|
-
const
|
|
40
|
+
const manager = managerEndpoints.includes(endpoint);
|
|
41
|
+
const allowed = [...(manager && endpoint !== 'remove-connection' ? [] : ['connectionId']), ...(endpoint === 'create' ? ['cards', 'loading'] : endpoint === 'use-profile' ? ['name'] : endpoint === 'confirm-owner' ? ['id']
|
|
42
|
+
: endpoint === 'update-agent' ? ['label', 'agentPreset', 'workspace', 'imageInput', 'voiceInput'] : endpoint === 'enable-input' ? ['kind'] : [])];
|
|
38
43
|
const data = payload;
|
|
39
|
-
if (Object.keys(data).some(key => !allowed.includes(key)) || Object.values(data).some(value => !['boolean', 'string'].includes(typeof value) || (typeof value === 'string' && value.length > 200))
|
|
44
|
+
if (Object.keys(data).some(key => !allowed.includes(key)) || Object.values(data).some(value => !['boolean', 'string'].includes(typeof value)) || Object.entries(data).some(([key, value]) => typeof value === 'string' && value.length > (key === 'workspace' ? 4096 : 200))
|
|
45
|
+
|| (data.connectionId !== undefined && !validConnectionId(data.connectionId)))
|
|
40
46
|
return failure('连接请求格式不正确。');
|
|
41
47
|
if ((endpoint === 'create' && ['cards', 'loading'].some(key => data[key] !== undefined && typeof data[key] !== 'boolean'))
|
|
42
48
|
|| (endpoint === 'use-profile' && typeof data.name !== 'string')
|
|
43
|
-
|| (endpoint === 'confirm-owner' && typeof data.id !== 'string')
|
|
49
|
+
|| (endpoint === 'confirm-owner' && typeof data.id !== 'string')
|
|
50
|
+
|| (endpoint === 'enable-input' && data.kind !== 'images' && data.kind !== 'voice'))
|
|
44
51
|
return failure('连接请求格式不正确。');
|
|
52
|
+
if (endpoint === 'update-agent') {
|
|
53
|
+
try {
|
|
54
|
+
parseAgentSettings(data);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return failure('Agent 设置格式不正确。');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
45
60
|
try {
|
|
61
|
+
if (endpoint === 'connections' && controller.list)
|
|
62
|
+
return { ok: true, value: controller.list() };
|
|
63
|
+
if (endpoint === 'add-connection' && controller.add)
|
|
64
|
+
return { ok: true, value: await controller.add() };
|
|
65
|
+
if (endpoint === 'remove-connection' && controller.remove && typeof data.connectionId === 'string')
|
|
66
|
+
return { ok: true, value: await controller.remove(data.connectionId) };
|
|
67
|
+
if (endpoint === 'agent-catalog' && controller.catalog)
|
|
68
|
+
return { ok: true, value: await controller.catalog() };
|
|
69
|
+
if (manager)
|
|
70
|
+
return failure('当前连接服务不支持机器人管理,请检查插件版本。');
|
|
71
|
+
const { connectionId, ...fields } = data;
|
|
46
72
|
if (endpoint === 'status')
|
|
47
|
-
return { ok: true, value: controller.snapshot() };
|
|
73
|
+
return { ok: true, value: connectionId === undefined ? controller.snapshot() : controller.snapshot(connectionId) };
|
|
48
74
|
if (endpoint === 'profiles')
|
|
49
|
-
return { ok: true, value: await controller.profiles(signal) };
|
|
50
|
-
|
|
75
|
+
return { ok: true, value: await (connectionId === undefined ? controller.profiles(signal) : controller.profiles(signal, connectionId)) };
|
|
76
|
+
const command = endpoint;
|
|
77
|
+
return { ok: true, value: await (connectionId === undefined ? controller.command(command, fields) : controller.command(command, fields, connectionId)) };
|
|
51
78
|
}
|
|
52
79
|
catch (error) {
|
|
53
80
|
return failure(error instanceof SetupError ? error.message : '连接操作暂时失败,请检查本机服务后重试。');
|
|
@@ -73,7 +100,7 @@ export function registerConnectionRpc(rpc, controller, ownerRoutes) {
|
|
|
73
100
|
throw new ConnectionRegistrationCleanupError('Connection routes could not be removed.');
|
|
74
101
|
};
|
|
75
102
|
try {
|
|
76
|
-
for (const endpoint of
|
|
103
|
+
for (const endpoint of allEndpoints) {
|
|
77
104
|
disposers.push(ownerRoutes.register({ path: `/api/larkbot/${endpoint}`, methods: ['POST'], requestBody: 'buffered',
|
|
78
105
|
async fetch(request) {
|
|
79
106
|
const envelope = await readConnectionEnvelope(request, `larkbot/${endpoint}`);
|
|
@@ -228,7 +255,8 @@ export async function applyOnboarding(ctx, options = {}) {
|
|
|
228
255
|
return;
|
|
229
256
|
}
|
|
230
257
|
const executable = await findLarkExecutable(options.executable);
|
|
231
|
-
const controller = new
|
|
258
|
+
const controller = new ConnectionManager(directory, () => ({ platform: new LarkPlatform({ executable }), executable,
|
|
259
|
+
validateAgent: settings => validateAgentSettings(ctx, settings),
|
|
232
260
|
checkRuntime: () => requireRuntime(ctx),
|
|
233
261
|
preflight: (config, signal) => checkConnectionModel(ctx, config, signal),
|
|
234
262
|
start: async (config, signal, health, pairedChat) => {
|
|
@@ -246,14 +274,15 @@ export async function applyOnboarding(ctx, options = {}) {
|
|
|
246
274
|
throw error;
|
|
247
275
|
}
|
|
248
276
|
},
|
|
249
|
-
});
|
|
277
|
+
}), () => agentCatalog(ctx));
|
|
250
278
|
const installed = installedPackage();
|
|
251
279
|
const updates = new UpdateCheck(installed?.version, options.latestRelease
|
|
252
280
|
?? (signal => installed ? npmLatest(installed.name, signal) : Promise.resolve(undefined)));
|
|
253
281
|
const control = {
|
|
254
|
-
snapshot:
|
|
255
|
-
profiles: signal => controller.profiles(signal),
|
|
256
|
-
command: async (endpoint, payload) => withUpdate(await controller.command(endpoint, payload), updates),
|
|
282
|
+
snapshot: id => withUpdate(controller.snapshot(id), updates),
|
|
283
|
+
profiles: (signal, id) => controller.profiles(signal, id),
|
|
284
|
+
command: async (endpoint, payload, id) => withUpdate(await controller.command(endpoint, payload, id), updates),
|
|
285
|
+
list: () => controller.list(), add: () => controller.add(), remove: id => controller.remove(id), catalog: () => controller.catalog(),
|
|
257
286
|
};
|
|
258
287
|
let remove;
|
|
259
288
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { registerApp as sdkRegisterApp } from '@larksuiteoapi/node-sdk';
|
|
2
|
+
import { type MediaInputKind } from '../media-scopes.js';
|
|
2
3
|
type RegisterAppOptions = Parameters<typeof sdkRegisterApp>[0];
|
|
3
4
|
type RegisterAppResult = Awaited<ReturnType<typeof sdkRegisterApp>>;
|
|
4
5
|
export type LarkBrand = 'feishu' | 'lark';
|
|
@@ -92,6 +93,11 @@ export declare class LarkPlatform {
|
|
|
92
93
|
url: string;
|
|
93
94
|
expireIn: number;
|
|
94
95
|
}) => void, signal: AbortSignal): Promise<void>;
|
|
96
|
+
enableInput(profile: string, kind: MediaInputKind, onQRCode: (info: {
|
|
97
|
+
url: string;
|
|
98
|
+
expireIn: number;
|
|
99
|
+
}) => void, signal: AbortSignal, expectedAppId: string): Promise<void>;
|
|
100
|
+
private authorizeScopes;
|
|
95
101
|
saveNewProfile(profile: string, app: RegisteredApp, signal?: AbortSignal): Promise<ProfileSummary>;
|
|
96
102
|
private profile;
|
|
97
103
|
private api;
|