mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 14:33:49 +08:00
Add manual IM bot credential access
This commit is contained in:
parent
d5d2c18ce0
commit
7123fce47a
42 changed files with 2458 additions and 889 deletions
|
|
@ -280,6 +280,50 @@ export class DingtalkController {
|
|||
}
|
||||
}
|
||||
|
||||
/** 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 = cleanString(clientId);
|
||||
const normalizedSecret = cleanString(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(() => undefined);
|
||||
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', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
|
||||
);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export function isBotSender(event) {
|
|||
|
||||
export 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const ACTIVE_REGISTRATION_STATES = new Set([
|
|||
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
|
||||
]);
|
||||
const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']);
|
||||
const ALL_VISIBLE_SENDERS = '*';
|
||||
|
||||
function idleConnection() {
|
||||
return {
|
||||
|
|
@ -227,6 +228,75 @@ export class MultiBotDshFeishuController {
|
|||
});
|
||||
}
|
||||
|
||||
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(() => undefined);
|
||||
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: new Date().toISOString(),
|
||||
createdAt: existing?.createdAt ?? 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: '机器人已经绑定,但长连接未就绪,请点击重试。',
|
||||
});
|
||||
}
|
||||
});
|
||||
this.#touch();
|
||||
return this.status(botId);
|
||||
});
|
||||
}
|
||||
|
||||
async reconnectBot(botId) {
|
||||
this.#assertOpen();
|
||||
return this.#withBotTransition(botId, async () => {
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export class QqHarnessBridge {
|
|||
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (sender !== this.#ownerUserOpenid) {
|
||||
if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
|
||||
this.#status.messagesRejected += 1;
|
||||
this.#status.lastRejectedAt = new Date().toISOString();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,48 @@ export class QqController {
|
|||
return publicAttempt(this.#attempts.get(attemptId));
|
||||
}
|
||||
|
||||
async bindCredentials({ appId, appSecret } = {}) {
|
||||
if (this.#closed) throw new Error('QQ controller is closed');
|
||||
const normalizedAppId = cleanString(appId);
|
||||
const normalizedSecret = cleanString(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(() => undefined);
|
||||
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 ?? new Date().toISOString(),
|
||||
connectedAt: 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, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。'));
|
||||
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;
|
||||
|
|
@ -281,6 +323,7 @@ export class QqController {
|
|||
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)));
|
||||
}
|
||||
|
||||
|
|
@ -358,7 +401,9 @@ export class QqController {
|
|||
}
|
||||
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -113,7 +113,8 @@ export class QqRuntime {
|
|||
});
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -161,6 +161,50 @@ export class WecomController {
|
|||
return publicAttempt(record);
|
||||
}
|
||||
|
||||
async bindCredentials({ botId, secret } = {}) {
|
||||
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
||||
const remoteBotId = cleanString(botId);
|
||||
const normalizedSecret = cleanString(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(() => undefined);
|
||||
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
remoteBotId,
|
||||
secretRef: identity.secretRef,
|
||||
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
||||
connectedAt: 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,
|
||||
safeError('connection-failed', '企业微信机器人已绑定,消息连接暂未就绪。'),
|
||||
);
|
||||
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;
|
||||
|
|
@ -268,6 +312,7 @@ export class WecomController {
|
|||
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)));
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +413,9 @@ export class WecomController {
|
|||
}
|
||||
|
||||
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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue