mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
251 lines
7.5 KiB
JavaScript
251 lines
7.5 KiB
JavaScript
import { RegistrationManager } from './registration-manager.mjs';
|
||
|
||
export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
|
||
|
||
export const REQUIRED_TENANT_SCOPES = Object.freeze([
|
||
'im:message.p2p_msg:readonly',
|
||
'im:message.group_at_msg:readonly',
|
||
'im:message:readonly',
|
||
'im:message:send_as_bot',
|
||
'im:message.reactions:write_only',
|
||
'im:message:recall',
|
||
'im:resource',
|
||
'cardkit:card:write',
|
||
]);
|
||
|
||
function safeConnectionStatus(runtime) {
|
||
if (!runtime) return {
|
||
ready: false,
|
||
feishuLongConnectionState: 'idle',
|
||
harnessReachable: false,
|
||
};
|
||
return runtime.status;
|
||
}
|
||
|
||
function publicBot(config) {
|
||
if (!config) return null;
|
||
const appIdMasked = config.appId.length > 12
|
||
? `${config.appId.slice(0, 8)}••••${config.appId.slice(-4)}`
|
||
: 'cli_••••';
|
||
return {
|
||
name: config.botName,
|
||
appIdMasked,
|
||
openId: config.botOpenId,
|
||
activated: config.activated,
|
||
domain: config.domain,
|
||
};
|
||
}
|
||
|
||
/** Coordinates QR provisioning, durable credentials and the live chat runtime. */
|
||
export class DshFeishuController {
|
||
#registerApp;
|
||
#verifyApp;
|
||
#credentials;
|
||
#configStore;
|
||
#createRuntime;
|
||
#registration;
|
||
#runtime = null;
|
||
#lastError = null;
|
||
#transition = Promise.resolve();
|
||
|
||
constructor({ registerApp, verifyApp, credentials, configStore, createRuntime }) {
|
||
if (typeof registerApp !== 'function') throw new Error('registerApp is required');
|
||
if (typeof verifyApp !== 'function') throw new Error('verifyApp is required');
|
||
if (!credentials) throw new Error('credentials service is required');
|
||
if (!configStore) throw new Error('config store is required');
|
||
if (typeof createRuntime !== 'function') throw new Error('createRuntime is required');
|
||
|
||
this.#registerApp = registerApp;
|
||
this.#verifyApp = verifyApp;
|
||
this.#credentials = credentials;
|
||
this.#configStore = configStore;
|
||
this.#createRuntime = createRuntime;
|
||
this.#registration = new RegistrationManager({
|
||
registerApp: this.#registerApp,
|
||
onCredentials: (result) => this.#serialize(() => this.#acceptCredentials(result)),
|
||
});
|
||
}
|
||
|
||
async initialize() {
|
||
const config = this.#configStore.get();
|
||
if (!config) return this.status();
|
||
return this.#serialize(async () => {
|
||
const resolved = await this.#credentials.resolve(FEISHU_SECRET_REF);
|
||
if (!resolved?.value) {
|
||
this.#lastError = {
|
||
code: 'missing_credentials',
|
||
message: '机器人凭据缺失,请重新扫码接入。',
|
||
};
|
||
return this.status();
|
||
}
|
||
try {
|
||
await this.#startRuntime(config, resolved.value);
|
||
this.#lastError = null;
|
||
} catch {
|
||
this.#lastError = {
|
||
code: 'connection_failed',
|
||
message: '机器人暂时无法连接飞书,请重试。',
|
||
};
|
||
}
|
||
return this.status();
|
||
});
|
||
}
|
||
|
||
startRegistration() {
|
||
this.#lastError = null;
|
||
this.#registration.start({
|
||
source: 'deepseek-harness',
|
||
createOnly: true,
|
||
appPreset: {
|
||
name: '{user} 的北汇星河 AI 助手',
|
||
desc: '连接飞书与 DeepSeek Harness,在聊天中使用企业 AI 助手。',
|
||
},
|
||
addons: {
|
||
preset: false,
|
||
scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
|
||
events: { items: { tenant: ['im.message.receive_v1'] } },
|
||
callbacks: { items: ['card.action.trigger'] },
|
||
},
|
||
});
|
||
return this.status();
|
||
}
|
||
|
||
cancelRegistration() {
|
||
this.#registration.cancel();
|
||
return this.status();
|
||
}
|
||
|
||
async reconnect() {
|
||
return this.#serialize(async () => {
|
||
const config = this.#configStore.get();
|
||
const resolved = await this.#credentials.resolve(FEISHU_SECRET_REF);
|
||
if (!config || !resolved?.value) {
|
||
this.#lastError = {
|
||
code: 'missing_credentials',
|
||
message: '没有可用的机器人凭据,请重新扫码接入。',
|
||
};
|
||
return this.status();
|
||
}
|
||
try {
|
||
await this.#startRuntime(config, resolved.value);
|
||
this.#lastError = null;
|
||
} catch {
|
||
this.#lastError = {
|
||
code: 'connection_failed',
|
||
message: '机器人暂时无法连接飞书,请重试。',
|
||
};
|
||
}
|
||
return this.status();
|
||
});
|
||
}
|
||
|
||
async disconnect() {
|
||
this.#registration.cancel();
|
||
return this.#serialize(async () => {
|
||
await this.#stopRuntime();
|
||
await this.#configStore.clear();
|
||
try {
|
||
await this.#credentials.unset(FEISHU_SECRET_REF);
|
||
} catch {
|
||
// A read-only environment value may shadow the managed store. Clearing
|
||
// the non-secret config still prevents automatic reuse of that value.
|
||
}
|
||
this.#lastError = null;
|
||
return this.status();
|
||
});
|
||
}
|
||
|
||
async close() {
|
||
this.#registration.cancel();
|
||
await this.#serialize(() => this.#stopRuntime());
|
||
}
|
||
|
||
status() {
|
||
const config = this.#configStore.get();
|
||
const registration = this.#registration.status();
|
||
const connection = safeConnectionStatus(this.#runtime);
|
||
const connected = connection.ready === true
|
||
&& connection.feishuLongConnectionState === 'connected'
|
||
&& connection.harnessReachable === true;
|
||
|
||
let phase = 'unconfigured';
|
||
if (connected) phase = 'connected';
|
||
else if (['starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched'].includes(registration.state)) {
|
||
phase = 'registering';
|
||
} else if (registration.state === 'saving') phase = 'connecting';
|
||
else if (this.#lastError || registration.state === 'error') phase = 'error';
|
||
else if (config) phase = 'disconnected';
|
||
|
||
return {
|
||
phase,
|
||
connected,
|
||
configured: Boolean(config),
|
||
bot: publicBot(config),
|
||
registration,
|
||
connection,
|
||
error: this.#lastError ?? registration.error ?? null,
|
||
};
|
||
}
|
||
|
||
async #acceptCredentials(result) {
|
||
const appId = result.client_id;
|
||
const appSecret = result.client_secret;
|
||
const ownerOpenId = result.user_info?.open_id;
|
||
const domain = result.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu';
|
||
if (!ownerOpenId) throw new Error('Feishu registration returned no owner open_id');
|
||
|
||
const bot = await this.#verifyApp({ appId, appSecret, domain });
|
||
await this.#credentials.set(FEISHU_SECRET_REF, appSecret);
|
||
let config;
|
||
try {
|
||
config = await this.#configStore.save({
|
||
appId,
|
||
ownerOpenId,
|
||
domain,
|
||
botName: bot.name,
|
||
botOpenId: bot.openId,
|
||
activated: bot.activated,
|
||
connectedAt: new Date().toISOString(),
|
||
});
|
||
} catch (error) {
|
||
await this.#credentials.unset(FEISHU_SECRET_REF).catch(() => undefined);
|
||
throw error;
|
||
}
|
||
|
||
try {
|
||
await this.#startRuntime(config, appSecret);
|
||
this.#lastError = null;
|
||
} catch (error) {
|
||
this.#lastError = {
|
||
code: 'connection_failed',
|
||
message: '机器人已经创建,但长连接未就绪,请点击重试。',
|
||
};
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async #startRuntime(config, appSecret) {
|
||
await this.#stopRuntime();
|
||
const runtime = await this.#createRuntime({ config, appSecret });
|
||
this.#runtime = runtime;
|
||
try {
|
||
await runtime.start();
|
||
} catch (error) {
|
||
if (this.#runtime === runtime) this.#runtime = null;
|
||
await runtime.stop({ preserveError: true }).catch(() => undefined);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async #stopRuntime() {
|
||
const runtime = this.#runtime;
|
||
this.#runtime = null;
|
||
if (runtime) await runtime.stop();
|
||
}
|
||
|
||
#serialize(operation) {
|
||
const result = this.#transition.then(operation, operation);
|
||
this.#transition = result.then(() => undefined, () => undefined);
|
||
return result;
|
||
}
|
||
}
|