diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index fb3263cadf..32b2d7c4ac 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -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'; @@ -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( @@ -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. @@ -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; @@ -360,11 +390,13 @@ export async function checkRequiredScopes(larkAppId: string): Promise { // 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); diff --git a/src/setup/open-platform-automation.ts b/src/setup/open-platform-automation.ts index e7a522ec28..85882ade4c 100644 --- a/src/setup/open-platform-automation.ts +++ b/src/setup/open-platform-automation.ts @@ -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) { return { clientId: appId, @@ -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'), diff --git a/test/scope-optional-autofix.test.ts b/test/scope-optional-autofix.test.ts index 06d244da6d..089a0c5167 100644 --- a/test/scope-optional-autofix.test.ts +++ b/test/scope-optional-autofix.test.ts @@ -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,'); }); @@ -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', () => { diff --git a/test/setup-open-platform-automation.test.ts b/test/setup-open-platform-automation.test.ts index 0cb4a71477..991058c3b4 100644 --- a/test/setup-open-platform-automation.test.ts +++ b/test/setup-open-platform-automation.test.ts @@ -23,9 +23,11 @@ import { extractOpenPlatformRedirectUrls, extractOpenPlatformSessionIdentity, extractOpenPlatformScopeEntries, + filterScopeManifest, getCookieHeader, mapFeishuQrPollingStatus, mapManifestScopesToOpenPlatformIds, + readDefaultScopeManifest, missingRedirectUrls, OpenPlatformApiError, parseSetupOpenPlatformAutoFlag, @@ -245,6 +247,141 @@ describe('Open Platform payload helpers', () => { }); }); +describe('filterScopeManifest — 只申请缺失项,避免全量 manifest 过度申请', () => { + const manifest = { + scopes: { + tenant: [ + 'im:message', + 'im:resource', + 'calendar:calendar:read', + 'application:application:self_manage', + ], + user: [ + 'im:message', + 'im:feed_group_v1:read', + 'im:feed_group_v1:write', + 'docs:document:readonly', + ], + }, + }; + + it('保留点名的权限并沿用 manifest 的 tenant/user 分桶归属', () => { + // im:feed_group_v1:* 只在 user 桶;im:resource 只在 tenant 桶——分桶必须来自 + // manifest,不能自己猜。 + const filtered = filterScopeManifest(manifest, [ + 'im:feed_group_v1:read', + 'im:feed_group_v1:write', + 'im:resource', + ]); + expect(filtered).toEqual({ + scopes: { + tenant: ['im:resource'], + user: ['im:feed_group_v1:read', 'im:feed_group_v1:write'], + }, + }); + }); + + it('同名权限同时落两桶时两桶都保留', () => { + const filtered = filterScopeManifest(manifest, ['im:message']); + expect(filtered).toEqual({ scopes: { tenant: ['im:message'], user: ['im:message'] } }); + }); + + it('不点名的权限一律不申请(日历/文档等不再被连带带上)', () => { + const filtered = filterScopeManifest(manifest, ['application:application:self_manage']); + expect(filtered.scopes?.tenant).toEqual(['application:application:self_manage']); + expect(filtered.scopes?.user).toEqual([]); + // 关键回归点:manifest 里的 calendar/docs 权限不会被带进申请集合。 + expect(filtered.scopes?.tenant).not.toContain('calendar:calendar:read'); + expect(filtered.scopes?.user).not.toContain('docs:document:readonly'); + }); + + it('manifest 里不存在的名字直接落空(交给 catalog 映射记 skipped)', () => { + const filtered = filterScopeManifest(manifest, ['im:nonexistent:scope']); + expect(filtered).toEqual({ scopes: { tenant: [], user: [] } }); + }); + + it('空缺失列表 → 空申请集合', () => { + expect(filterScopeManifest(manifest, [])).toEqual({ scopes: { tenant: [], user: [] } }); + }); + + /** + * 裁剪之后 `scopeCount` 的语义变了:从「整份清单导入了多少」变成「**缺失的那几项 + * 里成功了多少**」。所以 `0` 不再等于「本来就齐」,反而最常见的成因是「一项都没 + * 补上」——调用方(event-dispatcher.tryAutoFixScopes)据此措辞,说反了就会在全部 + * 失败时谎报「所有必需权限已在应用清单中」,而这句话同时进管理员 DM。 + * + * 这里跑真实的 automation 拿到真实的 `scopeCount / skippedScopeCount / + * scopeWarning` 三元组,验证三种成因**确实可区分**——否则调用方无论怎么写文案都 + * 只能靠猜。 + */ + it('三种 scopeCount===0 成因在结果里可区分(调用方措辞的依据)', async () => { + const FEED = ['im:feed_group_v1:read', 'im:feed_group_v1:write']; + const narrowed = filterScopeManifest(readDefaultScopeManifest(), FEED); + + const run = async (label: string, opts: { + catalog: { appScopeList: any[]; userScopeList: any[] }; + rejectScopeUpdate?: boolean; + manifest?: any; + }) => { + const dir = mkdtempSync(join(tmpdir(), `scope0-${label}-`)); + const sessionFile = join(dir, 'feishu-session.json'); + writeStoredCookiesToSessionFile(sessionFile, [cookie()]); + const sub = openPlatformSubscriptionMock('cli_s'); + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + const href = String(url); + if (href === 'https://ask.feishu.cn/') return new Response('ask home', { status: 200 }); + if (href.endsWith('/app/cli_s/auth')) return new Response('', { status: 200 }); + if (href.includes('/scope/all/')) return Response.json({ code: 0, data: opts.catalog }); + if (href.includes('/scope/update/')) { + return opts.rejectScopeUpdate + ? Response.json({ code: 1, msg: 'scope not grantable for tenant' }) + : Response.json({ code: 0 }); + } + if (href.includes('/app_version/list/')) return Response.json({ code: 0, data: { versions: [{ appVersion: '1.0.0' }] } }); + if (href.includes('/app_version/create/')) return Response.json({ code: 0, data: { versionId: 'v1' } }); + return sub.handle(href, init) ?? Response.json({ code: 0 }); + }) as typeof fetch; + const r = await automateOpenPlatformSetup({ + appId: 'cli_s', sessionFilePath: sessionFile, fetchImpl, disableQrLogin: true, + scopeManifest: opts.manifest ?? narrowed, + }); + expect(r.ok, `${label}: ok=false reason=${(r as any).reason}`).toBe(true); + if (!r.ok) throw new Error('unreachable'); + return { scopeCount: r.scopeCount, skipped: r.skippedScopeCount, warned: Boolean(r.scopeWarning) }; + }; + + const catalogWithFeed = { + appScopeList: [{ id: 't1', name: 'im:message' }], + userScopeList: [{ id: 'u1', name: 'im:feed_group_v1:read' }, { id: 'u2', name: 'im:feed_group_v1:write' }], + }; + const catalogWithoutFeed = { + appScopeList: [{ id: 't1', name: 'im:message' }, { id: 't2', name: 'im:resource' }], + userScopeList: [{ id: 'u1', name: 'im:message' }], + }; + + // ① 成功落地:scopeCount>0 —— 调用方说「N 项权限已导入」 + expect(await run('applied', { catalog: catalogWithFeed })) + .toEqual({ scopeCount: 2, skipped: 0, warned: false }); + + // ② 租户目录里根本没有这两项 → 一个 id 都映射不出来,scope/update 都不会发。 + // scopeCount=0 但 skipped>0 —— 必须说「不在租户目录、需手动开通」。 + expect(await run('not-in-catalog', { catalog: catalogWithoutFeed })) + .toEqual({ scopeCount: 0, skipped: 2, warned: false }); + + // ③ 目录里有、但开放平台整批拒了 → scopeCount 被归零且带 scopeWarning。 + // 必须说「开放平台拒绝了申请」,不能说「已齐全」。 + expect(await run('rejected', { catalog: catalogWithFeed, rejectScopeUpdate: true })) + .toEqual({ scopeCount: 0, skipped: 0, warned: true }); + + // ④ 真的无事可做(申请集合为空)才是「所有必需权限已在应用清单中」: + // 三个信号全为零/假,与 ②③ 明确可区分。 + expect(await run('nothing-missing', { + catalog: catalogWithFeed, manifest: { scopes: { tenant: [], user: [] } }, + })).toEqual({ scopeCount: 0, skipped: 0, warned: false }); + }); +}); + + describe('redirect 白名单读→合并→写', () => { /** postJson 桩:读接口返回 `read`(或抛错),写接口按 `writeResults` 顺序成功/失败。 */ function stubPostJson(opts: {