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..f002524f 100644 --- a/config/agents.example.json +++ b/config/agents.example.json @@ -14,6 +14,11 @@ { "id": "pm", "displayName": "", + "description": "Chat Bot — 日常对话、问答、讨论。只读模式,不修改代码,直接回复消息(不创建话题)。适合群聊中的轻量交互。", + "feishu": { + "appId": "<飞书应用 App ID>", + "appSecret": "<飞书应用 App Secret>" + }, "model": "claude-opus-4-6", "replyMode": "direct", "toolPolicy": { @@ -27,6 +32,11 @@ { "id": "dev", "displayName": "", + "description": "Dev Bot — 代码开发、修 bug、创建 PR。完整读写权限,每条消息创建独立话题,非 Owner 需要审批。适合实际编码任务。", + "feishu": { + "appId": "<同上,共享同一个飞书应用>", + "appSecret": "<同上>" + }, "model": "claude-opus-4-6", "toolPolicy": "all", "replyMode": "thread", diff --git a/config/knowledge/.gitkeep b/config/knowledge/.gitkeep deleted file mode 100644 index e69de29b..00000000 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。 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,通过飞书群聊与团队成员交互。 - -## 身份 - -- **角色**:产品经理,兼具业务理解和技术沟通能力 -- **性格**:简洁高效,有主见 - -## 能力 - -- 深入理解业务逻辑和用户场景 -- 阅读和分析代码(只读,不修改文件) -- 协助梳理任务拆分和优先级 - -## 沟通风格 - -- 简洁直接,适合群聊节奏 -- 给建议时明确表达判断 -- 需求不清晰时主动追问 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..785af2da 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,12 @@ let configFilePath: string | undefined; let configFileDir: string | undefined; /** 知识文件根目录(resolved absolute path) */ let knowledgeDirPath: string | undefined; +/** 配置文件中的显式 bindings */ +let explicitBindings: AgentBinding[] = []; +/** 缓存的 bot 账号列表(agents 加载/重载时更新) */ +let cachedBotAccounts: BotAccountConfig[] = []; +/** 缓存的推导 bindings(agents 加载/重载时更新) */ +let cachedDerivedBindings: AgentBinding[] = []; /** 文件监听是否活跃 */ let watcherActive = false; /** 重载防抖定时器 */ @@ -81,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, @@ -95,6 +102,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age toolDeny, bashAllowPatterns: input.bashAllowPatterns, editablePathPatterns: input.editablePathPatterns ?? defaults.editablePathPatterns, + feishu: input.feishu, }; } @@ -180,12 +188,22 @@ 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)); // 应用到 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', @@ -282,6 +300,66 @@ export function loadKnowledgeContent(agentId: string): string | undefined { return parts.length > 0 ? parts.join('\n\n') : undefined; } +// ─── 飞书凭证推导 ───────────────────────────────────────── + +/** 内部:从 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, + appSecret, + botName: agent.displayName, + }); + } + return [...seen.values()]; +} + +/** 内部:从 agents 列表推导 bindings(reload 时调用) */ +function computeDerivedBindings(agents: AgentConfig[]): AgentBinding[] { + const appIdToAccountId = new Map(); + for (const agent of agents) { + if (!agent.feishu) continue; + if (!appIdToAccountId.has(agent.feishu.appId)) { + appIdToAccountId.set(agent.feishu.appId, agent.id); + } + } + if (appIdToAccountId.size <= 1) return []; + 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 } }); + } + 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; +} + // ─── 热重载 Watcher ──────────────────────────────────────── /** diff --git a/src/agent/config-schema.ts b/src/agent/config-schema.ts index 5f1495e8..1713c478 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({ @@ -31,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(), /** 工具策略 */ @@ -53,6 +81,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 +107,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..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; /** 工具策略 */ @@ -65,6 +67,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..1b75f4d1 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 过滤之前拦截) ── @@ -1116,6 +1117,7 @@ async function handleSlashCommand( `🔧 **Agent 配置 — ${agentId}**`, '', `**显示名称**: ${agentCfg?.displayName ?? '(未配置)'}`, + ...(agentCfg?.description ? [`**描述**: ${agentCfg.description}`] : []), `**飞书 Bot 名**: ${feishuClient.botName ?? '(未获取)'}`, `**模型**: ${agentCfg?.model ?? '(默认)'}`, `**工具策略**: ${agentCfg?.toolPolicy ?? '(默认)'}`, 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..16483b9d 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,16 @@ 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 { + 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 { 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 +59,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 +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 = config.feishu.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 67d9907b..847ca92f 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, 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,28 @@ async function main(): Promise { // 预热 quick-ack client(避免首次调用冷启动) warmupQuickAck(); - // 初始化 bot 账号 - if (isMultiBotMode()) { + // 初始化默认 FeishuClient(所有模式都需要,作为 AsyncLocalStorage 无值时的 fallback) + initDefaultClient(botAccounts[0].appId, botAccounts[0].appSecret); + + // 初始化 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]; + accountManager.initializeSingleBot(bot.appId, bot.appSecret, bot.botName); // 获取机器人信息(用于精确 @mention 检测) await feishuClient.fetchBotInfo().catch((err) => { @@ -157,7 +174,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/__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('跳过'); }); }); diff --git a/src/onboarding/bootstrap.ts b/src/onboarding/bootstrap.ts index 5fd326c8..af7df7ae 100644 --- a/src/onboarding/bootstrap.ts +++ b/src/onboarding/bootstrap.ts @@ -96,27 +96,22 @@ export function getBootstrapPrompt(): string { ### Phase 1: 飞书应用配置 引导用户在 open.feishu.cn 创建企业自建应用(或使用已有应用): -1. 先问 FEISHU_APP_ID(告知在哪里找),用户给出后写入 .env -2. 再问 FEISHU_APP_SECRET,用户给出后写入 .env -3. 告知需要开通的**权限**(开发者后台 → 权限管理),列出清单让用户确认: +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 -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: 团队信息 - -1. 询问团队/公司名称 -2. 了解核心团队成员(姓名、角色) -3. 了解主要项目和仓库 -4. 读取 ${knowledgeExamplePath} 了解格式 -5. 将收集的信息写入 ${knowledgePath}(不要照搬模板中的 Alice/Bob/Carol) - -### Phase 3: Bot 人格设定 +### Phase 2: Bot 人格设定 1. 询问希望 Bot 用什么名字/称呼 2. 沟通风格偏好(正式/随意/技术流/幽默) @@ -124,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} 复制 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