mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-12 02:33:19 +08:00
Internalize all IM channel implementations
This commit is contained in:
parent
8996476693
commit
bd469f58f8
95 changed files with 25012 additions and 100 deletions
708
src/channels/dingtalk/dingtalk-controller.mjs
Normal file
708
src/channels/dingtalk/dingtalk-controller.mjs
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
deriveDingtalkBotIdentity,
|
||||
deriveDingtalkSenderKey,
|
||||
maskDingtalkClientId,
|
||||
maskDingtalkSenderId,
|
||||
} from './config-store.mjs';
|
||||
|
||||
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
||||
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function safeError(code, message) {
|
||||
return Object.freeze({ code, message });
|
||||
}
|
||||
|
||||
function nowFrom(clock) {
|
||||
return typeof clock?.now === 'function' ? clock.now() : clock();
|
||||
}
|
||||
|
||||
function isoNow(clock) {
|
||||
return new Date(nowFrom(clock)).toISOString();
|
||||
}
|
||||
|
||||
function abortError() {
|
||||
return new DOMException('DingTalk provisioning was cancelled', 'AbortError');
|
||||
}
|
||||
|
||||
function publicAttempt(record) {
|
||||
if (!record) return null;
|
||||
return {
|
||||
attemptId: record.id,
|
||||
status: record.state,
|
||||
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
|
||||
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
||||
...(record.pollIntervalMs ? { pollIntervalMs: record.pollIntervalMs } : {}),
|
||||
...(record.botId ? { botId: record.botId } : {}),
|
||||
...(record.alreadyConnected ? { alreadyConnected: true } : {}),
|
||||
...(record.error ? { error: structuredClone(record.error) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeStatus(runtime) {
|
||||
if (!runtime) return {};
|
||||
const value = typeof runtime.status === 'function' ? runtime.status() : runtime.status;
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function isRuntimeConnected(runtime, status) {
|
||||
if (!runtime) return false;
|
||||
if (status.connected === false || status.ready === false) return false;
|
||||
const state = cleanString(
|
||||
status.dingtalkStreamState
|
||||
?? status.dingtalkConnectionState
|
||||
?? status.connectionState
|
||||
?? status.state,
|
||||
)?.toLowerCase();
|
||||
if (['failed', 'error', 'offline', 'disconnected', 'stopped'].includes(state)) return false;
|
||||
return status.connected === true
|
||||
|| status.ready === true
|
||||
|| state === 'connected'
|
||||
|| state === 'ready';
|
||||
}
|
||||
|
||||
function normalizePendingSender(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const staffId = cleanString(value.staffId ?? value.senderStaffId ?? value.senderId);
|
||||
if (!staffId) return null;
|
||||
const suppliedRequestId = cleanString(value.requestId);
|
||||
const opaqueRequestId = suppliedRequestId
|
||||
&& /^ding_sender_[A-Za-z0-9_-]{1,100}$/.test(suppliedRequestId)
|
||||
&& !suppliedRequestId.includes(staffId)
|
||||
? suppliedRequestId
|
||||
: null;
|
||||
if (!opaqueRequestId) return null;
|
||||
return {
|
||||
requestId: opaqueRequestId,
|
||||
staffId,
|
||||
displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? '钉钉用户',
|
||||
requestedAt: cleanString(value.requestedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function internalPendingSenders(status) {
|
||||
if (!Array.isArray(status.pendingSenders)) return [];
|
||||
const seen = new Set();
|
||||
const senders = [];
|
||||
for (const value of status.pendingSenders) {
|
||||
const sender = normalizePendingSender(value);
|
||||
if (!sender || seen.has(sender.staffId)) continue;
|
||||
seen.add(sender.staffId);
|
||||
senders.push(sender);
|
||||
}
|
||||
return senders;
|
||||
}
|
||||
|
||||
function publicPendingSender(sender) {
|
||||
return {
|
||||
requestId: sender.requestId,
|
||||
displayName: sender.displayName,
|
||||
senderIdMasked: maskDingtalkSenderId(sender.staffId),
|
||||
requestedAt: sender.requestedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function publicApprovedSender(sender) {
|
||||
return {
|
||||
senderKey: sender.senderKey,
|
||||
displayName: cleanString(sender.displayName) ?? '钉钉用户',
|
||||
senderIdMasked: maskDingtalkSenderId(sender.staffId),
|
||||
approvedAt: cleanString(sender.approvedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** Coordinates DingTalk QR registration, credentials, runtimes, and sender approvals. */
|
||||
export class DingtalkController {
|
||||
#deviceAuth;
|
||||
#credentials;
|
||||
#configStore;
|
||||
#createRuntime;
|
||||
#deleteState;
|
||||
#logger;
|
||||
#clock;
|
||||
#runtimes = new Map();
|
||||
#errors = new Map();
|
||||
#attempts = new Map();
|
||||
#activeAttemptId = null;
|
||||
#transitions = new Map();
|
||||
#revision = 0;
|
||||
#closed = false;
|
||||
|
||||
/**
|
||||
* @param {object} options Controller dependencies.
|
||||
* @param {object} options.deviceAuth Host-only DingTalk device auth client.
|
||||
* @param {object} options.credentials DSH credential provider.
|
||||
* @param {object} options.configStore Loaded DingTalk config store.
|
||||
* @param {Function} options.createRuntime Runtime factory.
|
||||
* @param {Function} [options.deleteState] Per-bot state cleanup callback.
|
||||
* @param {Console} [options.logger] Host logger.
|
||||
* @param {{now(): number}|(()=>number)} [options.clock] Injectable clock.
|
||||
*/
|
||||
constructor({
|
||||
deviceAuth,
|
||||
credentials,
|
||||
configStore,
|
||||
createRuntime,
|
||||
deleteState = async () => {},
|
||||
logger = console,
|
||||
clock = Date,
|
||||
}) {
|
||||
if (!deviceAuth
|
||||
|| typeof deviceAuth.start !== 'function'
|
||||
|| typeof deviceAuth.poll !== 'function') {
|
||||
throw new TypeError('DingtalkController requires a DingTalk device auth client');
|
||||
}
|
||||
if (!credentials
|
||||
|| typeof credentials.resolve !== 'function'
|
||||
|| typeof credentials.set !== 'function'
|
||||
|| typeof credentials.unset !== 'function') {
|
||||
throw new TypeError('DingtalkController requires the DSH credential provider');
|
||||
}
|
||||
if (!configStore
|
||||
|| typeof configStore.list !== 'function'
|
||||
|| typeof configStore.get !== 'function'
|
||||
|| typeof configStore.getByClientId !== 'function'
|
||||
|| typeof configStore.save !== 'function'
|
||||
|| typeof configStore.remove !== 'function') {
|
||||
throw new TypeError('DingtalkController requires a loaded config store');
|
||||
}
|
||||
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
||||
if (typeof deleteState !== 'function') throw new TypeError('deleteState must be a function');
|
||||
if (typeof clock !== 'function' && typeof clock?.now !== 'function') {
|
||||
throw new TypeError('clock must be a function or expose now()');
|
||||
}
|
||||
this.#deviceAuth = deviceAuth;
|
||||
this.#credentials = credentials;
|
||||
this.#configStore = configStore;
|
||||
this.#createRuntime = createRuntime;
|
||||
this.#deleteState = deleteState;
|
||||
this.#logger = logger;
|
||||
this.#clock = clock;
|
||||
}
|
||||
|
||||
/** Starts all configured DingTalk runtimes whose secrets are available. */
|
||||
async initialize() {
|
||||
if (this.#closed) return this.status();
|
||||
for (const config of this.#configStore.list()) {
|
||||
const current = this.#runtimes.get(config.botId);
|
||||
try {
|
||||
if (isRuntimeConnected(current, runtimeStatus(current))) continue;
|
||||
} catch {
|
||||
// A runtime with an unreadable status is replaced below.
|
||||
}
|
||||
await this.#withBotTransition(config.botId, async () => {
|
||||
const latest = this.#configStore.get(config.botId);
|
||||
if (!latest || this.#closed) return;
|
||||
const clientSecret = await this.#resolveSecret(latest.secretRef);
|
||||
if (!clientSecret) {
|
||||
this.#errors.set(
|
||||
latest.botId,
|
||||
safeError('missing-secret', '钉钉机器人凭据缺失,请移除后重新扫码。'),
|
||||
);
|
||||
this.#touch();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(latest, clientSecret);
|
||||
this.#errors.delete(latest.botId);
|
||||
} catch {
|
||||
this.#errors.set(
|
||||
latest.botId,
|
||||
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
|
||||
);
|
||||
this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
|
||||
}
|
||||
this.#touch();
|
||||
});
|
||||
}
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** Starts one DingTalk QR registration, cancelling any prior active attempt. */
|
||||
async startProvisioning({ signal } = {}) {
|
||||
if (this.#closed) throw new Error('dsh-dingtalk controller is closed');
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
const record = {
|
||||
id: randomUUID(),
|
||||
state: 'starting',
|
||||
controller: new AbortController(),
|
||||
deviceCode: null,
|
||||
verificationUrl: null,
|
||||
expiresAt: null,
|
||||
pollIntervalMs: null,
|
||||
pollTask: null,
|
||||
botId: null,
|
||||
alreadyConnected: false,
|
||||
error: null,
|
||||
};
|
||||
this.#attempts.set(record.id, record);
|
||||
this.#activeAttemptId = record.id;
|
||||
this.#touch();
|
||||
const abortFromRequest = () => record.controller.abort(signal?.reason);
|
||||
if (signal?.aborted) abortFromRequest();
|
||||
else signal?.addEventListener('abort', abortFromRequest, { once: true });
|
||||
try {
|
||||
const begun = await this.#deviceAuth.start({ signal: record.controller.signal });
|
||||
this.#assertAttemptActive(record);
|
||||
record.deviceCode = cleanString(begun.deviceCode);
|
||||
record.verificationUrl = cleanString(begun.verificationUrl);
|
||||
record.expiresAt = Number(begun.expiresAt);
|
||||
record.pollIntervalMs = Number(begun.pollIntervalMs);
|
||||
if (!record.deviceCode
|
||||
|| !record.verificationUrl
|
||||
|| !Number.isFinite(record.expiresAt)
|
||||
|| !Number.isFinite(record.pollIntervalMs)
|
||||
|| record.pollIntervalMs <= 0) {
|
||||
throw new Error('DingTalk device auth returned incomplete registration metadata');
|
||||
}
|
||||
record.state = 'pending';
|
||||
this.#touch();
|
||||
return publicAttempt(record);
|
||||
} catch (error) {
|
||||
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
||||
record.state = 'cancelled';
|
||||
record.error = safeError('cancelled', '扫码接入已取消。');
|
||||
} else {
|
||||
record.state = 'failed';
|
||||
record.error = safeError('qr-start-failed', '无法生成钉钉二维码,请稍后重试。');
|
||||
}
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#touch();
|
||||
if (record.state === 'failed') throw error;
|
||||
return publicAttempt(record);
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abortFromRequest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Polls one QR registration without exposing its device code or returned secret. */
|
||||
async registrationStatus(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
if (!record) return null;
|
||||
if (TERMINAL_ATTEMPT_STATES.has(record.state) || record.state === 'starting') {
|
||||
return publicAttempt(record);
|
||||
}
|
||||
if (nowFrom(this.#clock) >= record.expiresAt) {
|
||||
record.state = 'expired';
|
||||
record.error = safeError('expired', '二维码已过期,请重新生成。');
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#touch();
|
||||
return publicAttempt(record);
|
||||
}
|
||||
if (!record.pollTask) {
|
||||
const task = this.#pollRegistration(record).finally(() => {
|
||||
if (record.pollTask === task) record.pollTask = null;
|
||||
});
|
||||
record.pollTask = task;
|
||||
}
|
||||
await record.pollTask;
|
||||
return publicAttempt(record);
|
||||
}
|
||||
|
||||
/** Cancels an active QR registration. */
|
||||
async cancelProvisioning(attemptId) {
|
||||
const record = this.#attempts.get(attemptId);
|
||||
if (!record) return null;
|
||||
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
||||
record.controller.abort();
|
||||
await record.pollTask?.catch(() => undefined);
|
||||
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
|
||||
record.error ??= safeError('cancelled', '扫码接入已取消。');
|
||||
}
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#touch();
|
||||
return publicAttempt(record);
|
||||
}
|
||||
|
||||
/** Replaces one bot runtime using its stored credential. */
|
||||
async reconnectBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown DingTalk bot');
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
const clientSecret = await this.#resolveSecret(config.secretRef);
|
||||
if (!clientSecret) throw new Error('The DingTalk client secret is missing');
|
||||
try {
|
||||
await this.#startRuntime(config, clientSecret);
|
||||
this.#errors.delete(botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(
|
||||
botId,
|
||||
safeError('connection-failed', '钉钉连接仍未就绪,请稍后重试。'),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
this.#touch();
|
||||
}
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** Removes one bot, its secret, runtime, and local conversation state. */
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown DingTalk bot');
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
const previousSecret = await this.#credentials.resolve(config.secretRef).catch(() => undefined);
|
||||
await this.#stopRuntime(botId);
|
||||
try {
|
||||
await this.#credentials.unset(config.secretRef);
|
||||
await this.#configStore.remove(botId);
|
||||
} catch (error) {
|
||||
if (cleanString(previousSecret?.value)) {
|
||||
await this.#credentials.set(config.secretRef, previousSecret.value).catch(() => undefined);
|
||||
await this.#startRuntime(config, previousSecret.value).catch(() => undefined);
|
||||
}
|
||||
throw new Error('Unable to remove the DingTalk bot safely.', { cause: error });
|
||||
}
|
||||
try {
|
||||
await this.#deleteState({ botId, config });
|
||||
} catch {
|
||||
this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} state cleanup failed`);
|
||||
}
|
||||
this.#errors.delete(botId);
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** Approves one opaque pending-sender request for a bot. */
|
||||
async approveSender(botId, requestId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown DingTalk bot');
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
const direct = typeof runtime?.pendingSender === 'function'
|
||||
? normalizePendingSender(runtime.pendingSender(requestId))
|
||||
: null;
|
||||
const pending = internalPendingSenders(runtimeStatus(runtime));
|
||||
const sender = direct?.requestId === requestId
|
||||
? direct
|
||||
: pending.find((candidate) => candidate.requestId === requestId);
|
||||
if (!sender) throw new Error('Unknown DingTalk sender approval request');
|
||||
if (config.approvedSenders.some((approved) => approved.staffId === sender.staffId)) {
|
||||
return this.status();
|
||||
}
|
||||
const updated = {
|
||||
...config,
|
||||
approvedSenders: [
|
||||
...config.approvedSenders,
|
||||
{
|
||||
senderKey: deriveDingtalkSenderKey(),
|
||||
staffId: sender.staffId,
|
||||
displayName: sender.displayName,
|
||||
approvedAt: isoNow(this.#clock),
|
||||
},
|
||||
],
|
||||
};
|
||||
await this.#saveAndRestart(config, updated);
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** Revokes one approved sender by its browser-safe sender key. */
|
||||
async revokeSender(botId, senderKey) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error('Unknown DingTalk bot');
|
||||
const index = config.approvedSenders.findIndex(
|
||||
(sender) => sender.senderKey === senderKey,
|
||||
);
|
||||
if (index === -1) throw new Error('Unknown approved DingTalk sender');
|
||||
const approvedSenders = [...config.approvedSenders];
|
||||
approvedSenders.splice(index, 1);
|
||||
await this.#saveAndRestart(config, { ...config, approvedSenders });
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** Returns browser-safe bot, health, and sender-approval state. */
|
||||
status() {
|
||||
const bots = this.#configStore.list().map((config) => {
|
||||
const runtime = this.#runtimes.get(config.botId);
|
||||
let currentStatus = {};
|
||||
try {
|
||||
currentStatus = runtimeStatus(runtime);
|
||||
} catch {
|
||||
currentStatus = { state: 'error' };
|
||||
}
|
||||
const connected = isRuntimeConnected(runtime, currentStatus);
|
||||
const accountError = this.#errors.get(config.botId);
|
||||
const state = connected ? 'connected' : accountError ? 'error' : 'offline';
|
||||
const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId));
|
||||
const pending = internalPendingSenders(currentStatus)
|
||||
.filter((sender) => !approvedIds.has(sender.staffId))
|
||||
.map(publicPendingSender);
|
||||
return {
|
||||
botId: config.botId,
|
||||
state,
|
||||
connected,
|
||||
configured: true,
|
||||
bot: {
|
||||
name: '钉钉机器人',
|
||||
clientIdMasked: maskDingtalkClientId(config.clientId),
|
||||
},
|
||||
health: {
|
||||
status: connected ? 'healthy' : accountError ? 'error' : 'offline',
|
||||
summary: connected
|
||||
? '钉钉 Stream 消息连接运行正常'
|
||||
: accountError?.message ?? '钉钉消息连接当前离线',
|
||||
lastCheckedAt: currentStatus.lastCheckedAt ?? null,
|
||||
},
|
||||
stats: {
|
||||
messagesReceived: Number(currentStatus.messagesReceived) || 0,
|
||||
messagesReplied: Number(currentStatus.messagesReplied) || 0,
|
||||
},
|
||||
senders: {
|
||||
pending,
|
||||
approved: config.approvedSenders.map(publicApprovedSender),
|
||||
},
|
||||
error: accountError ? structuredClone(accountError) : null,
|
||||
};
|
||||
});
|
||||
const connectedCount = bots.filter((bot) => bot.connected).length;
|
||||
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: this.#revision,
|
||||
state: active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
||||
? 'provisioning'
|
||||
: bots.length === 0
|
||||
? 'disconnected'
|
||||
: connectedCount === bots.length
|
||||
? 'connected'
|
||||
: connectedCount > 0
|
||||
? 'degraded'
|
||||
: 'offline',
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: connectedCount },
|
||||
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
||||
? { provisioning: publicAttempt(active) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Cancels provisioning and stops every bot runtime. */
|
||||
async close() {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
await Promise.allSettled([...this.#transitions.values()]);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
}
|
||||
|
||||
async #pollRegistration(record) {
|
||||
try {
|
||||
this.#assertAttemptActive(record);
|
||||
const response = await this.#deviceAuth.poll({
|
||||
deviceCode: record.deviceCode,
|
||||
signal: record.controller.signal,
|
||||
});
|
||||
this.#assertAttemptActive(record);
|
||||
const state = cleanString(response.status)?.toUpperCase();
|
||||
if (state === 'WAITING') {
|
||||
record.state = 'pending';
|
||||
record.error = null;
|
||||
} else if (state === 'SUCCESS') {
|
||||
const clientId = cleanString(response.clientId);
|
||||
const clientSecret = cleanString(response.clientSecret);
|
||||
if (!clientId || !clientSecret) throw new Error('DingTalk returned incomplete credentials');
|
||||
record.state = 'connecting';
|
||||
record.error = null;
|
||||
this.#touch();
|
||||
const activation = await this.#activateBot(record, { clientId, clientSecret });
|
||||
record.botId = activation.botId;
|
||||
record.alreadyConnected = activation.alreadyConnected;
|
||||
record.state = 'connected';
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
} else if (state === 'EXPIRED') {
|
||||
record.state = 'expired';
|
||||
record.error = safeError('expired', '二维码已过期,请重新生成。');
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
} else if (state === 'FAIL') {
|
||||
record.state = 'failed';
|
||||
record.error = safeError('authorization-failed', '钉钉未完成机器人授权,请重新扫码。');
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
} else {
|
||||
record.state = 'pending';
|
||||
record.error = safeError('poll-pending', '钉钉授权状态暂时不可用,正在重试。');
|
||||
}
|
||||
} catch (error) {
|
||||
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
||||
record.state = 'cancelled';
|
||||
record.error = safeError('cancelled', '扫码接入已取消。');
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
} else if (record.state === 'connecting') {
|
||||
record.state = 'failed';
|
||||
record.error = safeError(
|
||||
'activation-failed',
|
||||
'钉钉已授权,但无法安全保存接入配置。',
|
||||
);
|
||||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||||
this.#logger.error?.('[dsh-dingtalk] bot activation failed');
|
||||
} else {
|
||||
record.state = 'pending';
|
||||
record.error = safeError('poll-failed', '钉钉授权查询暂时失败,正在重试。');
|
||||
}
|
||||
} finally {
|
||||
this.#touch();
|
||||
this.#pruneAttempts();
|
||||
}
|
||||
}
|
||||
|
||||
async #activateBot(record, { clientId, clientSecret }) {
|
||||
const identity = deriveDingtalkBotIdentity(clientId);
|
||||
const previousConfig = this.#configStore.getByClientId(clientId);
|
||||
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
clientId,
|
||||
secretRef: identity.secretRef,
|
||||
approvedSenders: previousConfig?.approvedSenders ?? [],
|
||||
};
|
||||
return this.#withBotTransition(identity.botId, async () => {
|
||||
const rollback = async () => {
|
||||
await this.#stopRuntime(identity.botId);
|
||||
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
||||
else if (this.#configStore.get(identity.botId)) {
|
||||
await this.#configStore.remove(identity.botId).catch(() => undefined);
|
||||
}
|
||||
await this.#restoreCredential(identity.secretRef, previousSecret);
|
||||
if (previousConfig && cleanString(previousSecret?.value)) {
|
||||
await this.#startRuntime(previousConfig, previousSecret.value).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
await this.#credentials.set(identity.secretRef, clientSecret);
|
||||
try {
|
||||
this.#assertAttemptActive(record);
|
||||
await this.#configStore.save(config);
|
||||
this.#assertAttemptActive(record);
|
||||
} catch (error) {
|
||||
await rollback();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, clientSecret);
|
||||
this.#assertAttemptActive(record);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch (error) {
|
||||
if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) {
|
||||
await rollback();
|
||||
throw abortError();
|
||||
}
|
||||
this.#errors.set(
|
||||
identity.botId,
|
||||
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
|
||||
);
|
||||
this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
|
||||
}
|
||||
return { botId: identity.botId, alreadyConnected: Boolean(previousConfig) };
|
||||
});
|
||||
}
|
||||
|
||||
async #saveAndRestart(previousConfig, nextConfig) {
|
||||
return this.#withBotTransition(previousConfig.botId, async () => {
|
||||
const clientSecret = await this.#resolveSecret(previousConfig.secretRef);
|
||||
if (!clientSecret) throw new Error('The DingTalk client secret is missing');
|
||||
await this.#configStore.save(nextConfig);
|
||||
try {
|
||||
await this.#startRuntime(nextConfig, clientSecret);
|
||||
this.#errors.delete(previousConfig.botId);
|
||||
} catch (error) {
|
||||
await this.#configStore.save(previousConfig).catch(() => undefined);
|
||||
await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
|
||||
this.#errors.set(
|
||||
previousConfig.botId,
|
||||
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
this.#touch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #startRuntime(config, clientSecret) {
|
||||
if (this.#closed) throw abortError();
|
||||
await this.#stopRuntime(config.botId);
|
||||
if (this.#closed) throw abortError();
|
||||
const runtime = await this.#createRuntime({
|
||||
botId: config.botId,
|
||||
config: structuredClone(config),
|
||||
clientSecret,
|
||||
});
|
||||
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
||||
throw new TypeError('createRuntime returned an invalid DingTalk runtime');
|
||||
}
|
||||
if (this.#closed) {
|
||||
await runtime.stop().catch(() => undefined);
|
||||
throw abortError();
|
||||
}
|
||||
this.#runtimes.set(config.botId, runtime);
|
||||
try {
|
||||
await runtime.start();
|
||||
if (this.#closed) {
|
||||
await runtime.stop().catch(() => undefined);
|
||||
throw abortError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.#runtimes.get(config.botId) === runtime) this.#runtimes.delete(config.botId);
|
||||
await runtime.stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #stopRuntime(botId) {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
this.#runtimes.delete(botId);
|
||||
await runtime?.stop().catch(() => {
|
||||
this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} failed to stop cleanly`);
|
||||
});
|
||||
}
|
||||
|
||||
async #resolveSecret(secretRef) {
|
||||
const result = await this.#credentials.resolve(secretRef).catch(() => undefined);
|
||||
return cleanString(result?.value);
|
||||
}
|
||||
|
||||
async #restoreCredential(secretRef, previous) {
|
||||
try {
|
||||
if (cleanString(previous?.value)) await this.#credentials.set(secretRef, previous.value);
|
||||
else await this.#credentials.unset(secretRef);
|
||||
} catch {
|
||||
this.#logger.error?.(`[dsh-dingtalk] failed to restore credential ${secretRef}`);
|
||||
}
|
||||
}
|
||||
|
||||
#assertAttemptActive(record) {
|
||||
if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) throw abortError();
|
||||
}
|
||||
|
||||
#withBotTransition(botId, operation) {
|
||||
if (this.#closed) return Promise.reject(new Error('dsh-dingtalk controller is closed'));
|
||||
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(operation);
|
||||
const settled = current.finally(() => {
|
||||
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
||||
});
|
||||
this.#transitions.set(botId, settled);
|
||||
return settled;
|
||||
}
|
||||
|
||||
#pruneAttempts() {
|
||||
for (const [id, record] of this.#attempts) {
|
||||
if (id !== this.#activeAttemptId
|
||||
&& TERMINAL_ATTEMPT_STATES.has(record.state)
|
||||
&& this.#attempts.size > 16) {
|
||||
this.#attempts.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#touch() {
|
||||
this.#revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export { DingtalkController as DingTalkController };
|
||||
Loading…
Add table
Add a link
Reference in a new issue