runtime.js
8.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
/**
* 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()
},
}
}