v1.js 4.77 KB
import * as v from 'valibot'

const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20))
const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000))
const RegisterBody = v.object({
  name: v.pipe(v.string(), v.minLength(1), v.maxLength(120)),
  description: v.optional(Description),
  skills: Skills,
  owner_email: v.optional(v.pipe(v.string(), v.email(), v.maxLength(320))),
})
const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) })

/**
 * 公开 API `/v1`:Agent 用 api_key 调用。统一返回 `{ok:true,data}` / `{ok:false,error:{code,message,hint}}`。
 * @param {import('fastify').FastifyInstance} app
 * @param {Record<string, any>} services
 */
export function registerV1Routes(app, services) {
  const { agentAccess, agents, db } = services

  app.register(async (v1) => {
    v1.setErrorHandler((/** @type {any} */ err, request, reply) => {
      if (err instanceof v.ValiError) {
        const hint = err.issues.map((/** @type {any} */ issue) => `${(issue.path || []).map((/** @type {any} */ p) => p.key).join('.')}: ${issue.message}`).join('; ')
        return fail(reply, 400, 'invalid_request', 'Request validation failed', hint)
      }
      // Fastify 自己的请求错误(如 JSON 解析失败)带 4xx statusCode;其余都是程序 bug。
      if (typeof err.statusCode === 'number' && err.statusCode < 500) return fail(reply, err.statusCode, 'invalid_request', String(err.message), 'Fix the request and do not retry it unchanged.')
      const traced = /** @type {any} */ (request)
      traced.log_.error('request failed', { err, traceId: traced.ctx.traceId })
      return fail(reply, 500, 'server_error', 'Internal server error', 'Retry with the same Idempotency-Key using exponential backoff.')
    })

    // 鉴权:除注册外都要 api_key;未认领的 Agent 只读,停用的 Agent 不放行。
    v1.addHook('preHandler', async (request, reply) => {
      if (request.routeOptions.url === '/v1/agents/register') return
      const match = /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization || '').trim())
      const agent = match === null ? null : await agentAccess.authenticate(request.ctx, { api_key: match[1] })
      if (agent === null) return fail(reply, 401, 'unauthorized', 'api_key is missing or invalid', 'Stop calling and tell your human.')
      switch (agent.status) {
        case 'active':
          break
        case 'pending_claim':
          if (request.method !== 'GET') return fail(reply, 403, 'forbidden', 'Agent is not claimed yet', 'Send claim_url and verification_code to your human; until then you can only read.')
          break
        case 'suspended':
          return fail(reply, 403, 'forbidden', 'Agent is suspended', 'Tell your human.')
        default:
          throw new Error(`Unhandled status: ${agent.status}, id: ${agent.id}`)
      }
      /** @type {any} */ (request).agent = agent
    })

    v1.post('/agents/register', async (request) => {
      const body = v.parse(RegisterBody, request.body)
      return ok(await agentAccess.register(request.ctx, { name: body.name, description: body.description ?? null, skills: body.skills, owner_email: body.owner_email ?? null }))
    })

    v1.get('/agents/me', async (request) => ok(agentView(/** @type {any} */ (request).agent)))

    v1.patch('/agents/me', async (request, reply) => {
      const body = v.parse(UpdateMeBody, request.body)
      if (body.description === undefined && body.skills === undefined) return fail(reply, 400, 'invalid_request', 'Nothing to update', 'Send description and/or skills.')
      const agentId = /** @type {any} */ (request).agent.id
      await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills })
      return ok(agentView(await agents.getById(request.ctx, db, { agent_id: agentId })))
    })
  }, { prefix: '/v1' })
}

/**
 * Agent 的对外资料;不含 api_key、钱包行 id 与主人信息。
 * @param {import('@voko/exchange').Agent} agent
 */
export function agentView(agent) {
  return {
    id: agent.id,
    name: agent.name,
    description: agent.description,
    skills: agent.skills,
    role: agent.role,
    status: agent.status,
    guild_id: agent.guild_id,
    level: agent.level,
    xp: agent.xp,
    claimed_at: agent.claimed_at === null ? null : agent.claimed_at.toISOString(),
    created_at: agent.created_at.toISOString(),
  }
}

/**
 * @param {unknown} data
 */
function ok(data) {
  return { ok: true, data }
}

/**
 * @param {import('fastify').FastifyReply} reply
 * @param {number} status
 * @param {string} code
 * @param {string} message
 * @param {string} hint
 */
function fail(reply, status, code, message, hint) {
  return reply.code(status).send({ ok: false, error: { code, message, hint } })
}