bots.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. // 机器人与 Bot Key(docs/design/13-bot-teams.md §2、ADR-0024)
  2. import crypto from 'node:crypto'
  3. import { and, desc, eq } from 'drizzle-orm'
  4. import { z } from 'zod'
  5. import type { BotDto, BotInput, BotKeyDto, BotSummary } from '@agent-server/shared'
  6. import type { DB } from '../db'
  7. import { botApiKeys, bots, teamBots } from '../db/schema'
  8. import { AppError, badRequest, conflict, notFound } from '../lib/errors'
  9. import { newId, nowIso } from '../lib/ids'
  10. import { hashKey, parse, type ProfileService } from '../services/catalog'
  11. type BotRow = typeof bots.$inferSelect
  12. const botSchema: z.ZodType<BotInput> = z.object({
  13. name: z.string().trim().min(1).max(30).refine(s => !/[\s@]/.test(s), '名称不能包含空格或 @'),
  14. // `kl:<Keyline 图标>:<颜色>`(后台的图标头像;早先的 `icon:<codicon>:<颜色>` 仍可用),或一个 emoji
  15. avatar: z.string().trim().min(1).max(48).refine(s => /^(kl|icon):[a-z0-9-]+:[a-z]+$/.test(s) || s.length <= 8, '头像格式不正确'),
  16. kind: z.enum(['lead', 'worker']),
  17. description: z.string().max(4000),
  18. profileId: z.string().min(1),
  19. enabled: z.boolean(),
  20. })
  21. const toDto = (r: BotRow): BotDto => ({
  22. id: r.id, name: r.name, avatar: r.avatar, kind: r.kind, description: r.description, profileId: r.profileId,
  23. enabled: r.enabled, createdAt: r.createdAt, updatedAt: r.updatedAt,
  24. })
  25. export class BotService {
  26. constructor(private db: DB, private profiles: ProfileService) {}
  27. list(): BotDto[] {
  28. return this.db.select().from(bots).orderBy(bots.kind, bots.name).all().map(toDto)
  29. }
  30. get(id: string): BotDto {
  31. const row = this.db.select().from(bots).where(eq(bots.id, id)).get()
  32. if (!row) throw notFound('机器人')
  33. return toDto(row)
  34. }
  35. /** Bot API 中展示的信息:引擎和模型取自 Profile(Profile 已删除时为 null) */
  36. summary(bot: BotDto): BotSummary {
  37. let provider: BotSummary['provider'] = null
  38. let model: string | null = null
  39. try {
  40. const p = this.profiles.get(bot.profileId)
  41. provider = p.provider
  42. model = p.model
  43. } catch {
  44. // Profile 已删除
  45. }
  46. return { id: bot.id, name: bot.name, avatar: bot.avatar, kind: bot.kind, description: bot.description, provider, model }
  47. }
  48. create(input: BotInput): BotDto {
  49. const data = this.check(input)
  50. const id = newId('bot')
  51. this.db.insert(bots).values({ id, ...data }).run()
  52. return this.get(id)
  53. }
  54. update(id: string, input: BotInput): BotDto {
  55. const prev = this.get(id)
  56. const data = this.check(input, id)
  57. // 已在工作组中的机器人不能改变类型,否则组长人数规则会被打破
  58. if (data.kind !== prev.kind && this.teamCount(id) > 0) throw badRequest('这个机器人已加入工作组,不能修改类型')
  59. this.db.update(bots).set({ ...data, updatedAt: nowIso() }).where(eq(bots.id, id)).run()
  60. return this.get(id)
  61. }
  62. delete(id: string) {
  63. this.get(id)
  64. const n = this.teamCount(id)
  65. if (n > 0) throw new AppError(400, 'bot_in_use', `这个机器人还在 ${n} 个工作组中,请先把它移出或删除这些工作组`)
  66. this.db.delete(bots).where(eq(bots.id, id)).run()
  67. }
  68. /** 引用了某个 Profile 的机器人名称(删除 Profile 前检查) */
  69. referencing(profileId: string): string[] {
  70. return this.list().filter(b => b.profileId === profileId).map(b => b.name)
  71. }
  72. private teamCount(botId: string) {
  73. return this.db.select().from(teamBots).where(eq(teamBots.botId, botId)).all().length
  74. }
  75. private check(input: BotInput, selfId?: string) {
  76. const data = parse(botSchema, input)
  77. this.profiles.get(data.profileId)
  78. const dup = this.db.select().from(bots).where(eq(bots.name, data.name)).get()
  79. if (dup && dup.id !== selfId) throw conflict('name_taken', `已有同名机器人:${data.name}`)
  80. return data
  81. }
  82. }
  83. export class BotKeyService {
  84. private lastTouched = new Map<string, number>()
  85. constructor(private db: DB, private botService: BotService) {}
  86. list(): BotKeyDto[] {
  87. return this.db.select().from(botApiKeys).orderBy(desc(botApiKeys.createdAt)).all()
  88. .map(r => ({ id: r.id, name: r.name, prefix: r.keyPrefix, enabled: r.enabled, allowedBotIds: r.allowedBotIds, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt }))
  89. }
  90. /** 返回明文 Key,只在创建时出现一次 */
  91. create(name: string, allowedBotIds: string[]): { key: string; botKey: BotKeyDto } {
  92. if (!name.trim()) throw badRequest('名称不能为空')
  93. const allowed = this.checkBots(allowedBotIds)
  94. const key = `bk_${crypto.randomBytes(24).toString('base64url')}`
  95. const id = newId('bkey')
  96. this.db.insert(botApiKeys).values({ id, name: name.trim(), keyHash: hashKey(key), keyPrefix: key.slice(0, 9), allowedBotIds: allowed }).run()
  97. return { key, botKey: this.list().find(k => k.id === id)! }
  98. }
  99. setBots(id: string, allowedBotIds: string[]) {
  100. const r = this.db.update(botApiKeys).set({ allowedBotIds: this.checkBots(allowedBotIds) }).where(eq(botApiKeys.id, id)).run()
  101. if (r.changes === 0) throw notFound('Bot Key')
  102. }
  103. setEnabled(id: string, enabled: boolean) {
  104. const r = this.db.update(botApiKeys).set({ enabled }).where(eq(botApiKeys.id, id)).run()
  105. if (r.changes === 0) throw notFound('Bot Key')
  106. }
  107. delete(id: string) {
  108. const r = this.db.delete(botApiKeys).where(eq(botApiKeys.id, id)).run()
  109. if (r.changes === 0) throw notFound('Bot Key')
  110. }
  111. /** 删除机器人后,从各个 Key 的可用列表中移除 */
  112. forgetBot(botId: string) {
  113. for (const k of this.list()) {
  114. if (k.allowedBotIds.includes(botId)) this.db.update(botApiKeys).set({ allowedBotIds: k.allowedBotIds.filter(b => b !== botId) }).where(eq(botApiKeys.id, k.id)).run()
  115. }
  116. }
  117. /** 来源可以使用的机器人 id;后台(admin)不受限,返回 null */
  118. allowedBots(source: string): Set<string> | null {
  119. if (source === 'admin') return null
  120. const row = this.db.select().from(botApiKeys).where(eq(botApiKeys.id, source)).get()
  121. return new Set(row?.allowedBotIds ?? [])
  122. }
  123. verify(key: string): string | null {
  124. const row = this.db.select().from(botApiKeys).where(and(eq(botApiKeys.keyHash, hashKey(key)), eq(botApiKeys.enabled, true))).get()
  125. if (!row) return null
  126. this.touch(row.id)
  127. return row.id
  128. }
  129. /** 浏览器令牌鉴权时检查签发它的 Key 仍然存在且启用 */
  130. isActive(id: string): boolean {
  131. const row = this.db.select({ id: botApiKeys.id }).from(botApiKeys).where(and(eq(botApiKeys.id, id), eq(botApiKeys.enabled, true))).get()
  132. if (row) this.touch(id)
  133. return !!row
  134. }
  135. names(): Map<string, string> {
  136. return new Map(this.db.select({ id: botApiKeys.id, name: botApiKeys.name }).from(botApiKeys).all().map(r => [r.id, r.name]))
  137. }
  138. private checkBots(ids: string[]) {
  139. const allowed = [...new Set(parse(z.array(z.string()), ids))]
  140. if (!allowed.length) throw badRequest('至少选择一个机器人')
  141. for (const id of allowed) this.botService.get(id)
  142. return allowed
  143. }
  144. /** 最近使用时间每分钟最多写一次 */
  145. private touch(id: string) {
  146. const now = Date.now()
  147. if ((this.lastTouched.get(id) ?? 0) < now - 60_000) {
  148. this.lastTouched.set(id, now)
  149. this.db.update(botApiKeys).set({ lastUsedAt: nowIso() }).where(eq(botApiKeys.id, id)).run()
  150. }
  151. }
  152. }