// 机器人与 Bot Key(docs/design/13-bot-teams.md §2、ADR-0024) import crypto from 'node:crypto' import { and, desc, eq } from 'drizzle-orm' import { z } from 'zod' import type { BotDto, BotInput, BotKeyDto, BotSummary } from '@agent-server/shared' import type { DB } from '../db' import { botApiKeys, bots, teamBots } from '../db/schema' import { AppError, badRequest, conflict, notFound } from '../lib/errors' import { newId, nowIso } from '../lib/ids' import { hashKey, parse, type ProfileService } from '../services/catalog' type BotRow = typeof bots.$inferSelect const botSchema: z.ZodType = z.object({ name: z.string().trim().min(1).max(30).refine(s => !/[\s@]/.test(s), '名称不能包含空格或 @'), // `kl::<颜色>`(后台的图标头像;早先的 `icon::<颜色>` 仍可用),或一个 emoji avatar: z.string().trim().min(1).max(48).refine(s => /^(kl|icon):[a-z0-9-]+:[a-z]+$/.test(s) || s.length <= 8, '头像格式不正确'), kind: z.enum(['lead', 'worker']), description: z.string().max(4000), profileId: z.string().min(1), enabled: z.boolean(), }) const toDto = (r: BotRow): BotDto => ({ id: r.id, name: r.name, avatar: r.avatar, kind: r.kind, description: r.description, profileId: r.profileId, enabled: r.enabled, createdAt: r.createdAt, updatedAt: r.updatedAt, }) export class BotService { constructor(private db: DB, private profiles: ProfileService) {} list(): BotDto[] { return this.db.select().from(bots).orderBy(bots.kind, bots.name).all().map(toDto) } get(id: string): BotDto { const row = this.db.select().from(bots).where(eq(bots.id, id)).get() if (!row) throw notFound('机器人') return toDto(row) } /** Bot API 中展示的信息:引擎和模型取自 Profile(Profile 已删除时为 null) */ summary(bot: BotDto): BotSummary { let provider: BotSummary['provider'] = null let model: string | null = null try { const p = this.profiles.get(bot.profileId) provider = p.provider model = p.model } catch { // Profile 已删除 } return { id: bot.id, name: bot.name, avatar: bot.avatar, kind: bot.kind, description: bot.description, provider, model } } create(input: BotInput): BotDto { const data = this.check(input) const id = newId('bot') this.db.insert(bots).values({ id, ...data }).run() return this.get(id) } update(id: string, input: BotInput): BotDto { const prev = this.get(id) const data = this.check(input, id) // 已在工作组中的机器人不能改变类型,否则组长人数规则会被打破 if (data.kind !== prev.kind && this.teamCount(id) > 0) throw badRequest('这个机器人已加入工作组,不能修改类型') this.db.update(bots).set({ ...data, updatedAt: nowIso() }).where(eq(bots.id, id)).run() return this.get(id) } delete(id: string) { this.get(id) const n = this.teamCount(id) if (n > 0) throw new AppError(400, 'bot_in_use', `这个机器人还在 ${n} 个工作组中,请先把它移出或删除这些工作组`) this.db.delete(bots).where(eq(bots.id, id)).run() } /** 引用了某个 Profile 的机器人名称(删除 Profile 前检查) */ referencing(profileId: string): string[] { return this.list().filter(b => b.profileId === profileId).map(b => b.name) } private teamCount(botId: string) { return this.db.select().from(teamBots).where(eq(teamBots.botId, botId)).all().length } private check(input: BotInput, selfId?: string) { const data = parse(botSchema, input) this.profiles.get(data.profileId) const dup = this.db.select().from(bots).where(eq(bots.name, data.name)).get() if (dup && dup.id !== selfId) throw conflict('name_taken', `已有同名机器人:${data.name}`) return data } } export class BotKeyService { private lastTouched = new Map() constructor(private db: DB, private botService: BotService) {} list(): BotKeyDto[] { return this.db.select().from(botApiKeys).orderBy(desc(botApiKeys.createdAt)).all() .map(r => ({ id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, allowedBotIds: r.allowedBotIds, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt })) } /** 返回明文 Key,只在创建时出现一次 */ create(name: string, allowedBotIds: string[]): { key: string; botKey: BotKeyDto } { if (!name.trim()) throw badRequest('名称不能为空') const allowed = this.checkBots(allowedBotIds) const key = `bk_${crypto.randomBytes(24).toString('base64url')}` const id = newId('bkey') this.db.insert(botApiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 9), allowedBotIds: allowed }).run() return { key, botKey: this.list().find(k => k.id === id)! } } setBots(id: string, allowedBotIds: string[]) { const r = this.db.update(botApiKeys).set({ allowedBotIds: this.checkBots(allowedBotIds) }).where(eq(botApiKeys.id, id)).run() if (r.changes === 0) throw notFound('Bot Key') } setEnabled(id: string, enabled: boolean) { const r = this.db.update(botApiKeys).set({ enabled }).where(eq(botApiKeys.id, id)).run() if (r.changes === 0) throw notFound('Bot Key') } delete(id: string) { const r = this.db.delete(botApiKeys).where(eq(botApiKeys.id, id)).run() if (r.changes === 0) throw notFound('Bot Key') } /** 删除机器人后,从各个 Key 的可用列表中移除 */ forgetBot(botId: string) { for (const k of this.list()) { if (k.allowedBotIds.includes(botId)) this.db.update(botApiKeys).set({ allowedBotIds: k.allowedBotIds.filter(b => b !== botId) }).where(eq(botApiKeys.id, k.id)).run() } } /** 来源可以使用的机器人 id;后台(admin)不受限,返回 null */ allowedBots(source: string): Set | null { if (source === 'admin') return null const row = this.db.select().from(botApiKeys).where(eq(botApiKeys.id, source)).get() return new Set(row?.allowedBotIds ?? []) } verify(key: string): string | null { const row = this.db.select().from(botApiKeys).where(and(eq(botApiKeys.keyHash, hashKey(key)), eq(botApiKeys.enabled, true))).get() if (!row) return null this.touch(row.id) return row.id } /** 浏览器令牌鉴权时检查签发它的 Key 仍然存在且启用 */ isActive(id: string): boolean { const row = this.db.select({ id: botApiKeys.id }).from(botApiKeys).where(and(eq(botApiKeys.id, id), eq(botApiKeys.enabled, true))).get() if (row) this.touch(id) return !!row } names(): Map { return new Map(this.db.select({ id: botApiKeys.id, name: botApiKeys.name }).from(botApiKeys).all().map(r => [r.id, r.name])) } private checkBots(ids: string[]) { const allowed = [...new Set(parse(z.array(z.string()), ids))] if (!allowed.length) throw badRequest('至少选择一个机器人') for (const id of allowed) this.botService.get(id) return allowed } /** 最近使用时间每分钟最多写一次 */ private touch(id: string) { const now = Date.now() if ((this.lastTouched.get(id) ?? 0) < now - 60_000) { this.lastTouched.set(id, now) this.db.update(botApiKeys).set({ lastUsedAt: nowIso() }).where(eq(botApiKeys.id, id)).run() } } }