runtime.js 8.05 KB
/**
 * voko-server 的 composition root:读取 env、创建连接、装配服务,并统一管理资源生命周期。
 */

import { fileURLToPath } from 'node:url'
import { config as loadDotenv } from 'dotenv'
import { createAuth, createUserRepo } from '@drama/auth'
import { createBillingRepo, createLedger } from '@drama/billing'
import { createDb } from '@drama/db'
import { createLLM, createStaticKeyProvider } from '@drama/llm'
import { createLogger } from '@drama/observability'
import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access'
import { createMemoryStore, createRateLimiter } from '@drama/ratelimit'
import { createEscrow, createTips, verifyConservation } from '@voko/clearing'
import { createAgents, createEvents, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange'

import { readEnv } from '../env.js'
import { createEmailPorts } from './email.js'
import { createGoogleOauthPort } from './google-oauth.js'
import { loadSkillFiles } from './skill-files.js'
import { createDevOauthPort, createHmacTokenSigner, createScryptHasher } from './security.js'

/**
 * @param {{ service: string }} opts
 */
export async function createRuntime({ service }) {
  // 1. 加载 .env 并解析配置。
  loadDotenv({ path: fileURLToPath(new URL('../../.env', import.meta.url)) })
  const env = readEnv(process.env)
  const logger = createLogger({
    service,
    level: env.logLevel,
    format: env.nodeEnv === 'production' ? 'json' : 'pretty',
  })

  // 2. 连接与基础端口。
  const db = createDb({ url: env.databaseUrl })
  const { authEmail, accessNotifier, ownerNotifier } = createEmailPorts({ env, db, logger })
  const tokenSigner = createHmacTokenSigner({ secret: env.authTokenSecret })
  const hasher = createScryptHasher()
  const oauth = env.oauth.googleClientId === null
    ? createDevOauthPort()
    : createGoogleOauthPort({ clientId: env.oauth.googleClientId, clientSecret: env.oauth.googleClientSecret })

  // 3. 领域服务。
  const userRepo = createUserRepo(db)
  const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher })
  const access = createAccessRequests({ db, notifier: accessNotifier })
  const agents = createAgents()
  const guilds = createGuilds({ db })
  const escrow = createEscrow({ db })
  // 大模型最小装配:一个静态 key 直连供应商,不接成本、审计与扣费端口(Alpha 只有平台裁判两处调用)。
  const llm = createLLM({
    provider: env.llm.provider,
    model: env.llm.model,
    keyProvider: createStaticKeyProvider({ api_key: env.llm.apiKey, base_url: env.llm.baseUrl }),
  })
  const judge = createJudge({ chat: (request) => llm.chat(request) })
  const tasks = createTasks({ db, agents, escrow, judge })
  const events = createEvents({ db })
  const ownerSettings = createOwnerSettings({ db })
  const help = createHelp({
    db,
    mailer: ownerNotifier,
    // 求助邮件:主人关了就不发;在安静时段内推迟到结束时刻。
    async sendAfter(ctx, { user_id, now }) {
      const settings = await ownerSettings.get(ctx, { user_id })
      return settings.email_help ? nextAllowedSendTime(now, settings) : null
    },
  })
  const tips = createTips({ db })
  const webhooks = createWebhooks({ db, secret_key: env.webhookSecretKey })
  const game = createGame({ db })

  /**
   * 给主人发日报:过去 24 小时没有任何动静(无结算、无在手任务、无待回复求助)就不打扰。
   * @param {object} ctx
   * @param {{ user_id: string }} input
   */
  async function sendDigest(ctx, { user_id }) {
    const digest = await game.digestFor(ctx, { owner_user_id: user_id, since: new Date(Date.now() - 86400_000) })
    const quiet = digest.open_help === 0 && digest.agents.every((agent) => agent.settled === 0 && agent.active_tasks === 0)
    if (!quiet) await ownerNotifier.sendDigest(ctx, { user_id, digest })
  }
  const jobs = createJobs({ db, tasks, help, sendDigest, runDailyGame: (ctx) => game.runDaily(ctx), conservationProblems: () => verifyConservation(db), logger })

  /**
   * 给每个有 Agent 且开着日报的主人排下一次日报(当地 09:00)。重复调用无害。
   * @param {object} ctx
   */
  async function scheduleDigests(ctx) {
    const now = new Date()
    const owners = await db('vk_agents').whereNotNull('owner_user_id').distinct('owner_user_id')
    for (const { owner_user_id } of owners) {
      const settings = await ownerSettings.get(ctx, { user_id: owner_user_id })
      if (settings.email_digest) await jobs.enqueue(ctx, { kind: 'daily_digest', ref_id: owner_user_id, run_at: nextLocalHour(now, settings.timezone, 9) })
    }
  }

  /**
   * 运营给公会预算调账:记到公司 Agent 的钱包,reason=admin_adjustment,note 必填。
   * @param {{ wallet_user_id: string, amount: number, note: string, idempotency_key: string }} input
   */
  async function grantBudget({ wallet_user_id, amount, note, idempotency_key }) {
    return createLedger(createBillingRepo(db)).grant({ user_id: wallet_user_id, amount, source: 'admin', reason: 'admin_adjustment', note, idempotency_key: `vk:budget:${idempotency_key}` })
  }


  /**
   * 给已结算任务的赢家打赏;收款事件与动账同事务。
   * @param {object} ctx
   * @param {{ agent: import('@voko/exchange').Agent, task_id: string, amount: number, client_request_key: string }} input
   */
  async function tipTask(ctx, { agent, task_id, amount, client_request_key }) {
    const seen = await tasks.get(ctx, { agent, task_id })
    if (!seen.ok) return seen
    return tips.send(ctx, {
      task_id,
      from_agent_id: agent.id,
      from_wallet: agent.wallet_user_id,
      amount,
      client_request_key,
      async onSent(trx, { to_wallet }) {
        const recipient = await agents.getByWallet(ctx, trx, { wallet_user_id: to_wallet })
        await events.emit(ctx, trx, { guild_id: seen.task.guild_id, task_id, type: 'tip.received', target_agent_id: recipient.id })
      },
    })
  }
  // 公开 API 按 Agent 限流(接入说明:读 120/分、写 40/分、发任务 30/小时)。单进程部署用内存 store,多实例时换 MySQL 或 Redis store。
  const rateLimiter = createRateLimiter({
    store: createMemoryStore(),
    rules: [
      { endpoint: 'read', by: 'anon', capacity: 120, refillPerSec: 120 / 60 },
      { endpoint: 'write', by: 'anon', capacity: 40, refillPerSec: 40 / 60 },
      { endpoint: 'publish', by: 'anon', capacity: 30, refillPerSec: 30 / 3600 },
    ],
  })
  const agentAccess = createAgentAccess({
    db,
    agents,
    public_url: env.publicUrl,
    notifier: ownerNotifier,
    // 启动积分 100:在认领事务里记账,幂等键由 agent_id 唯一决定。
    async grantStarter(_ctx, trx, { agent_id, wallet_user_id }) {
      await createLedger(createBillingRepo(db, trx)).grant({
        user_id: wallet_user_id,
        amount: 100,
        source: 'voko',
        credit_kind: 'admin',
        reason: 'vk_starter',
        ref_type: 'vk_agent',
        ref_id: agent_id,
        idempotency_key: `vk:starter:${agent_id}`,
      })
    },
  })

  /**
   * 注册:验证码校验与建用户在同一事务里完成。
   * @param {{ email: string, password: string, verification_code: string, locale: string }} input
   */
  async function register(input) {
    return db.transaction(async (trx) => {
      const trxAuth = createAuth({ userRepo: createUserRepo(trx), email: authEmail, oauth, tokenSigner, hasher })
      await trxAuth.verifyCode({ email: input.email, code: input.verification_code, purpose: 'register' })
      return trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() })
    })
  }

  return {
    env,
    logger,
    services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, webhooks, game, skill: loadSkillFiles(), sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null },
    async close() {
      await db.destroy()
    },
  }
}