Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions src/im/lark/event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
buildEventSubDeepLink,
buildScopeDeepLink,
} from '../../setup/verify-permissions.js';
import { automateOpenPlatformSetup, probeVcMeetingEventSubscription } from '../../setup/open-platform-automation.js';
import { automateOpenPlatformSetup, probeVcMeetingEventSubscription, readDefaultScopeManifest, filterScopeManifest } from '../../setup/open-platform-automation.js';
import { type Brand, larkHosts, normalizeBrand, sdkDomain } from './lark-hosts.js';
import { tryHandleGrantCommand } from './grant-command.js';
import { tryHandleInviteCommand } from './invite-command.js';
Expand Down Expand Up @@ -260,11 +260,18 @@ async function tryAutoFixScopes(
try {
const totalMissing = missingCritical.length + missingOptional.length;
logger.info(`[${larkAppId}] attempting auto-fix for ${totalMissing} missing scopes via Open Platform...`);
// 只申请「实际缺失」的那几项:把默认清单裁成缺失集合再传进去,避免
// 「用缺失项当触发器、却拿完整 manifest(300+ 项)当申请集合」——那会让补一个
// im:feed_group_v1:read 连带申请日历/文档/表格等一大批 botmux 不校验的权限。
// 分桶归属仍以默认 manifest 为准(见 filterScopeManifest)。
const wantedScopeNames = [...missingCritical, ...missingOptional].map(s => s.name);
const scopeManifest = filterScopeManifest(readDefaultScopeManifest(), wantedScopeNames);
const result = await automateOpenPlatformSetup({
appId: bot.config.larkAppId,
brand,
maxWaitMs: 60_000,
disableQrLogin: opts?.disableQrLogin,
scopeManifest,
onStatus: (msg) => logger.info(`[${larkAppId}] auto-fix: ${msg}`),
onQrCode: (info) => {
logger.warn(
Expand All @@ -276,14 +283,32 @@ async function tryAutoFixScopes(
});

if (result.ok) {
// ⚠️ `scopeCount === 0` 有三种**互相矛盾**的成因,不能共用一句话。自从申请集合
// 被裁成「缺失项」后(见上方 filterScopeManifest),它的语义从「整份清单导入了
// 多少」变成「**缺失的那几项里成功了多少**」,于是 0 不再等于「本来就齐」,
// 反而最可能是「一项都没补上」——照旧说「所有必需权限已在应用清单中」就成了
// 恰在全部失败时谎报齐全。三种成因按可诊断性分开:
// ① scopeWarning 有值 → scope/update 被租户整批拒了(开放平台明确报错)
// ② skippedScopeCount > 0 → 这些名字不在该租户的开放平台目录里,不可授予
// (automation 自己 warn 过「not present in the Open Platform catalog」)
// ③ 都没有 → 申请集合本就是空的(缺失项已在清单中),才是真的「已齐全」
// 这句同时进 daemon 日志**和**下面的管理员 DM,DM 开头写着「✅ 已自动修复了
// 缺失的权限」,一旦这里说反就自相矛盾,管理员会以为不用管。
const scopeDetail = result.scopeCount > 0
? `${result.scopeCount} 项权限已导入${result.skippedScopeCount > 0 ? `(${result.skippedScopeCount} 项跳过)` : ''}`
: '所有必需权限已在应用清单中';
logger.info(
`[${larkAppId}] auto-fix succeeded: ${scopeDetail}, ` +
: result.scopeWarning
? `0 项权限已导入——开放平台拒绝了本次权限申请(${result.scopeWarning}),需到「权限管理」手动开通`
: result.skippedScopeCount > 0
? `0 项权限已导入——这 ${result.skippedScopeCount} 项不在当前租户的开放平台权限目录中,无法自动申请,需到「权限管理」手动开通`
: '所有必需权限已在应用清单中';
// 一项都没申请上时,这次自愈**没有**修好任何东西,日志级别也不该是「succeeded」。
const autoFixEffective = result.scopeCount > 0 || (!result.scopeWarning && result.skippedScopeCount === 0);
const summary =
`[${larkAppId}] auto-fix ${autoFixEffective ? 'succeeded' : 'could NOT apply the missing scopes'}: ${scopeDetail}, ` +
`version ${result.versionId ?? 'n/a'} published, ` +
`${result.subscribedEventCount} events subscribed`,
);
`${result.subscribedEventCount} events subscribed`;
if (autoFixEffective) logger.info(summary);
else logger.warn(summary);
// opt-in / optional-only path: succeeded silently, no admin DM (a bot that
// never enabled the feature must not be pinged just because a non-critical
// scope was topped up in the background). The log line above is the record.
Expand All @@ -294,13 +319,18 @@ async function tryAutoFixScopes(
if (adminOpenId) {
const fixedList = [...missingCritical, ...missingOptional];
const missingList = fixedList.map(s => `• ${s.desc} (\`${s.name}\`)`).join('\n');
// 标题跟着实际结果走:一项都没落地时说「已自动修复」是谎报,管理员会以为
// 不用管,而这条路径的下游(缺 critical scope)恰恰是最需要人工介入的。
await dmAdmin(
larkAppId,
adminOpenId,
`✅ botmux 已自动为机器人 "${bot.botName ?? larkAppId}" 修复了缺失的权限:\n\n${missingList}\n\n` +
(autoFixEffective
? `✅ botmux 已自动为机器人 "${bot.botName ?? larkAppId}" 修复了缺失的权限:\n\n`
: `⚠️ botmux 尝试自动为机器人 "${bot.botName ?? larkAppId}" 补齐以下权限,但没能申请成功,需要你手动开通:\n\n`) +
`${missingList}\n\n` +
`${scopeDetail},新版本已发布。\n` +
`权限变更可能需要 1-2 分钟生效。如仍有问题执行 \`botmux restart\`。`,
`auto-fixed ${fixedList.length} scopes`,
autoFixEffective ? `auto-fixed ${fixedList.length} scopes` : `auto-fix could not apply ${fixedList.length} scopes`,
);
}
return true;
Expand Down Expand Up @@ -360,11 +390,13 @@ export async function checkRequiredScopes(larkAppId: string): Promise<void> {
// self_manage 后下次重启就能自检了。
if (infoData.code === 99991672) {
// Chicken-and-egg: app lacks self_manage so we can't even check what scopes
// are missing. Try the Open Platform web-session auto-fix to add ALL
// required scopes (including self_manage) in one shot.
// are missing. Try the Open Platform web-session auto-fix to add every
// botmux-required scope (including self_manage) in one shot — passing the
// full BOTMUX_REQUIRED_SCOPES so the next restart's self-check finds them
// all present, without over-applying the whole 300+ scope manifest.
if (brand === 'feishu') {
const fixed = await tryAutoFixScopes(larkAppId, bot, brand,
[{ name: SELF_MANAGE_SCOPE, desc: '应用自查 (免审批)' }], []);
const requiredNow = BOTMUX_REQUIRED_SCOPES.map(s => ({ name: s.name, desc: s.desc }));
const fixed = await tryAutoFixScopes(larkAppId, bot, brand, requiredNow, []);
if (fixed) return;
}
const selfManageAuthUrl = buildScopeDeepLink(bot.config.larkAppId, SELF_MANAGE_SCOPE, brand);
Expand Down
28 changes: 27 additions & 1 deletion src/setup/open-platform-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,32 @@ export function mapManifestScopesToOpenPlatformIds(
};
}

/**
* 把整份 scope 清单裁到「只保留 `wantedNames` 里点名的权限」,tenant / user 分桶
* **原样沿用 manifest 的归属**。
*
* 权限自愈只缺某几项时,历史实现把整份 {@link readDefaultScopeManifest}(300+ 项)
* 全量 `operation:'add'` 追加进去——「用缺失项当触发器,却拿完整清单当申请集合」,
* 于是补一个 `im:feed_group_v1:read` 会连带申请日历/文档/表格等一大批 botmux 自己
* 都不校验的权限。调用方先用本函数把 manifest 裁成缺失项,再传给
* {@link automateOpenPlatformSetup} 的 `scopeManifest`,申请集合就与缺失集合一致。
*
* ⚠️ 故意**不**自己猜 bucket:`im:feed_group_v1:*` 只在 user 桶、`im:resource` 只在
* tenant 桶,同名权限也可能同时落两个桶(manifest 里有 121 项 tenant∩user 重叠)。
* 以 manifest 的分桶为准,能落哪个桶就保留哪个桶,避免把 user 权限误当 tenant 申请。
* 不在 manifest 里的名字直接落空(automation 侧的 catalog 映射也会把它算进
* skippedScopeCount),不硬塞。
*/
export function filterScopeManifest(manifest: ScopeManifest, wantedNames: string[]): ScopeManifest {
const wanted = new Set(uniqueStrings(wantedNames));
return {
scopes: {
tenant: uniqueStrings(manifest.scopes?.tenant ?? []).filter(name => wanted.has(name)),
user: uniqueStrings(manifest.scopes?.user ?? []).filter(name => wanted.has(name)),
},
};
}

export function buildScopeUpdatePayload(appId: string, mapped: Pick<MappedScopeIds, 'tenantScopeIds' | 'userScopeIds'>) {
return {
clientId: appId,
Expand Down Expand Up @@ -2027,7 +2053,7 @@ async function pollFeishuQrLogin(
};
}

function readDefaultScopeManifest(): ScopeManifest {
export function readDefaultScopeManifest(): ScopeManifest {
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(here, 'lark-scopes.json'),
Expand Down
68 changes: 68 additions & 0 deletions test/scope-optional-autofix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ describe('tryAutoFixScopes — silent / disableQrLogin plumbing', () => {
expect(region).toContain('opts?: { disableQrLogin?: boolean; silent?: boolean }');
});

it('only requests the actually-missing scopes (filtered manifest, not the full 300+)', () => {
// Regression: the automation was called with no scopeManifest, so it applied
// the entire default manifest. It must now derive the manifest from the
// missing critical+optional names and pass it through.
expect(region).toContain('filterScopeManifest(readDefaultScopeManifest(), wantedScopeNames)');
expect(region).toMatch(/const wantedScopeNames = \[\.\.\.missingCritical, \.\.\.missingOptional\]\.map\(s => s\.name\)/);
expect(region).toContain('scopeManifest,');
});

it('threads disableQrLogin into the Open Platform automation', () => {
expect(region).toContain('disableQrLogin: opts?.disableQrLogin,');
});
Expand All @@ -87,6 +96,65 @@ describe('tryAutoFixScopes — silent / disableQrLogin plumbing', () => {
expect(silentIdx).toBeGreaterThanOrEqual(0);
expect(adminIdx).toBeGreaterThan(silentIdx);
});

it('does not claim success when zero scopes actually landed', () => {
// Regression: `scopeCount` now means "how many of the MISSING ones landed",
// so 0 most likely means "none applied" — not "nothing was missing". The old
// single-ternary said 「所有必需权限已在应用清单中」in both cases, i.e. it
// reported "all present" exactly when the top-up had fully failed.
expect(region).toContain('const autoFixEffective =');
// The three causes must be told apart: platform rejection (scopeWarning),
// not-in-catalog (skippedScopeCount), genuinely nothing missing.
expect(region).toContain('result.scopeWarning');
expect(region).toContain('result.skippedScopeCount > 0');
// ...and the honest branch must not reuse the "already present" wording.
const ineffectiveIdx = region.indexOf('0 项权限已导入');
expect(ineffectiveIdx, 'no explicit "0 项权限已导入" wording for the failed case').toBeGreaterThanOrEqual(0);
});

it('downgrades the log level and the DM headline when nothing landed', () => {
// A failed top-up must not log `succeeded` at info, nor open the admin DM
// with 「✅ 已自动修复了缺失的权限」— that headline is what makes an admin
// stop looking, and this path (missing CRITICAL scopes) is the one that most
// needs a human.
expect(region).toContain('logger.warn(summary)');
expect(region).toMatch(/autoFixEffective\s*$|autoFixEffective\s*\?/m);
expect(region).toContain('没能申请成功');
});
});

/**
* The 99991672 chicken-and-egg branch: the app lacks `self_manage`, so botmux
* cannot even read its own scope list. It must still ask for every
* botmux-required scope in one shot (so the NEXT restart's self-check passes),
* but must not fall back to the full 300+ manifest.
*
* Source-region pinned for the same reason as above (checkRequiredScopes is a
* network-driven function); the automation-side filtering itself is covered
* behaviorally in test/setup-open-platform-automation.test.ts.
*/
describe('checkRequiredScopes — 99991672 chicken-and-egg scope request set', () => {
const region = (() => {
const anchor = 'if (infoData.code === 99991672) {';
const start = src.indexOf(anchor);
expect(start, '99991672 branch not found').toBeGreaterThanOrEqual(0);
return src.slice(start, start + 1200);
})();

it('asks for every botmux-required scope, not just self_manage', () => {
// Passing only self_manage used to be cosmetic: the param never reached the
// request set (automation fell back to the full manifest), it only fed the
// log/DM text. Now that the manifest IS derived from these names, the list
// has to be the real one or the next restart still finds scopes missing.
expect(region).toMatch(/const requiredNow = BOTMUX_REQUIRED_SCOPES\.map\(s => \(\{ name: s\.name, desc: s\.desc \}\)\)/);
expect(region).toContain('tryAutoFixScopes(larkAppId, bot, brand, requiredNow, [])');
});

it('still only runs on feishu and falls through to the manual deep-link DM', () => {
expect(region).toContain("if (brand === 'feishu') {");
expect(region).toContain('if (fixed) return;');
expect(region).toContain('buildScopeDeepLink(bot.config.larkAppId, SELF_MANAGE_SCOPE, brand)');
});
});

describe('ensureVcMeetingEventsSubscribed — startup VC-event check-then-configure', () => {
Expand Down
Loading