| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- // 机器人与 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<BotInput> = z.object({
- name: z.string().trim().min(1).max(30).refine(s => !/[\s@]/.test(s), '名称不能包含空格或 @'),
- // `kl:<Keyline 图标>:<颜色>`(后台的图标头像;早先的 `icon:<codicon>:<颜色>` 仍可用),或一个 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<string, number>()
- 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<string> | 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<string, string> {
- 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()
- }
- }
- }
|