fix(feishu): complete permissions repair flow

This commit is contained in:
xmanrui 2026-08-25 04:25:21 +08:00
parent 1a72e5491f
commit 10ca7eec2c
22 changed files with 574 additions and 392 deletions

View file

@ -106,9 +106,10 @@ const CARD_OVERLOAD_NOTICE_COOLDOWN_MS = 5_000;
const CARD_DATA_TIMEOUT_MS = 5_000;
const REPAIR_LINK_WAIT_MS = 15_000;
const REPAIR_POLL_INTERVAL_MS = 1_000;
const REPAIR_ACTIVE_STATES = new Set([
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched', 'saving',
const REPAIR_AUTHORIZATION_STATES = new Set([
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
]);
const REPAIR_ACTIVE_STATES = new Set([...REPAIR_AUTHORIZATION_STATES, 'saving']);
const REPAIR_TERMINAL_STATES = new Set([
'succeeded', 'expired', 'cancelled', 'error',
]);
@ -150,7 +151,7 @@ function artifactFailureText(fileName, error) {
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
switch (error?.code) {
case 'artifact-permission-required':
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。', { name });
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。', { name });
case 'artifact-too-large':
return t('结果文件「{name}」超过飞书 30 MB 上限,未发送。', { name });
case 'artifact-empty':
@ -410,7 +411,6 @@ export class FeishuHarnessBridge {
#botOpenId;
#groupResponseMode;
#repair;
#repairOwnerOpenIds;
#repairAttempt = null;
#repairMonitorVersion = 0;
#repairPollIntervalMs;
@ -443,7 +443,6 @@ export class FeishuHarnessBridge {
botOpenId,
groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
repair,
repairOwnerOpenIds,
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
@ -480,10 +479,6 @@ export class FeishuHarnessBridge {
this.#botOpenId = nonEmptyString(botOpenId);
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
this.#repair = repair ?? null;
const repairOwners = repairOwnerOpenIds ?? allowedSenderOpenIds;
this.#repairOwnerOpenIds = new Set(
[...(repairOwners ?? [])].filter((value) => typeof value === 'string' && value && value !== '*'),
);
this.#repairPollIntervalMs = repairPollIntervalMs;
this.#repairLinkWaitMs = repairLinkWaitMs;
this.#cardDataTimeoutMs = cardDataTimeoutMs;
@ -1075,13 +1070,8 @@ export class FeishuHarnessBridge {
return;
}
const actorOpenId = strictSenderOpenId(event);
if (!actorOpenId || !this.#repairOwnerOpenIds.has(actorOpenId)) {
await this.#send(
event.message.chat_id,
this.#repairOwnerOpenIds.size === 0
? t('当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。')
: t('此操作只能由机器人接入者在私聊中发起,未进行任何修改。'),
);
if (!actorOpenId) {
await this.#send(event.message.chat_id, t('无法识别当前发送者,未发起修复。'));
return;
}
if (!this.#repair) {
@ -1110,7 +1100,7 @@ export class FeishuHarnessBridge {
return;
}
if (attempt.actorOpenId !== actorOpenId) {
await this.#send(chatId, t('另一位管理员正在修复该机器人,本次不会显示其授权信息。'));
await this.#send(chatId, t('另一位用户正在修复该机器人,本次不会显示其授权信息。'));
return;
}
if (operation === 'cancel') {
@ -1162,21 +1152,53 @@ export class FeishuHarnessBridge {
async #startRepair({ actorOpenId, chatId }) {
const previous = this.#repairAttempt;
let restarted = false;
if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) {
if (previous.actorOpenId !== actorOpenId) {
await this.#send(chatId, t('另一位管理员正在修复该机器人,本次不会显示其授权信息。'));
await this.#send(chatId, t('另一位用户正在修复该机器人,本次不会显示其授权信息。'));
return;
}
let current;
try {
const current = await this.#refreshRepairAttempt(previous);
if (REPAIR_ACTIVE_STATES.has(current.state) && previous.verificationUrl) {
await this.#sendRepairLink(chatId, previous.verificationUrl, current, { existing: true });
return;
}
current = await this.#refreshRepairAttempt(previous);
} catch {
await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
return;
}
// Once Feishu has accepted the update, starting another attempt could
// race the credential swap and callback probe. Status commands remain
// available while that non-cancellable convergence is in progress.
if (current.state === 'saving') {
await this.#send(chatId, this.#repairStatusText(current));
return;
}
// Feishu launcher links carry one-time user codes. Opening one with the
// wrong Open Platform account can consume it even though authorization
// did not succeed, so a fresh bare /repair must replace a still-waiting
// attempt instead of redisplaying the same unusable URL.
if (REPAIR_AUTHORIZATION_STATES.has(current.state)) {
let cancelled;
try {
cancelled = repairSnapshot(
await this.#repair.cancel(this.#repairArgs(previous)),
{ botId: this.#botId },
);
previous.snapshot = cancelled;
} catch {
await this.#send(chatId, t('暂时无法取消旧修复任务,未生成新链接;请稍后重试。'));
return;
}
if (!REPAIR_TERMINAL_STATES.has(cancelled.state)) {
await this.#send(chatId, this.#repairStatusText(cancelled));
return;
}
previous.stopped = true;
this.#repairMonitorVersion += 1;
restarted = cancelled.state === 'cancelled';
} else if (REPAIR_TERMINAL_STATES.has(current.state)) {
previous.stopped = true;
this.#repairMonitorVersion += 1;
}
}
let snapshot;
@ -1225,7 +1247,7 @@ export class FeishuHarnessBridge {
await this.#send(chatId, t('飞书未返回授权链接,已中止本次修复。'));
return;
}
await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot);
await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot, { restarted });
this.#monitorRepair(attempt);
}
@ -1303,15 +1325,15 @@ export class FeishuHarnessBridge {
});
}
async #sendRepairLink(chatId, url, snapshot, { existing = false } = {}) {
async #sendRepairLink(chatId, url, snapshot, { restarted = false } = {}) {
const remaining = snapshot.remainingSeconds
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
const expiry = remaining === null
? t('链接为短期有效')
: t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
await this.#send(chatId, [
existing ? t('已有一个修复任务在等待授权。') : t('🔧 准备修复卡片按钮。'),
t('本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。'),
restarted ? t('旧授权链接已作废,已生成新的修复链接。') : t('🔧 准备补全权限与回调。'),
t('本次最多增量添加三项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);以及 im:resource(用于上传机器人发送的图片或文件)。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
'',
t('当前设备直接打开:'),
url,

View file

@ -124,7 +124,7 @@ function backButton() {
* - presetCatalog: object|null (预设目录, {items,defaultId,_currentId})
* - modelCatalog: object|null (模型目录, {groups,current})
* Number fallback: 1=续写 2=新会话 3=会话列表 4=状态
* 5=修复 6=帮助.
* 5=补全权限 6=帮助.
*/
export function menuCard(ctx) {
const {
@ -323,7 +323,7 @@ export function menuCard(ctx) {
// 命令与数字兜底说明
elements.push({ tag: 'div', text: markdown(t(
'**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧修复 · **6**帮助',
'**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧补全权限 · **6**帮助',
)) });
return cardWith(t('🤖 助手中心'), elements);
}
@ -521,7 +521,7 @@ export function menuHelpText() {
'🎮 任务控制',
'/stop 停止当前任务',
'/steer 指令 给 Agent 补充指令',
'/repair 修复卡片按钮回调',
'/repair 补全飞书权限与卡片回调',
].map((line) => t(line)).join('\n');
}
@ -572,14 +572,14 @@ const HELP_TEXT_COMMANDS = [
'`/batch` — 开启批量输入(仅私聊,最多 10 条文字)',
'`/send` — 提交当前批次',
'`/cancel` — 取消当前批次',
'`/repair` — 修复卡片按钮',
'`/repair` — 补全飞书权限与卡片回调',
].join('\n');
const HELP_NUMBER_FALLBACK = [
'**💡 数字兜底**',
'回复数字快速操作:',
'**1**工作区列表 · **2**新会话 · **3**会话/关注',
'**4**状态 · **5**修复 · **6**帮助',
'**4**状态 · **5**补全权限 · **6**帮助',
].join('\n');
export function helpCard(extraTextLines = []) {

View file

@ -267,7 +267,6 @@ export class FeishuRuntime {
botOpenId: this.#botOpenId,
groupResponseMode: this.#groupResponseMode,
repair: this.#repair,
repairOwnerOpenIds: new Set(this.#ownerOpenIds.filter((value) => value !== '*')),
replyTimeoutMs: this.#replyTimeoutMs,
signal,
logger: this.#logger,

View file

@ -5,7 +5,7 @@ const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
const FEISHU_IMAGE_PERMISSION_MESSAGE =
'飞书机器人缺少图片读取权限。请在飞书开放平台为该应用添加 im:message:readonly,发布新版本并完成必要的管理员审批后,再重新发送图片。';
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
export function conversationKey(event) {
const chatType = event?.message?.chat_type;

View file

@ -869,22 +869,6 @@ export class MultiBotDshFeishuController {
'Feishu returned credentials for a different account domain.',
);
}
if (record.initiator.actorOpenId && record.initiator.actorOpenId !== ownerOpenId) {
throw this.#callbackRepairError(
record,
'repair_owner_mismatch',
'The Feishu repair was confirmed by a different operator.',
);
}
if (!target.ownerOpenIds.includes(ALL_VISIBLE_SENDERS)
&& !target.ownerOpenIds.includes(ownerOpenId)) {
throw this.#callbackRepairError(
record,
'repair_owner_mismatch',
'The Feishu repair operator is not an owner of this configured bot.',
);
}
record.stage = 'verifying_identity';
let verified;
try {
@ -1008,7 +992,7 @@ export class MultiBotDshFeishuController {
record.stage = 'awaiting_callback';
try {
const proof = await runtime.beginCardActionProbe({
expectedOperatorOpenId: ownerOpenId,
expectedOperatorOpenId: record.initiator.actorOpenId ?? ownerOpenId,
timeoutMs: this.#callbackProbeTimeoutMs,
...(record.initiator.chatId ? { chatId: record.initiator.chatId } : {}),
});

View file

@ -1,6 +1,8 @@
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) {
@ -71,8 +73,10 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
/**
* 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 cannot accidentally add scopes,
* events, presets, or createOnly.
* 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;
@ -106,6 +110,7 @@ export class CallbackRepairManager {
appId: this.#appId,
addons: {
preset: false,
scopes: { tenant: [FEISHU_MESSAGE_READ_SCOPE, FEISHU_RESOURCE_SCOPE] },
callbacks: { items: [CARD_ACTION_CALLBACK] },
},
});