diff --git a/src/bot-registry.ts b/src/bot-registry.ts index f2a1e0e6f..fe9e1a6f3 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -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[]; @@ -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, diff --git a/src/cli.ts b/src/cli.ts index 29a3e961e..39b729ee9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1039,8 +1039,8 @@ async function resolveScannerAllowedUser( async function promptRequiredOwner(rl: ReturnType): Promise { 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(); @@ -1051,11 +1051,11 @@ async function promptRequiredOwner(rl: ReturnType): 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; @@ -1173,6 +1173,11 @@ async function promptBotConfig(rl: ReturnType): 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); @@ -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)}]: `); diff --git a/src/daemon.ts b/src/daemon.ts index 80932bbb6..922f1c6c4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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'; @@ -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; @@ -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 () => { @@ -17749,7 +17784,7 @@ export async function startDaemon(botIndex?: number): Promise { // 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 { @@ -17779,7 +17814,7 @@ export async function startDaemon(botIndex?: number): Promise { // entry transient so the pure fn can recover each from cache. const throwStatus = new Map(); 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, diff --git a/src/dashboard/bot-onboarding.ts b/src/dashboard/bot-onboarding.ts index a1faff1b2..c406ce383 100644 --- a/src/dashboard/bot-onboarding.ts +++ b/src/dashboard/bot-onboarding.ts @@ -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, @@ -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(表单第一步已展示并确认), @@ -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); } @@ -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 : ''; @@ -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_)。`, }; } @@ -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' }, diff --git a/src/dashboard/web/bot-onboarding.tsx b/src/dashboard/web/bot-onboarding.tsx index 432657d3b..29511af82 100644 --- a/src/dashboard/web/bot-onboarding.tsx +++ b/src/dashboard/web/bot-onboarding.tsx @@ -338,7 +338,8 @@ function OnboardingJobView(props: { {t('botOnboarding.ownerLabel')} = { 'card.config.text_sent': '✏️ Text-fields card sent to your DM ↓', 'card.config.text_send_fail': '⚠️ Send failed (you may not have a 1:1 with this bot yet)', 'config.label.autoStartPrompt': 'On-join first prompt', - 'cmd.config.allow_usage': 'Usage: /botconfig set allowedUsers confirm\ne.g.: /botconfig set allowedUsers alice@corp.com, ou_xxx confirm', - 'cmd.config.allow_invalid': '⚠️ Invalid entries (need a full email or on_/ou_ prefix): {items}', + 'cmd.config.allow_usage': 'Usage: /botconfig set allowedUsers confirm\ne.g.: /botconfig set allowedUsers alice@corp.com, 13011112222, ou_xxx confirm', + 'cmd.config.allow_invalid': '⚠️ Invalid entries (need a full email, mobile (11-digit CN / +country-code overseas), or on_/ou_ prefix): {items}', 'cmd.config.allow_confirm': '⚠️ About to set the admin list to:\n{list}\n\nThis changes who can manage this bot. To confirm, resend with a trailing `confirm`:\n/botconfig set allowedUsers {list} confirm', 'cmd.config.allow_ok': '✅ Admin list updated ({total} entries, {count} open_ids resolved). Effective immediately.', 'cmd.config.allow_lockout': '⛔ The new list does not include you — that would lock you out (self-lockout guard). Keep yourself in the list and retry.', @@ -732,7 +732,7 @@ export const messages: Record = { 'setup.supported_clis': 'Supported CLIs: 1) claude-code 2) aiden 3) coco 4) codex 5) cursor 6) gemini 7) opencode 8) antigravity 9) mtr 10) hermes 11) codex-app 12) mira 13) seed 14) traex 15) pi 16) copilot 17) oh-my-pi 18) relay', 'setup.prompt_cli_choice': 'CLI adapter [1]: ', 'setup.prompt_working_dir': 'Default working directory [~]: ', - 'setup.prompt_allowed_users': 'Allowed users (emails or open_ids, comma-separated; empty = no restriction): ', + 'setup.prompt_allowed_users': 'Allowed users (emails / mobiles / open_ids, comma-separated; empty = no restriction): ', 'setup.prompt_lang': 'UI language [zh/en, default en]: ', 'setup.section_lark_app_config': '── Lark app config ──', 'setup.section_reconfigure': '── Reconfigure ──', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 3093abb0f..cc7be589f 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -419,8 +419,8 @@ export const messages: Record = { 'card.config.text_sent': '✏️ 文本设置卡已发到你私聊 ↓', 'card.config.text_send_fail': '⚠️ 发送失败(你可能没和本 bot 开过私聊)', 'config.label.autoStartPrompt': '入群首轮 prompt', - 'cmd.config.allow_usage': '用法:/botconfig set allowedUsers <邮箱/on_/ou_,逗号或空格分隔> 确认\n例:/botconfig set allowedUsers alice@corp.com, ou_xxx 确认', - 'cmd.config.allow_invalid': '⚠️ 非法条目(需完整邮箱或 on_/ou_ 开头):{items}', + 'cmd.config.allow_usage': '用法:/botconfig set allowedUsers <邮箱/手机号/on_/ou_,逗号或空格分隔> 确认\n例:/botconfig set allowedUsers alice@corp.com, 13011112222, ou_xxx 确认', + 'cmd.config.allow_invalid': '⚠️ 非法条目(需完整邮箱、手机号(大陆 11 位 / 海外带 + 国家码)或 on_/ou_ 开头):{items}', 'cmd.config.allow_confirm': '⚠️ 即将把管理员名单改为:\n{list}\n\n这会改变谁能管理本机器人。确认请重发并在结尾加 `确认`:\n/botconfig set allowedUsers {list} 确认', 'cmd.config.allow_ok': '✅ 管理员名单已更新({total} 条,解析出 {count} 个 open_id),立即生效。', 'cmd.config.allow_lockout': '⛔ 新名单不含你自己,会把你踢出管理员(防自锁)。请在名单中保留自己再试。', @@ -735,7 +735,7 @@ export const messages: Record = { 'setup.supported_clis': '支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) cursor 6) gemini 7) opencode 8) antigravity 9) mtr 10) hermes 11) codex-app 12) mira 13) seed 14) traex 15) pi 16) copilot 17) oh-my-pi 18) relay', 'setup.prompt_cli_choice': 'CLI 适配器 [1]: ', 'setup.prompt_working_dir': '默认工作目录 [~]: ', - 'setup.prompt_allowed_users': '允许的用户 (邮箱或 open_id,逗号分隔,留空=不限制): ', + 'setup.prompt_allowed_users': '允许的用户 (邮箱/手机号/open_id,逗号分隔,留空=不限制): ', 'setup.prompt_lang': 'UI 语言 / UI language [zh/en, 默认 zh]: ', 'setup.section_lark_app_config': '── 飞书应用配置 ──', 'setup.section_reconfigure': '── 重新配置 ──', diff --git a/src/im/lark/client.ts b/src/im/lark/client.ts index 6e4e80dd6..35a75db78 100644 --- a/src/im/lark/client.ts +++ b/src/im/lark/client.ts @@ -13,6 +13,7 @@ import { listObservedBots } from '../../services/observed-bots-store.js'; import { getBotCapability } from '../../services/bot-profile-store.js'; import { resolveTeamRoleFile } from '../../core/role-resolver.js'; import { type Brand, larkHosts, normalizeBrand, sdkDomain } from './lark-hosts.js'; +import { canonicalMobileKey, isMobileEntry, normalizeMobileEntry } from '../../setup/bot-config-editor.js'; type LarkRequestParams = Record; @@ -1034,6 +1035,13 @@ export async function resolveAllowedUsersWithMap( const openIds: string[] = []; const emails: string[] = []; const unionIds: string[] = []; + // Mobile entries: keep the raw config string as the map key (exact-match with + // allowedUsers), but remember the normalized (spaces/dashes stripped) form to + // send to the API. batch_get_id accepts `mobiles` under the same + // contact:user.id:readonly scope as emails. Lets phone-registered users with + // no corporate email be an owner. + const mobiles: string[] = []; + const mobileRawByNorm = new Map(); for (const v of raw) { if (v.startsWith('ou_')) { map.set(v, v); @@ -1045,12 +1053,16 @@ export async function resolveAllowedUsersWithMap( // union_id (跨应用稳定):运行时权限/私信/卡片全是 open_id 原生的, // 启动时用本 app 凭证把 on_ 翻成本 app 的 ou_,下游一律照旧用 open_id。 unionIds.push(v); + } else if (isMobileEntry(v)) { + const norm = normalizeMobileEntry(v); + mobiles.push(norm); + mobileRawByNorm.set(norm, v); } else { emails.push(v); } } - if (emails.length > 0 || unionIds.length > 0 || openIds.length > 0) { + if (emails.length > 0 || unionIds.length > 0 || openIds.length > 0 || mobiles.length > 0) { const c = getBotClient(larkAppId); // Literal open_id is app-scoped. Keep it as-is for compatibility, but @@ -1150,6 +1162,71 @@ export async function resolveAllowedUsersWithMap( logger.warn(`resolveAllowedUsers failed: ${err.message}`); } } + + if (mobiles.length > 0) { + // Mirror the email branch exactly (same transient/definitive contract), + // but over the `mobiles` field. Map keys are the RAW config entries (via + // mobileRawByNorm) so exact-match with allowedUsers holds even though the + // API is queried with the normalized number. + try { + const res = await (c as any).contact.v3.user.batchGetId({ + params: { user_id_type: 'open_id' }, + data: { mobiles, include_resigned: false }, + }); + if (res.code !== 0) { + // Whole-request failure — not a per-mobile verdict. Mark every + // requested mobile TRANSIENT so a real owner isn't fail-closed out. + errored = true; + for (const norm of mobiles) { + const rawEntry = mobileRawByNorm.get(norm) ?? norm; + entryStatus.set(rawEntry, 'transient'); + } + logger.warn(`Failed to resolve mobiles to open_ids: ${res.msg} (code: ${res.code})`); + } else { + const userList: any[] = res.data?.user_list ?? []; + // Index the API echo by a SINGLE canonical E.164 key. The API may echo + // a mobile with or without the leading `+`, and Feishu does NOT promise + // a byte-identical echo — canonicalMobileKey folds each number to one + // stable key (trusting `+` as the country code; only a genuinely-bare + // CN 11-digit number gets an 86 prefix). A single key per number, NOT a + // key SET: a set that stripped `+` and then treated every leading-1 + // number as CN would collide a US `+1 3XX…` with a CN bare `13X…` and + // bind the owner to the wrong person / evict a co-owner on overwrite. + const byKey = new Map(); + for (const item of userList) { + if (item.user_id && item.mobile) { + byKey.set(canonicalMobileKey(normalizeMobileEntry(String(item.mobile))), item.user_id); + } else if (!item.user_id) { + logger.warn(`Could not resolve mobile: ${item.mobile}`); + } + } + for (const norm of mobiles) { + const rawEntry = mobileRawByNorm.get(norm) ?? norm; + // Match the requested number by its canonical key. Covers CN bare-11 + // ↔ +86 in both directions. If Feishu echoed an overseas number with + // the `+` dropped it becomes a safe MISS (definitive → owner falls + // back to email/union_id), never a cross-number mis-bind. + const uid = byKey.get(canonicalMobileKey(norm)); + if (uid) { + map.set(rawEntry, uid); + entryStatus.set(rawEntry, 'resolved'); + logger.info(`Resolved ${rawEntry} → ${uid}`); + } else { + // code-0 but this mobile absent from user_list → definitive miss + // (no such user / not visible), same as the email case. + entryStatus.set(rawEntry, 'definitive'); + } + } + } + } catch (err: any) { + errored = true; + for (const norm of mobiles) { + const rawEntry = mobileRawByNorm.get(norm) ?? norm; + entryStatus.set(rawEntry, 'transient'); + } + logger.warn(`resolveAllowedUsers (mobiles) failed: ${err.message}`); + } + } } // 解析不改变顺序:按 allowedUsers 的「原始配置顺序」回填 open_id,使 diff --git a/src/setup/bot-config-editor.ts b/src/setup/bot-config-editor.ts index 7bb96be5f..e579c886e 100644 --- a/src/setup/bot-config-editor.ts +++ b/src/setup/bot-config-editor.ts @@ -95,16 +95,77 @@ export function resolveCliId(input: string | undefined): CliId | undefined { /** 完整邮箱(含 @ 和域名)。用于区分"完整邮箱"与"邮箱前缀"——后者解析时会被静默丢弃。 */ const FULL_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +/** + * 手机号 allowedUsers 条目。飞书 batch_get_id 的 `mobiles` 字段:中国大陆号 + * 可直接填 11 位(无需 +86);非大陆号必须带 `+` 国家/地区码。为避免把邮箱 + * 前缀/随手输入误判成手机号,规则收紧为二选一: + * - `+` 开头的 E.164:`+` 后跟 6–15 位数字(E.164 规范上限 15 位,如 + * +14155550123、+8613011112222、+123456789012345) + * - 纯 11 位大陆号,以 1 开头(如 13011112222) + * 允许中间出现空格/连字符,判定前先归一化掉。 + */ +const MOBILE_RE = /^(?:\+\d{6,15}|1\d{10})$/; + +/** 去掉手机号里的空格与连字符(用户可能填 "+86 130-1111-2222"),便于校验/解析。 */ +export function normalizeMobileEntry(entry: string): string { + return entry.trim().replace(/[\s-]/g, ''); +} + +/** 是否是手机号形式的 allowedUsers 条目(已归一化空格/连字符后判定)。 */ +export function isMobileEntry(entry: string): boolean { + return MOBILE_RE.test(normalizeMobileEntry(entry)); +} + +/** + * 手机号规范化匹配键:把一个已归一化的手机号折叠成**唯一**的 E.164 数字键,用于 + * 把飞书 `batch_get_id` 响应里回带的号码对回本地请求的号码。请求侧和响应侧都过 + * 本函数,键相等即同一号码。 + * + * 规则(**不能像早期实现那样无脑剥 `+` 再一律当中国裸号**——那会让美国 `+1 3XX…` + * 与中国裸号 `13X…` 折叠成同键,把 owner 绑到错的人 / 顶掉另一个 owner): + * - `+` 开头:`+` 是权威国家码,直接剥掉 `+` 得到「国家码+号码」的 E.164 数字串 + * (`+8613011112222`→`8613011112222`,`+14155550123`→`14155550123`)。 + * - 无 `+` 的纯 11 位、以 1 开头:按配置契约(MOBILE_RE:裸号只接受中国 11 位) + * 判定为中国大陆号,补 `86`(`13011112222`→`8613011112222`),从而与带 `+86` + * 的请求/响应对齐。 + * - 其它(已带 `86` 前缀等):原样。 + * + * 安全取舍:若飞书对海外号回带时**丢掉了 `+`**,海外裸号会被误判成中国号而与请求 + * 不等 → 判 definitive miss(owner 用邮箱/union_id 兜底),这是 fail-closed 的**漏配**; + * 绝不会把两个不同的人折叠成同一 owner(那才是危险的错配)。宁可漏配不可错配。 + */ +export function canonicalMobileKey(normalized: string): string { + if (normalized.startsWith('+')) return normalized.slice(1); + if (/^1\d{10}$/.test(normalized)) return '86' + normalized; // 中国大陆裸号 → 补国家码 + return normalized; +} + +/** + * 单个 allowedUsers 条目是否需要「启动/刷新时经飞书 contact API 解析成本 app + * 的 open_id」。运行时权限(canTalk/canOperate)只认原生 ou_,故 email / union_id + * (on_) / 手机号 / 甚至字面 ou_(要做跨 app 诊断)都要走一次解析;只有无法寻址 + * 的裸串(邮箱前缀等)不需要。 + * + * 这是解析闸的**唯一真源**——daemon 启动闸、throw 兜底、allowed-users-apply 的 + * needsContactResolve 全都调它,避免「新增一种身份格式后各处平行谓词漏改」导致 + * 该格式在入口校验通过、却在某个解析闸被静默跳过(手机号 owner 冷启动自锁即 + * 此类 bug)。新增身份格式时**只改这里**。 + */ +export function entryNeedsContactResolve(entry: string): boolean { + return entry.includes('@') || entry.startsWith('on_') || entry.startsWith('ou_') || isMobileEntry(entry); +} + /** * 合法的 allowedUsers 条目: * - on_xxx union_id — 跨应用稳定,推荐 * - 完整邮箱 — 人类可读,推荐 + * - 手机号 — 大陆号直填 11 位;海外号带 + 区号。适合无企业邮箱的个人用户 * - ou_xxx open_id — 仅对签发该 ID 的同一应用有效,不推荐跨 bot 复用 - * 裸邮箱前缀(如 "alice")不合法:解析器只认 ou_/on_ 或完整邮箱。 + * 裸邮箱前缀(如 "alice")不合法:解析器只认 ou_/on_、完整邮箱或手机号。 */ export function isValidAllowedUserEntry(entry: string): boolean { const s = entry.trim(); - return s.startsWith('ou_') || s.startsWith('on_') || FULL_EMAIL_RE.test(s); + return s.startsWith('ou_') || s.startsWith('on_') || FULL_EMAIL_RE.test(s) || isMobileEntry(s); } /** 返回非法的 allowedUsers 条目(既不是 ou_ 也不是完整邮箱,典型是裸邮箱前缀)。 */ @@ -419,7 +480,7 @@ export function applyBotConfigEdits>( const invalid = findInvalidAllowedUserEntries(entries); if (invalid.length > 0) { throw new Error( - `allowedUsers 条目必须是完整邮箱(如 alice@example.com)或 open_id(ou_xxx),不能是邮箱前缀: ${invalid.join(', ')}`, + `allowedUsers 条目必须是完整邮箱(如 alice@example.com)、手机号(大陆号直填 11 位,海外号带 + 区号)、union_id(on_xxx)或 open_id(ou_xxx),不能是邮箱前缀: ${invalid.join(', ')}`, ); } out.allowedUsers = entries; diff --git a/src/setup/setup-args.ts b/src/setup/setup-args.ts index 60bf1beed..dbd09b7e4 100644 --- a/src/setup/setup-args.ts +++ b/src/setup/setup-args.ts @@ -318,7 +318,7 @@ export function buildBotFromAddFlags(flags: SetupBotFlags): Record }; const bot = applyBotConfigEdits(base, input); if (!hasOwnerEntry(bot.allowedUsers)) { - throw new Error('--allowed-users 至少需要一个完整邮箱、union_id(on_xxx)或 open_id(ou_xxx)作为 owner。'); + throw new Error('--allowed-users 至少需要一个完整邮箱、手机号(大陆号直填,海外带 + 区号)、union_id(on_xxx)或 open_id(ou_xxx)作为 owner。'); } assertOwnerWhenChatGroups(bot); return bot; diff --git a/src/utils/allowed-users-apply.ts b/src/utils/allowed-users-apply.ts index 2a8349d46..ed6f8a3d9 100644 --- a/src/utils/allowed-users-apply.ts +++ b/src/utils/allowed-users-apply.ts @@ -19,6 +19,7 @@ */ import type { EntryResolveStatus } from '../im/lark/client.js'; +import { entryNeedsContactResolve } from '../setup/bot-config-editor.js'; export interface AllowedUsersResolveResultLike { resolved: string[]; @@ -65,9 +66,9 @@ export interface ApplyAllowedUsersResolveOutput { notice: string | null; } -/** Config entries that require a contact resolve (email / union / literal ou_). */ +/** Config entries that require a contact resolve (email / union / literal ou_ / mobile). */ function needsContactResolve(rawEntries: string[]): boolean { - return rawEntries.some(u => u.includes('@') || u.startsWith('on_') || u.startsWith('ou_')); + return rawEntries.some(entryNeedsContactResolve); } function entryStatusOf( diff --git a/test/bot-config-editor.test.ts b/test/bot-config-editor.test.ts index c9aaaa007..6984141be 100644 --- a/test/bot-config-editor.test.ts +++ b/test/bot-config-editor.test.ts @@ -5,10 +5,14 @@ import { assertOwnerWhenChatGroups, botProcessEnv, botProcessName, + canonicalMobileKey, + entryNeedsContactResolve, findInvalidAllowedUserEntries, hasOwnerEntry, + isMobileEntry, isValidAllowedUserEntry, normalizeBotConfig, + normalizeMobileEntry, parseBotConfigsJson, parseBotSelection, removeBotConfig, @@ -97,6 +101,75 @@ describe('parseBotSelection', () => { }); }); +describe('mobile allowedUsers entries', () => { + it('normalizeMobileEntry strips spaces and dashes', () => { + expect(normalizeMobileEntry('+86 130-1111-2222')).toBe('+8613011112222'); + expect(normalizeMobileEntry(' 130 1111 2222 ')).toBe('13011112222'); + }); + + it('isMobileEntry accepts CN bare 11-digit and + country-code E.164', () => { + expect(isMobileEntry('13011112222')).toBe(true); // CN, no +86 + expect(isMobileEntry('+8613011112222')).toBe(true); // CN with code + expect(isMobileEntry('+14155550123')).toBe(true); // US + expect(isMobileEntry('+86 130-1111-2222')).toBe(true); // spaced/dashed + }); + + it('isMobileEntry rejects things that are not phone numbers', () => { + expect(isMobileEntry('alice')).toBe(false); // bare prefix + expect(isMobileEntry('alice@example.com')).toBe(false); // email + expect(isMobileEntry('12345')).toBe(false); // too short / not CN + expect(isMobileEntry('2011112222')).toBe(false); // 10-digit non-CN, no + + expect(isMobileEntry('ou_abc')).toBe(false); // open_id + expect(isMobileEntry('+123')).toBe(false); // too short for E.164 + }); + + it('isValidAllowedUserEntry treats a valid mobile as valid', () => { + expect(isValidAllowedUserEntry('13011112222')).toBe(true); + expect(isValidAllowedUserEntry('+14155550123')).toBe(true); + expect(findInvalidAllowedUserEntries(['13011112222', 'alice'])).toEqual(['alice']); + }); + + it('isMobileEntry accepts the full 15-digit E.164 upper bound', () => { + // E.164 caps the national+country number at 15 digits. The old /\+\d{6,14}/ + // bound rejected the max-length case; guard against that regression. + expect(isMobileEntry('+123456789012345')).toBe(true); // 15 digits — max E.164 + expect(isMobileEntry('+12345678901234')).toBe(true); // 14 digits + expect(isMobileEntry('+1234567890123456')).toBe(false); // 16 digits — over spec + }); + + it('entryNeedsContactResolve covers every addressable form incl. bare mobile', () => { + // This shared predicate is the SINGLE gate the daemon startup / throw-fallback + // / allowed-users-apply all consult. A bare mobile MUST return true, else a + // mobile-only owner is never resolved to an ou_ and gets fail-closed locked + // out on every cold start (the P1 this fix closes). + expect(entryNeedsContactResolve('13011112222')).toBe(true); // CN bare mobile + expect(entryNeedsContactResolve('+14155550123')).toBe(true); // E.164 mobile + expect(entryNeedsContactResolve('+8613011112222')).toBe(true); // CN +86 + expect(entryNeedsContactResolve('alice@example.com')).toBe(true);// email + expect(entryNeedsContactResolve('on_abc')).toBe(true); // union_id + expect(entryNeedsContactResolve('ou_abc')).toBe(true); // literal ou_ (diag) + expect(entryNeedsContactResolve('alice')).toBe(false); // bare prefix — unaddressable + expect(entryNeedsContactResolve('')).toBe(false); + }); + + it('canonicalMobileKey reconciles CN bare↔+86 WITHOUT colliding US +1 numbers', () => { + const key = (n: string) => canonicalMobileKey(normalizeMobileEntry(n)); + // CN bare 11-digit and its +86 form fold to the same key (both directions). + expect(key('13011112222')).toBe(key('+8613011112222')); + expect(key('13011112222')).toBe(key('8613011112222')); + // Overseas E.164 with + is trusted as-is (country code preserved). + expect(key('+14155550123')).toBe('14155550123'); + // CRITICAL anti-collision: a US +1 3XX number must NOT fold to the same key + // as a CN bare 13X number (both are 11 digits starting with 1). The old + // strip-+-then-assume-CN key SET collided these and bound the owner to the + // wrong person / evicted a co-owner on map overwrite. + expect(key('+13011112222')).not.toBe(key('13011112222')); + // Different real numbers never share a key. + expect(key('+14155550123')).not.toBe(key('+14155550999')); + expect(key('13011112222')).not.toBe(key('13111112222')); + }); +}); + describe('applyBotConfigEdits', () => { it('normalizes the custom bot status name', () => { expect(botProcessName({ name: 'botmux-Codex Main' }, 0)).toBe('botmux-Codex-Main'); @@ -197,6 +270,13 @@ describe('applyBotConfigEdits', () => { expect(edited.allowedUsers).toEqual(['alice@example.com', 'ou_abc']); }); + it('accepts mobile numbers in allowedUsers (CN bare 11-digit + E.164)', () => { + const edited = applyBotConfigEdits({ + larkAppId: 'app', larkAppSecret: 'secret', cliId: 'claude-code', + }, { allowedUsers: '13011112222, +14155550123, on_x' }); + expect(edited.allowedUsers).toEqual(['13011112222', '+14155550123', 'on_x']); + }); + it('keeps fields unchanged on empty input and clears optional fields with dash', () => { const updated = applyBotConfigEdits({ larkAppId: 'app', diff --git a/test/dashboard-bot-onboarding.test.ts b/test/dashboard-bot-onboarding.test.ts index 2319e8607..142a0490f 100644 --- a/test/dashboard-bot-onboarding.test.ts +++ b/test/dashboard-bot-onboarding.test.ts @@ -425,6 +425,48 @@ describe('BotOnboardingManager', () => { rmSync(dir, { recursive: true, force: true }); }); + it('submitOwner accepts a resolvable mobile owner and queries the mobiles field (not emails)', async () => { + // Regression for the P2 where detectUnusableOwnerEntries sent a mobile into + // the `emails` query → code 0 + empty user_list → a valid mobile owner was + // wrongly rejected as "unusable". The mobile must go through the `mobiles` + // field, and a resolvable one must be accepted. + const dir = mkdtempSync(join(tmpdir(), 'botmux-onboard-mobile-')); + const manager = new BotOnboardingManager({ + botsJsonPath: join(dir, 'bots.json'), + registerApp: async () => ({ + ok: true, + appId: 'cli_new', + appSecret: 'super-secret-value', + brand: 'feishu', + userOpenId: 'ou_owner', + }), + validateCredentials: async () => ({ ok: true }), + automateOpenPlatform: async () => autoOk(), + renderQrDataUrl: () => 'data:image/svg+xml;base64,qr', + }); + const job = manager.start(); + await job.done; + expect(manager.get(job.id)?.status).toBe('needs_owner'); + + // 该手机号在本企业可解析 → usable → 通过。 + batchGetIdMock.mockResolvedValueOnce({ + code: 0, + data: { user_list: [{ mobile: '13011112222', user_id: 'ou_resolved_mobile' }] }, + }); + const r = await manager.submitOwner(job.id, ['13011112222']); + expect(r.ok).toBe(true); + expect(manager.get(job.id)?.status).toBe('completed'); + + // 关键断言:查询走的是 mobiles 字段(不是 emails),否则合法手机号会被误拒。 + expect(batchGetIdMock).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ mobiles: ['13011112222'] }) }), + ); + const bots = JSON.parse(readFileSync(join(dir, 'bots.json'), 'utf-8')); + expect(bots[0]).toMatchObject({ larkAppId: 'cli_new', allowedUsers: ['13011112222'] }); + + rmSync(dir, { recursive: true, force: true }); + }); + it('restores a needs_owner job after a dashboard restart and then completes it', async () => { const dir = mkdtempSync(join(tmpdir(), 'botmux-onboard-restart-')); const botsJsonPath = join(dir, 'bots.json');