mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
203 lines
7.2 KiB
JavaScript
203 lines
7.2 KiB
JavaScript
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
|
|
|
|
export const DINGTALK_ENDPOINTS = Object.freeze({
|
|
status: 'connection.status',
|
|
beginProvisioning: 'provision.begin',
|
|
pollProvisioning: 'provision.poll',
|
|
cancelProvisioning: 'provision.cancel',
|
|
bindCredentials: 'bot.bind-credentials',
|
|
reconnectBot: 'bot.reconnect',
|
|
deleteBot: 'bot.delete',
|
|
});
|
|
|
|
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
|
const SNAPSHOT_STATES = new Set(['disconnected', 'offline', 'provisioning', 'connected', 'degraded']);
|
|
const PROVISION_STATES = new Set([
|
|
'starting',
|
|
'pending',
|
|
'scanned',
|
|
'authorizing',
|
|
'creating',
|
|
'connecting',
|
|
'connected',
|
|
'expired',
|
|
'failed',
|
|
'cancelled',
|
|
]);
|
|
const HEALTH_STATES = new Set(['healthy', 'checking', 'degraded', 'offline']);
|
|
const FORBIDDEN_ERROR_FIELDS = /(client[_-]?secret|secret[_-]?ref|device[_-]?code|app[_-]?secret|access[_-]?token|token)/i;
|
|
const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i;
|
|
const MAX_QR_SOURCE_LENGTH = 2 * 1024 * 1024;
|
|
|
|
function isRecord(value) {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function optionalString(value, maxLength = 240) {
|
|
if (typeof value !== 'string') return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed.slice(0, maxLength) : undefined;
|
|
}
|
|
|
|
function opaqueId(value) {
|
|
const id = optionalString(value, 128);
|
|
return id && /^[a-z\d_-]+$/i.test(id) ? id : undefined;
|
|
}
|
|
|
|
function timestamp(value) {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const parsed = Date.parse(value);
|
|
return Number.isNaN(parsed) ? undefined : parsed;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function nonNegativeInteger(value) {
|
|
const number = Number(value);
|
|
return Number.isSafeInteger(number) && number >= 0 ? number : 0;
|
|
}
|
|
|
|
function clamp(value, min, max, fallback) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
|
|
}
|
|
|
|
function safeErrorCode(value, fallback) {
|
|
const code = optionalString(value, 80);
|
|
return code && /^[a-z][a-z\d_.:-]*$/i.test(code) && !FORBIDDEN_ERROR_FIELDS.test(code)
|
|
? code
|
|
: fallback;
|
|
}
|
|
|
|
function sanitizeMessage(value, fallback) {
|
|
const message = optionalString(value, 480) ?? fallback;
|
|
if (FORBIDDEN_ERROR_FIELDS.test(message)) return fallback;
|
|
return message.replace(/([=:]\s*)[^\s,;,。]+/g, '$1••••••').slice(0, 240);
|
|
}
|
|
|
|
function normalizeError(value, fallbackCode, fallbackMessage) {
|
|
if (!isRecord(value)) return undefined;
|
|
return {
|
|
code: safeErrorCode(value.code, fallbackCode),
|
|
message: sanitizeMessage(value.message, fallbackMessage),
|
|
};
|
|
}
|
|
|
|
export function unwrapRpcResult(result) {
|
|
if (!isRecord(result) || typeof result.ok !== 'boolean') {
|
|
throw new Error('钉钉服务返回了无法识别的响应');
|
|
}
|
|
if (!result.ok) {
|
|
const error = new Error(sanitizeMessage(result.error?.message, '钉钉操作失败'));
|
|
error.code = safeErrorCode(result.error?.code, 'DINGTALK_RPC_ERROR');
|
|
throw error;
|
|
}
|
|
return result.value;
|
|
}
|
|
|
|
export function safeQrSource(value) {
|
|
if (typeof value !== 'string' || value.length > MAX_QR_SOURCE_LENGTH) return undefined;
|
|
return QR_DATA_URL.test(value) ? value : undefined;
|
|
}
|
|
|
|
export function normalizeProvisioning(value, now = Date.now()) {
|
|
const source = isRecord(value?.provisioning) ? value.provisioning : value;
|
|
if (!isRecord(source)) throw new Error('钉钉服务没有返回扫码绑定进度');
|
|
const attemptId = opaqueId(source.attemptId);
|
|
if (!attemptId) throw new Error('钉钉扫码服务没有返回有效的绑定任务');
|
|
|
|
const reportedStatus = optionalString(source.status, 32) ?? optionalString(source.state, 32);
|
|
const status = PROVISION_STATES.has(reportedStatus) ? reportedStatus : 'failed';
|
|
const expiresAt = timestamp(source.expiresAt)
|
|
?? now + clamp(source.expiresIn, 1, 2 * 60 * 60, 10 * 60) * 1_000;
|
|
const result = {
|
|
attemptId,
|
|
status,
|
|
expiresAt,
|
|
pollIntervalMs: clamp(source.pollIntervalMs, 1_000, 10_000, 3_000),
|
|
};
|
|
const qrCodeDataUrl = safeQrSource(source.qrCodeDataUrl);
|
|
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
|
|
if (opaqueId(source.botId)) result.botId = opaqueId(source.botId);
|
|
if (source.alreadyConnected === true) result.alreadyConnected = true;
|
|
const error = normalizeError(
|
|
source.error,
|
|
'DINGTALK_PROVISION_FAILED',
|
|
'钉钉机器人没有接入完成',
|
|
);
|
|
if (error) result.error = error;
|
|
return result;
|
|
}
|
|
|
|
function normalizeBot(value) {
|
|
if (!isRecord(value)) return undefined;
|
|
const botId = opaqueId(value.botId);
|
|
if (!botId) return undefined;
|
|
const bot = isRecord(value.bot) ? value.bot : {};
|
|
const connected = value.connected === true;
|
|
const reportedState = ACCOUNT_STATES.has(value.state) ? value.state : 'offline';
|
|
const state = connected ? 'connected' : reportedState === 'connected' ? 'connecting' : reportedState;
|
|
const health = isRecord(value.health) ? value.health : {};
|
|
const stats = isRecord(value.stats) ? value.stats : {};
|
|
return {
|
|
botId,
|
|
state,
|
|
connected,
|
|
configured: value.configured !== false,
|
|
bot: {
|
|
name: optionalString(bot.name, 100) ?? '钉钉机器人',
|
|
clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',
|
|
},
|
|
health: {
|
|
status: HEALTH_STATES.has(health.status)
|
|
? health.status
|
|
: connected ? 'healthy' : 'offline',
|
|
summary: optionalString(health.summary, 200)
|
|
?? (connected ? '钉钉 Stream 长连接运行正常' : '钉钉连接尚未就绪'),
|
|
lastCheckedAt: timestamp(health.lastCheckedAt),
|
|
lastConnectedAt: timestamp(health.lastConnectedAt),
|
|
},
|
|
stats: {
|
|
messagesReceived: nonNegativeInteger(stats.messagesReceived),
|
|
messagesReplied: nonNegativeInteger(stats.messagesReplied),
|
|
},
|
|
error: normalizeError(value.error, 'DINGTALK_ACCOUNT_ERROR', '钉钉连接尚未就绪') ?? null,
|
|
};
|
|
}
|
|
|
|
export function normalizeSnapshot(value) {
|
|
const source = isRecord(value?.snapshot) ? value.snapshot : value;
|
|
if (!isRecord(source) || !Array.isArray(source.bots)) {
|
|
throw new Error('钉钉服务没有返回有效的机器人列表');
|
|
}
|
|
const seen = new Set();
|
|
const bots = source.bots.map(normalizeBot).filter((bot) => {
|
|
if (!bot || seen.has(bot.botId)) return false;
|
|
seen.add(bot.botId);
|
|
return true;
|
|
});
|
|
return {
|
|
schemaVersion: Number.isSafeInteger(source.schemaVersion) ? source.schemaVersion : 1,
|
|
revision: nonNegativeInteger(source.revision),
|
|
state: SNAPSHOT_STATES.has(source.state) ? source.state : 'offline',
|
|
bots,
|
|
totals: {
|
|
configured: bots.length,
|
|
connected: bots.filter((bot) => bot.connected).length,
|
|
},
|
|
provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
|
|
};
|
|
}
|
|
|
|
export function presentError(error) {
|
|
return {
|
|
code: safeErrorCode(error?.code, 'DINGTALK_ERROR'),
|
|
message: sanitizeMessage(error?.message, '钉钉操作失败,请稍后重试'),
|
|
};
|
|
}
|
|
|
|
export function formatRemaining(milliseconds) {
|
|
const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1_000) || 0);
|
|
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
}
|