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

File diff suppressed because it is too large Load diff

View file

@ -680,6 +680,49 @@ var DingtalkController = class {
signal?.removeEventListener("abort", abortFromRequest);
}
}
/** Binds one DingTalk application with an existing Client ID and Client Secret. */
async bindCredentials({ clientId, clientSecret } = {}) {
if (this.#closed) throw new Error("dsh-dingtalk controller is closed");
const normalizedClientId = cleanString3(clientId);
const normalizedSecret = cleanString3(clientSecret);
if (!normalizedClientId || !normalizedSecret) {
throw new TypeError("DingTalk Client ID and Client Secret are required");
}
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
if (this.#closed) throw new Error("dsh-dingtalk controller is closed");
const identity = deriveDingtalkBotIdentity(normalizedClientId);
await this.#withBotTransition(identity.botId, async () => {
if (this.#closed) throw abortError();
const previousConfig = this.#configStore.getByClientId(normalizedClientId);
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0);
if (this.#closed) throw abortError();
const config = {
botId: identity.botId,
clientId: normalizedClientId,
secretRef: identity.secretRef,
approvedSenders: previousConfig?.approvedSenders ?? []
};
await this.#credentials.set(identity.secretRef, normalizedSecret);
try {
await this.#configStore.save(config);
} catch (error) {
await this.#restoreCredential(identity.secretRef, previousSecret);
throw error;
}
try {
await this.#startRuntime(config, normalizedSecret);
this.#errors.delete(identity.botId);
} catch {
this.#errors.set(
identity.botId,
safeError("connection-failed", "\u9489\u9489\u5DF2\u63A5\u5165\uFF0C\u4F46\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002")
);
this.#logger.warn?.("[dsh-dingtalk] credential-bound bot saved but its connection is not ready");
}
this.#touch();
});
return this.status();
}
/** Polls one QR registration without exposing its device code or returned secret. */
async registrationStatus(attemptId) {
const record = this.#attempts.get(attemptId);
@ -3024,6 +3067,7 @@ var 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",
@ -3051,6 +3095,9 @@ function exactKeys(value, allowed) {
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) {
@ -3062,6 +3109,9 @@ function payloadFailure(endpoint, payload) {
if ([DINGTALK_ENDPOINTS.pollProvisioning, DINGTALK_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
return exactKeys(payload, ["attemptId"]) && validId(payload.attemptId) ? 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 : "bot.reconnect requires a botId.";
}
@ -3125,6 +3175,7 @@ function assertController(controller) {
"startProvisioning",
"registrationStatus",
"cancelProvisioning",
"bindCredentials",
"reconnectBot",
"deleteBot",
"approveSender",
@ -3171,6 +3222,8 @@ 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) {
@ -3591,6 +3644,7 @@ function isBotSender(event) {
}
function isAllowedSender(event, allowedOpenIds) {
if (!allowedOpenIds || allowedOpenIds.size === 0) return false;
if (allowedOpenIds.has("*")) return true;
const senderOpenId = event?.sender?.sender_id?.open_id;
return typeof senderOpenId === "string" && allowedOpenIds.has(senderOpenId);
}
@ -4838,6 +4892,7 @@ var ACTIVE_REGISTRATION_STATES2 = /* @__PURE__ */ new Set([
"domain_switched"
]);
var MUTABLE_REGISTRATION_STATES = /* @__PURE__ */ new Set([...ACTIVE_REGISTRATION_STATES2, "saving"]);
var ALL_VISIBLE_SENDERS = "*";
function idleConnection() {
return {
ready: false,
@ -5029,6 +5084,68 @@ var MultiBotDshFeishuController = class {
selectedBotId: botId
});
}
async bindCredentials({ appId, appSecret, domain = "feishu" } = {}) {
this.#assertOpen();
const normalizedAppId = typeof appId === "string" ? appId.trim() : "";
const normalizedSecret = typeof appSecret === "string" ? appSecret.trim() : "";
const normalizedDomain = domain === "lark" ? "lark" : "feishu";
if (!normalizedAppId || !normalizedSecret) {
throw new TypeError("Feishu App ID and App Secret are required");
}
return this.#serializeConfig(async () => {
this.#assertOpen();
const bot = await this.#verifyApp({
appId: normalizedAppId,
appSecret: normalizedSecret,
domain: normalizedDomain
});
this.#assertOpen();
const existing = this.#configStore.list().find(
(candidate) => candidate.appId === normalizedAppId
);
const botId = existing?.id ?? this.#createBotId();
if (typeof botId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(botId) || !existing && this.#configStore.getBot(botId)) {
throw new Error("Bot id generator returned an invalid or duplicate id");
}
const secretRef = existing?.secretRef ?? secretRefFor(botId);
const previousSecret = await this.#credentials.resolve(secretRef).catch(() => void 0);
const config = {
...existing,
id: botId,
appId: normalizedAppId,
secretRef,
ownerOpenIds: existing?.ownerOpenIds?.length ? existing.ownerOpenIds : [ALL_VISIBLE_SENDERS],
domain: normalizedDomain,
botName: bot.name,
botOpenId: bot.openId,
activated: bot.activated,
deletionPending: false,
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
};
await this.#credentials.set(secretRef, normalizedSecret);
let saved;
try {
saved = await this.#configStore.saveBot(config);
} catch (error) {
await this.#restoreCredential(secretRef, previousSecret);
throw error;
}
await this.#withBotTransition(botId, async () => {
try {
await this.#startRuntime(saved, normalizedSecret);
this.#botErrors.delete(botId);
} catch {
this.#botErrors.set(botId, {
code: "connection_failed",
message: "\u673A\u5668\u4EBA\u5DF2\u7ECF\u7ED1\u5B9A\uFF0C\u4F46\u957F\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u8BF7\u70B9\u51FB\u91CD\u8BD5\u3002"
});
}
});
this.#touch();
return this.status(botId);
});
}
async reconnectBot(botId) {
this.#assertOpen();
return this.#withBotTransition(botId, async () => {
@ -5571,6 +5688,7 @@ var FEISHU_ENDPOINTS = Object.freeze({
beginProvisioning: "provision.begin",
pollProvisioning: "provision.poll",
cancelProvisioning: "provision.cancel",
bindCredentials: "bot.bind-credentials",
reconnectBot: "bot.reconnect",
disconnectBot: "bot.disconnect",
deleteBot: "bot.delete",
@ -5642,6 +5760,9 @@ function finiteNumber(value) {
function safeOpaqueId(value) {
return typeof value === "string" && SAFE_ID.test(value);
}
function validCredential2(value, maxLength) {
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
}
function publicError2(error) {
if (!error || typeof error !== "object") return null;
const code = typeof error.code === "string" && Object.hasOwn(PUBLIC_ERROR_MESSAGES, error.code) ? error.code : "registration_failed";
@ -5795,6 +5916,9 @@ function validPayload(endpoint, payload) {
}
return null;
}
if (endpoint === FEISHU_ENDPOINTS.bindCredentials) {
return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["appId", "appSecret"])) && validCredential2(payload.appId, 256) && validCredential2(payload.appSecret, 1024) ? null : "Credential binding requires App ID and App Secret.";
}
if (endpoint === FEISHU_ENDPOINTS.pollProvisioning || endpoint === FEISHU_ENDPOINTS.cancelProvisioning) {
return hasOnlyKeys(payload, /* @__PURE__ */ new Set(["attemptId"])) && safeOpaqueId(payload.attemptId) ? null : "A single valid attemptId is required.";
}
@ -5929,6 +6053,14 @@ 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 || connectionFacts(current?.connection).connected;
@ -6532,6 +6664,47 @@ var QqController = class {
registrationStatus(attemptId) {
return publicAttempt2(this.#attempts.get(attemptId));
}
async bindCredentials({ appId, appSecret } = {}) {
if (this.#closed) throw new Error("QQ controller is closed");
const normalizedAppId = cleanString6(appId);
const normalizedSecret = cleanString6(appSecret);
if (!normalizedAppId || !normalizedSecret) {
throw new TypeError("QQ AppID and AppSecret are required");
}
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
if (this.#closed) throw new Error("QQ controller is closed");
const identity = deriveQqBotIdentity(normalizedAppId);
await this.#withBotTransition(identity.botId, async () => {
if (this.#closed) throw new Error("QQ controller is closed");
const previousConfig = this.#configStore.getByAppId(normalizedAppId);
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0);
if (this.#closed) throw new Error("QQ controller is closed");
const config = {
botId: identity.botId,
appId: normalizedAppId,
secretRef: identity.secretRef,
ownerUserOpenid: previousConfig?.ownerUserOpenid ?? "*",
createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
};
await this.#credentials.set(identity.secretRef, normalizedSecret);
try {
await this.#configStore.save(config);
} catch (error) {
await this.#restoreCredential(identity.secretRef, previousSecret);
throw error;
}
try {
await this.#startRuntime(config, normalizedSecret);
this.#errors.delete(identity.botId);
} catch (error) {
this.#errors.set(identity.botId, safeError2("connection-failed", "QQ \u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002"));
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error);
}
this.#touch();
});
return this.status();
}
async cancelProvisioning(attemptId) {
const record = this.#attempts.get(attemptId);
if (!record) return null;
@ -6627,6 +6800,7 @@ var QqController = class {
if (this.#closed) return;
this.#closed = true;
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
await Promise.allSettled([...this.#transitions.values()]);
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
}
async #completeProvisioning(record, credentials) {
@ -6701,7 +6875,9 @@ var QqController = class {
});
}
async #startRuntime(config, appSecret) {
if (this.#closed) throw new Error("QQ controller is closed");
await this.#stopRuntime(config.botId);
if (this.#closed) throw new Error("QQ controller is closed");
const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret });
if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") {
throw new TypeError("createRuntime returned an invalid QQ runtime");
@ -6824,7 +7000,7 @@ var QqHarnessBridge = class {
if (!["c2c", "group"].includes(message.kind) || this.#state.hasSeen(messageId)) return;
this.#status.messagesReceived += 1;
this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString();
if (sender !== this.#ownerUserOpenid) {
if (this.#ownerUserOpenid !== "*" && sender !== this.#ownerUserOpenid) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString();
return;
@ -7012,7 +7188,7 @@ var QqRuntime = class {
});
bot.use?.(this.#typingMiddleware({
keepAlive: true,
predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid
predicate: (ctx) => this.#config.ownerUserOpenid === "*" || ctx?.message?.senderId === this.#config.ownerUserOpenid
}));
const controller = new AbortController();
this.#abortController = controller;
@ -7426,6 +7602,7 @@ var QQ_ENDPOINTS = Object.freeze({
beginProvisioning: "provision.begin",
pollProvisioning: "provision.poll",
cancelProvisioning: "provision.cancel",
bindCredentials: "bot.bind-credentials",
reconnectBot: "bot.reconnect",
deleteBot: "bot.delete"
});
@ -7447,6 +7624,9 @@ function exactKeys2(value, allowed) {
function validId2(value) {
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
}
function validCredential3(value, maxLength) {
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
}
function payloadFailure2(endpoint, payload) {
if (!isRecord2(payload)) return "Payload must be an object.";
if (endpoint === QQ_ENDPOINTS.status) return exactKeys2(payload, []) ? null : "connection.status does not accept fields.";
@ -7456,6 +7636,9 @@ function payloadFailure2(endpoint, payload) {
if ([QQ_ENDPOINTS.pollProvisioning, QQ_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
return exactKeys2(payload, ["attemptId"]) && validId2(payload.attemptId) ? null : `${endpoint} requires an attemptId.`;
}
if (endpoint === QQ_ENDPOINTS.bindCredentials) {
return exactKeys2(payload, ["appId", "appSecret"]) && validCredential3(payload.appId, 256) && validCredential3(payload.appSecret, 1024) ? null : "bot.bind-credentials requires AppID and AppSecret.";
}
if (endpoint === QQ_ENDPOINTS.reconnectBot) {
return exactKeys2(payload, ["botId"]) && validId2(payload.botId) ? null : "bot.reconnect requires a botId.";
}
@ -7491,7 +7674,7 @@ async function publicStatus2(status, encodeQr) {
return sanitizePublic2(value);
}
function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) {
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 = /* @__PURE__ */ new Map();
@ -7519,6 +7702,8 @@ function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) {
value = await withEncodedQr2(current, cachedEncode);
} else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) {
value = sanitizePublic2(await controller.cancelProvisioning(payload.attemptId));
} else if (endpoint === QQ_ENDPOINTS.bindCredentials) {
value = await publicStatus2(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === QQ_ENDPOINTS.reconnectBot) {
value = await publicStatus2(await controller.reconnectBot(payload.botId), cachedEncode);
} else {
@ -8024,6 +8209,49 @@ var WecomController = class {
await record.polling.catch(() => void 0);
return publicAttempt3(record);
}
async bindCredentials({ botId, secret } = {}) {
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
const remoteBotId = cleanString9(botId);
const normalizedSecret = cleanString9(secret);
if (!remoteBotId || !normalizedSecret) {
throw new TypeError("Enterprise WeChat Bot ID and Secret are required");
}
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
const identity = deriveWecomBotIdentity(remoteBotId);
await this.#withBotTransition(identity.botId, async () => {
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId);
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0);
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
const config = {
botId: identity.botId,
remoteBotId,
secretRef: identity.secretRef,
createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
};
await this.#credentials.set(identity.secretRef, normalizedSecret);
try {
await this.#configStore.save(config);
} catch (error) {
await this.#restoreCredential(identity.secretRef, previousSecret);
throw error;
}
try {
await this.#startRuntime(config, normalizedSecret);
this.#errors.delete(identity.botId);
} catch {
this.#errors.set(
identity.botId,
safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")
);
this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} credential connection failed`);
}
this.#touch();
});
return this.status();
}
async cancelProvisioning(attemptId) {
const record = this.#attempts.get(attemptId);
if (!record) return null;
@ -8117,6 +8345,7 @@ var WecomController = class {
if (this.#closed) return;
this.#closed = true;
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
await Promise.allSettled([...this.#transitions.values()]);
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
}
async #pollAttempt(record) {
@ -8213,7 +8442,9 @@ var WecomController = class {
});
}
async #startRuntime(config, secret) {
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
await this.#stopRuntime(config.botId);
if (this.#closed) throw new Error("Enterprise WeChat controller is closed");
const runtime = await this.#createRuntime({ botId: config.botId, config, secret });
if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") {
throw new TypeError("createRuntime returned an invalid Enterprise WeChat runtime");
@ -8907,6 +9138,7 @@ var WECOM_ENDPOINTS = Object.freeze({
beginProvisioning: "provision.begin",
pollProvisioning: "provision.poll",
cancelProvisioning: "provision.cancel",
bindCredentials: "bot.bind-credentials",
reconnectBot: "bot.reconnect",
deleteBot: "bot.delete"
});
@ -8929,6 +9161,9 @@ function exactKeys3(value, allowed) {
function validId3(value) {
return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
}
function validCredential4(value, maxLength) {
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength;
}
function payloadFailure3(endpoint, payload) {
if (!isRecord3(payload)) return "Payload must be an object.";
if (endpoint === WECOM_ENDPOINTS.status) return exactKeys3(payload, []) ? null : "connection.status does not accept fields.";
@ -8938,6 +9173,9 @@ function payloadFailure3(endpoint, payload) {
if ([WECOM_ENDPOINTS.pollProvisioning, WECOM_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
return exactKeys3(payload, ["attemptId"]) && validId3(payload.attemptId) ? null : `${endpoint} requires an attemptId.`;
}
if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
return exactKeys3(payload, ["botId", "secret"]) && validCredential4(payload.botId, 512) && validCredential4(payload.secret, 1024) ? null : "bot.bind-credentials requires Bot ID and Secret.";
}
if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
return exactKeys3(payload, ["botId"]) && validId3(payload.botId) ? null : "bot.reconnect requires a botId.";
}
@ -8973,7 +9211,7 @@ async function publicStatus3(status, encodeQr) {
return sanitizePublic3(value);
}
function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) {
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})`);
}
@ -9006,6 +9244,8 @@ function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) {
value = await withEncodedQr3(current, cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) {
value = sanitizePublic3(await controller.cancelProvisioning(payload.attemptId));
} else if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
value = await publicStatus3(await controller.bindCredentials(payload), cachedEncode);
} else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
value = await publicStatus3(await controller.reconnectBot(payload.botId), cachedEncode);
} else {