From a942a048e7722cd6cb8ebdb89c91e3bab0ae5105 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 18:44:07 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E9=A3=9E=E4=B9=A6=E5=87=AD?= =?UTF-8?q?=E8=AF=81=E8=BF=81=E5=85=A5=20agents.json=EF=BC=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=85=8D=E7=BD=AE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个 agent 内嵌 feishu 字段(appId + appSecret),一个文件看到全貌。 系统启动时从 agents.json 推导 bot 账号和路由规则。 主要改动: - config-schema: 新增 FeishuAccountSchema + AgentBindingSchema - config-loader: deriveBotAccounts/deriveBindings 从 agent config 推导 - config.ts: 移除 FEISHU_APP_ID/SECRET、BOT_ACCOUNTS、AGENT_BINDINGS - index.ts: 启动顺序改为 agentConfig → deriveBotAccounts → accountManager - client.ts: FeishuClient 构造函数 appId/appSecret 改为必传,增加 initDefaultClient - server.ts: startServer 接收 primaryBot 参数 - oauth.ts: 从 deriveBotAccounts 获取凭证 - onboarding prompt: 飞书 keys 写入 agents.json 而非 .env Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 11 ++--- config/agents.example.json | 8 +++ src/__tests__/config.test.ts | 43 +--------------- src/agent/config-loader.ts | 78 +++++++++++++++++++++++++++++- src/agent/config-schema.ts | 30 ++++++++++++ src/agent/registry.ts | 5 ++ src/agent/types.ts | 2 + src/config.ts | 47 +++--------------- src/feishu/__tests__/oauth.test.ts | 9 +++- src/feishu/client.ts | 26 +++++++--- src/feishu/event-handler.ts | 5 +- src/feishu/multi-account.ts | 4 +- src/feishu/oauth.ts | 12 +++-- src/index.ts | 61 ++++++++++++++--------- src/onboarding/bootstrap.ts | 5 +- src/server.ts | 16 +++--- 16 files changed, 223 insertions(+), 139 deletions(-) diff --git a/.env.example b/.env.example index cff98ca6..aa94f397 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ -# === 飞书应用配置 === -FEISHU_APP_ID=cli_xxxxxxxxxx -FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx +# === 飞书配置 === +# 飞书应用凭证 (appId/appSecret) 已迁移到 config/agents.json 的 agent.feishu 字段 +# 运行 npm run onboard 自动配置,或手动编辑 config/agents.json # 事件接收模式 (默认 websocket,推荐): # websocket - 长连接,无需公网/域名/HTTPS,SDK 主动连接飞书 @@ -112,11 +112,6 @@ FEISHU_TOOLS_CALENDAR=true # 无配置文件时自动使用内置 dev agent 兜底 # AGENT_CONFIG_PATH=./config/agents.json -# === 多 Bot / 多 Agent 配置 === -# 多 bot 账号配置 (JSON 格式,每个 bot 需要独立的 app_id/app_secret) -# BOT_ACCOUNTS=[{"id":"dev-bot","appId":"cli_xxx","appSecret":"xxx"},{"id":"pm-bot","appId":"cli_yyy","appSecret":"yyy"}] -# Agent 绑定规则 (JSON 格式,将 agent 角色绑定到特定 bot/chat) -# AGENT_BINDINGS=[{"agentId":"dev","botId":"dev-bot"},{"agentId":"pm","botId":"pm-bot"}] # === 服务配置 === PORT=3000 diff --git a/config/agents.example.json b/config/agents.example.json index fefdb55e..2753612f 100644 --- a/config/agents.example.json +++ b/config/agents.example.json @@ -14,6 +14,10 @@ { "id": "pm", "displayName": "", + "feishu": { + "appId": "<飞书应用 App ID>", + "appSecret": "<飞书应用 App Secret>" + }, "model": "claude-opus-4-6", "replyMode": "direct", "toolPolicy": { @@ -27,6 +31,10 @@ { "id": "dev", "displayName": "", + "feishu": { + "appId": "<同上,共享同一个飞书应用>", + "appSecret": "<同上>" + }, "model": "claude-opus-4-6", "toolPolicy": "all", "replyMode": "thread", diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index c5f042a0..a8bd15b6 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -12,36 +12,7 @@ describe('config', () => { } describe('validateConfig', () => { - it('should return errors when FEISHU_APP_ID is missing', async () => { - vi.stubEnv('FEISHU_APP_ID', ''); - vi.stubEnv('FEISHU_APP_SECRET', 'secret'); - vi.stubEnv('BOT_ACCOUNTS', ''); - const { validateConfig } = await loadConfig(); - const errors = validateConfig(); - expect(errors.some(e => e.includes('FEISHU_APP_ID is required'))).toBe(true); - }); - - it('should return errors when FEISHU_APP_SECRET is missing', async () => { - vi.stubEnv('FEISHU_APP_ID', 'id'); - vi.stubEnv('FEISHU_APP_SECRET', ''); - vi.stubEnv('BOT_ACCOUNTS', ''); - const { validateConfig } = await loadConfig(); - const errors = validateConfig(); - expect(errors.some(e => e.includes('FEISHU_APP_SECRET is required'))).toBe(true); - }); - - it('should return errors for both missing fields', async () => { - vi.stubEnv('FEISHU_APP_ID', ''); - vi.stubEnv('FEISHU_APP_SECRET', ''); - vi.stubEnv('BOT_ACCOUNTS', ''); - const { validateConfig } = await loadConfig(); - const errors = validateConfig(); - expect(errors).toHaveLength(2); - }); - - it('should return empty array when all required fields are set', async () => { - vi.stubEnv('FEISHU_APP_ID', 'myid'); - vi.stubEnv('FEISHU_APP_SECRET', 'mysecret'); + it('should return empty array (feishu validation moved to agents.json)', async () => { const { validateConfig } = await loadConfig(); const errors = validateConfig(); expect(errors).toHaveLength(0); @@ -50,32 +21,24 @@ describe('config', () => { describe('environment variable parsing', () => { it('should parse comma-separated ALLOWED_USER_IDS', async () => { - vi.stubEnv('FEISHU_APP_ID', 'id'); - vi.stubEnv('FEISHU_APP_SECRET', 'secret'); vi.stubEnv('ALLOWED_USER_IDS', 'user1, user2, user3'); const { config } = await loadConfig(); expect(config.security.allowedUserIds).toEqual(['user1', 'user2', 'user3']); }); it('should handle empty ALLOWED_USER_IDS', async () => { - vi.stubEnv('FEISHU_APP_ID', 'id'); - vi.stubEnv('FEISHU_APP_SECRET', 'secret'); vi.stubEnv('ALLOWED_USER_IDS', ''); const { config } = await loadConfig(); expect(config.security.allowedUserIds).toEqual([]); }); it('should parse PORT as integer', async () => { - vi.stubEnv('FEISHU_APP_ID', 'id'); - vi.stubEnv('FEISHU_APP_SECRET', 'secret'); vi.stubEnv('PORT', '8080'); const { config } = await loadConfig(); expect(config.server.port).toBe(8080); }); it('should parse CLAUDE_TIMEOUT as integer', async () => { - vi.stubEnv('FEISHU_APP_ID', 'id'); - vi.stubEnv('FEISHU_APP_SECRET', 'secret'); vi.stubEnv('CLAUDE_TIMEOUT', '600'); const { config } = await loadConfig(); expect(config.claude.timeoutSeconds).toBe(600); @@ -84,16 +47,12 @@ describe('config', () => { describe('defaults', () => { it('should default eventMode to websocket', async () => { - vi.stubEnv('FEISHU_APP_ID', 'id'); - vi.stubEnv('FEISHU_APP_SECRET', 'secret'); delete process.env.FEISHU_EVENT_MODE; const { config } = await loadConfig(); expect(config.feishu.eventMode).toBe('websocket'); }); it('should default work dir to parent of process.cwd()', async () => { - vi.stubEnv('FEISHU_APP_ID', 'id'); - vi.stubEnv('FEISHU_APP_SECRET', 'secret'); vi.stubEnv('DEFAULT_WORK_DIR', ''); const { config } = await loadConfig(); const { dirname } = await import('node:path'); diff --git a/src/agent/config-loader.ts b/src/agent/config-loader.ts index 6782f08e..59abb0fc 100644 --- a/src/agent/config-loader.ts +++ b/src/agent/config-loader.ts @@ -15,7 +15,7 @@ import { logger } from '../utils/logger.js'; import { agentRegistry } from './registry.js'; import { AgentConfigFileSchema } from './config-schema.js'; import type { AgentDefaults, AgentConfigInput, ToolPolicyValue } from './config-schema.js'; -import type { AgentConfig, ToolPolicy } from './types.js'; +import type { AgentConfig, ToolPolicy, BotAccountConfig, AgentBinding } from './types.js'; // ─── 内置默认值(无配置文件时的 fallback) ─────────────────── @@ -37,6 +37,8 @@ let configFilePath: string | undefined; let configFileDir: string | undefined; /** 知识文件根目录(resolved absolute path) */ let knowledgeDirPath: string | undefined; +/** 配置文件中的显式 bindings */ +let explicitBindings: AgentBinding[] = []; /** 文件监听是否活跃 */ let watcherActive = false; /** 重载防抖定时器 */ @@ -95,6 +97,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age toolDeny, bashAllowPatterns: input.bashAllowPatterns, editablePathPatterns: input.editablePathPatterns ?? defaults.editablePathPatterns, + feishu: input.feishu, }; } @@ -180,6 +183,12 @@ export function reloadAgentConfig(): LoadResult { knowledgeDirPath = undefined; } + // 保存显式 bindings + explicitBindings = (configFile.bindings ?? []).map(b => ({ + agentId: b.agentId, + match: b.match, + })); + // 合并每个 agent const agents = configFile.agents.map(input => mergeAgentConfig(input, defaults)); @@ -282,6 +291,73 @@ export function loadKnowledgeContent(agentId: string): string | undefined { return parts.length > 0 ? parts.join('\n\n') : undefined; } +// ─── 飞书凭证推导 ───────────────────────────────────────── + +/** + * 从 agents.json 的 feishu 字段推导 bot 账号列表。 + * 按 appId 去重,多个 agent 共享同一 appId 时只创建一个 bot 账号。 + * 返回空数组表示没有配置飞书凭证。 + */ +export function deriveBotAccounts(): BotAccountConfig[] { + const agents = agentRegistry.list(); + const seen = new Map(); + + for (const agent of agents) { + if (!agent.feishu) continue; + const { appId, appSecret } = agent.feishu; + if (seen.has(appId)) continue; + + seen.set(appId, { + accountId: agent.id, // 用第一个使用此 appId 的 agent id 作为 accountId + appId, + appSecret, + botName: agent.displayName, + }); + } + + return [...seen.values()]; +} + +/** + * 从 agents.json 的 feishu 字段自动推导 bindings。 + * 仅在多 bot 模式(多个不同 appId)时生成。 + * 单 bot 模式返回空数组,使用默认路由逻辑。 + */ +export function deriveBindings(): AgentBinding[] { + const agents = agentRegistry.list(); + const appIdToAccountId = new Map(); + const bindings: AgentBinding[] = []; + + // 先建立 appId → accountId 映射 + for (const agent of agents) { + if (!agent.feishu) continue; + if (!appIdToAccountId.has(agent.feishu.appId)) { + appIdToAccountId.set(agent.feishu.appId, agent.id); + } + } + + // 只有一个唯一 appId → 单 bot,不需要自动 binding + if (appIdToAccountId.size <= 1) return []; + + // 多 bot:每个 agent 绑定到其 appId 对应的 accountId + for (const agent of agents) { + if (!agent.feishu) continue; + const accountId = appIdToAccountId.get(agent.feishu.appId); + if (!accountId) continue; + bindings.push({ + agentId: agent.id, + match: { accountId }, + }); + } + + return bindings; +} + +/** 获取配置文件中用户显式配置的 bindings */ +export function getExplicitBindings(): AgentBinding[] { + return explicitBindings; +} + // ─── 热重载 Watcher ──────────────────────────────────────── /** diff --git a/src/agent/config-schema.ts b/src/agent/config-schema.ts index 5f1495e8..989d5f2b 100644 --- a/src/agent/config-schema.ts +++ b/src/agent/config-schema.ts @@ -24,6 +24,32 @@ const ToolPolicyDetailedSchema = z.object({ /** toolPolicy 支持两种格式 */ export const ToolPolicySchema = z.union([ToolPolicySimpleSchema, ToolPolicyDetailedSchema]); +// ─── 飞书账号 ──────────────────────────────────────────────── + +/** 飞书应用凭证(内嵌在 agent 配置中) */ +const FeishuAccountSchema = z.object({ + appId: z.string().min(1), + appSecret: z.string().min(1), +}); + +// ─── Binding 路由 ──────────────────────────────────────────── + +/** Binding 匹配条件 */ +const BindingMatchSchema = z.object({ + accountId: z.string().optional(), + peer: z.object({ + kind: z.enum(['group', 'direct']), + id: z.string(), + }).optional(), + userId: z.string().optional(), +}); + +/** Agent Binding — 消息路由规则 */ +const AgentBindingSchema = z.object({ + agentId: z.string(), + match: BindingMatchSchema, +}); + // ─── Agent 配置输入(用户填写,除 id 外全部 optional) ───── export const AgentConfigInputSchema = z.object({ @@ -53,6 +79,8 @@ export const AgentConfigInputSchema = z.object({ bashAllowPatterns: z.array(z.string()).optional(), /** 即使 readOnly 也允许 Edit/Write 的路径 glob 列表(相对于 cwd,如 "config/personas/*") */ editablePathPatterns: z.array(z.string()).optional(), + /** 飞书应用凭证(appId + appSecret) */ + feishu: FeishuAccountSchema.optional(), }); // ─── Defaults(全部 optional) ────────────────────────────── @@ -77,6 +105,8 @@ export const AgentConfigFileSchema = z.object({ knowledgeDir: z.string().optional(), defaults: AgentDefaultsSchema.optional(), agents: z.array(AgentConfigInputSchema).min(1), + /** 消息路由规则(可选,覆盖 feishu 字段自动推导的路由) */ + bindings: z.array(AgentBindingSchema).optional(), }); // ─── 导出类型 ────────────────────────────────────────────── diff --git a/src/agent/registry.ts b/src/agent/registry.ts index 434dca48..80bf4407 100644 --- a/src/agent/registry.ts +++ b/src/agent/registry.ts @@ -43,6 +43,11 @@ class AgentRegistry { return [...this.agents.keys()]; } + /** 所有已注册的 agent 配置 */ + list(): AgentConfig[] { + return [...this.agents.values()]; + } + /** 默认 agent(兜底) */ get defaultAgentId(): AgentId { return 'dev'; diff --git a/src/agent/types.ts b/src/agent/types.ts index 1f11bf57..6404b298 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -65,6 +65,8 @@ export interface AgentConfig { bashAllowPatterns?: string[]; /** 即使 readOnly 也允许 Edit/Write 的路径 glob 列表(相对于 cwd,如 "config/personas/*") */ editablePathPatterns?: string[]; + /** 飞书应用凭证(从 agents.json 加载) */ + feishu?: { appId: string; appSecret: string }; } // ─── Binding 路由 ──────────────────────────────────────── diff --git a/src/config.ts b/src/config.ts index bb07dd51..c6d0d567 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,25 +2,7 @@ import dotenv from 'dotenv'; dotenv.config(); import { dirname } from 'node:path'; -import type { BotAccountConfig, AgentBinding, GroupConfig } from './agent/types.js'; - -function parseBotAccounts(raw?: string): BotAccountConfig[] { - if (!raw?.trim()) return []; - try { - return JSON.parse(raw) as BotAccountConfig[]; - } catch { - return []; - } -} - -function parseAgentBindings(raw?: string): AgentBinding[] { - if (!raw?.trim()) return []; - try { - return JSON.parse(raw) as AgentBinding[]; - } catch { - return []; - } -} +import type { GroupConfig } from './agent/types.js'; function parseGroupConfigs(raw?: string): Record { if (!raw?.trim()) return {}; @@ -32,10 +14,8 @@ function parseGroupConfigs(raw?: string): Record { } export const config = { - // 飞书配置 + // 飞书配置(凭证在 agents.json 的 agent.feishu 字段中,此处仅保留事件/工具配置) feishu: { - appId: process.env.FEISHU_APP_ID || '', - appSecret: process.env.FEISHU_APP_SECRET || '', encryptKey: process.env.FEISHU_ENCRYPT_KEY || '', verifyToken: process.env.FEISHU_VERIFY_TOKEN || '', /** 事件接收模式: 'webhook' (HTTP 回调,需要公网) | 'websocket' (长连接,无需公网) */ @@ -130,10 +110,6 @@ export const config = { // 多 Agent 配置 agent: { - /** 多 bot 账号配置 (JSON 数组),未配置时退化为单 bot 模式 */ - botAccounts: parseBotAccounts(process.env.BOT_ACCOUNTS), - /** Agent 路由规则 (JSON 数组),未配置时所有消息走 dev agent */ - bindings: parseAgentBindings(process.env.AGENT_BINDINGS), /** 群配置 (JSON 对象: chatId → GroupConfig) */ groupConfigs: parseGroupConfigs(process.env.GROUP_CONFIGS), /** Agent 配置文件路径 (默认 ./config/agents.json,不存在则使用内置默认值) */ @@ -220,19 +196,12 @@ export const config = { }, }; -/** 检查必要配置是否存在 */ +/** 检查必要配置是否存在(飞书凭证在 agents.json 中校验,此处仅检查非飞书配置) */ export function validateConfig(): string[] { - const errors: string[] = []; - const hasMultiBot = config.agent.botAccounts.length > 0; - // 多 bot 模式下不需要 FEISHU_APP_ID/SECRET(从 BOT_ACCOUNTS 读取) - if (!hasMultiBot) { - if (!config.feishu.appId) errors.push('FEISHU_APP_ID is required (or configure BOT_ACCOUNTS)'); - if (!config.feishu.appSecret) errors.push('FEISHU_APP_SECRET is required (or configure BOT_ACCOUNTS)'); - } - return errors; + return []; } -/** 是否多 bot 模式 */ -export function isMultiBotMode(): boolean { - return config.agent.botAccounts.length > 0; -} +/** 多 bot 模式标记(由 index.ts 在 deriveBotAccounts 后设置) */ +let _multiBotMode = false; +export function setMultiBotMode(value: boolean): void { _multiBotMode = value; } +export function isMultiBotMode(): boolean { return _multiBotMode; } diff --git a/src/feishu/__tests__/oauth.test.ts b/src/feishu/__tests__/oauth.test.ts index b385ea68..fb8b6322 100644 --- a/src/feishu/__tests__/oauth.test.ts +++ b/src/feishu/__tests__/oauth.test.ts @@ -9,13 +9,18 @@ vi.mock('../../utils/logger.js', () => ({ vi.mock('../../config.js', () => ({ config: { feishu: { - appId: 'cli_test_app_id', - appSecret: 'test_app_secret_32bytes_long_xxx', oauth: { redirectUri: 'https://example.com/feishu/oauth/callback', scopes: 'task:task:read task:task:write' }, }, }, })); +// Mock config-loader (deriveBotAccounts returns test credentials) +vi.mock('../../agent/config-loader.js', () => ({ + deriveBotAccounts: () => [ + { accountId: 'test', appId: 'cli_test_app_id', appSecret: 'test_app_secret_32bytes_long_xxx', botName: 'TestBot' }, + ], +})); + // Mock feishuClient + feishuClientContext const mockRequest = vi.fn(); const mockSendText = vi.fn(); diff --git a/src/feishu/client.ts b/src/feishu/client.ts index 4831f56c..83c5a2ac 100644 --- a/src/feishu/client.ts +++ b/src/feishu/client.ts @@ -1,5 +1,5 @@ import * as lark from '@larksuiteoapi/node-sdk'; -import { config } from '../config.js'; + import { logger } from '../utils/logger.js'; import { formatMergeForwardSubMessage } from './message-parser.js'; import { chatBotRegistry } from './bot-registry.js'; @@ -35,10 +35,10 @@ export class FeishuClient { return this._botName; } - constructor(appId?: string, appSecret?: string) { + constructor(appId: string, appSecret: string) { this.client = new lark.Client({ - appId: appId ?? config.feishu.appId, - appSecret: appSecret ?? config.feishu.appSecret, + appId, + appSecret, disableTokenCache: false, }); } @@ -895,10 +895,17 @@ export function registerClientResolver(resolver: (accountId: string) => FeishuCl * 在多 bot 模式下,通过 Proxy 自动路由到 AsyncLocalStorage 中绑定的 per-account client。 * 单 bot 模式下或 AsyncLocalStorage 无值时,回退到默认实例。 */ -const _defaultClient = new FeishuClient(); +/** 默认 FeishuClient 实例(由 initDefaultClient 设置) */ +let _defaultClient: FeishuClient | undefined; + +/** 初始化默认 FeishuClient(单 bot 模式下由 index.ts 调用) */ +export function initDefaultClient(appId: string, appSecret: string): FeishuClient { + _defaultClient = new FeishuClient(appId, appSecret); + return _defaultClient; +} -export const feishuClient: FeishuClient = new Proxy(_defaultClient, { - get(target, prop, receiver) { +export const feishuClient: FeishuClient = new Proxy({} as FeishuClient, { + get(_target, prop, receiver) { const accountId = feishuClientContext.getStore(); if (accountId && accountId !== 'default' && _clientResolver) { const client = _clientResolver(accountId); @@ -906,6 +913,9 @@ export const feishuClient: FeishuClient = new Proxy(_defaultClient, { return Reflect.get(client, prop, receiver); } } - return Reflect.get(target, prop, receiver); + if (!_defaultClient) { + throw new Error('FeishuClient not initialized — call initDefaultClient() first'); + } + return Reflect.get(_defaultClient, prop, receiver); }, }); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index acbc5f32..24a24afd 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -28,7 +28,7 @@ import { agentRegistry } from '../agent/registry.js'; import { accountManager } from './multi-account.js'; import { chatBotRegistry } from './bot-registry.js'; import type { AgentId } from '../agent/types.js'; -import { readPersonaFile, loadKnowledgeContent, getAgentConfigInfo } from '../agent/config-loader.js'; +import { readPersonaFile, loadKnowledgeContent, getAgentConfigInfo, getExplicitBindings, deriveBindings } from '../agent/config-loader.js'; import { resolveMentions } from './mention-resolver.js'; import { createDiscussionMcpServer } from '../agent/tools/discussion.js'; import { generateAuthUrl, hasCallbackUrl, handleManualCode } from './oauth.js'; @@ -686,8 +686,9 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd logger.info({ userId, chatId, chatType, rootId, threadId, accountId, text: text.slice(0, 100), hasImages: !!images?.length }, 'Received message'); // ── 多 Agent: Binding Router 选 agent 角色(提前解析,供 @mention 过滤使用) ── + const allBindings = [...getExplicitBindings(), ...deriveBindings()]; const agentId: AgentId = isMultiBotMode() - ? resolveAgent(config.agent.bindings, { accountId, chatId, userId, chatType: chatType as 'group' | 'p2p' }) + ? resolveAgent(allBindings, { accountId, chatId, userId, chatType: chatType as 'group' | 'p2p' }) : 'dev'; // 单 bot 模式默认 dev agent // ── 无需 @mention 的斜杠命令(在 @mention 过滤之前拦截) ── diff --git a/src/feishu/multi-account.ts b/src/feishu/multi-account.ts index e244c2b0..a21841a5 100644 --- a/src/feishu/multi-account.ts +++ b/src/feishu/multi-account.ts @@ -70,14 +70,14 @@ export class AccountManager { /** * 初始化单 bot 兼容模式(使用现有 FEISHU_APP_ID / FEISHU_APP_SECRET) */ - initializeSingleBot(appId: string, appSecret: string): void { + initializeSingleBot(appId: string, appSecret: string, botName?: string): void { this._singleBotMode = true; const feishuClient = new FeishuClient(appId, appSecret); const account: BotAccount = { accountId: 'default', appId, appSecret, - botName: 'default', + botName: botName ?? 'default', feishuClient, }; this.accounts.set('default', account); diff --git a/src/feishu/oauth.ts b/src/feishu/oauth.ts index 4535fa01..866ede65 100644 --- a/src/feishu/oauth.ts +++ b/src/feishu/oauth.ts @@ -4,6 +4,7 @@ import { feishuClient, feishuClientContext } from './client.js'; import { accountManager } from './multi-account.js'; import { sessionManager } from '../session/manager.js'; import { logger } from '../utils/logger.js'; +import { deriveBotAccounts } from '../agent/config-loader.js'; // ============================================================ // 飞书 OAuth 2.0 用户授权 @@ -32,9 +33,14 @@ const STATE_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes * Sign the state payload with HMAC-SHA256 using app secret. * Format: base64url(json).signature */ +/** 获取主 bot 的 appSecret(用于 OAuth HMAC 签名) */ +function getPrimaryAppSecret(): string { + return deriveBotAccounts()[0]?.appSecret ?? ''; +} + function signState(payload: OAuthState): string { const data = Buffer.from(JSON.stringify(payload)).toString('base64url'); - const sig = createHmac('sha256', config.feishu.appSecret) + const sig = createHmac('sha256', getPrimaryAppSecret()) .update(data) .digest('base64url'); return `${data}.${sig}`; @@ -51,7 +57,7 @@ export function verifyState(state: string): OAuthState | undefined { const data = state.slice(0, dotIndex); const sig = state.slice(dotIndex + 1); - const expected = createHmac('sha256', config.feishu.appSecret) + const expected = createHmac('sha256', getPrimaryAppSecret()) .update(data) .digest('base64url'); @@ -85,7 +91,7 @@ const FALLBACK_REDIRECT_URI = 'http://127.0.0.1:3000/feishu/oauth/callback'; export function generateAuthUrl(userId: string, chatId: string): string { const state = signState({ userId, chatId, ts: Date.now() }); const redirectUri = encodeURIComponent(config.feishu.oauth.redirectUri || FALLBACK_REDIRECT_URI); - const appId = config.feishu.appId; + const appId = deriveBotAccounts()[0]?.appId ?? ''; // 显式请求 scope,确保 user_access_token 包含所需权限(如 task:task:read)。 // 不传 scope 时飞书文档称默认授权全部权限,但实测某些权限不会自动包含。 const scopes = config.feishu.oauth.scopes; diff --git a/src/index.ts b/src/index.ts index 67d9907b..b843b05e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import { config, validateConfig, isMultiBotMode } from './config.js'; +import { config, validateConfig, isMultiBotMode, setMultiBotMode } from './config.js'; import { logger } from './utils/logger.js'; import { startServer, closeServer } from './server.js'; import { sessionManager } from './session/manager.js'; @@ -8,11 +8,11 @@ import { cleanupTmpDirs, cleanupExpiredCaches } from './workspace/cache.js'; import { pipelineStore } from './pipeline/store.js'; import { recoverInterruptedPipelines } from './pipeline/runner.js'; import { killOrphanedClaudeProcesses } from './utils/process-cleanup.js'; -import { feishuClient, runWithAccountId } from './feishu/client.js'; +import { feishuClient, initDefaultClient, runWithAccountId } from './feishu/client.js'; import { cleanupExpiredApprovals } from './feishu/approval.js'; import { accountManager } from './feishu/multi-account.js'; import { validateBindings } from './agent/router.js'; -import { loadAgentConfig, startConfigWatcher, stopConfigWatcher, reloadAgentConfig } from './agent/config-loader.js'; +import { loadAgentConfig, startConfigWatcher, stopConfigWatcher, reloadAgentConfig, deriveBotAccounts, deriveBindings, getExplicitBindings } from './agent/config-loader.js'; import { chatBotRegistry } from './feishu/bot-registry.js'; import { initializeMemory, closeMemory, runMemoryMaintenance } from './memory/init.js'; import { warmup as warmupQuickAck } from './utils/quick-ack.js'; @@ -28,30 +28,43 @@ const INTERRUPTED_SESSIONS_FILE = '/tmp/anycode-interrupted.json'; async function main(): Promise { logger.info('Starting Feishu Claude Code Bridge...'); - // 检查配置 + // 检查基础配置 const errors = validateConfig(); if (errors.length > 0) { for (const err of errors) { logger.error(err); } - logger.error('Please check your .env configuration'); process.exit(1); } + // 加载 agent 配置文件(含飞书凭证) + const agentConfigResult = loadAgentConfig(); + if (!agentConfigResult.loaded) { + logger.error({ error: agentConfigResult.error }, 'Failed to load agents.json — cannot start without agent config'); + logger.error('Run "npm run onboard" to create config/agents.json, or copy from config/agents.example.json'); + process.exit(1); + } + + // 从 agent 配置推导 bot 账号 + const botAccounts = deriveBotAccounts(); + if (botAccounts.length === 0) { + logger.error('No feishu credentials found in agents.json — each agent needs a "feishu" field with appId and appSecret'); + process.exit(1); + } + + const multiBotMode = botAccounts.length > 1; + setMultiBotMode(multiBotMode); + + // 合并 bindings:显式配置 > 自动推导 + const allBindings = [...getExplicitBindings(), ...deriveBindings()]; + logger.info({ defaultWorkDir: config.claude.defaultWorkDir, timeoutSeconds: config.claude.timeoutSeconds, - multiBotMode: isMultiBotMode(), + multiBotMode, + botAccounts: botAccounts.map(a => a.accountId), }, 'Configuration loaded'); - // 加载 agent 配置文件(热重载支持) - const agentConfigResult = loadAgentConfig(); - if (agentConfigResult.error && config.agent.configPath) { - // 显式配置了 AGENT_CONFIG_PATH 但加载失败 → 致命错误 - logger.error({ error: agentConfigResult.error }, 'Failed to load AGENT_CONFIG_PATH'); - process.exit(1); - } - // 启动配置文件监听(热重载) startConfigWatcher(); @@ -131,24 +144,26 @@ async function main(): Promise { // 预热 quick-ack client(避免首次调用冷启动) warmupQuickAck(); - // 初始化 bot 账号 - if (isMultiBotMode()) { + // 初始化 bot 账号(从 agents.json 的 feishu 字段推导) + if (multiBotMode) { // 多 bot 模式:初始化所有账号 - await accountManager.initialize(config.agent.botAccounts); + await accountManager.initialize(botAccounts); // 校验 binding 配置 - const bindingWarnings = validateBindings(config.agent.bindings); + const bindingWarnings = validateBindings(allBindings); for (const w of bindingWarnings) { logger.warn({ warning: w }, 'Agent binding configuration warning'); } logger.info({ - accounts: config.agent.botAccounts.map((a) => a.accountId), - bindings: config.agent.bindings.length, + accounts: botAccounts.map((a) => a.accountId), + bindings: allBindings.length, }, 'Multi-bot mode initialized'); } else { - // 单 bot 模式:向后兼容 - accountManager.initializeSingleBot(config.feishu.appId, config.feishu.appSecret); + // 单 bot 模式 + const bot = botAccounts[0]; + initDefaultClient(bot.appId, bot.appSecret); + accountManager.initializeSingleBot(bot.appId, bot.appSecret, bot.botName); // 获取机器人信息(用于精确 @mention 检测) await feishuClient.fetchBotInfo().catch((err) => { @@ -157,7 +172,7 @@ async function main(): Promise { } // 启动 HTTP 服务 - startServer(); + startServer(multiBotMode ? undefined : { appId: botAccounts[0].appId, appSecret: botAccounts[0].appSecret }); // 恢复被中断的管道(服务重启后通知用户) recoverInterruptedPipelines().catch((err) => { diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts index 5fd326c8..6c80a1b3 100644 --- a/src/onboarding/bootstrap.ts +++ b/src/onboarding/bootstrap.ts @@ -96,8 +96,9 @@ export function getBootstrapPrompt(): string { ### Phase 1: 飞书应用配置 引导用户在 open.feishu.cn 创建企业自建应用(或使用已有应用): -1. 先问 FEISHU_APP_ID(告知在哪里找),用户给出后写入 .env -2. 再问 FEISHU_APP_SECRET,用户给出后写入 .env +1. 先问 App ID(告知在哪里找),用户给出后暂存 +2. 再问 App Secret,用户给出后,将 appId 和 appSecret 写入 agents.json 中每个 agent 的 feishu 字段 + 示例格式:agent 对象中添加 "feishu": { "appId": "cli_xxx", "appSecret": "xxx" } 3. 告知需要开通的**权限**(开发者后台 → 权限管理),列出清单让用户确认: - 必须:im:message, im:message:send_as_bot, im:chat:readonly, contact:contact.base:readonly - 推荐:im:resource, im:chat diff --git a/src/server.ts b/src/server.ts index a220f356..51145bd4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,16 +25,18 @@ let httpServer: Server | undefined; * - 不需要公网 IP,不需要配置回调地址 * - 适合开发调试、没有公网 IP 的场景 */ -export function startServer(): void { +export function startServer(primaryBot?: { appId: string; appSecret: string }): void { const { port } = config.server; const eventDispatcher = createEventDispatcher(); if (isMultiBotMode()) { startMultiBotWebSocketMode(eventDispatcher, port); } else if (config.feishu.eventMode === 'websocket') { - startWebSocketMode(eventDispatcher, port); + if (!primaryBot) throw new Error('primaryBot is required for single-bot WebSocket mode'); + startWebSocketMode(eventDispatcher, port, primaryBot); } else { - startWebhookMode(eventDispatcher, port); + if (!primaryBot) throw new Error('primaryBot is required for single-bot webhook mode'); + startWebhookMode(eventDispatcher, port, primaryBot); } } @@ -66,7 +68,7 @@ function registerOAuthRoute(app: express.Express): void { // 模式一: HTTP Webhook (需要公网) // ============================================================ -function startWebhookMode(eventDispatcher: lark.EventDispatcher, port: number): void { +function startWebhookMode(eventDispatcher: lark.EventDispatcher, port: number, _bot: { appId: string; appSecret: string }): void { const app = express(); app.use(express.json()); registerOAuthRoute(app); @@ -101,11 +103,11 @@ function startWebhookMode(eventDispatcher: lark.EventDispatcher, port: number): // 模式二: WebSocket 长连接 (无需公网) // ============================================================ -function startWebSocketMode(eventDispatcher: lark.EventDispatcher, port: number): void { +function startWebSocketMode(eventDispatcher: lark.EventDispatcher, port: number, bot: { appId: string; appSecret: string }): void { // WSClient 主动连接飞书,通过 WebSocket 接收事件 const wsClient = new lark.WSClient({ - appId: config.feishu.appId, - appSecret: config.feishu.appSecret, + appId: bot.appId, + appSecret: bot.appSecret, }); // 将 EventDispatcher 传给 WSClient From 2826257355a3cf4ae248a3ea8adf17829aaaa5c1 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 18:48:33 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=20index.ts=20?= =?UTF-8?q?=E4=B8=AD=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20isMultiBotMode?= =?UTF-8?q?=20=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index b843b05e..124f00f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import { config, validateConfig, isMultiBotMode, setMultiBotMode } from './config.js'; +import { config, validateConfig, setMultiBotMode } from './config.js'; import { logger } from './utils/logger.js'; import { startServer, closeServer } from './server.js'; import { sessionManager } from './session/manager.js'; From 2237f4092bbf75e6175102a9f489af0cca21cfac Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 18:55:29 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?multi-bot=20initDefaultClient=20+=20=E7=BC=93=E5=AD=98=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20+=20OAuth=20=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 多 bot 模式也调用 initDefaultClient(修复 AsyncLocalStorage 无值时崩溃) - deriveBotAccounts/deriveBindings 改为缓存模式,reload 时刷新 - OAuth getPrimaryAppSecret 无账号时 throw 而非返回空字符串 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/config-loader.ts | 61 +++++++++++++++++++------------------- src/feishu/oauth.ts | 8 +++-- src/index.ts | 4 ++- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/src/agent/config-loader.ts b/src/agent/config-loader.ts index 59abb0fc..012e246c 100644 --- a/src/agent/config-loader.ts +++ b/src/agent/config-loader.ts @@ -39,6 +39,10 @@ let configFileDir: string | undefined; let knowledgeDirPath: string | undefined; /** 配置文件中的显式 bindings */ let explicitBindings: AgentBinding[] = []; +/** 缓存的 bot 账号列表(agents 加载/重载时更新) */ +let cachedBotAccounts: BotAccountConfig[] = []; +/** 缓存的推导 bindings(agents 加载/重载时更新) */ +let cachedDerivedBindings: AgentBinding[] = []; /** 文件监听是否活跃 */ let watcherActive = false; /** 重载防抖定时器 */ @@ -195,6 +199,10 @@ export function reloadAgentConfig(): LoadResult { // 应用到 registry agentRegistry.replaceAll(agents); + // 刷新缓存(bot 账号 + 推导 bindings) + cachedBotAccounts = computeBotAccounts(agents); + cachedDerivedBindings = computeDerivedBindings(agents); + logger.info( { path: configFilePath, agentCount: agents.length, agentIds: agents.map(a => a.id) }, 'Agent config loaded', @@ -293,66 +301,59 @@ export function loadKnowledgeContent(agentId: string): string | undefined { // ─── 飞书凭证推导 ───────────────────────────────────────── -/** - * 从 agents.json 的 feishu 字段推导 bot 账号列表。 - * 按 appId 去重,多个 agent 共享同一 appId 时只创建一个 bot 账号。 - * 返回空数组表示没有配置飞书凭证。 - */ -export function deriveBotAccounts(): BotAccountConfig[] { - const agents = agentRegistry.list(); +/** 内部:从 agents 列表计算 bot 账号(reload 时调用) */ +function computeBotAccounts(agents: AgentConfig[]): BotAccountConfig[] { const seen = new Map(); - for (const agent of agents) { if (!agent.feishu) continue; const { appId, appSecret } = agent.feishu; if (seen.has(appId)) continue; - seen.set(appId, { - accountId: agent.id, // 用第一个使用此 appId 的 agent id 作为 accountId + accountId: agent.id, appId, appSecret, botName: agent.displayName, }); } - return [...seen.values()]; } -/** - * 从 agents.json 的 feishu 字段自动推导 bindings。 - * 仅在多 bot 模式(多个不同 appId)时生成。 - * 单 bot 模式返回空数组,使用默认路由逻辑。 - */ -export function deriveBindings(): AgentBinding[] { - const agents = agentRegistry.list(); +/** 内部:从 agents 列表推导 bindings(reload 时调用) */ +function computeDerivedBindings(agents: AgentConfig[]): AgentBinding[] { const appIdToAccountId = new Map(); - const bindings: AgentBinding[] = []; - - // 先建立 appId → accountId 映射 for (const agent of agents) { if (!agent.feishu) continue; if (!appIdToAccountId.has(agent.feishu.appId)) { appIdToAccountId.set(agent.feishu.appId, agent.id); } } - - // 只有一个唯一 appId → 单 bot,不需要自动 binding if (appIdToAccountId.size <= 1) return []; - - // 多 bot:每个 agent 绑定到其 appId 对应的 accountId + const bindings: AgentBinding[] = []; for (const agent of agents) { if (!agent.feishu) continue; const accountId = appIdToAccountId.get(agent.feishu.appId); if (!accountId) continue; - bindings.push({ - agentId: agent.id, - match: { accountId }, - }); + bindings.push({ agentId: agent.id, match: { accountId } }); } - return bindings; } +/** + * 获取缓存的 bot 账号列表(agents 加载/重载时自动更新)。 + * 按 appId 去重,返回空数组表示没有配置飞书凭证。 + */ +export function deriveBotAccounts(): BotAccountConfig[] { + return cachedBotAccounts; +} + +/** + * 获取缓存的推导 bindings(agents 加载/重载时自动更新)。 + * 仅多 bot 模式(多个不同 appId)时非空。 + */ +export function deriveBindings(): AgentBinding[] { + return cachedDerivedBindings; +} + /** 获取配置文件中用户显式配置的 bindings */ export function getExplicitBindings(): AgentBinding[] { return explicitBindings; diff --git a/src/feishu/oauth.ts b/src/feishu/oauth.ts index 866ede65..16483b9d 100644 --- a/src/feishu/oauth.ts +++ b/src/feishu/oauth.ts @@ -35,7 +35,9 @@ const STATE_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes */ /** 获取主 bot 的 appSecret(用于 OAuth HMAC 签名) */ function getPrimaryAppSecret(): string { - return deriveBotAccounts()[0]?.appSecret ?? ''; + const accounts = deriveBotAccounts(); + if (!accounts.length) throw new Error('No bot accounts configured — cannot sign OAuth state'); + return accounts[0].appSecret; } function signState(payload: OAuthState): string { @@ -91,7 +93,9 @@ const FALLBACK_REDIRECT_URI = 'http://127.0.0.1:3000/feishu/oauth/callback'; export function generateAuthUrl(userId: string, chatId: string): string { const state = signState({ userId, chatId, ts: Date.now() }); const redirectUri = encodeURIComponent(config.feishu.oauth.redirectUri || FALLBACK_REDIRECT_URI); - const appId = deriveBotAccounts()[0]?.appId ?? ''; + const accounts = deriveBotAccounts(); + if (!accounts.length) throw new Error('No bot accounts configured — cannot generate OAuth URL'); + const appId = accounts[0].appId; // 显式请求 scope,确保 user_access_token 包含所需权限(如 task:task:read)。 // 不传 scope 时飞书文档称默认授权全部权限,但实测某些权限不会自动包含。 const scopes = config.feishu.oauth.scopes; diff --git a/src/index.ts b/src/index.ts index 124f00f7..847ca92f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -144,6 +144,9 @@ async function main(): Promise { // 预热 quick-ack client(避免首次调用冷启动) warmupQuickAck(); + // 初始化默认 FeishuClient(所有模式都需要,作为 AsyncLocalStorage 无值时的 fallback) + initDefaultClient(botAccounts[0].appId, botAccounts[0].appSecret); + // 初始化 bot 账号(从 agents.json 的 feishu 字段推导) if (multiBotMode) { // 多 bot 模式:初始化所有账号 @@ -162,7 +165,6 @@ async function main(): Promise { } else { // 单 bot 模式 const bot = botAccounts[0]; - initDefaultClient(bot.appId, bot.appSecret); accountManager.initializeSingleBot(bot.appId, bot.appSecret, bot.botName); // 获取机器人信息(用于精确 @mention 检测) From d7d81ab3aed391f1cda067fc715b24fad1123a60 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 19:51:01 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20agent=20=E9=85=8D=E7=BD=AE=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20description=20=E5=AD=97=E6=AE=B5=EF=BC=8C=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E9=85=8D=E7=BD=AE=E5=8A=A0=E8=A7=92=E8=89=B2=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit description 字段解释 agent 的定位和特点: - PM: Chat Bot — 只读、直接回复、轻量对话 - Dev: Dev Bot — 完整读写、创建话题、编码任务 /config 命令也展示 description。 Co-Authored-By: Claude Opus 4.6 (1M context) --- config/agents.example.json | 2 ++ src/agent/config-loader.ts | 1 + src/agent/config-schema.ts | 2 ++ src/agent/types.ts | 2 ++ src/feishu/event-handler.ts | 1 + 5 files changed, 8 insertions(+) diff --git a/config/agents.example.json b/config/agents.example.json index 2753612f..f002524f 100644 --- a/config/agents.example.json +++ b/config/agents.example.json @@ -14,6 +14,7 @@ { "id": "pm", "displayName": "", + "description": "Chat Bot — 日常对话、问答、讨论。只读模式,不修改代码,直接回复消息(不创建话题)。适合群聊中的轻量交互。", "feishu": { "appId": "<飞书应用 App ID>", "appSecret": "<飞书应用 App Secret>" @@ -31,6 +32,7 @@ { "id": "dev", "displayName": "", + "description": "Dev Bot — 代码开发、修 bug、创建 PR。完整读写权限,每条消息创建独立话题,非 Owner 需要审批。适合实际编码任务。", "feishu": { "appId": "<同上,共享同一个飞书应用>", "appSecret": "<同上>" diff --git a/src/agent/config-loader.ts b/src/agent/config-loader.ts index 012e246c..785af2da 100644 --- a/src/agent/config-loader.ts +++ b/src/agent/config-loader.ts @@ -87,6 +87,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age return { id: input.id, displayName: input.displayName ?? input.id, + description: input.description, model: input.model ?? defaults.model ?? BUILTIN_DEFAULTS.model!, toolPolicy, readOnly, diff --git a/src/agent/config-schema.ts b/src/agent/config-schema.ts index 989d5f2b..1713c478 100644 --- a/src/agent/config-schema.ts +++ b/src/agent/config-schema.ts @@ -57,6 +57,8 @@ export const AgentConfigInputSchema = z.object({ id: z.string().min(1), /** 显示名称 */ displayName: z.string().optional(), + /** Agent 角色描述(说明该 agent 的定位和特点,便于理解配置意图) */ + description: z.string().optional(), /** 模型名称 */ model: z.string().optional(), /** 工具策略 */ diff --git a/src/agent/types.ts b/src/agent/types.ts index 6404b298..84749b9a 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -37,6 +37,8 @@ export interface AgentConfig { id: AgentId; /** 显示名称(用于日志和审批卡片) */ displayName: string; + /** 角色描述(说明该 agent 的定位和特点) */ + description?: string; /** 默认模型 */ model: string; /** 工具策略 */ diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 24a24afd..1b75f4d1 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -1117,6 +1117,7 @@ async function handleSlashCommand( `🔧 **Agent 配置 — ${agentId}**`, '', `**显示名称**: ${agentCfg?.displayName ?? '(未配置)'}`, + ...(agentCfg?.description ? [`**描述**: ${agentCfg.description}`] : []), `**飞书 Bot 名**: ${feishuClient.botName ?? '(未获取)'}`, `**模型**: ${agentCfg?.model ?? '(默认)'}`, `**工具策略**: ${agentCfg?.toolPolicy ?? '(默认)'}`, From 2ff51d50571ff13d16eafe4409e156a71fbe752e Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 19:51:51 +0800 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=20pm.example.md?= =?UTF-8?q?=20=E5=92=8C=20.gitkeep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pm.example.md 已被 assistant.example.md 替代 - .gitkeep 不再需要(目录中已有 .example.md 文件) Co-Authored-By: Claude Opus 4.6 (1M context) --- config/knowledge/.gitkeep | 0 config/personas/.gitkeep | 0 config/personas/pm.example.md | 18 ------------------ 3 files changed, 18 deletions(-) delete mode 100644 config/knowledge/.gitkeep delete mode 100644 config/personas/.gitkeep delete mode 100644 config/personas/pm.example.md diff --git a/config/knowledge/.gitkeep b/config/knowledge/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/config/personas/.gitkeep b/config/personas/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/config/personas/pm.example.md b/config/personas/pm.example.md deleted file mode 100644 index 561d6b44..00000000 --- a/config/personas/pm.example.md +++ /dev/null @@ -1,18 +0,0 @@ -你是一个产品经理 Bot,通过飞书群聊与团队成员交互。 - -## 身份 - -- **角色**:产品经理,兼具业务理解和技术沟通能力 -- **性格**:简洁高效,有主见 - -## 能力 - -- 深入理解业务逻辑和用户场景 -- 阅读和分析代码(只读,不修改文件) -- 协助梳理任务拆分和优先级 - -## 沟通风格 - -- 简洁直接,适合群聊节奏 -- 给建议时明确表达判断 -- 需求不清晰时主动追问 From a31922b2ab1600827356e5fe4f88866d87023420 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 19:52:42 +0800 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20team.example.md=20=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E7=AC=A6=E8=AF=AD=E4=B9=89=E5=8C=96=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=20Alice/Bob/Carol=20=E5=81=87=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- config/knowledge/team.example.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/knowledge/team.example.md b/config/knowledge/team.example.md index e62de9fb..9e975767 100644 --- a/config/knowledge/team.example.md +++ b/config/knowledge/team.example.md @@ -2,19 +2,19 @@ ## Organization -Your Company / Your Team — brief team description. +(在 onboarding 中设定 — 填写你的公司/团队名称和简介) ## Key People | Name | Role | Notes | |------|------|-------| -| Alice | Team lead | Full-stack, project owner | -| Bob | Backend | API and infrastructure | -| Carol | Frontend | UI/UX implementation | +| (姓名) | (角色) | (备注) | ## Repositories | Repo | Description | |------|-------------| -| [org/main-app](https://github.com/org/main-app) | Main application | -| [org/shared-lib](https://github.com/org/shared-lib) | Shared library | +| (仓库地址) | (项目简介) | + +--- +此文件是模板。运行 `npm run onboard` 后会根据你的团队信息生成正式的 team.md。 From cd415bf4379381aaf3f893682c9acea491c5c1dc Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 19:59:11 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20onboarding=20=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=92=8C=E4=BA=8B=E4=BB=B6=E8=AE=A2=E9=98=85=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E4=B8=BA=E4=B8=80=E6=AD=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 都是去飞书后台操作不需要回传值,没必要分两轮等用户回复。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/onboarding/bootstrap.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts index 6c80a1b3..fd3f5c1c 100644 --- a/src/onboarding/bootstrap.ts +++ b/src/onboarding/bootstrap.ts @@ -99,15 +99,17 @@ export function getBootstrapPrompt(): string { 1. 先问 App ID(告知在哪里找),用户给出后暂存 2. 再问 App Secret,用户给出后,将 appId 和 appSecret 写入 agents.json 中每个 agent 的 feishu 字段 示例格式:agent 对象中添加 "feishu": { "appId": "cli_xxx", "appSecret": "xxx" } -3. 告知需要开通的**权限**(开发者后台 → 权限管理),列出清单让用户确认: +3. 权限和事件订阅**合并为一步提示**(都是去飞书后台操作,不需要用户回传内容): + 在一条消息中列出所有需要配置的内容,让用户一次性完成后回复"完成": + **权限**(开发者后台 → 权限管理): - 必须:im:message, im:message:send_as_bot, im:chat:readonly, contact:contact.base:readonly - 推荐:im:resource, im:chat -4. 告知需要添加的**事件订阅**(开发者后台 → 事件与回调 → 添加事件),这是单独的一步: + **事件订阅**(开发者后台 → 事件与回调 → 添加事件): - im.message.receive_v1 — 接收消息(必须) - - card.action.trigger — 卡片按钮交互回调(必须,否则 AskUser 等卡片按钮不生效) - - p2p_chat_create — 用户首次私聊 Bot - - im.chat.member.bot.added_v1 — Bot 被拉入群 -5. 提醒用户:配置完权限和事件后需要在「版本管理与发布」创建版本并发布,权限才生效 + - card.action.trigger — 卡片按钮交互回调(必须) + - p2p_chat_create — 用户首次私聊 Bot(推荐) + - im.chat.member.bot.added_v1 — Bot 被拉入群(推荐) + **最后**:在「版本管理与发布」创建版本并发布,权限才生效 ### Phase 2: 团队信息 From 84ac72c03b6bfa5178cfb0d2ac27372718f61fb9 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 20:00:11 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20onboarding=20=E5=85=88=E6=94=B6?= =?UTF-8?q?=E9=9B=86=20Bot=20=E4=BA=BA=E6=A0=BC=E5=86=8D=E6=94=B6=E9=9B=86?= =?UTF-8?q?=E5=9B=A2=E9=98=9F=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/onboarding/bootstrap.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts index fd3f5c1c..af7df7ae 100644 --- a/src/onboarding/bootstrap.ts +++ b/src/onboarding/bootstrap.ts @@ -111,15 +111,7 @@ export function getBootstrapPrompt(): string { - im.chat.member.bot.added_v1 — Bot 被拉入群(推荐) **最后**:在「版本管理与发布」创建版本并发布,权限才生效 -### Phase 2: 团队信息 - -1. 询问团队/公司名称 -2. 了解核心团队成员(姓名、角色) -3. 了解主要项目和仓库 -4. 读取 ${knowledgeExamplePath} 了解格式 -5. 将收集的信息写入 ${knowledgePath}(不要照搬模板中的 Alice/Bob/Carol) - -### Phase 3: Bot 人格设定 +### Phase 2: Bot 人格设定 1. 询问希望 Bot 用什么名字/称呼 2. 沟通风格偏好(正式/随意/技术流/幽默) @@ -127,6 +119,14 @@ export function getBootstrapPrompt(): string { 4. 读取 ${personaExamplePath} 了解人设文件的格式 5. 将人格设定写入人设文件(文件名不要叫 pm.md,用 assistant.md 或与 Bot 名字相关的名字) +### Phase 3: 团队信息 + +1. 询问团队/公司名称 +2. 了解核心团队成员(姓名、角色) +3. 了解主要项目和仓库 +4. 读取 ${knowledgeExamplePath} 了解格式 +5. 将收集的信息写入 ${knowledgePath}(不要照搬模板占位内容) + ### Phase 4: Agent 配置文件 1. 如果 ${agentsPath} 不存在,从 ${agentsExamplePath} 复制 From 94ed5b43ef15d0104f9fec573d93f1b0cc127cb5 Mon Sep 17 00:00:00 2001 From: unclee Date: Wed, 8 Apr 2026 21:04:45 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20bootstrap=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=80=82=E9=85=8D=E6=A8=A1=E6=9D=BF=E5=8D=A0=E4=BD=8D=E7=AC=A6?= =?UTF-8?q?=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alice/Bob/Carol 已从模板中移除,测试改为检查 "占位" 关键词。 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/onboarding/__tests__/bootstrap.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onboarding/__tests__/bootstrap.test.ts b/src/onboarding/__tests__/bootstrap.test.ts index 5a784c81..f914e211 100644 --- a/src/onboarding/__tests__/bootstrap.test.ts +++ b/src/onboarding/__tests__/bootstrap.test.ts @@ -138,7 +138,7 @@ describe('getBootstrapPrompt', () => { it('should contain important rules', () => { const prompt = getBootstrapPrompt(); - expect(prompt).toContain('Alice/Bob/Carol'); + expect(prompt).toContain('占位'); expect(prompt).toContain('跳过'); }); });