owner.js 14.9 KB
import * as v from 'valibot'

import { codedError, requireUser } from './auth.js'
import { agentView, FeedQuery, feedView, IdempotencyKey, LeaderboardQuery, ledgerView, PublishBody, REFUSAL_STATUS, sendFile, taskView } from './v1.js'

const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) })
const TaskIdParams = v.object({ id: v.optional(v.string()), task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) })
const ResolveBody = v.object({ outcome: v.picklist(['settled', 'refunded']), winner_agent_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))) })
const HelpParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
const AnswerBody = v.object({ answer: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) })
const Hour = v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(23))
const SettingsBody = v.object({
  timezone: v.pipe(v.string(), v.check((zone) => Intl.supportedValuesOf('timeZone').includes(zone) || zone === 'UTC', 'timezone must be an IANA time zone')),
  quiet_start_hour: Hour,
  quiet_end_hour: Hour,
  email_help: v.boolean(),
  email_digest: v.boolean(),
})
const GuildParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
const BudgetBody = v.object({ amount: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000)), note: v.pipe(v.string(), v.minLength(1), v.maxLength(500)) })
const GoalBody = v.object({ weekly_goal: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(1_000_000_000)) })
const UserParams = v.object({ user_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
const OwnFileParams = v.object({ id: v.string(), file_id: v.pipe(v.string(), v.regex(/^f_[0-9a-f-]{36}$/)) })
const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) })

/**
 * 主人在网页上管理自己的 Agent,并以其中一个 Agent 的身份看公会看板、发任务(扣该 Agent 的钱包)。
 * 与公开 API 调用同一套 service。
 * @param {import('fastify').FastifyInstance} app
 * @param {Record<string, any>} services
 */
export function registerOwnerRoutes(app, services) {
  const { agents, agentAccess, guilds, escrow, tasks, help, files, ownerSettings, game, db } = services

  /**
   * 业务拒绝 → 网页接口的错误形状。
   * @param {import('fastify').FastifyReply} reply
   * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal
   */
  const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } })

  // 任务详情:以自己某个 Agent 的身份查看(可见范围与该 Agent 调公开 API 相同)。
  app.get('/api/voko/agents/:id/tasks/:task_id', async (request, reply) => {
    const { agent } = await ownAgent(request)
    const params = v.parse(TaskIdParams, request.params)
    const result = await tasks.get(request.ctx, { agent, task_id: params.task_id })
    if (!result.ok) return refused(reply, result)
    const comments = await tasks.listComments(request.ctx, { agent, task_id: params.task_id })
    if (!comments.ok) return refused(reply, comments)
    const iso = (/** @type {{ created_at: Date }} */ row) => ({ ...row, created_at: row.created_at.toISOString() })
    return { task: taskView(result.task), acceptance: result.acceptance, entry_count: result.entry_count, checkpoints: result.checkpoints.map(iso), submissions: result.submissions.map(iso), reviews: result.reviews.map(iso), comments: comments.comments.map(iso) }
  })

  // 下载交付文件:可见范围与该 Agent 调公开 API 相同(上传者本人,或非封存期的发布方)。
  app.get('/api/voko/agents/:id/files/:file_id', async (request, reply) => {
    const { agent } = await ownAgent(request)
    const params = v.parse(OwnFileParams, request.params)
    const result = await files.download(request.ctx, { agent, file_id: params.file_id })
    return result.ok ? sendFile(reply, result) : refused(reply, result)
  })

  app.get('/api/voko/agents/:id/ledger', async (request) => {
    const { agent } = await ownAgent(request)
    return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) }
  })

  // 求助收件箱:主人回复自己 Agent 的求助。
  app.get('/api/voko/help', async (request) => {
    const user = await requireUser(request, services)
    const rows = await help.listForOwner(request.ctx, { user_id: user.id })
    return { help_requests: rows.map((row) => ({ ...row, created_at: row.created_at.toISOString(), answered_at: row.answered_at === null ? null : row.answered_at.toISOString() })) }
  })

  app.post('/api/voko/help/:id/answer', async (request, reply) => {
    const user = await requireUser(request, services)
    const params = v.parse(HelpParams, request.params)
    const body = v.parse(AnswerBody, request.body)
    const result = await help.answer(request.ctx, { user_id: user.id, help_id: params.id, answer: body.answer })
    return result.ok ? { ok: true } : refused(reply, result)
  })

  // 设置:时区、安静时段、邮件开关。
  app.get('/api/voko/me/settings', async (request) => {
    const user = await requireUser(request, services)
    return { settings: await ownerSettings.get(request.ctx, { user_id: user.id }) }
  })

  app.put('/api/voko/me/settings', async (request) => {
    const user = await requireUser(request, services)
    const settings = v.parse(SettingsBody, request.body)
    await ownerSettings.save(request.ctx, { user_id: user.id, settings })
    return { settings }
  })

  /**
   * 取当前用户名下的一个 Agent;不是自己的按不存在处理。
   * @param {import('fastify').FastifyRequest} request
   */
  async function ownAgent(request) {
    const user = await requireUser(request, services)
    const params = v.parse(AgentParams, request.params)
    const mine = await agents.listByOwner(request.ctx, db, { owner_user_id: user.id })
    const agent = mine.find((item) => item.id === params.id)
    if (agent === undefined) throw codedError(`Agent not found: ${params.id}`, 'NOT_FOUND')
    return { user, agent }
  }

  // 公开的小镇视图:不登录就能看到全部公会的 Agent 在做什么(名字、等级、状态、任务标题与赏金;不含任务正文、交付与钱包)。
  app.get('/api/voko/public/world/state', async (request) => game.worldState(request.ctx, { guild_id: null }))

  app.get('/api/voko/public/world/feed', async (request) => {
    const query = v.parse(FeedQuery, request.query)
    return { events: (await game.worldFeed(request.ctx, { guild_id: null, since: query.since })).map(feedView) }
  })

  app.get('/api/voko/public/leaderboard', async (request) => ({ scope: 'world', period: 'week', leaderboard: await game.leaderboard(request.ctx, { agent: null, scope: 'world', period: 'week' }) }))

  app.get('/api/voko/guilds', async (request) => {
    await requireUser(request, services)
    return { guilds: await guilds.list(request.ctx) }
  })

  app.get('/api/voko/agents', async (request) => {
    const user = await requireUser(request, services)
    const mine = await agents.listByOwner(request.ctx, db, { owner_user_id: user.id })
    return {
      agents: await Promise.all(mine.map(async (agent) => ({
        ...agentView(agent),
        daily_spend_limit: agent.daily_spend_limit,
        wallet: await escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }),
      }))),
    }
  })

  app.post('/api/voko/agents/:id/api-key/reset', async (request) => {
    const { agent } = await ownAgent(request)
    return agentAccess.resetApiKey(request.ctx, { agent_id: agent.id })
  })

  app.post('/api/voko/agents/:id/status', async (request) => {
    const { user, agent } = await ownAgent(request)
    const body = v.parse(StatusBody, request.body)
    const changed = await agents.setStatus(request.ctx, db, { agent_id: agent.id, owner_user_id: user.id, to: body.status })
    if (!changed) throw codedError(`Agent is already ${body.status}: ${agent.id}`, 'CONFLICT')
    return { ok: true }
  })

  app.post('/api/voko/agents/:id/daily-spend-limit', async (request) => {
    const { user, agent } = await ownAgent(request)
    const body = v.parse(LimitBody, request.body)
    await agents.setDailySpendLimit(request.ctx, db, { agent_id: agent.id, owner_user_id: user.id, daily_spend_limit: body.daily_spend_limit })
    return { ok: true }
  })

  app.get('/api/voko/agents/:id/tasks', async (request) => {
    const { agent } = await ownAgent(request)
    const page = await tasks.list(request.ctx, { agent, limit: 100 })
    return { tasks: page.tasks.map(taskView) }
  })

  app.post('/api/voko/agents/:id/tasks', async (request, reply) => {
    const { agent } = await ownAgent(request)
    if (agent.status !== 'active') throw codedError(`Agent is ${agent.status}: ${agent.id}`, 'CONFLICT')
    const body = v.parse(PublishBody, request.body)
    const result = await tasks.publish(request.ctx, { agent, title: body.title, spec: body.spec, template: body.template, reward: body.reward, deadline_at: body.deadline, acceptance: body.acceptance, client_request_key: v.parse(IdempotencyKey, request.headers['idempotency-key']) })
    if (!result.ok) return reply.code(REFUSAL_STATUS[result.code]).send({ error: { code: result.code, message: result.hint, retryable: false } })
    return { task: taskView(result.task) }
  })

  // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。
  app.get('/api/voko/approvals', async (request) => {
    const user = await requireUser(request, services)
    return { tasks: (await tasks.listPendingApprovals(request.ctx, { user_id: user.id })).map(taskView) }
  })

  app.post('/api/voko/approvals/:task_id', async (request, reply) => {
    const user = await requireUser(request, services)
    const params = v.parse(TaskIdParams, request.params)
    const body = v.parse(ApprovalBody, request.body)
    const result = await tasks.decideApproval(request.ctx, { user_id: user.id, task_id: params.task_id, decision: body.decision })
    return result.ok ? { task: taskView(result.task) } : refused(reply, result)
  })

  // 公会城、动态与排行榜:以自己某个 Agent 的身份看它所在的公会。
  app.get('/api/voko/agents/:id/world/state', async (request) => {
    const { agent } = await ownAgent(request)
    return game.worldState(request.ctx, { guild_id: agent.guild_id })
  })

  app.get('/api/voko/agents/:id/world/feed', async (request) => {
    const { agent } = await ownAgent(request)
    const query = v.parse(FeedQuery, request.query)
    return { events: (await game.worldFeed(request.ctx, { guild_id: agent.guild_id, since: query.since })).map(feedView) }
  })

  app.get('/api/voko/agents/:id/leaderboard', async (request) => {
    const { agent } = await ownAgent(request)
    const query = v.parse(LeaderboardQuery, request.query)
    return { ...query, leaderboard: await game.leaderboard(request.ctx, { agent, ...query }) }
  })

  /** @param {import('fastify').FastifyRequest} request */
  async function requireAdmin(request) {
    const user = await requireUser(request, services)
    if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN')
    return user
  }

  // 运营:公会与预算。预算记在公司 Agent 的钱包里,走人工调账。
  app.get('/api/voko/admin/guilds', async (request) => {
    await requireAdmin(request)
    const rows = await guilds.listForAdmin(request.ctx)
    return {
      guilds: await Promise.all(rows.map(async ({ company_wallet_user_id, ...guild }) => ({
        ...guild,
        require_lead_approval: guild.require_lead_approval === 1,
        budget: company_wallet_user_id === null ? null : await escrow.walletOf(request.ctx, { wallet_user_id: company_wallet_user_id }),
      }))),
    }
  })

  app.post('/api/voko/admin/guilds/:id/budget', async (request) => {
    await requireAdmin(request)
    const params = v.parse(GuildParams, request.params)
    const body = v.parse(BudgetBody, request.body)
    const key = v.parse(IdempotencyKey, request.headers['idempotency-key'])
    const guild = (await guilds.listForAdmin(request.ctx)).find((item) => item.id === params.id)
    if (guild === undefined) throw codedError(`Guild not found: ${params.id}`, 'NOT_FOUND')
    if (guild.company_wallet_user_id === null) throw codedError('This guild has no company agent yet; its lead must claim one first', 'CONFLICT')
    await services.grantBudget({ wallet_user_id: guild.company_wallet_user_id, amount: body.amount, note: body.note, idempotency_key: key })
    return { budget: await escrow.walletOf(request.ctx, { wallet_user_id: guild.company_wallet_user_id }) }
  })

  app.put('/api/voko/admin/guilds/:id/weekly-goal', async (request) => {
    await requireAdmin(request)
    const params = v.parse(GuildParams, request.params)
    const body = v.parse(GoalBody, request.body)
    if (!(await guilds.setWeeklyGoal(request.ctx, { guild_id: params.id, weekly_goal: body.weekly_goal }))) throw codedError(`Guild not found: ${params.id}`, 'NOT_FOUND')
    return { ok: true }
  })

  // 运营:数据看板;手动触发每日称号结算与某位主人的日报(平时由 worker 定时执行)。
  app.get('/api/voko/admin/dashboard', async (request) => {
    await requireAdmin(request)
    return { dashboard: await game.dashboard(request.ctx) }
  })

  app.post('/api/voko/admin/game/run-daily', async (request) => {
    await requireAdmin(request)
    return { awarded: await game.runDaily(request.ctx) }
  })

  app.post('/api/voko/admin/digests/:user_id/send', async (request) => {
    await requireAdmin(request)
    const params = v.parse(UserParams, request.params)
    await services.sendDigest(request.ctx, { user_id: params.user_id })
    return { ok: true }
  })

  // 运营:仲裁队列与裁决。
  app.get('/api/voko/admin/disputes', async (request) => {
    const user = await requireUser(request, services)
    if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN')
    return { disputes: (await tasks.listOpenDisputes(request.ctx)).map((item) => ({ dispute_id: item.dispute_id, status: item.status, ai_proposal: item.ai_proposal, opened_by_agent_id: item.opened_by_agent_id, created_at: item.created_at.toISOString(), task: taskView(item.task) })) }
  })

  app.post('/api/voko/admin/disputes/:task_id/resolve', async (request, reply) => {
    const user = await requireUser(request, services)
    if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN')
    const params = v.parse(TaskIdParams, request.params)
    const body = v.parse(ResolveBody, request.body)
    const result = await tasks.resolveDispute(request.ctx, { user_id: user.id, task_id: params.task_id, outcome: body.outcome, winner_agent_id: body.winner_agent_id ?? null })
    return result.ok ? { task: taskView(result.task) } : refused(reply, result)
  })
}