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
19 changes: 19 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,19 @@ export interface BotConfig {
workingDir?: string;
workingDirs?: string[];
allowedUsers?: string[];
/**
* Owner's native app-scoped `open_id` (`ou_…`), captured at setup from the
* device-flow scanner identity. UNLIKE `allowedUsers` (which may hold `on_`/
* email entries needing a contact-API resolve every boot), this is stored raw
* and never resolved — so it survives a contact-API outage. Two uses:
* 1. a fail-safe DM recipient for allowedUsers-resolve failure notices, so
* the owner is reachable even when the resolve that would have produced
* their open_id is the very thing that failed (cold-start race);
* 2. an always-available owner anchor for runtime permission checks.
* Optional: bots created before this field, or via paths without a scanner
* identity, simply have none and fall back to the resolved allowlist.
*/
ownerOpenId?: string;
allowedChatGroups?: string[];
/** Oncall bindings: chat_id → default workingDir. Any group member can talk; allowedUsers still gates card buttons / daemon commands. */
oncallChats?: OncallChat[];
Expand Down Expand Up @@ -2121,6 +2134,12 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
workingDir: workingDirs?.[0] ?? entry.workingDir,
workingDirs,
allowedUsers: entry.allowedUsers,
// Only a well-formed native open_id is trusted; anything else (stray on_/
// email/garbage) is dropped so the fail-safe recipient can never be a
// value that itself needs resolving.
ownerOpenId: typeof entry.ownerOpenId === 'string' && entry.ownerOpenId.startsWith('ou_')
? entry.ownerOpenId
: undefined,
allowedChatGroups,
oncallChats,
defaultOncall,
Expand Down
17 changes: 11 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,8 +1039,8 @@ async function resolveScannerAllowedUser(
async function promptRequiredOwner(rl: ReturnType<typeof createInterface>): Promise<string[]> {
printInputHelp('管理员 (owner)', [
'必填。至少一个能操作机器人的管理员,多个值用逗号分隔。',
'推荐格式(优先级高到低):完整邮箱(alice@example.com)> union_id(on_xxx,跨应用稳定)> open_id(ou_xxx,仅限同一应用)。',
'注意:必须是完整邮箱,邮箱前缀(如 alice)无法解析、不接受。',
'推荐格式(优先级高到低):完整邮箱(alice@example.com)> union_id(on_xxx,跨应用稳定)> 手机号(大陆号直填 11 位,海外号带 + 区号)> open_id(ou_xxx,仅限同一应用)。',
'注意:邮箱必须完整,邮箱前缀(如 alice)无法解析、不接受。没有企业邮箱可用手机号。',
]);
for (;;) {
const raw = (await ask(rl, '管理员 (owner): ')).trim();
Expand All @@ -1051,11 +1051,11 @@ async function promptRequiredOwner(rl: ReturnType<typeof createInterface>): Prom
}
const invalid = findInvalidAllowedUserEntries(entries);
if (invalid.length > 0) {
console.log(` ❌ 以下不是完整邮箱、union_id 或 open_id(邮箱前缀不接受): ${invalid.join(', ')}`);
console.log(` ❌ 以下不是完整邮箱、手机号(大陆 11 位 / 海外带 + 国家码)、union_id 或 open_id(邮箱前缀不接受): ${invalid.join(', ')}`);
continue;
}
if (!hasOwnerEntry(entries)) {
console.log(' ❌ 至少需要一个完整邮箱、union_id 或 open_id 作为 owner。');
console.log(' ❌ 至少需要一个完整邮箱、手机号、union_id 或 open_id 作为 owner。');
continue;
}
return entries;
Expand Down Expand Up @@ -1173,6 +1173,11 @@ async function promptBotConfig(rl: ReturnType<typeof createInterface>): Promise<
const owner = await resolveScannerAllowedUser(creds.appId, creds.appSecret, creds.userOpenId, creds.brand);
if (owner) {
bot.allowedUsers = [owner];
// Persist the native ou_ too. allowedUsers may hold the cross-app-stable
// on_ (union_id) form that needs a contact-API resolve every boot; this
// raw open_id never does, so it stays a valid fail-safe DM recipient even
// when that resolve is the very thing failing (cold-start race).
bot.ownerOpenId = creds.userOpenId;
} else {
console.log('⚠️ 无法确认扫码人的 open_id 属于当前新应用,请手动填写 owner。');
bot.allowedUsers = await promptRequiredOwner(rl);
Expand Down Expand Up @@ -1359,8 +1364,8 @@ async function promptEditBotConfig(
}

printInputHelp('允许的用户', [
'可选。限制哪些飞书用户可以操作机器人,支持完整邮箱(如 alice@example.com)、union_id(on_xxx)或 open_id(ou_xxx),多个值用逗号分隔。',
'注意:必须是完整邮箱,邮箱前缀(如 alice)无法解析、会被丢弃。',
'可选。限制哪些飞书用户可以操作机器人,支持完整邮箱(如 alice@example.com)、union_id(on_xxx)、手机号(大陆号直填,海外带 + 区号)或 open_id(ou_xxx),多个值用逗号分隔。',
'注意:邮箱必须完整,邮箱前缀(如 alice)无法解析、会被丢弃。',
'留空保留当前值;输入 - 清空限制。',
]);
input.allowedUsers = await ask(rl, `允许的用户 [${formatOptionalValue(bot.allowedUsers)}]: `);
Expand Down
43 changes: 39 additions & 4 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { checkAllowedChatGroupsConfig } from './services/allowed-chat-groups.js'
import type { Session, VcMeetingImTurnOrigin } from './types.js';
import { ensureCjkFontsInstalled } from './utils/font-installer.js';
import { scrubTmuxServerGlobalEnv } from './setup/ensure-tmux.js';
import { entryNeedsContactResolve } from './setup/bot-config-editor.js';
import { invalidWorkingDirs } from './utils/working-dir.js';
import { validateWorkingDir } from './core/working-dir.js';
import type { DaemonToWorker, LarkMessage } from './types.js';
Expand Down Expand Up @@ -3092,9 +3093,23 @@ function notifyAllowedUsersResolveFailure(
): void {
logger.error(`[${larkAppId}] ${notice}`);
const unique = [...new Set(recipients.filter(u => typeof u === 'string' && u.startsWith('ou_')))];
// Fail-safe recipient: on a cold start the resolve that would have produced
// the owner's open_id is the very thing that just failed, so `recipients` is
// empty and the owner would hear nothing. Fall back to the static ownerOpenId
// captured at setup — a raw ou_ that never needs resolving, so it survives
// the contact-API outage. Appended (not replacing) so a partial resolve still
// reaches every recovered owner too.
let staticOwner: string | undefined;
try {
staticOwner = getBot(larkAppId).config.ownerOpenId;
} catch { /* bot gone mid-flight — nothing to fall back to */ }
if (staticOwner && staticOwner.startsWith('ou_') && !unique.includes(staticOwner)) {
unique.push(staticOwner);
}
if (unique.length === 0) {
logger.error(
`[${larkAppId}] allowedUsers resolve failed with no open_id recipient available for DM; ` +
`[${larkAppId}] allowedUsers resolve failed with no open_id recipient available for DM ` +
`(no resolved owner and no static ownerOpenId in bots.json); ` +
`check daemon logs and Feishu contact API, then restart this bot.`,
);
return;
Expand Down Expand Up @@ -3125,7 +3140,27 @@ function notifyAllowedUsersResolveFailure(
}

function scheduleAllowedUsersResolveRetry(larkAppId: string, attempt = 1): void {
if (attempt > 3) return;
if (attempt > 3) {
// Retries exhausted (startup + 3 retries all degraded). Don't just fall
// silent — the owner has been locked out for ~7.5 min and auto-recovery
// won't try again. Emit a terminal notice so they know to intervene. Only
// when the allowlist is still actually broken: a config change or a bot
// teardown mid-retry is not an exhaustion worth alarming on.
try {
const bot = getBot(larkAppId);
const stillConfigured = (bot.config.allowedUsers ?? []).length > 0;
const stillEmpty = (bot.resolvedAllowedUsers ?? []).length === 0;
if (stillConfigured && stillEmpty) {
notifyAllowedUsersResolveFailure(
larkAppId,
`allowedUsers 自动解析在启动后重试 3 次仍失败,运行时白名单为空 —— 期间包括你在内的所有人都会被拒。` +
`请检查网络 / 飞书 contact API 后执行 \`botmux restart\` 重新解析。`,
bot.resolvedAllowedUsers ?? [],
);
}
} catch { /* bot gone — nothing to report */ }
return;
}
const delayMs = attempt === 1 ? 30_000 : attempt === 2 ? 120_000 : 300_000;
const timer = setTimeout(() => {
void (async () => {
Expand Down Expand Up @@ -17749,7 +17784,7 @@ export async function startDaemon(botIndex?: number): Promise<void> {
// schedule retries. The cache is a persistent sidecar — NOT the dashboard
// descriptor, which is overwritten early in boot and deleted on shutdown.
const configured = bot.config.allowedUsers ?? bot.resolvedAllowedUsers;
const needsResolve = configured.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_'));
const needsResolve = configured.some(entryNeedsContactResolve);
if (needsResolve) {
const previousResolvedMap = readAllowedUsersCache(cfg.larkAppId);
try {
Expand Down Expand Up @@ -17779,7 +17814,7 @@ export async function startDaemon(botIndex?: number): Promise<void> {
// entry transient so the pure fn can recover each from cache.
const throwStatus = new Map<string, EntryResolveStatus>();
for (const e of configured) {
if (e.includes('@') || e.startsWith('on_') || e.startsWith('ou_')) throwStatus.set(e, 'transient');
if (entryNeedsContactResolve(e)) throwStatus.set(e, 'transient');
}
const applied = applyAllowedUsersResolve({
rawEntries: configured,
Expand Down
31 changes: 26 additions & 5 deletions src/dashboard/bot-onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
import { readBotsJsonOrEmpty, writeBotsJsonAtomic } from '../setup/bots-store.js';
import { atomicWriteFileSync } from '../utils/atomic-write.js';
import { logger } from '../utils/logger.js';
import { normalizeBotConfig, findInvalidAllowedUserEntries, hasOwnerEntry } from '../setup/bot-config-editor.js';
import { normalizeBotConfig, findInvalidAllowedUserEntries, hasOwnerEntry, isMobileEntry, normalizeMobileEntry } from '../setup/bot-config-editor.js';
import { tryRegisterApp, type RegisterAppOptions, type RegisterAppResult } from '../setup/register-app.js';
import {
validateCredentials,
Expand Down Expand Up @@ -1712,11 +1712,19 @@ export class BotOnboardingManager {
// 关键顺序:先确认 owner, 再决定是否落盘 + 终态。completed 必须意味着「bots.json
// 里这个 bot 带着至少一个 owner」, 绝不产出空 allowedUsers 的可启动 bot。
let ownerEntry: string | undefined;
// Native ou_ to persist as the resolve-independent fail-safe recipient —
// only when the scanner path verifies it belongs to THIS app (below).
let ownerOpenId: string | undefined;
if (result.userOpenId) {
// registerApp 返回的 open_id 来自扫码链路; 用新 app 自身凭证验证, 失败不
// fallback 写入该 (常为跨 app 的) ou_——避免把其他 app 视角的 open_id 固化
// 成 owner, 导致 /grant 和授权卡片一直判 non-owner。
ownerEntry = await resolveScannerAllowedUser(result.appId, result.appSecret, result.userOpenId, result.brand);
// Verified against this app (ownerEntry set) → the native open_id is a
// trustworthy owner anchor; store it raw so a boot-time contact-API blip
// can't strip our only DM recipient. (Skip on the email path below: no
// native open_id there, only an on_/email that still needs resolving.)
if (ownerEntry) ownerOpenId = result.userOpenId;
}
if (!ownerEntry && sessionEmail) {
// Web 主路径:创建应用的登录账号邮箱就是 owner(表单第一步已展示并确认),
Expand All @@ -1727,7 +1735,7 @@ export class BotOnboardingManager {
}

if (ownerEntry) {
const addedBotIndex = this.persistBot({ ...bot, allowedUsers: [ownerEntry] });
const addedBotIndex = this.persistBot({ ...bot, allowedUsers: [ownerEntry], ...(ownerOpenId ? { ownerOpenId } : {}) });
if (!activationPending) {
await this.runLiveStart(id, result.appId);
}
Expand Down Expand Up @@ -1790,10 +1798,10 @@ export class BotOnboardingManager {
const entries = rawEntries.map(e => e.trim()).filter(Boolean);
const invalid = findInvalidAllowedUserEntries(entries);
if (invalid.length > 0) {
return { ok: false, error: 'invalid_entries', message: `不是完整邮箱、union_id(on_) 或 open_id(ou_):${invalid.join(', ')}` };
return { ok: false, error: 'invalid_entries', message: `不是完整邮箱、手机号(大陆 11 位 / 海外带 + 国家码)、union_id(on_) 或 open_id(ou_):${invalid.join(', ')}` };
}
if (!hasOwnerEntry(entries)) {
return { ok: false, error: 'no_owner', message: '至少需要一个完整邮箱、union_id(on_) 或 open_id(ou_) 作为 owner。' };
return { ok: false, error: 'no_owner', message: '至少需要一个完整邮箱、手机号(大陆 11 位 / 海外带 + 国家码)、union_id(on_) 或 open_id(ou_) 作为 owner。' };
}

const appId = typeof pending.larkAppId === 'string' ? pending.larkAppId : '';
Expand All @@ -1805,7 +1813,7 @@ export class BotOnboardingManager {
return {
ok: false,
error: 'unusable_owner',
message: `以下身份在当前应用里无法解析(可能是其他应用的 open_id,或邮箱不在本企业):${unusable.join(', ')}。请改用本企业邮箱或 union_id(on_)。`,
message: `以下身份在当前应用里无法解析(可能是其他应用的 open_id,或邮箱/手机号不在本企业):${unusable.join(', ')}。请改用本企业邮箱、手机号或 union_id(on_)。`,
};
}

Expand Down Expand Up @@ -2123,6 +2131,19 @@ async function detectUnusableOwnerEntries(
} else if (entry.startsWith('on_')) {
// union_id:无确凿的跨 app 否定信号, 放行。
continue;
} else if (isMobileEntry(entry)) {
// 手机号走 batch_get_id 的 `mobiles` 字段(与运行时 resolver 同口径),
// 不能落到下面的 emails 分支——否则手机号被当邮箱查, code 0 + 空 user_list
// 会把合法手机号误判为「不在本企业」而拒绝(P2)。判定同 email:成功响应
// 里没有任何带 user_id 的条目 → 确凿不在本企业。
const res = await client.contact.v3.user.batchGetId({
params: { user_id_type: 'open_id' },
data: { mobiles: [normalizeMobileEntry(entry)], include_resigned: false },
});
if (res?.code === 0) {
const list: any[] = res.data?.user_list ?? [];
if (!list.some(u => u?.user_id)) unusable.push(entry);
}
} else {
const res = await client.contact.v3.user.batchGetId({
params: { user_id_type: 'open_id' },
Expand Down
3 changes: 2 additions & 1 deletion src/dashboard/web/bot-onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@ function OnboardingJobView(props: {
<span>{t('botOnboarding.ownerLabel')}</span>
<input
id="ob-owner"
type="email"
type="text"
inputMode="email"
placeholder={t('botOnboarding.ownerPlaceholder')}
autoComplete="off"
spellCheck={false}
Expand Down
16 changes: 8 additions & 8 deletions src/dashboard/web/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,14 @@ const zh: DashboardMessages = {
'botOnboarding.restartHint': '已写入 bots.json。自动上线未成功,执行 botmux restart 后新机器人生效。',
'botOnboarding.liveOk': '机器人已自动上线,无需重启,直接在飞书里 @ 它即可使用。',
'botOnboarding.needsOwnerTitle': '还差一步:确认管理员',
'botOnboarding.needsOwnerDescription': '无法从扫码账号确认管理员身份。填写企业邮箱后即可完成;确认前机器人不会启动。',
'botOnboarding.ownerLabel': '管理员邮箱',
'botOnboarding.ownerPlaceholder': 'name@company.com',
'botOnboarding.needsOwnerDescription': '无法从扫码账号确认管理员身份。填写企业邮箱或手机号后即可完成;确认前机器人不会启动。',
'botOnboarding.ownerLabel': '管理员邮箱或手机号',
'botOnboarding.ownerPlaceholder': 'name@company.com 或 13011112222',
'botOnboarding.ownerUseId': '使用 ID',
'botOnboarding.ownerIdLabel': 'union_id / open_id',
'botOnboarding.ownerIdPlaceholder': 'on_xxx 或 ou_xxx',
'botOnboarding.ownerSubmit': '确认并完成',
'botOnboarding.ownerEmpty': '请填写管理员邮箱或 ID。',
'botOnboarding.ownerEmpty': '请填写管理员邮箱、手机号或 ID。',
'botOnboarding.ownerInvalid': '管理员身份校验失败,请检查后重试。',
'botOnboarding.qrAlt': '飞书扫码添加机器人二维码',
'overview.title': '工作台',
Expand Down Expand Up @@ -2099,14 +2099,14 @@ const en: DashboardMessages = {
'botOnboarding.restartHint': 'bots.json has been updated. Auto-start did not succeed; run botmux restart for the new bot to take effect.',
'botOnboarding.liveOk': 'The bot is live — no restart needed. Just @mention it in Feishu to use it.',
'botOnboarding.needsOwnerTitle': 'One more step: confirm an administrator',
'botOnboarding.needsOwnerDescription': 'We could not confirm an administrator from the scanned account. Enter a company email to finish; the bot will not start before confirmation.',
'botOnboarding.ownerLabel': 'Administrator email',
'botOnboarding.ownerPlaceholder': 'name@company.com',
'botOnboarding.needsOwnerDescription': 'We could not confirm an administrator from the scanned account. Enter a company email or mobile number to finish; the bot will not start before confirmation.',
'botOnboarding.ownerLabel': 'Administrator email or mobile',
'botOnboarding.ownerPlaceholder': 'name@company.com or +14155550123',
'botOnboarding.ownerUseId': 'Use an ID',
'botOnboarding.ownerIdLabel': 'union_id / open_id',
'botOnboarding.ownerIdPlaceholder': 'on_xxx or ou_xxx',
'botOnboarding.ownerSubmit': 'Confirm and finish',
'botOnboarding.ownerEmpty': 'Enter an administrator email or ID.',
'botOnboarding.ownerEmpty': 'Enter an administrator email, mobile, or ID.',
'botOnboarding.ownerInvalid': 'Administrator validation failed. Check the value and retry.',
'botOnboarding.qrAlt': 'Feishu bot onboarding QR code',
'overview.title': 'Workbench',
Expand Down
Loading