Add manual IM bot credential access

This commit is contained in:
xmanrui 2026-08-16 01:54:06 +08:00
parent d5d2c18ce0
commit 7123fce47a
42 changed files with 2458 additions and 889 deletions

View file

@ -6,6 +6,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
beginProvisioning: 'provision.begin',
pollProvisioning: 'provision.poll',
cancelProvisioning: 'provision.cancel',
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
approveSender: 'bot.sender.approve',
@ -38,6 +39,10 @@ function validId(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
}
function validCredential(value, maxLength) {
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}
function payloadFailure(endpoint, payload) {
if (!isRecord(payload)) return 'Payload must be an object.';
if (endpoint === DINGTALK_ENDPOINTS.status) {
@ -53,6 +58,13 @@ function payloadFailure(endpoint, payload) {
? null
: `${endpoint} requires an attemptId.`;
}
if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
return exactKeys(payload, ['clientId', 'clientSecret'])
&& validCredential(payload.clientId, 256)
&& validCredential(payload.clientSecret, 1024)
? null
: 'bot.bind-credentials requires Client ID and Client Secret.';
}
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId)
? null
@ -138,6 +150,7 @@ function assertController(controller) {
'startProvisioning',
'registrationStatus',
'cancelProvisioning',
'bindCredentials',
'reconnectBot',
'deleteBot',
'approveSender',
@ -187,6 +200,8 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
value = await controller.cancelProvisioning(payload.attemptId);
if (!value) return badRequest('The provisioning attempt no longer exists.');
value = sanitizePublic(value);
} else if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {

View file

@ -59,6 +59,10 @@ function safeOpaqueId(value) {
return typeof value === 'string' && SAFE_ID.test(value);
}
function validCredential(value, maxLength) {
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}
function publicError(error) {
if (!error || typeof error !== 'object') return null;
const code = typeof error.code === 'string' && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code)
@ -233,6 +237,13 @@ function validPayload(endpoint, payload) {
}
return null;
}
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
return hasOnlyKeys(payload, new Set(['appId', 'appSecret']))
&& validCredential(payload.appId, 256)
&& validCredential(payload.appSecret, 1024)
? null
: 'Credential binding requires App ID and App Secret.';
}
if (endpoint === FEISHU_ENDPOINTS.pollProvisioning
|| endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
return hasOnlyKeys(payload, new Set(['attemptId'])) && safeOpaqueId(payload.attemptId)
@ -393,6 +404,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
if (url) qrCache.delete(url);
attemptQr.delete(payload.attemptId);
value = { status: 'failed', message: 'Registration was cancelled.' };
} else if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
if (typeof controller.bindCredentials !== 'function') {
throw new Error('Credential binding is unavailable');
}
value = await toPublicFeishuStatus(
await controller.bindCredentials(payload),
{ encodeQr: cachedEncodeQr },
);
} else if (endpoint === FEISHU_ENDPOINTS.testConnection) {
const current = await controller.status();
const alreadyConnected = current?.connected === true

View file

@ -6,6 +6,7 @@ export const QQ_ENDPOINTS = Object.freeze({
beginProvisioning: 'provision.begin',
pollProvisioning: 'provision.poll',
cancelProvisioning: 'provision.cancel',
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
});
@ -27,6 +28,10 @@ function validId(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
}
function validCredential(value, maxLength) {
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}
function payloadFailure(endpoint, payload) {
if (!isRecord(payload)) return 'Payload must be an object.';
if (endpoint === QQ_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
@ -38,6 +43,12 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
? null : `${endpoint} requires an attemptId.`;
}
if (endpoint === QQ_ENDPOINTS.bindCredentials) {
return exactKeys(payload, ['appId', 'appSecret'])
&& validCredential(payload.appId, 256)
&& validCredential(payload.appSecret, 1024)
? null : 'bot.bind-credentials requires AppID and AppSecret.';
}
if (endpoint === QQ_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
}
@ -76,7 +87,7 @@ async function publicStatus(status, encodeQr) {
}
export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'bindCredentials', 'reconnectBot', 'deleteBot']) {
if (typeof controller?.[method] !== 'function') throw new TypeError(`A complete QQ controller is required (${method})`);
}
const qrCache = new Map();
@ -104,6 +115,8 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
value = await withEncodedQr(current, cachedEncode);
} else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) {
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
} else if (endpoint === QQ_ENDPOINTS.bindCredentials) {
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else {

View file

@ -6,6 +6,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
beginProvisioning: 'provision.begin',
pollProvisioning: 'provision.poll',
cancelProvisioning: 'provision.cancel',
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
});
@ -27,6 +28,10 @@ function validId(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
}
function validCredential(value, maxLength) {
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}
function payloadFailure(endpoint, payload) {
if (!isRecord(payload)) return 'Payload must be an object.';
if (endpoint === WECOM_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
@ -38,6 +43,12 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
? null : `${endpoint} requires an attemptId.`;
}
if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
return exactKeys(payload, ['botId', 'secret'])
&& validCredential(payload.botId, 512)
&& validCredential(payload.secret, 1024)
? null : 'bot.bind-credentials requires Bot ID and Secret.';
}
if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
}
@ -76,7 +87,7 @@ async function publicStatus(status, encodeQr) {
}
export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'bindCredentials', 'reconnectBot', 'deleteBot']) {
if (typeof controller?.[method] !== 'function') {
throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`);
}
@ -109,6 +120,8 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
value = await withEncodedQr(current, cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) {
value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
} else if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
} else {