mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
feat(feishu): add group message permission authorization
This commit is contained in:
parent
7b892c02f5
commit
7bf12628ce
17 changed files with 1581 additions and 258 deletions
71
src/channels/feishu/group-message-permission-manager.mjs
Normal file
71
src/channels/feishu/group-message-permission-manager.mjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { RegistrationManager } from './registration-manager.mjs';
|
||||
import { assertTargetedAppUpdateUrl } from './repair-manager.mjs';
|
||||
|
||||
export const FEISHU_GROUP_MESSAGE_SCOPE = 'im:message.group_msg';
|
||||
export const GROUP_MESSAGE_PERMISSION_OPERATION = 'group_message_permission';
|
||||
|
||||
function accountsDomain(domain) {
|
||||
return domain === 'lark' ? 'accounts.larksuite.com' : 'accounts.feishu.cn';
|
||||
}
|
||||
|
||||
export function assertGroupMessagePermissionUrl(value, expectedAppId, domain = 'feishu') {
|
||||
return assertTargetedAppUpdateUrl(
|
||||
value,
|
||||
expectedAppId,
|
||||
domain,
|
||||
'Feishu group message permission update',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally grants the one sensitive tenant scope needed by all-message
|
||||
* mode to an existing app. The fixed manifest prevents this UI action from
|
||||
* creating another app or silently adding unrelated capabilities.
|
||||
*/
|
||||
export class GroupMessagePermissionManager {
|
||||
#manager;
|
||||
#appId;
|
||||
#domain;
|
||||
|
||||
constructor({ registerApp, onCredentials, appId, domain = 'feishu' } = {}) {
|
||||
if (typeof registerApp !== 'function') throw new TypeError('registerApp is required');
|
||||
if (typeof onCredentials !== 'function') throw new TypeError('onCredentials is required');
|
||||
if (typeof appId !== 'string' || !appId.trim()) throw new TypeError('appId is required');
|
||||
if (domain !== 'feishu' && domain !== 'lark') throw new TypeError('domain is invalid');
|
||||
|
||||
this.#appId = appId.trim();
|
||||
this.#domain = domain;
|
||||
this.#manager = new RegistrationManager({
|
||||
registerApp: (options) => registerApp({
|
||||
...options,
|
||||
onQRCodeReady: (info) => {
|
||||
assertGroupMessagePermissionUrl(info?.url, this.#appId, this.#domain);
|
||||
options.onQRCodeReady(info);
|
||||
},
|
||||
}),
|
||||
onCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
return this.#manager.start({
|
||||
source: 'deepseek-harness-group-message-permission',
|
||||
domain: accountsDomain(this.#domain),
|
||||
appId: this.#appId,
|
||||
addons: {
|
||||
preset: false,
|
||||
scopes: { tenant: [FEISHU_GROUP_MESSAGE_SCOPE] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
status() {
|
||||
return this.#manager.status();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
return this.#manager.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupMessagePermissionManager;
|
||||
|
|
@ -5,6 +5,10 @@ import {
|
|||
CALLBACK_REPAIR_OPERATION,
|
||||
CallbackRepairManager,
|
||||
} from './repair-manager.mjs';
|
||||
import {
|
||||
GROUP_MESSAGE_PERMISSION_OPERATION,
|
||||
GroupMessagePermissionManager,
|
||||
} from './group-message-permission-manager.mjs';
|
||||
import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
|
||||
import {
|
||||
isFeishuGroupResponseMode,
|
||||
|
|
@ -15,6 +19,10 @@ 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 TARGETED_APP_UPDATE_OPERATIONS = new Set([
|
||||
CALLBACK_REPAIR_OPERATION,
|
||||
GROUP_MESSAGE_PERMISSION_OPERATION,
|
||||
]);
|
||||
const ALL_VISIBLE_SENDERS = '*';
|
||||
const DEFAULT_CALLBACK_PROBE_TIMEOUT_MS = 120_000;
|
||||
const MAX_CALLBACK_PROBE_TIMEOUT_MS = 600_000;
|
||||
|
|
@ -81,6 +89,7 @@ function configuredBotFingerprint(config) {
|
|||
botOpenId: config.botOpenId,
|
||||
activated: config.activated,
|
||||
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
||||
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
||||
deletionPending: config.deletionPending === true,
|
||||
connectedAt: config.connectedAt ?? null,
|
||||
createdAt: config.createdAt ?? null,
|
||||
|
|
@ -108,7 +117,7 @@ export class MultiBotDshFeishuController {
|
|||
#runtimes = new Map();
|
||||
#botErrors = new Map();
|
||||
#registrations = new Map();
|
||||
#activeRepairs = new Map();
|
||||
#activeAppUpdates = new Map();
|
||||
#botOwnership = new Map();
|
||||
#latestRegistrationId = null;
|
||||
#configTransition = Promise.resolve();
|
||||
|
|
@ -239,12 +248,15 @@ export class MultiBotDshFeishuController {
|
|||
const target = this.#requireBot(botId);
|
||||
if (target.deletionPending) throw new Error('Cannot repair a Feishu bot pending deletion');
|
||||
|
||||
const activeId = this.#activeRepairs.get(botId);
|
||||
const activeId = this.#activeAppUpdates.get(botId);
|
||||
const active = activeId ? this.#registrations.get(activeId) : null;
|
||||
if (active && MUTABLE_REGISTRATION_STATES.has(active.manager.status().state)) {
|
||||
if (active.operation !== CALLBACK_REPAIR_OPERATION) {
|
||||
throw new Error('Another Feishu app update is already active for this bot');
|
||||
}
|
||||
return this.registrationStatus(active.id);
|
||||
}
|
||||
this.#activeRepairs.delete(botId);
|
||||
this.#activeAppUpdates.delete(botId);
|
||||
|
||||
const id = this.#createRegistrationId();
|
||||
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) {
|
||||
|
|
@ -283,7 +295,66 @@ export class MultiBotDshFeishuController {
|
|||
},
|
||||
});
|
||||
this.#registrations.set(id, record);
|
||||
this.#activeRepairs.set(botId, id);
|
||||
this.#activeAppUpdates.set(botId, id);
|
||||
this.#latestRegistrationId = id;
|
||||
this.#trimRegistrations();
|
||||
record.manager.start();
|
||||
this.#touch();
|
||||
return this.registrationStatus(id);
|
||||
}
|
||||
|
||||
startGroupMessagePermission(botId) {
|
||||
this.#assertOpen();
|
||||
const target = this.#requireBot(botId);
|
||||
if (target.deletionPending) {
|
||||
throw new Error('Cannot update permissions for a Feishu bot pending deletion');
|
||||
}
|
||||
|
||||
const activeId = this.#activeAppUpdates.get(botId);
|
||||
const active = activeId ? this.#registrations.get(activeId) : null;
|
||||
if (active && MUTABLE_REGISTRATION_STATES.has(active.manager.status().state)) {
|
||||
if (active.operation !== GROUP_MESSAGE_PERMISSION_OPERATION) {
|
||||
throw new Error('Another Feishu app update is already active for this bot');
|
||||
}
|
||||
return this.registrationStatus(active.id);
|
||||
}
|
||||
this.#activeAppUpdates.delete(botId);
|
||||
|
||||
const id = this.#createRegistrationId();
|
||||
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) {
|
||||
throw new Error('Registration id generator returned an invalid or duplicate id');
|
||||
}
|
||||
const record = {
|
||||
id,
|
||||
operation: GROUP_MESSAGE_PERMISSION_OPERATION,
|
||||
manager: null,
|
||||
botId,
|
||||
createdNew: false,
|
||||
cancelled: false,
|
||||
remoteCommitted: false,
|
||||
processing: null,
|
||||
publicError: null,
|
||||
stage: 'authorizing',
|
||||
target: structuredClone(target),
|
||||
targetFingerprint: configuredBotFingerprint(target),
|
||||
initiator: { actorOpenId: null, chatId: null },
|
||||
};
|
||||
record.manager = new GroupMessagePermissionManager({
|
||||
registerApp: this.#registerApp,
|
||||
appId: target.appId,
|
||||
domain: target.domain,
|
||||
onCredentials: (result) => {
|
||||
record.remoteCommitted = true;
|
||||
const processing = this.#acceptGroupMessagePermission(record, result);
|
||||
const tracked = processing.finally(() => {
|
||||
if (record.processing === tracked) record.processing = null;
|
||||
});
|
||||
record.processing = tracked;
|
||||
return tracked;
|
||||
},
|
||||
});
|
||||
this.#registrations.set(id, record);
|
||||
this.#activeAppUpdates.set(botId, id);
|
||||
this.#latestRegistrationId = id;
|
||||
this.#trimRegistrations();
|
||||
record.manager.start();
|
||||
|
|
@ -308,7 +379,7 @@ export class MultiBotDshFeishuController {
|
|||
// Once registerApp has returned, the platform-side update has committed.
|
||||
// A repair must finish converging the returned credential and callback
|
||||
// probe; cancelling here cannot roll that remote mutation back.
|
||||
if (record.operation === CALLBACK_REPAIR_OPERATION && state === 'saving') {
|
||||
if (TARGETED_APP_UPDATE_OPERATIONS.has(record.operation) && state === 'saving') {
|
||||
return this.registrationStatus(attemptId);
|
||||
}
|
||||
if (!MUTABLE_REGISTRATION_STATES.has(state)) {
|
||||
|
|
@ -485,6 +556,11 @@ export class MultiBotDshFeishuController {
|
|||
}
|
||||
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
||||
const config = this.#requireBot(botId);
|
||||
if (groupResponseMode === 'all' && config.groupMessagePermissionGranted !== true) {
|
||||
const error = new Error('Authorize im:message.group_msg before enabling all group messages');
|
||||
error.code = 'group_message_permission_required';
|
||||
throw error;
|
||||
}
|
||||
const saved = await this.#configStore.saveBot({ ...config, groupResponseMode });
|
||||
this.#runtimes.get(botId)?.setGroupResponseMode?.(saved.groupResponseMode);
|
||||
this.#touch();
|
||||
|
|
@ -516,14 +592,14 @@ export class MultiBotDshFeishuController {
|
|||
async close() {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
const repairProcessing = [];
|
||||
const appUpdateProcessing = [];
|
||||
for (const record of this.#registrations.values()) {
|
||||
const state = record.manager.status().state;
|
||||
if (record.operation === CALLBACK_REPAIR_OPERATION && state === 'saving') {
|
||||
if (TARGETED_APP_UPDATE_OPERATIONS.has(record.operation) && state === 'saving') {
|
||||
// Stop projecting an in-flight repair, but do not request its local
|
||||
// credential rollback after the remote update has committed.
|
||||
record.manager.cancel();
|
||||
if (record.processing) repairProcessing.push(record.processing);
|
||||
if (record.processing) appUpdateProcessing.push(record.processing);
|
||||
} else if (MUTABLE_REGISTRATION_STATES.has(state)) {
|
||||
record.cancelled = true;
|
||||
record.manager.cancel();
|
||||
|
|
@ -532,7 +608,7 @@ export class MultiBotDshFeishuController {
|
|||
await this.#configTransition;
|
||||
await Promise.allSettled([...this.#botTransitions.values()]);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
|
||||
await Promise.allSettled(repairProcessing);
|
||||
await Promise.allSettled(appUpdateProcessing);
|
||||
// A committed repair can be between SDK completion and its serialized
|
||||
// credential/runtime transition when close begins. Waiting for the repair
|
||||
// can therefore create a replacement runtime after the first drain. Drain
|
||||
|
|
@ -554,6 +630,7 @@ export class MultiBotDshFeishuController {
|
|||
connected,
|
||||
configured: true,
|
||||
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
||||
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
||||
bot: publicBot(config),
|
||||
connection,
|
||||
error,
|
||||
|
|
@ -605,6 +682,158 @@ export class MultiBotDshFeishuController {
|
|||
};
|
||||
}
|
||||
|
||||
async #acceptGroupMessagePermission(record, result) {
|
||||
const appId = result.client_id;
|
||||
const appSecret = result.client_secret;
|
||||
const ownerOpenId = optionalNonEmptyString(result.user_info?.open_id);
|
||||
const tenantBrand = result.user_info?.tenant_brand;
|
||||
const target = record.target;
|
||||
|
||||
if (record.cancelled) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'abort',
|
||||
'Group message permission update was cancelled before local activation.',
|
||||
);
|
||||
}
|
||||
if (appId !== target.appId) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'repair_app_mismatch',
|
||||
'Feishu returned credentials for a different app.',
|
||||
);
|
||||
}
|
||||
if (!ownerOpenId) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'repair_owner_missing',
|
||||
'Feishu returned no permission operator identity.',
|
||||
);
|
||||
}
|
||||
if (tenantBrand !== undefined && tenantBrand !== target.domain) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'repair_domain_mismatch',
|
||||
'Feishu returned credentials for a different account domain.',
|
||||
);
|
||||
}
|
||||
if (!target.ownerOpenIds.includes(ALL_VISIBLE_SENDERS)
|
||||
&& !target.ownerOpenIds.includes(ownerOpenId)) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'repair_owner_mismatch',
|
||||
'The Feishu permission operator is not an owner of this configured bot.',
|
||||
);
|
||||
}
|
||||
|
||||
record.stage = 'verifying_identity';
|
||||
let verified;
|
||||
try {
|
||||
verified = await this.#verifyApp({ appId, appSecret, domain: target.domain });
|
||||
} catch (error) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'repair_credentials_invalid',
|
||||
'Feishu could not verify the updated app credentials.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (target.botOpenId && verified?.openId !== target.botOpenId) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'repair_bot_mismatch',
|
||||
'The updated Feishu app belongs to a different bot identity.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.#serializeConfig(() => this.#withBotTransition(record.botId, async () => {
|
||||
const current = this.#configStore.getBot(record.botId);
|
||||
if (!current
|
||||
|| current.deletionPending
|
||||
|| configuredBotFingerprint(current) !== record.targetFingerprint) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'repair_target_changed',
|
||||
'The Feishu bot changed while its permission update was in progress.',
|
||||
);
|
||||
}
|
||||
|
||||
let previous;
|
||||
try {
|
||||
previous = await this.#credentials.resolve(current.secretRef);
|
||||
} catch (error) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'credential_update_failed',
|
||||
'Unable to read the current Feishu credential.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (previous?.value !== appSecret) {
|
||||
record.stage = 'persisting_secret';
|
||||
try {
|
||||
await this.#credentials.set(current.secretRef, appSecret);
|
||||
} catch (writeError) {
|
||||
const observed = await this.#credentials.resolve(current.secretRef).catch(() => null);
|
||||
if (observed?.value !== appSecret) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'credential_update_failed',
|
||||
'Unable to store the updated Feishu credential.',
|
||||
writeError,
|
||||
);
|
||||
}
|
||||
}
|
||||
const persisted = await this.#credentials.resolve(current.secretRef).catch(() => null);
|
||||
if (persisted?.value !== appSecret) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'credential_state_unknown',
|
||||
'The updated Feishu credential could not be confirmed after writing.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
record.stage = 'enabling_all_messages';
|
||||
let saved;
|
||||
try {
|
||||
saved = await this.#configStore.saveBot({
|
||||
...current,
|
||||
groupMessagePermissionGranted: true,
|
||||
groupResponseMode: 'all',
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'group_message_permission_save_failed',
|
||||
'The permission was accepted, but all-message mode could not be saved.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
record.stage = 'restarting';
|
||||
try {
|
||||
await this.#startRuntime(saved, appSecret);
|
||||
} catch (error) {
|
||||
this.#botErrors.set(record.botId, {
|
||||
code: 'connection_failed',
|
||||
message: '群消息权限已开通,但机器人长连接未就绪,请点击重试。',
|
||||
});
|
||||
this.#touch();
|
||||
throw this.#callbackRepairError(
|
||||
record,
|
||||
'group_message_permission_connection_failed',
|
||||
'The permission was accepted, but the Feishu runtime could not restart.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
this.#botErrors.delete(record.botId);
|
||||
record.stage = 'verified';
|
||||
record.publicError = null;
|
||||
this.#touch();
|
||||
}));
|
||||
}
|
||||
|
||||
async #acceptCallbackRepair(record, result) {
|
||||
const appId = result.client_id;
|
||||
const appSecret = result.client_secret;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ function normalizeBot(value, { legacy = false } = {}) {
|
|||
botOpenId: cleanString(value.botOpenId),
|
||||
activated: value.activated ?? null,
|
||||
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
|
||||
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
||||
deletionPending: value.deletionPending === true,
|
||||
connectedAt: cleanString(value.connectedAt),
|
||||
createdAt: cleanString(value.createdAt) ?? cleanString(value.connectedAt),
|
||||
|
|
|
|||
|
|
@ -17,12 +17,17 @@ function launcherDomain(domain) {
|
|||
* never fall back to the create-only flow. This catches regressions such as a
|
||||
* literal `{{client_id}}` before the broken URL reaches the browser.
|
||||
*/
|
||||
export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu') {
|
||||
export function assertTargetedAppUpdateUrl(
|
||||
value,
|
||||
expectedAppId,
|
||||
domain = 'feishu',
|
||||
operationLabel = 'Feishu app update',
|
||||
) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error('Feishu callback repair returned an invalid verification URL');
|
||||
throw new Error(`${operationLabel} returned an invalid verification URL`);
|
||||
}
|
||||
const supportedDomain = domain === 'feishu' || domain === 'lark';
|
||||
const clientIds = url.searchParams.getAll('clientID');
|
||||
|
|
@ -49,11 +54,20 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
|
|||
|| addons.length !== 1
|
||||
|| !addons[0]?.trim()
|
||||
|| hasPlaceholder) {
|
||||
throw new Error('Feishu callback repair returned an unsafe verification URL');
|
||||
throw new Error(`${operationLabel} returned an unsafe verification URL`);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu') {
|
||||
return assertTargetedAppUpdateUrl(
|
||||
value,
|
||||
expectedAppId,
|
||||
domain,
|
||||
'Feishu callback repair',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One targeted update attempt for an existing Feishu app. It intentionally
|
||||
* shares RegistrationManager's polling/state implementation while fixing the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue