@cubeai/dsh-feishu-channels 0.0.0-stage → 0.2.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/LICENSE +201 -0
- package/NOTICE +15 -0
- package/README.md +56 -2
- package/client.js +4189 -0
- package/cordis.patch.yml +4 -0
- package/docs/coexistence.md +112 -0
- package/docs/dsh-feishu-channels-plan.md +1406 -0
- package/docs/ui-revamp-plan.md +263 -0
- package/docs/verification.md +120 -0
- package/icon.svg +17 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +76 -4
- package/src/channels/connection-state.js +12 -0
- package/src/channels/diagnostics.js +77 -0
- package/src/channels/factory.js +20 -0
- package/src/channels/feishu-app.js +267 -0
- package/src/channels/feishu.js +399 -0
- package/src/channels/media.js +9 -0
- package/src/channels/meta.js +24 -0
- package/src/channels/qr/feishu.js +84 -0
- package/src/channels/qr/hub.js +229 -0
- package/src/channels/qr/render.js +8 -0
- package/src/channels/qr/shared.js +50 -0
- package/src/engine/abort.js +35 -0
- package/src/engine/access.js +23 -0
- package/src/engine/agent-options.js +19 -0
- package/src/engine/approval.js +70 -0
- package/src/engine/assistant-settings.js +38 -0
- package/src/engine/assistant-text.js +11 -0
- package/src/engine/atomic-file.js +45 -0
- package/src/engine/chat-commands.js +752 -0
- package/src/engine/choice-delivery.js +20 -0
- package/src/engine/choices.js +103 -0
- package/src/engine/command-locale.js +70 -0
- package/src/engine/command-messages.js +434 -0
- package/src/engine/command-permissions.js +38 -0
- package/src/engine/command-replies.js +59 -0
- package/src/engine/credentials.js +54 -0
- package/src/engine/deferred-delivery.js +260 -0
- package/src/engine/delivery-history.js +39 -0
- package/src/engine/file-delivery.js +215 -0
- package/src/engine/file-input.js +52 -0
- package/src/engine/file-log.js +25 -0
- package/src/engine/gateway.js +1337 -0
- package/src/engine/image-input.js +31 -0
- package/src/engine/json-state.js +25 -0
- package/src/engine/keyed-queue.js +20 -0
- package/src/engine/merge.js +56 -0
- package/src/engine/message-progress.js +274 -0
- package/src/engine/question.js +201 -0
- package/src/engine/quiet-logger.js +25 -0
- package/src/engine/reply-stream.js +86 -0
- package/src/engine/router.js +713 -0
- package/src/engine/secure-file.js +8 -0
- package/src/engine/seen-store.js +43 -0
- package/src/engine/session-export.js +64 -0
- package/src/engine/session-history.js +30 -0
- package/src/engine/session-id.js +62 -0
- package/src/engine/session-store.js +109 -0
- package/src/engine/session-title.js +29 -0
- package/src/engine/split.js +291 -0
- package/src/engine/workspace-path.js +9 -0
- package/src/index.js +62 -0
- package/src/manager.js +1325 -0
package/src/manager.js
ADDED
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
import { replyText } from './engine/command-locale.js';
|
|
2
|
+
import { normalizeCommandPermissions } from './engine/command-permissions.js';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createChannelAdapter } from './channels/factory.js';
|
|
7
|
+
import { provisionFeishuApp } from './channels/feishu-app.js';
|
|
8
|
+
import { connectionState } from './channels/connection-state.js';
|
|
9
|
+
import { DiagnosticError, probe } from './channels/diagnostics.js';
|
|
10
|
+
import { fileOperation } from './engine/abort.js';
|
|
11
|
+
import { CHANNEL_META, CHANNEL_ORDER, supportsQr } from './channels/meta.js';
|
|
12
|
+
import { PairingHub } from './channels/qr/hub.js';
|
|
13
|
+
import { SessionMapStore } from './engine/session-store.js';
|
|
14
|
+
import { createFileVault, createServiceVault, credentialRef } from './engine/credentials.js';
|
|
15
|
+
import { ImEngine } from './engine/gateway.js';
|
|
16
|
+
import { SeenStore } from './engine/seen-store.js';
|
|
17
|
+
import { normalizeAssistantModel, normalizePermission, normalizeWorkspacePath } from './engine/assistant-settings.js';
|
|
18
|
+
import { KeyedSerialQueue } from './engine/keyed-queue.js';
|
|
19
|
+
import { backupCorruptFileSync, writeFileAtomicSync } from './engine/atomic-file.js';
|
|
20
|
+
import { sameWorkspacePath } from './engine/workspace-path.js';
|
|
21
|
+
export const API_CLIENT_HEADER = 'x-dsh-feishu-channels-client';
|
|
22
|
+
const MAX_API_BODY_BYTES = 1024 * 1024;
|
|
23
|
+
const ACCOUNT_REMARK_LIMIT = 40;
|
|
24
|
+
/** 消息发送方式只有排队与插话两档;缺失或未知值一律回到排队,保持旧配置语义。 */
|
|
25
|
+
function normalizeSendMode(input) {
|
|
26
|
+
return input === 'steer' ? 'steer' : 'queue';
|
|
27
|
+
}
|
|
28
|
+
function channelAutoPrefix(platform) {
|
|
29
|
+
return `${CHANNEL_META[platform].label}账号`;
|
|
30
|
+
}
|
|
31
|
+
function parseAutoOrdinal(platform, name) {
|
|
32
|
+
const trimmed = String(name ?? '').trim();
|
|
33
|
+
const prefix = channelAutoPrefix(platform);
|
|
34
|
+
if (trimmed === prefix)
|
|
35
|
+
return 1;
|
|
36
|
+
if (!trimmed.startsWith(`${prefix} `))
|
|
37
|
+
return undefined;
|
|
38
|
+
const suffix = trimmed.slice(prefix.length + 1);
|
|
39
|
+
if (!/^\d+$/.test(suffix))
|
|
40
|
+
return undefined;
|
|
41
|
+
const ordinal = Number(suffix);
|
|
42
|
+
return Number.isSafeInteger(ordinal) && ordinal >= 1 ? ordinal : undefined;
|
|
43
|
+
}
|
|
44
|
+
function autoAccountName(platform, ordinal) {
|
|
45
|
+
return `${channelAutoPrefix(platform)} ${ordinal}`;
|
|
46
|
+
}
|
|
47
|
+
class ApiRequestError extends Error {
|
|
48
|
+
status;
|
|
49
|
+
constructor(status, message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.status = status;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** 宿主负责身份认证;插件仍限制写请求格式,避免简单跨站表单与错误载荷。 */
|
|
55
|
+
function validateApiMutation(request) {
|
|
56
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
57
|
+
if (method === 'GET' || method === 'HEAD')
|
|
58
|
+
return undefined;
|
|
59
|
+
const markerValue = request.headers[API_CLIENT_HEADER];
|
|
60
|
+
const marker = Array.isArray(markerValue) ? markerValue[0] : markerValue;
|
|
61
|
+
if (marker !== '1')
|
|
62
|
+
return { status: 403, error: 'forbidden mutation request' };
|
|
63
|
+
const typeValue = request.headers['content-type'];
|
|
64
|
+
const contentType = String(Array.isArray(typeValue) ? typeValue[0] ?? '' : typeValue ?? '').split(';', 1)[0].trim().toLowerCase();
|
|
65
|
+
if (contentType !== 'application/json')
|
|
66
|
+
return { status: 415, error: 'content-type must be application/json' };
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/** 累计原始字节后一次性解码,避免 UTF-8 多字节字符跨 data chunk 时被替换字符破坏。 */
|
|
70
|
+
export function readApiJsonBody(req, maxBodyBytes = MAX_API_BODY_BYTES) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let bytes = 0;
|
|
74
|
+
let oversized = false;
|
|
75
|
+
let settled = false;
|
|
76
|
+
const finish = (result) => {
|
|
77
|
+
if (settled)
|
|
78
|
+
return;
|
|
79
|
+
settled = true;
|
|
80
|
+
resolve(result);
|
|
81
|
+
};
|
|
82
|
+
// 超限后停止累计但不掐断连接,等 end 后统一按 413 拒绝,防止内存被撑爆
|
|
83
|
+
req.on('data', (chunk) => {
|
|
84
|
+
if (oversized)
|
|
85
|
+
return;
|
|
86
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
87
|
+
bytes += buffer.length;
|
|
88
|
+
if (bytes > maxBodyBytes) {
|
|
89
|
+
oversized = true;
|
|
90
|
+
chunks.length = 0;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
chunks.push(buffer);
|
|
94
|
+
});
|
|
95
|
+
req.on('error', () => finish({ body: {}, oversized, invalidJson: true }));
|
|
96
|
+
req.on('end', () => {
|
|
97
|
+
if (oversized) {
|
|
98
|
+
finish({ body: {}, oversized: true, invalidJson: false });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const raw = Buffer.concat(chunks, bytes).toString('utf8');
|
|
103
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
104
|
+
const valid = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed);
|
|
105
|
+
finish({ body: valid ? parsed : {}, oversized: false, invalidJson: !valid });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
finish({ body: {}, oversized: false, invalidJson: true });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** 把不可解析的配置移走后再回到空配置,避免下一次 flush 覆盖唯一副本。 */
|
|
114
|
+
export function backupCorruptConfig(file) {
|
|
115
|
+
return backupCorruptFileSync(file);
|
|
116
|
+
}
|
|
117
|
+
export class ChannelManager {
|
|
118
|
+
file;
|
|
119
|
+
stateDir;
|
|
120
|
+
sessions;
|
|
121
|
+
engine;
|
|
122
|
+
vault;
|
|
123
|
+
pairing;
|
|
124
|
+
log;
|
|
125
|
+
ctx;
|
|
126
|
+
engineConfig;
|
|
127
|
+
store = { channels: {}, allowlist: {}, pending: {} };
|
|
128
|
+
running = new Map();
|
|
129
|
+
diagnosticJobs = new Map();
|
|
130
|
+
diagnosticAbort = new AbortController();
|
|
131
|
+
channelOperations = new KeyedSerialQueue();
|
|
132
|
+
apiDisposers = [];
|
|
133
|
+
// dispose 后阻止 initEnabled/startOne 再拉起渠道,避免插件重载时新旧双实例并存
|
|
134
|
+
disposed = false;
|
|
135
|
+
constructor(options) {
|
|
136
|
+
this.ctx = options.ctx;
|
|
137
|
+
this.engineConfig = options.engineConfig;
|
|
138
|
+
this.stateDir = options.stateDir;
|
|
139
|
+
this.file = join(options.stateDir, 'channels.json');
|
|
140
|
+
this.sessions = new SessionMapStore(join(options.stateDir, 'sessions.json'));
|
|
141
|
+
this.log = options.log;
|
|
142
|
+
this.load();
|
|
143
|
+
this.applyAssistant(this.store.assistant);
|
|
144
|
+
this.applyWorkspace(this.store.cwd);
|
|
145
|
+
this.applyPermission(this.store.permission);
|
|
146
|
+
this.migrateAccountSettings();
|
|
147
|
+
const seen = new SeenStore(join(options.stateDir, 'seen.json'));
|
|
148
|
+
const credentials = options.ctx.credentials;
|
|
149
|
+
this.vault = credentials
|
|
150
|
+
? createServiceVault(credentials)
|
|
151
|
+
: createFileVault(join(options.stateDir, 'secrets.json'));
|
|
152
|
+
this.engine = new ImEngine(options.ctx, this.sessions, seen, options.engineConfig, options.log, (accountId, msg) => {
|
|
153
|
+
this.requestAuthorization(accountId, msg);
|
|
154
|
+
return replyText('未授权:请管理员在设置 → 飞书频道 中批准你的访问。');
|
|
155
|
+
}, (accountId) => this.accountEngineConfig(accountId), (accountId) => this.store.channels[accountId]?.privateAccess === 'all' ? 'all' : 'approved', (accountId) => normalizeCommandPermissions(this.store.channels[accountId]?.commandPermissions), join(options.stateDir, 'deferred-delivery.json'));
|
|
156
|
+
for (const [channelId, users] of Object.entries(this.store.allowlist)) {
|
|
157
|
+
for (const userId of users)
|
|
158
|
+
this.engine.addAllowed(channelId, userId);
|
|
159
|
+
}
|
|
160
|
+
this.pairing = new PairingHub({
|
|
161
|
+
log: options.log,
|
|
162
|
+
onSuccess: async (id, creds) => {
|
|
163
|
+
const settings = {};
|
|
164
|
+
for (const key of Object.keys(creds).filter((key) => key.startsWith('__setting_'))) {
|
|
165
|
+
settings[key.slice('__setting_'.length)] = key === '__setting_commandPermissions' ? JSON.parse(creds[key]) : creds[key];
|
|
166
|
+
delete creds[key];
|
|
167
|
+
}
|
|
168
|
+
const result = await this.connect(id, creds, settings);
|
|
169
|
+
if (!result.ok)
|
|
170
|
+
throw new Error(result.error ?? '保存失败');
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
this.flush();
|
|
174
|
+
}
|
|
175
|
+
list() {
|
|
176
|
+
return CHANNEL_ORDER.map((id) => {
|
|
177
|
+
const meta = CHANNEL_META[id];
|
|
178
|
+
const accounts = Object.entries(this.store.channels)
|
|
179
|
+
.filter(([accountId, state]) => this.platformOf(accountId, state) === id)
|
|
180
|
+
.map(([accountId, state]) => this.accountView(accountId, state));
|
|
181
|
+
const online = accounts.filter((item) => item.connected).length;
|
|
182
|
+
const first = accounts[0];
|
|
183
|
+
return {
|
|
184
|
+
id,
|
|
185
|
+
label: meta.label,
|
|
186
|
+
description: meta.description,
|
|
187
|
+
kind: meta.kind,
|
|
188
|
+
fields: meta.fields,
|
|
189
|
+
connected: online > 0,
|
|
190
|
+
receiveEnabled: accounts.some((item) => item.receiveEnabled),
|
|
191
|
+
configuredKeys: first?.configuredKeys ?? [],
|
|
192
|
+
status: accounts.length === 0 ? '未配置' : `${online}/${accounts.length} 在线`,
|
|
193
|
+
accounts,
|
|
194
|
+
online,
|
|
195
|
+
total: accounts.length,
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
accountView(accountId, state) {
|
|
200
|
+
const platform = this.platformOf(accountId, state);
|
|
201
|
+
const config = state.config ?? {};
|
|
202
|
+
const adapter = this.running.get(accountId);
|
|
203
|
+
let status = state.receiveEnabled === false ? '已停止' : state.lastError ?? (state.enabled ? '未连接' : '已停止');
|
|
204
|
+
try {
|
|
205
|
+
if (adapter)
|
|
206
|
+
status = adapter.status();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// 状态读取失败不能影响其他账号,也不向浏览器泄露 SDK 异常。
|
|
210
|
+
status = '状态读取失败';
|
|
211
|
+
}
|
|
212
|
+
const runtimeState = connectionState(status);
|
|
213
|
+
const connected = adapter !== undefined && runtimeState === 'connected';
|
|
214
|
+
const nameOrdinal = this.stableNameOrdinal(platform, state);
|
|
215
|
+
const canonicalName = autoAccountName(platform, nameOrdinal);
|
|
216
|
+
const name = String(state.name || canonicalName).trim();
|
|
217
|
+
const autoName = name === canonicalName || name === channelAutoPrefix(platform);
|
|
218
|
+
return {
|
|
219
|
+
id: accountId,
|
|
220
|
+
platform,
|
|
221
|
+
name,
|
|
222
|
+
autoName,
|
|
223
|
+
nameOrdinal,
|
|
224
|
+
appId: typeof config.appId === 'string' ? config.appId : '',
|
|
225
|
+
hasAppSecret: Boolean(config.appSecretRef || config.appSecret),
|
|
226
|
+
provisioning: config.provisioning,
|
|
227
|
+
connected,
|
|
228
|
+
connectionState: runtimeState,
|
|
229
|
+
receiveConfigured: state.receiveEnabled !== false,
|
|
230
|
+
receiveEnabled: connected && state.receiveEnabled !== false,
|
|
231
|
+
configuredKeys: Object.keys(config).filter((key) => Boolean(config[key]) && !key.endsWith('Ref')),
|
|
232
|
+
status,
|
|
233
|
+
assistant: normalizeAssistantModel(state.assistant ?? {}) ?? this.currentAssistant(),
|
|
234
|
+
cwd: normalizeWorkspacePath(state.cwd) ?? this.currentWorkspace(),
|
|
235
|
+
permission: normalizePermission(state.permission, this.permissionPresets().names) ?? this.currentPermission(),
|
|
236
|
+
agentPreset: state.agentPreset || this.engineConfig.agentPreset || 'standard',
|
|
237
|
+
commandPermissions: normalizeCommandPermissions(state.commandPermissions),
|
|
238
|
+
privateAccess: state.privateAccess === 'all' ? 'all' : 'approved',
|
|
239
|
+
sendMode: normalizeSendMode(state.sendMode),
|
|
240
|
+
lastCheckedAt: state.lastCheckedAt,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
channelSessions() {
|
|
244
|
+
const groups = new Map(CHANNEL_ORDER.map(id => [id, {
|
|
245
|
+
id,
|
|
246
|
+
label: CHANNEL_META[id].label,
|
|
247
|
+
sessions: [],
|
|
248
|
+
}]));
|
|
249
|
+
for (const item of this.sessions.list()) {
|
|
250
|
+
groups.get(this.platformOf(item.channel, this.store.channels[item.channel]))?.sessions.push(item);
|
|
251
|
+
}
|
|
252
|
+
return [...groups.values()].filter((group) => group.sessions.length > 0);
|
|
253
|
+
}
|
|
254
|
+
async connect(id, config, settings) {
|
|
255
|
+
if (!CHANNEL_META[id])
|
|
256
|
+
return { ok: false, error: '未知渠道' };
|
|
257
|
+
const accountId = this.accountIdFor(id, config ?? {});
|
|
258
|
+
return this.channelOperations.run(accountId, () => this.connectNow(id, accountId, config, settings ?? {}));
|
|
259
|
+
}
|
|
260
|
+
async connectNow(id, accountId, config, settings = {}) {
|
|
261
|
+
const incoming = { ...(config ?? {}) };
|
|
262
|
+
const existed = Boolean(this.store.channels[accountId]);
|
|
263
|
+
const hadAnotherIdentity = !existed && Object.entries(this.store.channels)
|
|
264
|
+
.some(([existingId, state]) => existingId !== accountId && this.platformOf(existingId, state) === id);
|
|
265
|
+
const prev = this.store.channels[accountId] ?? {};
|
|
266
|
+
const normalized = await this.normalizeAccountSettings(id, settings, prev);
|
|
267
|
+
if (!normalized.ok)
|
|
268
|
+
return normalized;
|
|
269
|
+
const nextConfig = await this.persistSecrets(id, accountId, { ...(prev.config ?? {}), ...incoming });
|
|
270
|
+
this.store.channels[accountId] = {
|
|
271
|
+
...prev,
|
|
272
|
+
id: accountId,
|
|
273
|
+
platform: id,
|
|
274
|
+
name: normalized.settings.name,
|
|
275
|
+
nameOrdinal: normalized.settings.nameOrdinal,
|
|
276
|
+
assistant: normalized.settings.assistant,
|
|
277
|
+
cwd: normalized.settings.cwd,
|
|
278
|
+
permission: normalized.settings.permission,
|
|
279
|
+
agentPreset: normalized.settings.agentPreset,
|
|
280
|
+
commandPermissions: normalized.settings.commandPermissions,
|
|
281
|
+
privateAccess: normalized.settings.privateAccess,
|
|
282
|
+
sendMode: normalized.settings.sendMode,
|
|
283
|
+
enabled: true,
|
|
284
|
+
receiveEnabled: true,
|
|
285
|
+
config: nextConfig,
|
|
286
|
+
};
|
|
287
|
+
this.flush();
|
|
288
|
+
if (!existed) {
|
|
289
|
+
try {
|
|
290
|
+
const resolved = await this.resolveSecrets(id, accountId, nextConfig);
|
|
291
|
+
if (resolved.appId && resolved.appSecret) {
|
|
292
|
+
const timeout = new Promise((resolve) => {
|
|
293
|
+
const timer = setTimeout(() => resolve({
|
|
294
|
+
checkedAt: new Date().toISOString(),
|
|
295
|
+
errors: [{ step: 'timeout', message: 'provisioning timed out' }],
|
|
296
|
+
}), 10_000);
|
|
297
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
298
|
+
});
|
|
299
|
+
const provisioning = await Promise.race([
|
|
300
|
+
provisionFeishuApp({
|
|
301
|
+
appId: resolved.appId,
|
|
302
|
+
appSecret: resolved.appSecret,
|
|
303
|
+
name: normalized.settings.name,
|
|
304
|
+
log: this.log,
|
|
305
|
+
}),
|
|
306
|
+
timeout,
|
|
307
|
+
]);
|
|
308
|
+
const current = this.store.channels[accountId];
|
|
309
|
+
if (current) {
|
|
310
|
+
current.config = { ...(current.config ?? {}), provisioning };
|
|
311
|
+
this.flush();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
this.log(`[manager] 应用配置初始化失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
this.seedAllowedUser(accountId, incoming.allowedUserId || incoming.ownerOpenId || nextConfig.allowedUserId || nextConfig.ownerOpenId);
|
|
320
|
+
try {
|
|
321
|
+
await this.startOne(accountId);
|
|
322
|
+
return { ok: true, accountId, created: !existed, newIdentity: hadAnotherIdentity };
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
this.log(`[manager] ${id} 连接失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
326
|
+
return { ok: false, error: '账号连接失败,请查看本机日志', accountId, created: !existed, newIdentity: hadAnotherIdentity };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async setReceive(id, receiveEnabled) {
|
|
330
|
+
const accountId = this.resolveAccountId(id);
|
|
331
|
+
if (!accountId)
|
|
332
|
+
return { ok: false, error: '账号不存在' };
|
|
333
|
+
return this.channelOperations.run(accountId, () => this.setReceiveNow(accountId, receiveEnabled));
|
|
334
|
+
}
|
|
335
|
+
async setReceiveNow(id, receiveEnabled) {
|
|
336
|
+
const state = this.store.channels[id];
|
|
337
|
+
if (!state?.enabled)
|
|
338
|
+
return { ok: false, error: '渠道未配置' };
|
|
339
|
+
state.receiveEnabled = receiveEnabled;
|
|
340
|
+
this.flush();
|
|
341
|
+
if (!receiveEnabled)
|
|
342
|
+
await this.stopOne(id);
|
|
343
|
+
else if (!this.running.has(id))
|
|
344
|
+
await this.startOne(id);
|
|
345
|
+
return { ok: true };
|
|
346
|
+
}
|
|
347
|
+
async disconnect(id) {
|
|
348
|
+
const accountId = this.resolveAccountId(id);
|
|
349
|
+
if (!accountId)
|
|
350
|
+
return;
|
|
351
|
+
await this.channelOperations.run(accountId, () => this.disconnectNow(accountId));
|
|
352
|
+
}
|
|
353
|
+
async disconnectNow(id) {
|
|
354
|
+
const state = this.store.channels[id];
|
|
355
|
+
if (state) {
|
|
356
|
+
state.enabled = false;
|
|
357
|
+
state.receiveEnabled = false;
|
|
358
|
+
this.flush();
|
|
359
|
+
}
|
|
360
|
+
await this.stopOne(id);
|
|
361
|
+
}
|
|
362
|
+
async remove(id) {
|
|
363
|
+
const accountId = this.resolveAccountId(id);
|
|
364
|
+
if (!accountId)
|
|
365
|
+
return;
|
|
366
|
+
await this.channelOperations.run(accountId, () => this.removeNow(accountId));
|
|
367
|
+
}
|
|
368
|
+
async removeNow(id) {
|
|
369
|
+
await this.stopOne(id);
|
|
370
|
+
const state = this.store.channels[id];
|
|
371
|
+
if (!state)
|
|
372
|
+
return;
|
|
373
|
+
const platform = this.platformOf(id, state);
|
|
374
|
+
for (const field of CHANNEL_META[platform].fields.filter((item) => item.secret)) {
|
|
375
|
+
const ref = state?.config?.[`${field.key}Ref`] || credentialRef(id, field.key);
|
|
376
|
+
await this.vault.unset(ref);
|
|
377
|
+
}
|
|
378
|
+
delete this.store.channels[id];
|
|
379
|
+
delete this.store.allowlist[id];
|
|
380
|
+
delete this.store.pending[id];
|
|
381
|
+
this.engine.clearAllowed(id);
|
|
382
|
+
this.flush();
|
|
383
|
+
}
|
|
384
|
+
attachMappedSessions() {
|
|
385
|
+
if (this.disposed)
|
|
386
|
+
return Promise.resolve();
|
|
387
|
+
return this.engine.attachMappedSessions();
|
|
388
|
+
}
|
|
389
|
+
async initEnabled() {
|
|
390
|
+
if (this.disposed)
|
|
391
|
+
return;
|
|
392
|
+
const started = Date.now();
|
|
393
|
+
await this.clearUnsupportedReasoningEfforts();
|
|
394
|
+
for (const [id, state] of Object.entries(this.store.channels)) {
|
|
395
|
+
if (this.disposed)
|
|
396
|
+
return;
|
|
397
|
+
if (state?.enabled && state.receiveEnabled !== false) {
|
|
398
|
+
const one = Date.now();
|
|
399
|
+
await this.channelOperations.run(id, () => this.startOne(id)).catch((error) => {
|
|
400
|
+
this.log(`[manager] 启动 ${id} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
401
|
+
});
|
|
402
|
+
this.log(`[boot] 渠道 ${id} 启动 ${Date.now() - one}ms`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
this.log(`[boot] initEnabled ${Date.now() - started}ms`);
|
|
406
|
+
}
|
|
407
|
+
registerApi(ctx) {
|
|
408
|
+
const webServer = ctx.webServer;
|
|
409
|
+
if (!webServer)
|
|
410
|
+
return;
|
|
411
|
+
const send = (res, status, body) => {
|
|
412
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
413
|
+
res.end(JSON.stringify(body));
|
|
414
|
+
};
|
|
415
|
+
const readJson = async (req) => {
|
|
416
|
+
const { body, oversized, invalidJson } = await readApiJsonBody(req);
|
|
417
|
+
if (oversized)
|
|
418
|
+
throw new ApiRequestError(413, '请求体超过 1MB 上限');
|
|
419
|
+
if (invalidJson)
|
|
420
|
+
throw new ApiRequestError(400, '请求体不是合法 JSON');
|
|
421
|
+
return body;
|
|
422
|
+
};
|
|
423
|
+
const dispatch = async (method, path, readBody, send) => {
|
|
424
|
+
const payload = async () => {
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
channels: this.list(),
|
|
428
|
+
groups: this.channelSessions(),
|
|
429
|
+
pending: this.pendingRequests(),
|
|
430
|
+
assistant: this.currentAssistant(),
|
|
431
|
+
};
|
|
432
|
+
};
|
|
433
|
+
try {
|
|
434
|
+
// 路由段统一从插件前缀之后开始,身份认证只由下方入口负责。
|
|
435
|
+
const parts = ('/dsh-feishu-channels/api' + path).split('/').filter(Boolean);
|
|
436
|
+
if (parts[2] === 'assistant' && parts.length === 3) {
|
|
437
|
+
if (method === 'GET') {
|
|
438
|
+
send(200, {
|
|
439
|
+
ok: true,
|
|
440
|
+
assistant: this.currentAssistant(),
|
|
441
|
+
cwd: this.currentWorkspace(),
|
|
442
|
+
permission: this.currentPermission(),
|
|
443
|
+
permissions: this.permissionOptions(),
|
|
444
|
+
agentPresets: await this.agentPresetOptions(),
|
|
445
|
+
agentPreset: this.engineConfig.agentPreset || 'standard',
|
|
446
|
+
providers: await this.listModelCatalog(),
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (method === 'POST') {
|
|
451
|
+
const body = await readBody();
|
|
452
|
+
const result = this.setAssistant(body);
|
|
453
|
+
send(result.ok ? 200 : 400, result);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
send(405, { ok: false, error: 'method not allowed' });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (parts[2] === 'sessions' && parts.length === 4 && method === 'POST') {
|
|
460
|
+
const action = parts[3];
|
|
461
|
+
const body = await readBody();
|
|
462
|
+
const sessionId = String(body.sessionId ?? '');
|
|
463
|
+
if (!sessionId) {
|
|
464
|
+
send(400, { ok: false, error: '缺少 sessionId' });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (action === 'rename') {
|
|
468
|
+
const title = String(body.title ?? '').trim();
|
|
469
|
+
if (!title) {
|
|
470
|
+
send(400, { ok: false, error: '缺少标题' });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const ok = this.engine.renameSession(sessionId, title);
|
|
474
|
+
send(ok ? 200 : 404, ok ? { ok: true, groups: this.channelSessions() } : { ok: false, error: '会话不存在' });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (action === 'remove') {
|
|
478
|
+
const ok = await this.engine.removeSession(sessionId);
|
|
479
|
+
send(ok ? 200 : 404, ok ? { ok: true, groups: this.channelSessions() } : { ok: false, error: '会话不存在' });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (action === 'cleanup-missing') {
|
|
483
|
+
const ok = await this.engine.cleanupMissingSession(sessionId);
|
|
484
|
+
send(200, { ok, groups: this.channelSessions() });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (action === 'ensure') {
|
|
488
|
+
const ok = await this.engine.ensureSession(sessionId);
|
|
489
|
+
send(ok ? 200 : 404, ok ? { ok: true, sessionId } : { ok: false, error: '会话不存在' });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
send(404, { ok: false, error: `未知会话操作 ${action}` });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (parts[2] === 'channels' && parts.length === 3 && method === 'GET') {
|
|
496
|
+
send(200, await payload());
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (parts[2] === 'channels' && parts.length === 6 && parts[4] === 'qr') {
|
|
500
|
+
const id = parts[3];
|
|
501
|
+
const action = parts[5];
|
|
502
|
+
if (!CHANNEL_META[id]) {
|
|
503
|
+
send(404, { ok: false, error: '未知渠道' });
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (!supportsQr(id)) {
|
|
507
|
+
send(400, { ok: false, error: '该渠道不支持扫码绑定' });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (action === 'status' && method === 'GET') {
|
|
511
|
+
send(200, { ok: true, pairing: this.pairing.view(id), channel: this.list().find((item) => item.id === id) });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (method !== 'POST') {
|
|
515
|
+
send(405, { ok: false, error: 'method not allowed' });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const body = await readBody();
|
|
519
|
+
if (action === 'start') {
|
|
520
|
+
const incoming = body.settings;
|
|
521
|
+
const workspaceError = this.workspaceSaveError(normalizeWorkspacePath(incoming?.cwd));
|
|
522
|
+
if (workspaceError) {
|
|
523
|
+
send(400, { ok: false, error: workspaceError });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const pairing = await this.pairing.start(id, pairingSettings(incoming));
|
|
527
|
+
send(pairing.status === 'failed' ? 400 : 200, { ok: pairing.status !== 'failed', pairing, error: pairing.error });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (action === 'refresh') {
|
|
531
|
+
const pairing = await this.pairing.refresh(id);
|
|
532
|
+
send(pairing.status === 'failed' ? 400 : 200, { ok: pairing.status !== 'failed', pairing, error: pairing.error });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (action === 'cancel') {
|
|
536
|
+
send(200, { ok: true, pairing: this.pairing.cancel(id) });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
send(404, { ok: false, error: `未知扫码操作 ${action}` });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (parts[2] === 'channels' && parts.length === 5 && method === 'POST') {
|
|
543
|
+
const id = parts[3];
|
|
544
|
+
const action = parts[4];
|
|
545
|
+
const body = await readBody();
|
|
546
|
+
if (action === 'connect') {
|
|
547
|
+
const result = await this.connect(id, body.config, body.settings);
|
|
548
|
+
send(result.ok ? 200 : 400, result.ok ? { ...result, channel: this.list().find((item) => item.id === id) } : result);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (action === 'receive') {
|
|
552
|
+
const result = await this.setReceive(id, body.receiveEnabled !== false);
|
|
553
|
+
send(result.ok ? 200 : 400, result.ok ? { ok: true, channel: this.list().find((item) => item.id === id) } : result);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (action === 'disconnect') {
|
|
557
|
+
await this.disconnect(id);
|
|
558
|
+
send(200, { ok: true, channel: this.list().find((item) => item.id === id) });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (action === 'remove') {
|
|
562
|
+
await this.remove(id);
|
|
563
|
+
send(200, { ok: true, channel: this.list().find((item) => item.id === id) });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (action === 'approve' || action === 'deny') {
|
|
567
|
+
const userId = String(body.userId ?? '');
|
|
568
|
+
if (!userId) {
|
|
569
|
+
send(400, { ok: false, error: '缺少 userId' });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const accountId = this.resolveAccountId(id);
|
|
573
|
+
if (!accountId) {
|
|
574
|
+
send(404, { ok: false, error: '账号不存在或该渠道包含多个账号' });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (action === 'approve')
|
|
578
|
+
this.approve(accountId, userId);
|
|
579
|
+
else
|
|
580
|
+
this.deny(accountId, userId);
|
|
581
|
+
send(200, { ok: true, pending: this.pendingRequests() });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
send(404, { ok: false, error: `未知操作 ${action}` });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (parts[2] === 'accounts' && parts.length === 5 && method === 'POST') {
|
|
588
|
+
const accountId = parts[3];
|
|
589
|
+
const action = parts[4];
|
|
590
|
+
const body = await readBody();
|
|
591
|
+
if (!this.store.channels[accountId]) {
|
|
592
|
+
send(404, { ok: false, error: '账号不存在' });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (action === 'settings') {
|
|
596
|
+
const result = await this.updateAccount(accountId, body);
|
|
597
|
+
send(result.ok ? 200 : 400, result);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (action === 'secret') {
|
|
601
|
+
const state = this.store.channels[accountId];
|
|
602
|
+
const platform = this.platformOf(accountId, state);
|
|
603
|
+
const resolved = await this.resolveSecrets(platform, accountId, state.config ?? {});
|
|
604
|
+
if (!resolved.appSecret) {
|
|
605
|
+
send(404, { ok: false, error: 'App Secret 未保存' });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
send(200, { ok: true, appId: resolved.appId ?? '', appSecret: resolved.appSecret });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (action === 'provision') {
|
|
612
|
+
const state = this.store.channels[accountId];
|
|
613
|
+
const platform = this.platformOf(accountId, state);
|
|
614
|
+
const resolved = await this.resolveSecrets(platform, accountId, state.config ?? {});
|
|
615
|
+
if (!resolved.appId || !resolved.appSecret) {
|
|
616
|
+
send(400, { ok: false, error: 'App ID 或 App Secret 未保存' });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const provisioning = await provisionFeishuApp({
|
|
620
|
+
appId: resolved.appId,
|
|
621
|
+
appSecret: resolved.appSecret,
|
|
622
|
+
name: state.name,
|
|
623
|
+
log: this.log,
|
|
624
|
+
});
|
|
625
|
+
state.config = { ...(state.config ?? {}), provisioning };
|
|
626
|
+
this.flush();
|
|
627
|
+
send(200, { ok: true, account: this.accountView(accountId, state), provisioning });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (action === 'receive') {
|
|
631
|
+
const result = await this.setReceive(accountId, body.receiveEnabled !== false);
|
|
632
|
+
send(result.ok ? 200 : 400, result);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (action === 'reconnect') {
|
|
636
|
+
const result = await this.reconnect(accountId);
|
|
637
|
+
send(result.ok ? 200 : 400, result);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (action === 'check') {
|
|
641
|
+
const result = await this.checkAccount(accountId);
|
|
642
|
+
send(result.ok ? 200 : 404, result);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (action === 'remove') {
|
|
646
|
+
await this.remove(accountId);
|
|
647
|
+
send(200, { ok: true });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (action === 'approve' || action === 'deny') {
|
|
651
|
+
const userId = String(body.userId ?? '');
|
|
652
|
+
if (!userId) {
|
|
653
|
+
send(400, { ok: false, error: '缺少 userId' });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
action === 'approve' ? this.approve(accountId, userId) : this.deny(accountId, userId);
|
|
657
|
+
send(200, { ok: true, pending: this.pendingRequests() });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
send(404, { ok: false, error: `未知账号操作 ${action}` });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
send(404, { ok: false, error: 'not found' });
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
// 单个路由异常不能让 HTTP 连接悬死,统一回 500 并落日志。
|
|
667
|
+
if (error instanceof ApiRequestError) {
|
|
668
|
+
send(error.status, { ok: false, error: error.message });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
672
|
+
this.log(`[manager] API 处理失败: ${detail}`);
|
|
673
|
+
send(500, { ok: false, error: '操作失败,请查看本机日志' });
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
const basePath = '/api/dsh-feishu-channels';
|
|
677
|
+
const dispose = webServer.register({
|
|
678
|
+
kind: 'prefix',
|
|
679
|
+
path: basePath,
|
|
680
|
+
handler: async (req, res) => {
|
|
681
|
+
try {
|
|
682
|
+
// 动态读取活动服务,并保留 requestRejection 的 this 绑定。
|
|
683
|
+
const connection = (typeof ctx.get === 'function' ? ctx.get('connection') : undefined);
|
|
684
|
+
if (typeof connection?.requestRejection !== 'function') {
|
|
685
|
+
send(res, 503, { ok: false, error: '宿主认证服务尚未就绪或版本不支持;请稍后重试或升级宿主。 / Retry or upgrade DSH to enable authenticated management.' });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const rejection = connection.requestRejection(req);
|
|
689
|
+
if (rejection !== undefined) {
|
|
690
|
+
send(res, rejection, { ok: false, error: rejection === 401 ? '请先登录 DSH / Sign in to DSH first' : '宿主拒绝此 Host 或跨站请求 / Host or cross-site request forbidden' });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const requestError = validateApiMutation(req);
|
|
694
|
+
if (requestError) {
|
|
695
|
+
send(res, requestError.status, { ok: false, error: requestError.error });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
699
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(basePath + '/')) {
|
|
700
|
+
send(res, 404, { ok: false, error: 'not found' });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
await dispatch(req.method ?? 'GET', url.pathname.slice(basePath.length), () => readJson(req), (status, body) => send(res, status, body));
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
this.log(`[manager] API 认证失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
707
|
+
send(res, 500, { ok: false, error: '操作失败,请查看本机日志' });
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
if (typeof dispose === 'function')
|
|
712
|
+
this.apiDisposers.push(dispose);
|
|
713
|
+
this.log('[manager] API 已注册 /api/dsh-feishu-channels');
|
|
714
|
+
}
|
|
715
|
+
disposeApi() {
|
|
716
|
+
this.disposed = true;
|
|
717
|
+
this.diagnosticAbort.abort();
|
|
718
|
+
for (const dispose of this.apiDisposers)
|
|
719
|
+
dispose();
|
|
720
|
+
this.apiDisposers = [];
|
|
721
|
+
this.pairing.dispose();
|
|
722
|
+
const activeIds = new Set([...this.running.keys(), ...this.channelOperations.keys()]);
|
|
723
|
+
for (const id of activeIds)
|
|
724
|
+
void this.channelOperations.run(id, () => this.stopOne(id));
|
|
725
|
+
this.engine.dispose();
|
|
726
|
+
}
|
|
727
|
+
currentAssistant() {
|
|
728
|
+
return normalizeAssistantModel(this.store.assistant ?? this.engineConfig);
|
|
729
|
+
}
|
|
730
|
+
async updateAccount(accountId, input) {
|
|
731
|
+
if (!this.store.channels[accountId])
|
|
732
|
+
return { ok: false, error: '账号不存在' };
|
|
733
|
+
return this.channelOperations.run(accountId, async () => {
|
|
734
|
+
const state = this.store.channels[accountId];
|
|
735
|
+
if (!state)
|
|
736
|
+
return { ok: false, error: '账号不存在' };
|
|
737
|
+
const platform = this.platformOf(accountId, state);
|
|
738
|
+
const normalized = await this.normalizeAccountSettings(platform, input, state);
|
|
739
|
+
if (!normalized.ok)
|
|
740
|
+
return normalized;
|
|
741
|
+
const previousCwd = normalizeWorkspacePath(state.cwd) ?? this.currentWorkspace();
|
|
742
|
+
const previousPreset = state.agentPreset || this.engineConfig.agentPreset || 'standard';
|
|
743
|
+
const presetChanged = previousPreset !== normalized.settings.agentPreset;
|
|
744
|
+
if (presetChanged) {
|
|
745
|
+
for (const record of this.sessions.list().filter(item => item.channel === accountId && !item.adopted && !item.agentPreset)) {
|
|
746
|
+
this.sessions.updateSession({ ...record, agentPreset: previousPreset });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const resetSessions = presetChanged || !sameWorkspacePath(previousCwd, normalized.settings.cwd);
|
|
750
|
+
const reloadSessions = resetSessions
|
|
751
|
+
|| !sameAssistantModel(state.assistant, normalized.settings.assistant)
|
|
752
|
+
|| state.permission !== normalized.settings.permission;
|
|
753
|
+
state.name = normalized.settings.name;
|
|
754
|
+
state.nameOrdinal = normalized.settings.nameOrdinal;
|
|
755
|
+
state.assistant = normalized.settings.assistant;
|
|
756
|
+
state.cwd = normalized.settings.cwd;
|
|
757
|
+
state.agentPreset = normalized.settings.agentPreset;
|
|
758
|
+
state.permission = normalized.settings.permission;
|
|
759
|
+
state.commandPermissions = normalized.settings.commandPermissions;
|
|
760
|
+
state.privateAccess = normalized.settings.privateAccess;
|
|
761
|
+
// 发送方式按注入时机读取,不需要重载会话。
|
|
762
|
+
state.sendMode = normalized.settings.sendMode;
|
|
763
|
+
this.flush();
|
|
764
|
+
if (reloadSessions)
|
|
765
|
+
await this.engine.reloadChannel(accountId, { resetSessions });
|
|
766
|
+
return { ok: true, account: this.accountView(accountId, state) };
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/** 查询平台或现有连接心跳;同账号合并请求,总等待 12 秒,不启动接收器。 */
|
|
770
|
+
checkAccount(accountId) {
|
|
771
|
+
const existing = this.diagnosticJobs.get(accountId);
|
|
772
|
+
if (existing)
|
|
773
|
+
return existing;
|
|
774
|
+
const job = this.channelOperations.run(accountId, async () => {
|
|
775
|
+
const state = this.store.channels[accountId];
|
|
776
|
+
if (!state)
|
|
777
|
+
return { ok: false, error: '账号不存在' };
|
|
778
|
+
if (this.disposed)
|
|
779
|
+
return { ok: false, error: '操作失败,请查看本机日志' };
|
|
780
|
+
const signal = AbortSignal.any([AbortSignal.timeout(12_000), this.diagnosticAbort.signal]);
|
|
781
|
+
let checks = [];
|
|
782
|
+
const preparation = await probe('credentials', signal, async () => {
|
|
783
|
+
let adapter = this.running.get(accountId);
|
|
784
|
+
if (!adapter) {
|
|
785
|
+
try {
|
|
786
|
+
const platform = this.platformOf(accountId, state);
|
|
787
|
+
const resolved = await fileOperation(this.resolveSecrets(platform, accountId, state.config ?? {}), signal);
|
|
788
|
+
signal.throwIfAborted();
|
|
789
|
+
adapter = createChannelAdapter(platform, resolved, this.log, this.accountStateDir(accountId, platform), { host: this.ctx });
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
signal.throwIfAborted();
|
|
793
|
+
if (error instanceof DiagnosticError)
|
|
794
|
+
throw error;
|
|
795
|
+
throw new DiagnosticError('local-state');
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (!adapter)
|
|
799
|
+
throw new DiagnosticError('auth');
|
|
800
|
+
if (!adapter.diagnose)
|
|
801
|
+
throw new DiagnosticError('unsupported');
|
|
802
|
+
checks = await fileOperation(adapter.diagnose(signal), signal);
|
|
803
|
+
});
|
|
804
|
+
if (preparation.status !== 'passed')
|
|
805
|
+
checks = [preparation];
|
|
806
|
+
if (this.disposed)
|
|
807
|
+
return { ok: false, error: '操作失败,请查看本机日志' };
|
|
808
|
+
const checkedAt = new Date().toISOString();
|
|
809
|
+
const previous = state.lastCheckedAt;
|
|
810
|
+
state.lastCheckedAt = checkedAt;
|
|
811
|
+
try {
|
|
812
|
+
this.flush();
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
state.lastCheckedAt = previous;
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
818
|
+
return { ok: true, account: this.accountView(accountId, state), diagnostics: { version: 1, checkedAt, checks } };
|
|
819
|
+
});
|
|
820
|
+
this.diagnosticJobs.set(accountId, job);
|
|
821
|
+
void job.finally(() => { if (this.diagnosticJobs.get(accountId) === job)
|
|
822
|
+
this.diagnosticJobs.delete(accountId); }).catch(() => undefined);
|
|
823
|
+
return job;
|
|
824
|
+
}
|
|
825
|
+
async reconnect(accountId) {
|
|
826
|
+
if (!this.store.channels[accountId])
|
|
827
|
+
return { ok: false, error: '账号不存在' };
|
|
828
|
+
return this.channelOperations.run(accountId, async () => {
|
|
829
|
+
try {
|
|
830
|
+
const state = this.store.channels[accountId];
|
|
831
|
+
state.enabled = true;
|
|
832
|
+
state.receiveEnabled = true;
|
|
833
|
+
await this.startOne(accountId);
|
|
834
|
+
return { ok: true };
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
this.log(`[manager] 重新连接 ${accountId} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
838
|
+
return { ok: false, error: '重新连接失败,请查看本机日志' };
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
setAssistant(input) {
|
|
843
|
+
const hasModel = input.provider !== undefined || input.model !== undefined;
|
|
844
|
+
const hasWorkspace = input.cwd !== undefined;
|
|
845
|
+
const hasPermission = input.permission !== undefined;
|
|
846
|
+
if (!hasModel && !hasWorkspace && !hasPermission)
|
|
847
|
+
return { ok: false, error: '请选择提供商、模型、工作区或权限' };
|
|
848
|
+
const nextAssistant = hasModel ? normalizeAssistantModel(input) : undefined;
|
|
849
|
+
if (hasModel && !nextAssistant)
|
|
850
|
+
return { ok: false, error: '请选择提供商和模型' };
|
|
851
|
+
const nextCwd = hasWorkspace ? normalizeWorkspacePath(input.cwd) : undefined;
|
|
852
|
+
if (hasWorkspace && !nextCwd)
|
|
853
|
+
return { ok: false, error: '请选择工作区' };
|
|
854
|
+
const nextPermission = hasPermission ? normalizePermission(input.permission, this.permissionPresets().names) : undefined;
|
|
855
|
+
if (hasPermission && !nextPermission)
|
|
856
|
+
return { ok: false, error: '请选择权限' };
|
|
857
|
+
if (hasModel) {
|
|
858
|
+
const next = nextAssistant;
|
|
859
|
+
this.store.assistant = next;
|
|
860
|
+
this.applyAssistant(next);
|
|
861
|
+
this.engine.setModel(next.provider, next.model, next.reasoningEffort);
|
|
862
|
+
this.log(`[manager] 助手模型已设为 ${next.provider}/${next.model}`);
|
|
863
|
+
}
|
|
864
|
+
if (hasWorkspace) {
|
|
865
|
+
const cwd = nextCwd;
|
|
866
|
+
this.store.cwd = cwd;
|
|
867
|
+
this.applyWorkspace(cwd);
|
|
868
|
+
this.log(`[manager] 工作区已设为 ${cwd}`);
|
|
869
|
+
}
|
|
870
|
+
if (hasPermission) {
|
|
871
|
+
const permission = nextPermission;
|
|
872
|
+
this.store.permission = permission;
|
|
873
|
+
this.applyPermission(permission);
|
|
874
|
+
this.log(`[manager] 权限已设为 ${permission}`);
|
|
875
|
+
}
|
|
876
|
+
this.flush();
|
|
877
|
+
return { ok: true, assistant: this.currentAssistant(), cwd: this.currentWorkspace(), permission: this.currentPermission() };
|
|
878
|
+
}
|
|
879
|
+
applyAssistant(assistant) {
|
|
880
|
+
const next = normalizeAssistantModel(assistant ?? {});
|
|
881
|
+
if (!next)
|
|
882
|
+
return;
|
|
883
|
+
this.engineConfig.provider = next.provider;
|
|
884
|
+
this.engineConfig.model = next.model;
|
|
885
|
+
this.engineConfig.reasoningEffort = next.reasoningEffort;
|
|
886
|
+
}
|
|
887
|
+
currentWorkspace() {
|
|
888
|
+
return this.store.cwd || this.engineConfig.cwd;
|
|
889
|
+
}
|
|
890
|
+
/** 宿主已提供工作区列表时,空列表和未登记路径都不能保存。列表不可用时保持原行为。 */
|
|
891
|
+
workspaceSaveError(cwd) {
|
|
892
|
+
const registered = this.registeredWorkspacePaths();
|
|
893
|
+
if (!registered)
|
|
894
|
+
return undefined;
|
|
895
|
+
if (registered.length === 0)
|
|
896
|
+
return '请先添加工作区';
|
|
897
|
+
if (!cwd || !registered.some(path => sameWorkspacePath(path, cwd)))
|
|
898
|
+
return '请选择工作区';
|
|
899
|
+
return undefined;
|
|
900
|
+
}
|
|
901
|
+
registeredWorkspacePaths() {
|
|
902
|
+
try {
|
|
903
|
+
const registry = this.ctx.get?.('workspaceRegistry');
|
|
904
|
+
if (typeof registry?.list !== 'function')
|
|
905
|
+
return undefined;
|
|
906
|
+
const items = registry.list();
|
|
907
|
+
if (!Array.isArray(items))
|
|
908
|
+
return undefined;
|
|
909
|
+
return items.flatMap(item => typeof item?.path === 'string' && item.path.trim() ? [item.path] : []);
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
return undefined;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
applyWorkspace(cwd) {
|
|
916
|
+
const next = normalizeWorkspacePath(cwd);
|
|
917
|
+
if (!next)
|
|
918
|
+
return;
|
|
919
|
+
this.engineConfig.cwd = next;
|
|
920
|
+
// 构造阶段 engine 还没建好,先只写配置;ImEngine 会读同一份 engineConfig。
|
|
921
|
+
this.engine?.setCwd(next);
|
|
922
|
+
}
|
|
923
|
+
currentPermission() {
|
|
924
|
+
const official = this.permissionPresets();
|
|
925
|
+
return normalizePermission(this.store.permission ?? this.engineConfig.permissionPreset, official.names) ?? official.defaultPreset;
|
|
926
|
+
}
|
|
927
|
+
applyPermission(permission) {
|
|
928
|
+
const next = normalizePermission(permission, this.permissionPresets().names);
|
|
929
|
+
if (!next)
|
|
930
|
+
return;
|
|
931
|
+
this.engineConfig.permissionPreset = next;
|
|
932
|
+
this.engine?.setPermission(next);
|
|
933
|
+
}
|
|
934
|
+
permissionOptions() {
|
|
935
|
+
const official = this.permissionPresets();
|
|
936
|
+
return official.names.map((name) => official.optionOf(name));
|
|
937
|
+
}
|
|
938
|
+
permissionPresets() {
|
|
939
|
+
return this.ctx.permissionPresets;
|
|
940
|
+
}
|
|
941
|
+
async listModelCatalog() {
|
|
942
|
+
const llm = this.ctx.get?.('llm');
|
|
943
|
+
const providers = llm?.listProviders?.() ?? [];
|
|
944
|
+
const out = [];
|
|
945
|
+
for (const item of providers) {
|
|
946
|
+
const models = llm?.listModels ? await llm.listModels(item.id).catch(() => []) : [];
|
|
947
|
+
out.push({
|
|
948
|
+
id: item.id,
|
|
949
|
+
name: item.name || item.id,
|
|
950
|
+
models: await Promise.all(models.map(async (model) => {
|
|
951
|
+
const resolved = llm?.resolveModelInfo === undefined
|
|
952
|
+
? undefined
|
|
953
|
+
: await llm.resolveModelInfo(item.id, model.id).catch(() => undefined);
|
|
954
|
+
const reasoning = resolved?.reasoning === undefined
|
|
955
|
+
? undefined
|
|
956
|
+
: {
|
|
957
|
+
efforts: resolved.reasoning.efforts.map((effort) => ({
|
|
958
|
+
id: effort.id,
|
|
959
|
+
name: effort.name,
|
|
960
|
+
...(effort.description === undefined ? {} : { description: effort.description }),
|
|
961
|
+
})),
|
|
962
|
+
...(resolved.reasoning.defaultEffort === undefined ? {} : { defaultEffort: resolved.reasoning.defaultEffort }),
|
|
963
|
+
};
|
|
964
|
+
return {
|
|
965
|
+
id: model.id,
|
|
966
|
+
name: model.name || model.id,
|
|
967
|
+
...(resolved?.description ?? model.description) === undefined
|
|
968
|
+
? {}
|
|
969
|
+
: { description: resolved?.description ?? model.description },
|
|
970
|
+
...(reasoning === undefined ? {} : { reasoning }),
|
|
971
|
+
};
|
|
972
|
+
})),
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
return out;
|
|
976
|
+
}
|
|
977
|
+
async startOne(id) {
|
|
978
|
+
await this.stopOne(id);
|
|
979
|
+
if (this.disposed)
|
|
980
|
+
return;
|
|
981
|
+
const state = this.store.channels[id];
|
|
982
|
+
if (!state)
|
|
983
|
+
throw new Error('账号不存在');
|
|
984
|
+
const platform = this.platformOf(id, state);
|
|
985
|
+
const resolved = await this.resolveSecrets(platform, id, state.config ?? {});
|
|
986
|
+
if (this.disposed)
|
|
987
|
+
return;
|
|
988
|
+
const accountDir = this.accountStateDir(id, platform);
|
|
989
|
+
let adapter;
|
|
990
|
+
// 渠道网络异常时 start 可能永久挂起,超时按启动失败处理(catch 会顺带 stop)
|
|
991
|
+
const START_TIMEOUT_MS = 30_000;
|
|
992
|
+
let startTimer;
|
|
993
|
+
try {
|
|
994
|
+
adapter = createChannelAdapter(platform, resolved, this.log, accountDir, {
|
|
995
|
+
accountId: id,
|
|
996
|
+
accountLabel: state.name || `${CHANNEL_META[platform].label}账号`,
|
|
997
|
+
host: this.ctx,
|
|
998
|
+
});
|
|
999
|
+
if (!adapter)
|
|
1000
|
+
throw new Error('凭据不足,无法启动渠道');
|
|
1001
|
+
this.seedAllowedUser(id, resolved.ownerOpenId || resolved.allowedUserId);
|
|
1002
|
+
this.engine.register(adapter);
|
|
1003
|
+
this.running.set(id, adapter);
|
|
1004
|
+
await Promise.race([
|
|
1005
|
+
adapter.start(),
|
|
1006
|
+
new Promise((_, reject) => {
|
|
1007
|
+
startTimer = setTimeout(() => reject(new Error('渠道启动超时')), START_TIMEOUT_MS);
|
|
1008
|
+
}),
|
|
1009
|
+
]);
|
|
1010
|
+
if (this.disposed) {
|
|
1011
|
+
this.engine.unregister(id);
|
|
1012
|
+
this.running.delete(id);
|
|
1013
|
+
await Promise.resolve(adapter.stop()).catch(() => undefined);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
catch (error) {
|
|
1018
|
+
if (adapter) {
|
|
1019
|
+
this.engine.unregister(id);
|
|
1020
|
+
this.running.delete(id);
|
|
1021
|
+
await Promise.resolve(adapter.stop()).catch(() => undefined);
|
|
1022
|
+
}
|
|
1023
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1024
|
+
this.log(`[manager] 启动 ${id} 失败: ${message}`);
|
|
1025
|
+
if (state) {
|
|
1026
|
+
state.lastError = '连接失败,请查看本机日志';
|
|
1027
|
+
this.flush();
|
|
1028
|
+
}
|
|
1029
|
+
throw error;
|
|
1030
|
+
}
|
|
1031
|
+
finally {
|
|
1032
|
+
if (startTimer)
|
|
1033
|
+
clearTimeout(startTimer);
|
|
1034
|
+
}
|
|
1035
|
+
if (state) {
|
|
1036
|
+
state.lastError = undefined;
|
|
1037
|
+
this.flush();
|
|
1038
|
+
}
|
|
1039
|
+
this.log(`[manager] ${id} 已启动:${adapter.status()}`);
|
|
1040
|
+
}
|
|
1041
|
+
async stopOne(id) {
|
|
1042
|
+
const adapter = this.running.get(id);
|
|
1043
|
+
if (!adapter)
|
|
1044
|
+
return;
|
|
1045
|
+
this.engine.unregister(id);
|
|
1046
|
+
this.running.delete(id);
|
|
1047
|
+
await Promise.resolve(adapter.stop()).catch(() => undefined);
|
|
1048
|
+
}
|
|
1049
|
+
async persistSecrets(platform, id, config) {
|
|
1050
|
+
const secrets = new Set((CHANNEL_META[platform].fields.filter((field) => field.secret)).map((field) => field.key));
|
|
1051
|
+
const out = { ...config };
|
|
1052
|
+
for (const key of secrets) {
|
|
1053
|
+
const value = out[key];
|
|
1054
|
+
if (!value)
|
|
1055
|
+
continue;
|
|
1056
|
+
const ref = credentialRef(id, key);
|
|
1057
|
+
await this.vault.set(ref, value);
|
|
1058
|
+
out[key] = '';
|
|
1059
|
+
out[`${key}Ref`] = ref;
|
|
1060
|
+
}
|
|
1061
|
+
return out;
|
|
1062
|
+
}
|
|
1063
|
+
async resolveSecrets(platform, id, config) {
|
|
1064
|
+
const secrets = new Set((CHANNEL_META[platform].fields.filter((field) => field.secret)).map((field) => field.key));
|
|
1065
|
+
const out = { ...config };
|
|
1066
|
+
for (const key of secrets) {
|
|
1067
|
+
const ref = out[`${key}Ref`] || credentialRef(id, key);
|
|
1068
|
+
const value = await this.vault.resolve(ref);
|
|
1069
|
+
if (value)
|
|
1070
|
+
out[key] = value;
|
|
1071
|
+
}
|
|
1072
|
+
return out;
|
|
1073
|
+
}
|
|
1074
|
+
load() {
|
|
1075
|
+
try {
|
|
1076
|
+
const value = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
1077
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
1078
|
+
throw new TypeError('channels.json 顶层必须是对象');
|
|
1079
|
+
const parsed = value;
|
|
1080
|
+
this.store = {
|
|
1081
|
+
version: 2,
|
|
1082
|
+
channels: parsed.channels ?? {},
|
|
1083
|
+
allowlist: parsed.allowlist ?? {},
|
|
1084
|
+
pending: parsed.pending ?? {},
|
|
1085
|
+
assistant: normalizeAssistantModel(parsed.assistant ?? {}),
|
|
1086
|
+
cwd: normalizeWorkspacePath(parsed.cwd),
|
|
1087
|
+
permission: normalizePermission(parsed.permission),
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
try {
|
|
1092
|
+
const backup = backupCorruptConfig(this.file);
|
|
1093
|
+
if (backup)
|
|
1094
|
+
this.log(`[manager] channels.json 损坏,已备份到 ${backup}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1095
|
+
}
|
|
1096
|
+
catch (backupError) {
|
|
1097
|
+
this.log(`[manager] channels.json 无法读取且备份失败,拒绝覆盖原文件: ${backupError instanceof Error ? backupError.message : String(backupError)}`);
|
|
1098
|
+
throw backupError;
|
|
1099
|
+
}
|
|
1100
|
+
this.store = { channels: {}, allowlist: {}, pending: {} };
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
migrateAccountSettings() {
|
|
1104
|
+
const assistant = this.currentAssistant() ?? { provider: this.engineConfig.provider, model: this.engineConfig.model };
|
|
1105
|
+
const cwd = this.currentWorkspace();
|
|
1106
|
+
const permission = this.currentPermission();
|
|
1107
|
+
for (const [id, state] of Object.entries(this.store.channels)) {
|
|
1108
|
+
const platform = this.platformOf(id, state);
|
|
1109
|
+
state.id = id;
|
|
1110
|
+
state.platform = platform;
|
|
1111
|
+
state.name = String(state.name || `${CHANNEL_META[platform].label}账号`).trim();
|
|
1112
|
+
state.assistant = normalizeAssistantModel(state.assistant ?? {}) ?? assistant;
|
|
1113
|
+
state.cwd = normalizeWorkspacePath(state.cwd) ?? cwd;
|
|
1114
|
+
state.permission = normalizePermission(state.permission, this.permissionPresets().names) ?? permission;
|
|
1115
|
+
state.privateAccess = state.privateAccess === 'all' ? 'all' : 'approved';
|
|
1116
|
+
state.sendMode = normalizeSendMode(state.sendMode);
|
|
1117
|
+
}
|
|
1118
|
+
this.store.version = 2;
|
|
1119
|
+
}
|
|
1120
|
+
async clearUnsupportedReasoningEfforts() {
|
|
1121
|
+
const llm = this.ctx.get?.('llm');
|
|
1122
|
+
if (!llm?.resolveModelInfo)
|
|
1123
|
+
return;
|
|
1124
|
+
let changed = false;
|
|
1125
|
+
await Promise.all(Object.entries(this.store.channels).map(async ([id, state]) => {
|
|
1126
|
+
const assistant = normalizeAssistantModel(state.assistant ?? {});
|
|
1127
|
+
if (!assistant?.reasoningEffort)
|
|
1128
|
+
return;
|
|
1129
|
+
const resolved = await llm.resolveModelInfo(assistant.provider, assistant.model).catch(() => undefined);
|
|
1130
|
+
if (resolved === undefined || resolved.reasoning !== undefined)
|
|
1131
|
+
return;
|
|
1132
|
+
state.assistant = { provider: assistant.provider, model: assistant.model };
|
|
1133
|
+
changed = true;
|
|
1134
|
+
this.log(`[manager] 已清理 ${id} 的无效推理等级 ${assistant.reasoningEffort}`);
|
|
1135
|
+
}));
|
|
1136
|
+
if (changed)
|
|
1137
|
+
this.flush();
|
|
1138
|
+
}
|
|
1139
|
+
stableNameOrdinal(platform, previous) {
|
|
1140
|
+
if (Number.isInteger(previous.nameOrdinal) && (previous.nameOrdinal ?? 0) >= 1)
|
|
1141
|
+
return previous.nameOrdinal;
|
|
1142
|
+
const parsed = parseAutoOrdinal(platform, previous.name);
|
|
1143
|
+
if (parsed)
|
|
1144
|
+
return parsed;
|
|
1145
|
+
const used = new Set();
|
|
1146
|
+
for (const [id, state] of Object.entries(this.store.channels)) {
|
|
1147
|
+
if (state === previous || this.platformOf(id, state) !== platform)
|
|
1148
|
+
continue;
|
|
1149
|
+
const ordinal = Number.isInteger(state.nameOrdinal) && (state.nameOrdinal ?? 0) >= 1
|
|
1150
|
+
? state.nameOrdinal
|
|
1151
|
+
: parseAutoOrdinal(platform, state.name);
|
|
1152
|
+
if (ordinal)
|
|
1153
|
+
used.add(ordinal);
|
|
1154
|
+
}
|
|
1155
|
+
let ordinal = 1;
|
|
1156
|
+
while (used.has(ordinal))
|
|
1157
|
+
ordinal += 1;
|
|
1158
|
+
return ordinal;
|
|
1159
|
+
}
|
|
1160
|
+
async normalizeAccountSettings(platform, input, previous) {
|
|
1161
|
+
const agentPreset = input.agentPreset ?? previous.agentPreset ?? this.engineConfig.agentPreset ?? 'standard';
|
|
1162
|
+
if (typeof agentPreset !== 'string' || !agentPreset.trim())
|
|
1163
|
+
return { ok: false, error: '请选择 Agent 预设' };
|
|
1164
|
+
if (input.agentPreset !== undefined) {
|
|
1165
|
+
const options = await this.agentPresetOptions();
|
|
1166
|
+
const selected = options.find(item => item.id === agentPreset);
|
|
1167
|
+
if (!selected || selected.broken)
|
|
1168
|
+
return { ok: false, error: 'Agent 预设不存在或不可用' };
|
|
1169
|
+
}
|
|
1170
|
+
const fallback = this.currentAssistant();
|
|
1171
|
+
const assistant = normalizeAssistantModel({
|
|
1172
|
+
provider: input.provider ?? previous.assistant?.provider ?? fallback?.provider,
|
|
1173
|
+
model: input.model ?? previous.assistant?.model ?? fallback?.model,
|
|
1174
|
+
reasoningEffort: input.reasoningEffort !== undefined ? input.reasoningEffort : previous.assistant?.reasoningEffort,
|
|
1175
|
+
});
|
|
1176
|
+
if (!assistant)
|
|
1177
|
+
return { ok: false, error: '请选择提供商和模型' };
|
|
1178
|
+
const cwd = normalizeWorkspacePath(input.cwd ?? previous.cwd ?? this.currentWorkspace());
|
|
1179
|
+
const workspaceError = this.workspaceSaveError(cwd);
|
|
1180
|
+
if (workspaceError)
|
|
1181
|
+
return { ok: false, error: workspaceError };
|
|
1182
|
+
if (!cwd)
|
|
1183
|
+
return { ok: false, error: '请选择工作区' };
|
|
1184
|
+
const permission = normalizePermission(input.permission ?? previous.permission ?? this.currentPermission(), this.permissionPresets().names);
|
|
1185
|
+
if (!permission)
|
|
1186
|
+
return { ok: false, error: '请选择权限' };
|
|
1187
|
+
const privateAccess = input.privateAccess === 'all' || (input.privateAccess === undefined && previous.privateAccess === 'all') ? 'all' : 'approved';
|
|
1188
|
+
const sendMode = normalizeSendMode(input.sendMode === undefined ? previous.sendMode : input.sendMode);
|
|
1189
|
+
let commandPermissions;
|
|
1190
|
+
try {
|
|
1191
|
+
commandPermissions = normalizeCommandPermissions(input.commandPermissions === undefined ? previous.commandPermissions : input.commandPermissions);
|
|
1192
|
+
}
|
|
1193
|
+
catch {
|
|
1194
|
+
return { ok: false, error: '命令权限配置无效' };
|
|
1195
|
+
}
|
|
1196
|
+
const nameOrdinal = this.stableNameOrdinal(platform, previous);
|
|
1197
|
+
const requested = input.name !== undefined ? String(input.name) : String(previous.name ?? '');
|
|
1198
|
+
const trimmed = requested.trim();
|
|
1199
|
+
if ([...trimmed].length > ACCOUNT_REMARK_LIMIT)
|
|
1200
|
+
return { ok: false, error: `账号备注不能超过 ${ACCOUNT_REMARK_LIMIT} 个字符` };
|
|
1201
|
+
if (/[\u0000-\u001F\u007F]/.test(trimmed))
|
|
1202
|
+
return { ok: false, error: '账号备注不能包含换行或控制字符' };
|
|
1203
|
+
const name = trimmed || autoAccountName(platform, nameOrdinal);
|
|
1204
|
+
return { ok: true, settings: { name, nameOrdinal, assistant, agentPreset, cwd, permission, privateAccess, sendMode, commandPermissions } };
|
|
1205
|
+
}
|
|
1206
|
+
accountIdFor(platform, config) {
|
|
1207
|
+
const identityKeys = { feishu: 'appId' };
|
|
1208
|
+
const identity = config[identityKeys[platform] ?? ''] || config.ownerOpenId || config.botToken || config.token;
|
|
1209
|
+
if (identity) {
|
|
1210
|
+
for (const [accountId, state] of Object.entries(this.store.channels)) {
|
|
1211
|
+
if (this.platformOf(accountId, state) !== platform)
|
|
1212
|
+
continue;
|
|
1213
|
+
const key = identityKeys[platform];
|
|
1214
|
+
if (key && state.config?.[key] === identity)
|
|
1215
|
+
return accountId;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
const suffix = identity
|
|
1219
|
+
? createHash('sha256').update(`${platform}\0${identity}`).digest('hex').slice(0, 12)
|
|
1220
|
+
: randomUUID().replaceAll('-', '').slice(0, 12);
|
|
1221
|
+
return `${platform}_${suffix}`;
|
|
1222
|
+
}
|
|
1223
|
+
resolveAccountId(id) {
|
|
1224
|
+
if (this.store.channels[id])
|
|
1225
|
+
return id;
|
|
1226
|
+
const matches = Object.entries(this.store.channels).filter(([accountId, state]) => this.platformOf(accountId, state) === id);
|
|
1227
|
+
return matches.length === 1 ? matches[0][0] : undefined;
|
|
1228
|
+
}
|
|
1229
|
+
platformOf(accountId, state) {
|
|
1230
|
+
if (state?.platform && CHANNEL_META[state.platform])
|
|
1231
|
+
return state.platform;
|
|
1232
|
+
const candidate = accountId.split('_', 1)[0];
|
|
1233
|
+
return CHANNEL_META[candidate] ? candidate : accountId;
|
|
1234
|
+
}
|
|
1235
|
+
accountStateDir(accountId, platform) {
|
|
1236
|
+
return accountId === platform ? join(this.stateDir, platform) : join(this.stateDir, 'accounts', accountId);
|
|
1237
|
+
}
|
|
1238
|
+
/** 与 Chat 使用同一预设名册,不向客户端暴露预设文件路径。 */
|
|
1239
|
+
async agentPresetOptions() {
|
|
1240
|
+
const presets = this.ctx.get?.('agentPresets');
|
|
1241
|
+
if (presets?.remoteExportList)
|
|
1242
|
+
return (await presets.remoteExportList()).presets.map(({ id, name, description, broken }) => ({ id, name, description, broken }));
|
|
1243
|
+
return [{ id: this.engineConfig.agentPreset || 'standard' }];
|
|
1244
|
+
}
|
|
1245
|
+
accountEngineConfig(accountId) {
|
|
1246
|
+
const state = this.store.channels[accountId];
|
|
1247
|
+
if (!state)
|
|
1248
|
+
return this.engineConfig;
|
|
1249
|
+
const assistant = normalizeAssistantModel(state.assistant ?? {});
|
|
1250
|
+
return {
|
|
1251
|
+
...this.engineConfig,
|
|
1252
|
+
agentPreset: state.agentPreset || this.engineConfig.agentPreset,
|
|
1253
|
+
cwd: normalizeWorkspacePath(state.cwd) ?? this.engineConfig.cwd,
|
|
1254
|
+
provider: assistant?.provider ?? this.engineConfig.provider,
|
|
1255
|
+
model: assistant?.model ?? this.engineConfig.model,
|
|
1256
|
+
reasoningEffort: assistant?.reasoningEffort,
|
|
1257
|
+
permissionPreset: normalizePermission(state.permission, this.permissionPresets().names) ?? this.engineConfig.permissionPreset,
|
|
1258
|
+
sendMode: normalizeSendMode(state.sendMode),
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
pendingRequests() {
|
|
1262
|
+
return Object.entries(this.store.pending).flatMap(([channelId, list]) => list.map((item) => ({ channelId, ...item })));
|
|
1263
|
+
}
|
|
1264
|
+
approve(id, userId) {
|
|
1265
|
+
const uid = userId.trim();
|
|
1266
|
+
const accountId = this.resolveAccountId(id);
|
|
1267
|
+
if (!uid || !accountId)
|
|
1268
|
+
return false;
|
|
1269
|
+
const list = this.store.allowlist[accountId] ?? [];
|
|
1270
|
+
if (!list.includes(uid))
|
|
1271
|
+
list.push(uid);
|
|
1272
|
+
this.store.allowlist[accountId] = list;
|
|
1273
|
+
this.store.pending[accountId] = (this.store.pending[accountId] ?? []).filter((item) => item.userId !== uid);
|
|
1274
|
+
this.engine.addAllowed(accountId, uid);
|
|
1275
|
+
this.flush();
|
|
1276
|
+
return true;
|
|
1277
|
+
}
|
|
1278
|
+
deny(id, userId) {
|
|
1279
|
+
const uid = userId.trim();
|
|
1280
|
+
const accountId = this.resolveAccountId(id);
|
|
1281
|
+
if (!uid || !accountId)
|
|
1282
|
+
return false;
|
|
1283
|
+
this.store.pending[accountId] = (this.store.pending[accountId] ?? []).filter((item) => item.userId !== uid);
|
|
1284
|
+
this.flush();
|
|
1285
|
+
return true;
|
|
1286
|
+
}
|
|
1287
|
+
seedAllowedUser(id, userId) {
|
|
1288
|
+
const uid = userId?.trim();
|
|
1289
|
+
if (!uid)
|
|
1290
|
+
return;
|
|
1291
|
+
this.approve(id, uid);
|
|
1292
|
+
}
|
|
1293
|
+
requestAuthorization(channelId, msg) {
|
|
1294
|
+
const userId = msg.userId?.trim();
|
|
1295
|
+
if (!userId)
|
|
1296
|
+
return;
|
|
1297
|
+
const list = this.store.pending[channelId] ?? [];
|
|
1298
|
+
if (!list.some((item) => item.userId === userId)) {
|
|
1299
|
+
list.push({ userId, username: msg.username, chatId: msg.chatId, time: Date.now() });
|
|
1300
|
+
this.store.pending[channelId] = list;
|
|
1301
|
+
this.flush();
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
flush() {
|
|
1305
|
+
writeFileAtomicSync(this.file, `${JSON.stringify(this.store, null, 2)}\n`);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
function pairingSettings(input) {
|
|
1309
|
+
if (!input)
|
|
1310
|
+
return {};
|
|
1311
|
+
const out = {};
|
|
1312
|
+
if (input.commandPermissions !== undefined)
|
|
1313
|
+
out.__setting_commandPermissions = JSON.stringify(normalizeCommandPermissions(input.commandPermissions));
|
|
1314
|
+
for (const key of ['name', 'provider', 'model', 'reasoningEffort', 'cwd', 'permission', 'privateAccess', 'sendMode', 'agentPreset']) {
|
|
1315
|
+
const value = input[key];
|
|
1316
|
+
if (value !== undefined && value !== null)
|
|
1317
|
+
out[`__setting_${key}`] = String(value);
|
|
1318
|
+
}
|
|
1319
|
+
return out;
|
|
1320
|
+
}
|
|
1321
|
+
function sameAssistantModel(left, right) {
|
|
1322
|
+
return left?.provider === right.provider
|
|
1323
|
+
&& left.model === right.model
|
|
1324
|
+
&& left.reasoningEffort === right.reasoningEffort;
|
|
1325
|
+
}
|