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.
Files changed (39) hide show
  1. package/README.md +6 -3
  2. package/dist/bridge.d.ts +2 -1
  3. package/dist/bridge.js +23 -13
  4. package/dist/client/AgentSettings.d.ts +12 -0
  5. package/dist/client/ConnectionCard.d.ts +5 -1
  6. package/dist/client/ManagerPanel.d.ts +3 -0
  7. package/dist/client/api.d.ts +14 -2
  8. package/dist/client/index.d.ts +1 -1
  9. package/dist/client/manager-styles.d.ts +2 -0
  10. package/dist/client/use-connection.d.ts +2 -2
  11. package/dist/client.cjs +727 -258
  12. package/dist/config.d.ts +4 -0
  13. package/dist/config.js +8 -0
  14. package/dist/dsh-runtime.d.ts +3 -0
  15. package/dist/dsh-runtime.js +44 -4
  16. package/dist/lark-cli.d.ts +7 -0
  17. package/dist/lark-cli.js +105 -1
  18. package/dist/media-input.d.ts +28 -0
  19. package/dist/media-input.js +127 -0
  20. package/dist/media-scopes.d.ts +5 -0
  21. package/dist/media-scopes.js +6 -0
  22. package/dist/message.d.ts +7 -0
  23. package/dist/message.js +27 -3
  24. package/dist/onboarding/agents.d.ts +9 -0
  25. package/dist/onboarding/agents.js +100 -0
  26. package/dist/onboarding/controller.d.ts +15 -2
  27. package/dist/onboarding/controller.js +155 -14
  28. package/dist/onboarding/host.d.ts +14 -2
  29. package/dist/onboarding/host.js +45 -16
  30. package/dist/onboarding/lark-platform.d.ts +6 -0
  31. package/dist/onboarding/lark-platform.js +15 -4
  32. package/dist/onboarding/manager.d.ts +32 -0
  33. package/dist/onboarding/manager.js +129 -0
  34. package/dist/onboarding/protocol.d.ts +51 -1
  35. package/dist/onboarding/settings-store.d.ts +11 -0
  36. package/dist/onboarding/settings-store.js +67 -0
  37. package/dist/session-store.d.ts +5 -1
  38. package/dist/session-store.js +32 -4
  39. 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
- capabilities: { ...this.state.capabilities, documents: this.documentsReady }, ...(this.documentSetup ? { documentSetup: this.documentSetup } : {}),
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) { await this.deps.store.write(record); this.record = 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.documentsReady = inspection.scopeStatus === 'verified' && defaultScopesReady(inspection.grantedScopes);
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 === 'cancel-documents') {
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.documentsReady = inspection.scopeStatus === 'verified' && defaultScopesReady(inspection.grantedScopes);
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 identity = createHash('sha256').update(JSON.stringify([record.appId, record.ownerOpenId])).digest('hex').slice(0, 24);
447
- const directory = join(this.deps.store.directory, 'bots', identity);
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: [] }], cardInteractions: record.cards, loadingReaction: record.loading, botMenu: this.menuReady,
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 = Pick<ConnectionController, 'snapshot' | 'profiles' | 'command'>;
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. */
@@ -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 { ConnectionController, SetupError } from './controller.js';
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 (!endpoints.has(endpoint))
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 allowed = endpoint === 'create' ? ['cards', 'loading'] : endpoint === 'use-profile' ? ['name'] : endpoint === 'confirm-owner' ? ['id'] : [];
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
- return { ok: true, value: await controller.command(endpoint, payload) };
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 endpoints) {
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 ConnectionController({ store: new ConnectionStore(directory), platform: new LarkPlatform({ executable }), executable,
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: () => withUpdate(controller.snapshot(), updates),
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;