dsh-im-ops/src/channels/feishu/repair-manager.mjs

128 lines
4.2 KiB
JavaScript

import { RegistrationManager } from './registration-manager.mjs';
export const CARD_ACTION_CALLBACK = 'card.action.trigger';
export const FEISHU_MESSAGE_READ_SCOPE = 'im:message:readonly';
export const FEISHU_RESOURCE_SCOPE = 'im:resource';
export const CALLBACK_REPAIR_OPERATION = 'callback_repair';
function accountsDomain(domain) {
return domain === 'lark' ? 'accounts.larksuite.com' : 'accounts.feishu.cn';
}
function launcherDomain(domain) {
return domain === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
}
/**
* The SDK owns the rest of the verification URL. The repair flow accepts only
* its target account host and singleton SDK/app/addon parameters, and it can
* 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 assertTargetedAppUpdateUrl(
value,
expectedAppId,
domain = 'feishu',
operationLabel = 'Feishu app update',
) {
let url;
try {
url = new URL(value);
} catch {
throw new Error(`${operationLabel} returned an invalid verification URL`);
}
const supportedDomain = domain === 'feishu' || domain === 'lark';
const clientIds = url.searchParams.getAll('clientID');
const transportProviders = url.searchParams.getAll('tp');
const addons = url.searchParams.getAll('addons');
const hasPlaceholder = String(expectedAppId).includes('{{')
|| String(expectedAppId).includes('}}')
|| [...url.searchParams.values()].some((item) => (
item.includes('{{') || item.includes('}}')
));
if (!supportedDomain
|| url.protocol !== 'https:'
// registerApp begins on accounts.* but the SDK deliberately returns the
// user-facing /page/launcher URL on open.*.
|| url.hostname !== launcherDomain(domain)
|| url.port !== ''
|| url.username !== ''
|| url.password !== ''
|| transportProviders.length !== 1
|| transportProviders[0] !== 'sdk'
|| clientIds.length !== 1
|| clientIds[0] !== expectedAppId
|| url.searchParams.has('createOnly')
|| addons.length !== 1
|| !addons[0]?.trim()
|| hasPlaceholder) {
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
* update manifest in one place so callers can add only the card callback, the
* message-read scope needed to download user-sent media, and the resource
* scope needed to upload bot-sent images/files, without adding unrelated
* scopes, events, presets, or createOnly.
*/
export class CallbackRepairManager {
#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) => {
assertCallbackRepairUrl(info?.url, this.#appId, this.#domain);
options.onQRCodeReady(info);
},
}),
onCredentials,
});
}
start() {
return this.#manager.start({
source: 'deepseek-harness-card-action-repair',
domain: accountsDomain(this.#domain),
appId: this.#appId,
addons: {
preset: false,
scopes: { tenant: [FEISHU_MESSAGE_READ_SCOPE, FEISHU_RESOURCE_SCOPE] },
callbacks: { items: [CARD_ACTION_CALLBACK] },
},
});
}
status() {
return this.#manager.status();
}
cancel() {
return this.#manager.cancel();
}
}
export default CallbackRepairManager;