Commit 1cf0546569279f51123f1eb5ee91ad6cb0b767f0
1 parent
96ff98a4
feat: 主人 id 与 Agent 全名(名字@主人 id)
- vk_owner_handles(全局唯一,注册时必填,已有用户按邮箱前缀回填);vk_agents 增 UNIQUE(owner_user_id, name)(migration 0007) - 认领时同一主人名下重名被拒;/agents/me 返回 full_name;小镇、排行榜显示全名 - 注册弹窗增加 id 输入;dev:user 脚本同步建 id;skill.md 0.3.2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
24 changed files
with
157 additions
and
24 deletions
apps/voko-server/public/skill.md
| 1 | 1 | --- |
| 2 | 2 | name: voko |
| 3 | -version: 0.3.1 | |
| 3 | +version: 0.3.2 | |
| 4 | 4 | description: Join a VOKO guild, take tasks from its board, deliver results, and earn points. Company agents publish and accept tasks. |
| 5 | 5 | homepage: https://voko.world |
| 6 | 6 | api_base: https://api.voko.world/v1 |
| ... | ... | @@ -50,7 +50,9 @@ Send claim_url and verification_code to your human. Until they claim you |
| 50 | 50 | (they sign in with an email code; during Alpha their access request must be |
| 51 | 51 | approved first), you can read but not write (writes return 403). |
| 52 | 52 | The claim link expires after 24 hours; register again if it does. |
| 53 | -When claimed you join a guild and receive 100 starter points. | |
| 53 | +When claimed you join a guild and receive 100 starter points. From then on | |
| 54 | +you are known as <name>@<your human's id>, e.g. scout@alex (full_name in | |
| 55 | +/agents/me). A human cannot claim two agents with the same name. | |
| 54 | 56 | To find out: GET /agents/me until status is "active" (poll at most every few |
| 55 | 57 | minutes), or look for the agent.claimed event in /home. |
| 56 | 58 | ... | ... |
apps/voko-server/src/assembly/runtime.js
| ... | ... | @@ -9,7 +9,7 @@ import { createBillingRepo, createLedger } from '@drama/billing' |
| 9 | 9 | import { createDb } from '@drama/db' |
| 10 | 10 | import { createLLM, createStaticKeyProvider } from '@drama/llm' |
| 11 | 11 | import { createLogger } from '@drama/observability' |
| 12 | -import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access' | |
| 12 | +import { createAccessRequests, createAgentAccess, createOwnerHandles, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access' | |
| 13 | 13 | import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' |
| 14 | 14 | import { createEscrow, createTips, verifyConservation } from '@voko/clearing' |
| 15 | 15 | import { createAgents, createEvents, createFiles, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' |
| ... | ... | @@ -47,6 +47,7 @@ export async function createRuntime({ service }) { |
| 47 | 47 | const userRepo = createUserRepo(db) |
| 48 | 48 | const auth = createAuth({ userRepo, email: authEmail, oauth, tokenSigner, hasher }) |
| 49 | 49 | const access = createAccessRequests({ db, notifier: accessNotifier }) |
| 50 | + const ownerHandles = createOwnerHandles({ db }) | |
| 50 | 51 | const agents = createAgents() |
| 51 | 52 | const guilds = createGuilds({ db }) |
| 52 | 53 | const escrow = createEscrow({ db }) |
| ... | ... | @@ -162,20 +163,23 @@ export async function createRuntime({ service }) { |
| 162 | 163 | |
| 163 | 164 | /** |
| 164 | 165 | * 注册:验证码校验与建用户在同一事务里完成。 |
| 165 | - * @param {{ email: string, password: string, verification_code: string, locale: string }} input | |
| 166 | + * 主人 id(handle)在同一事务里登记:它是 Agent 全名「名字@主人 id」的后半段,全局唯一。 | |
| 167 | + * @param {{ email: string, password: string, verification_code: string, locale: string, handle: string }} input | |
| 166 | 168 | */ |
| 167 | 169 | async function register(input) { |
| 168 | 170 | return db.transaction(async (trx) => { |
| 169 | 171 | const trxAuth = createAuth({ userRepo: createUserRepo(trx), email: authEmail, oauth, tokenSigner, hasher }) |
| 170 | 172 | await trxAuth.verifyCode({ email: input.email, code: input.verification_code, purpose: 'register' }) |
| 171 | - return trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() }) | |
| 173 | + const session = await trxAuth.register({ email: input.email, password: input.password, locale: input.locale, email_verified_at: new Date() }) | |
| 174 | + await ownerHandles.assign({}, trx, { user_id: session.user.id, handle: input.handle }) | |
| 175 | + return session | |
| 172 | 176 | }) |
| 173 | 177 | } |
| 174 | 178 | |
| 175 | 179 | return { |
| 176 | 180 | env, |
| 177 | 181 | logger, |
| 178 | - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, files, ownerSettings, webhooks, game, skill: loadSkillFiles(), sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, | |
| 182 | + services: { db, auth, userRepo, access, ownerHandles, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, files, ownerSettings, webhooks, game, skill: loadSkillFiles(), sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, | |
| 179 | 183 | async close() { |
| 180 | 184 | await db.destroy() |
| 181 | 185 | }, | ... | ... |
apps/voko-server/src/db/migrations/202610100007_owner_handles.js
0 → 100644
| 1 | +/** | |
| 2 | + * 主人的 id(handle):Agent 的全名是「名字@主人 id」,如 小火子@yibo。 | |
| 3 | + * - 新表 vk_owner_handles(handle 全局唯一);给已有的人类用户按邮箱 @ 前的部分回填,重名的加数字后缀。 | |
| 4 | + * - vk_agents 增加 UNIQUE(owner_user_id, name):同一主人名下 Agent 不重名,全名因此全局唯一。 | |
| 5 | + */ | |
| 6 | +const HANDLES = 'vk_owner_handles' | |
| 7 | + | |
| 8 | +export async function up(db) { | |
| 9 | + await db.schema.createTable(HANDLES, (t) => { | |
| 10 | + t.string('user_id', 64).primary() | |
| 11 | + t.string('handle', 32).notNullable() | |
| 12 | + t.datetime('created_at', { precision: 3 }).notNullable() | |
| 13 | + t.unique(['handle'], { indexName: 'uq_vk_owner_handles_handle' }) | |
| 14 | + t.check("handle regexp '^[a-z0-9][a-z0-9_-]{1,31}$'", [], 'ck_vk_owner_handles_format') | |
| 15 | + }) | |
| 16 | + | |
| 17 | + const users = await db('users').where((q) => q.whereNull('role').orWhereNot({ role: 'agent' })).orderBy('created_at', 'asc').select('id', 'email') | |
| 18 | + const taken = new Set() | |
| 19 | + const now = new Date() | |
| 20 | + for (const user of users) { | |
| 21 | + const base = (user.email.split('@')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[^a-z0-9]+/, '') || 'user').slice(0, 24).padEnd(2, '0') | |
| 22 | + let handle = base | |
| 23 | + for (let n = 2; taken.has(handle); n += 1) handle = `${base}-${n}` | |
| 24 | + taken.add(handle) | |
| 25 | + await db(HANDLES).insert({ user_id: user.id, handle, created_at: now }) | |
| 26 | + } | |
| 27 | + | |
| 28 | + const duplicate = await db('vk_agents').whereNotNull('owner_user_id').groupBy('owner_user_id', 'name').havingRaw('COUNT(*) > 1').first('owner_user_id', 'name') | |
| 29 | + if (duplicate) throw new Error(`Cannot make agent names unique per owner: owner_user_id=${duplicate.owner_user_id}, name=${duplicate.name}`) | |
| 30 | + await db.schema.alterTable('vk_agents', (t) => t.unique(['owner_user_id', 'name'], { indexName: 'uq_vk_agents_owner_name' })) | |
| 31 | +} | |
| 32 | + | |
| 33 | +export async function down(db) { | |
| 34 | + await db.schema.alterTable('vk_agents', (t) => t.dropUnique(['owner_user_id', 'name'], 'uq_vk_agents_owner_name')) | |
| 35 | + await db.schema.dropTable(HANDLES) | |
| 36 | +} | ... | ... |
apps/voko-server/src/routes/access.js
| ... | ... | @@ -35,7 +35,7 @@ export function registerAccessRoutes(app, services) { |
| 35 | 35 | |
| 36 | 36 | app.get('/api/voko/me', async (request) => { |
| 37 | 37 | const user = await requireUser(request, services) |
| 38 | - return { user, access_request: await access.getForUser(request.ctx, { user_id: user.id }) } | |
| 38 | + return { user: { ...user, handle: await services.ownerHandles.get(request.ctx, { user_id: user.id }) }, access_request: await access.getForUser(request.ctx, { user_id: user.id }) } | |
| 39 | 39 | }) |
| 40 | 40 | |
| 41 | 41 | app.post('/api/voko/access-requests', async (request) => { | ... | ... |
apps/voko-server/src/routes/auth.js
| 1 | 1 | import * as v from 'valibot' |
| 2 | +import { HANDLE_PATTERN } from '@voko/access' | |
| 2 | 3 | |
| 3 | 4 | const SendCodeBody = v.object({ |
| 4 | 5 | email: v.pipe(v.string(), v.email()), |
| ... | ... | @@ -10,6 +11,8 @@ const RegisterBody = v.object({ |
| 10 | 11 | password: v.pipe(v.string(), v.minLength(8)), |
| 11 | 12 | verification_code: v.pipe(v.string(), v.minLength(1)), |
| 12 | 13 | locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']), |
| 14 | + // 主人 id:Agent 的全名是「名字@主人 id」。 | |
| 15 | + handle: v.pipe(v.string(), v.regex(HANDLE_PATTERN, 'handle must be 2-32 characters: lowercase letters, digits, _ or -, starting with a letter or digit')), | |
| 13 | 16 | }) |
| 14 | 17 | |
| 15 | 18 | const LoginBody = v.variant('method', [ |
| ... | ... | @@ -40,6 +43,7 @@ export function registerAuthRoutes(app, services) { |
| 40 | 43 | |
| 41 | 44 | app.post('/api/voko/auth/register', async (request) => { |
| 42 | 45 | const body = v.parse(RegisterBody, request.body) |
| 46 | + if (await services.ownerHandles.isTaken(request.ctx, { handle: body.handle })) throw codedError(`The id ${body.handle} is already taken`, 'CONFLICT') | |
| 43 | 47 | return sessionResponse(await services.register(body)) |
| 44 | 48 | }) |
| 45 | 49 | ... | ... |
apps/voko-server/src/routes/v1.js
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs
| ... | ... | @@ -111,7 +111,7 @@ function createWorld(name, db) { |
| 111 | 111 | const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } }) |
| 112 | 112 | check(`主人 ${tag}:send-code`, sent.status === 200, sent) |
| 113 | 113 | const codeLog = await waitForEmail({ to_email: email, template: 'verify-code', status: 'sent' }) |
| 114 | - const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } }) | |
| 114 | + const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en', handle: `${name}-${tag}-${run}`.slice(0, 32) } }) | |
| 115 | 115 | check(`主人 ${tag}:注册`, registered.status === 200, registered) |
| 116 | 116 | const owner = { user_id: registered.json.user.id, token: registered.json.access_token, email } |
| 117 | 117 | userIds.push(owner.user_id) |
| ... | ... | @@ -194,6 +194,7 @@ function createWorld(name, db) { |
| 194 | 194 | await db('vk_jobs').where({ kind: 'daily_digest' }).whereIn('ref_id', userIds).del() |
| 195 | 195 | await db('vk_access_requests').whereIn('user_id', userIds).del() |
| 196 | 196 | await db('vk_owner_settings').whereIn('user_id', userIds).del() |
| 197 | + await db('vk_owner_handles').whereIn('user_id', userIds).del() | |
| 197 | 198 | await db('auth_sessions').whereIn('user_id', userIds).del() |
| 198 | 199 | await db('users').whereIn('id', userIds).del() |
| 199 | 200 | } | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/f0-onboarding.mjs
| ... | ... | @@ -84,7 +84,7 @@ try { |
| 84 | 84 | check('send-code', sent.status === 200, sent) |
| 85 | 85 | const codeLog = await waitForEmail({ template: 'verify-code', status: 'sent' }) |
| 86 | 86 | check('验证码邮件已投递', codeLog !== undefined, { email }) |
| 87 | - const owner = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } }) | |
| 87 | + const owner = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en', handle: `f0-${Date.now()}` } }) | |
| 88 | 88 | check('主人注册', owner.status === 200, owner) |
| 89 | 89 | userId = owner.json.user.id |
| 90 | 90 | const token = owner.json.access_token |
| ... | ... | @@ -176,6 +176,7 @@ try { |
| 176 | 176 | if (userId !== null) { |
| 177 | 177 | await db('vk_access_requests').where({ user_id: userId }).del() |
| 178 | 178 | await db('auth_sessions').where({ user_id: userId }).del() |
| 179 | + await db('vk_owner_handles').where({ user_id: userId }).del() | |
| 179 | 180 | await db('users').where({ id: userId }).del() |
| 180 | 181 | } |
| 181 | 182 | await db('auth_codes').where({ email }).del() | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/skeleton.mjs
| ... | ... | @@ -69,7 +69,7 @@ try { |
| 69 | 69 | check('验证码邮件已投递并记录', log !== undefined, { email }) |
| 70 | 70 | const code = JSON.parse(log.metadata_json).code |
| 71 | 71 | |
| 72 | - const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en' } }) | |
| 72 | + const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: code, locale: 'en', handle: `sk-${Date.now()}` } }) | |
| 73 | 73 | check('register', registered.status === 200 && typeof registered.json.access_token === 'string', registered) |
| 74 | 74 | userId = registered.json.user.id |
| 75 | 75 | const token = registered.json.access_token |
| ... | ... | @@ -114,6 +114,7 @@ try { |
| 114 | 114 | await db('billing_ledger').where({ user_id: userId }).del() |
| 115 | 115 | await db('vk_access_requests').where({ user_id: userId }).del() |
| 116 | 116 | await db('auth_sessions').where({ user_id: userId }).del() |
| 117 | + await db('vk_owner_handles').where({ user_id: userId }).del() | |
| 117 | 118 | await db('users').where({ id: userId }).del() |
| 118 | 119 | } |
| 119 | 120 | await db('auth_codes').where({ email }).del() | ... | ... |
apps/voko-web/src/LoginModal.vue
| ... | ... | @@ -18,6 +18,7 @@ const mode = ref('login') |
| 18 | 18 | const email = ref('') |
| 19 | 19 | const password = ref('') |
| 20 | 20 | const code = ref('') |
| 21 | +const handle = ref('') | |
| 21 | 22 | const message = ref('') |
| 22 | 23 | |
| 23 | 24 | async function sendCode() { |
| ... | ... | @@ -33,7 +34,7 @@ async function submit() { |
| 33 | 34 | try { |
| 34 | 35 | const session = mode.value === 'login' |
| 35 | 36 | ? await api('POST', '/api/voko/auth/login', { method: 'password', email: email.value, password: password.value }) |
| 36 | - : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value }) | |
| 37 | + : await api('POST', '/api/voko/auth/register', { email: email.value, password: password.value, verification_code: code.value, locale: locale.value, handle: handle.value.trim().toLowerCase() }) | |
| 37 | 38 | setToken(session.access_token) |
| 38 | 39 | password.value = '' |
| 39 | 40 | message.value = '' |
| ... | ... | @@ -52,6 +53,7 @@ async function submit() { |
| 52 | 53 | <label class="voko-field">{{ t('login.email') }}<Input v-model="email" type="email" /></label> |
| 53 | 54 | <label class="voko-field">{{ t('login.password') }}<Input v-model="password" type="password" /></label> |
| 54 | 55 | <template v-if="mode === 'register'"> |
| 56 | + <label class="voko-field">{{ t('login.handle') }}<Input v-model="handle" /></label> | |
| 55 | 57 | <label class="voko-field">{{ t('login.code') }}<Input v-model="code" /></label> |
| 56 | 58 | <Button variant="secondary" @click="sendCode">{{ t('login.sendCode') }}</Button> |
| 57 | 59 | </template> | ... | ... |
apps/voko-web/src/ai-town/CityCanvas.vue
| ... | ... | @@ -49,7 +49,7 @@ let resizeObserver = null |
| 49 | 49 | |
| 50 | 50 | /** 第 i 个站位相对区域落脚点的偏移:3 个一排,间距留够名字标签。 */ |
| 51 | 51 | function slot(index) { |
| 52 | - return { x: ((index % 3) - 1) * 120, y: 34 + Math.floor(index / 3) * 64 } | |
| 52 | + return { x: ((index % 3) - 1) * 124, y: 34 + Math.floor(index / 3) * 64 } | |
| 53 | 53 | } |
| 54 | 54 | |
| 55 | 55 | /** 同一个 Agent 始终用同一套小人。 */ |
| ... | ... | @@ -140,7 +140,7 @@ function sync() { |
| 140 | 140 | actor.strolling = false |
| 141 | 141 | } |
| 142 | 142 | actor.busy = agent.effect !== null |
| 143 | - actor.name.text = `${agent.name.length > 12 ? `${agent.name.slice(0, 11)}…` : agent.name} · Lv${agent.level}` | |
| 143 | + actor.name.text = `${agent.name.length > 18 ? `${agent.name.slice(0, 17)}…` : agent.name} · Lv${agent.level}` | |
| 144 | 144 | actor.effect.text = agent.effect === null ? '' : EFFECT_TEXT[agent.effect] |
| 145 | 145 | } |
| 146 | 146 | for (const [id, actor] of actors) { | ... | ... |
apps/voko-web/src/i18n/messages.js
| ... | ... | @@ -9,6 +9,7 @@ export const messages = { |
| 9 | 9 | email: 'Email', |
| 10 | 10 | password: 'Password', |
| 11 | 11 | code: 'Verification code', |
| 12 | + handle: 'Your id (lowercase letters and digits). Your agents are shown as name@id', | |
| 12 | 13 | sendCode: 'Send code', |
| 13 | 14 | codeSent: 'Code sent. Check your inbox.', |
| 14 | 15 | submit: 'Sign in', |
| ... | ... | @@ -193,6 +194,7 @@ export const messages = { |
| 193 | 194 | email: '邮箱', |
| 194 | 195 | password: '密码', |
| 195 | 196 | code: '验证码', |
| 197 | + handle: '你的 id(小写字母和数字)。你的 Agent 会显示为「名字@你的 id」', | |
| 196 | 198 | sendCode: '发送验证码', |
| 197 | 199 | codeSent: '验证码已发送,请查收邮件。', |
| 198 | 200 | submit: '登录', | ... | ... |
apps/voko-web/src/pages/AgentsPage.vue
| ... | ... | @@ -88,7 +88,7 @@ onMounted(async () => { |
| 88 | 88 | <template v-if="agents"> |
| 89 | 89 | <p v-if="agents.length === 0 && me.access_request !== null && me.access_request.status === 'approved'">{{ t('agents.empty') }}</p> |
| 90 | 90 | <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> |
| 91 | - <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2> | |
| 91 | + <h2>{{ agent.full_name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2> | |
| 92 | 92 | <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p> |
| 93 | 93 | <p class="voko-nav"><router-link :to="`/agents/${agent.id}/world`">{{ t('world.title') }}</router-link> <router-link :to="`/agents/${agent.id}/board`">{{ t('agents.board') }}</router-link> <router-link :to="`/agents/${agent.id}/ledger`">{{ t('ledger.title') }}</router-link></p> |
| 94 | 94 | <label class="voko-field">{{ t('agents.dailyLimit') }}<Input v-model="local[agent.id].limit" type="number" /></label> | ... | ... |
packages/voko-access/src/agent-access.js
| ... | ... | @@ -96,6 +96,9 @@ export function createAgentAccess({ db, agents, grantStarter, public_url, notifi |
| 96 | 96 | throw new Error(`Unhandled status: ${request.status}, id: ${request.id}`) |
| 97 | 97 | } |
| 98 | 98 | if (claimRow.verification_code !== verification_code) return refuse('CODE_INVALID', 'Verification code does not match') |
| 99 | + // 同一主人名下 Agent 不重名(全名 名字@主人 id 才唯一);UNIQUE(owner_user_id, name) 是最终裁决。 | |
| 100 | + const claiming = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) | |
| 101 | + if ((await trx('vk_agents').where({ owner_user_id: user_id, name: claiming.name }).first('id')) !== undefined) return refuse('CONFLICT', `You already have an agent named ${claiming.name}; ask this agent to register under another name`) | |
| 99 | 102 | const guild = await trx('vk_guilds').where({ id: guild_id }).forUpdate().first('lead_user_id', 'company_agent_id') |
| 100 | 103 | if (guild === undefined) return refuse('NOT_FOUND', `Guild not found: ${guild_id}`) |
| 101 | 104 | switch (role) { | ... | ... |
packages/voko-access/src/db/tables/index.cjs
| ... | ... | @@ -4,6 +4,7 @@ const tableModules = [ |
| 4 | 4 | require('./vk_api_keys.cjs'), |
| 5 | 5 | require('./vk_webhook_deliveries.cjs'), |
| 6 | 6 | require('./vk_owner_settings.cjs'), |
| 7 | + require('./vk_owner_handles.cjs'), | |
| 7 | 8 | ] |
| 8 | 9 | |
| 9 | 10 | const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) | ... | ... |
packages/voko-access/src/db/tables/vk_owner_handles.cjs
0 → 100644
| 1 | +const tableName = 'vk_owner_handles' | |
| 2 | + | |
| 3 | +function buildTable(t) { | |
| 4 | + t.string('user_id', 64).primary() | |
| 5 | + t.string('handle', 32).notNullable() | |
| 6 | + t.datetime('created_at', { precision: 3 }).notNullable() | |
| 7 | + t.unique(['handle'], { indexName: 'uq_vk_owner_handles_handle' }) | |
| 8 | + t.check("handle regexp '^[a-z0-9][a-z0-9_-]{1,31}$'", [], 'ck_vk_owner_handles_format') | |
| 9 | +} | |
| 10 | + | |
| 11 | +module.exports = { tableName, buildTable } | ... | ... |
packages/voko-access/src/index.js
| ... | ... | @@ -12,4 +12,5 @@ export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent |
| 12 | 12 | export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js' |
| 13 | 13 | export { nextAllowedSendTime, nextLocalHour } from './quiet-hours.js' |
| 14 | 14 | export { createWebhooks, signWebhook, isPublicAddress } from './webhooks.js' |
| 15 | +export { createOwnerHandles, HANDLE_PATTERN } from './owner-handles.js' | |
| 15 | 16 | export { default as accessDbSchema } from './db/tables/index.cjs' | ... | ... |
packages/voko-access/src/owner-handles.js
0 → 100644
| 1 | +const TABLE = 'vk_owner_handles' | |
| 2 | + | |
| 3 | +/** 主人 id 的格式:小写字母、数字、下划线、连字符,2–32 位,字母或数字开头。与 DB CHECK 一致。 */ | |
| 4 | +export const HANDLE_PATTERN = /^[a-z0-9][a-z0-9_-]{1,31}$/ | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 主人的 id(handle)。Agent 的全名是「名字@主人 id」,如 小火子@yibo。 | |
| 8 | + * @param {{ db: import('knex').Knex }} deps | |
| 9 | + */ | |
| 10 | +export function createOwnerHandles({ db }) { | |
| 11 | + if (typeof db !== 'function') throw new Error('createOwnerHandles: db is required') | |
| 12 | + | |
| 13 | + /** | |
| 14 | + * @param {object} _ctx | |
| 15 | + * @param {{ handle: string }} input | |
| 16 | + * @returns {Promise<boolean>} | |
| 17 | + */ | |
| 18 | + async function isTaken(_ctx, { handle }) { | |
| 19 | + return (await db(TABLE).where({ handle }).first('user_id')) !== undefined | |
| 20 | + } | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * 注册时在同一事务里登记;handle 被占用由 UNIQUE 裁决(调用方应先用 isTaken 给出友好提示)。 | |
| 24 | + * @param {object} _ctx | |
| 25 | + * @param {import('knex').Knex} trx | |
| 26 | + * @param {{ user_id: string, handle: string }} input | |
| 27 | + */ | |
| 28 | + async function assign(_ctx, trx, { user_id, handle }) { | |
| 29 | + await trx(TABLE).insert({ user_id, handle, created_at: new Date() }) | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * @param {object} _ctx | |
| 34 | + * @param {{ user_id: string }} input | |
| 35 | + * @returns {Promise<string|null>} | |
| 36 | + */ | |
| 37 | + async function get(_ctx, { user_id }) { | |
| 38 | + const row = await db(TABLE).where({ user_id }).first('handle') | |
| 39 | + return row === undefined ? null : row.handle | |
| 40 | + } | |
| 41 | + | |
| 42 | + return { isTaken, assign, get } | |
| 43 | +} | ... | ... |
packages/voko-exchange/src/agents.js
| ... | ... | @@ -18,9 +18,12 @@ export const AGENT_ROLES = Object.freeze(['company', 'member']) |
| 18 | 18 | * level: number, |
| 19 | 19 | * xp: number, |
| 20 | 20 | * daily_spend_limit: number|null, |
| 21 | + * owner_handle: string|null, | |
| 22 | + * full_name: string, | |
| 21 | 23 | * claimed_at: Date|null, |
| 22 | 24 | * created_at: Date, |
| 23 | - * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at 为 null,其余状态三者必有。 | |
| 25 | + * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at / owner_handle 为 null,其余状态必有。 | |
| 26 | + * `full_name` 是对外显示的全名:已认领为「名字@主人 id」(如 小火子@yibo),未认领时就是名字。 | |
| 24 | 27 | */ |
| 25 | 28 | |
| 26 | 29 | /** |
| ... | ... | @@ -39,6 +42,7 @@ export function agentFromRow(row) { |
| 39 | 42 | case 'suspended': |
| 40 | 43 | requiredString(row.owner_user_id, 'owner_user_id', id) |
| 41 | 44 | requiredString(row.guild_id, 'guild_id', id) |
| 45 | + requiredString(row.owner_handle, 'owner_handle', id) | |
| 42 | 46 | if (!(row.claimed_at instanceof Date)) throw new Error(`vk_agents.claimed_at missing, id: ${id}`) |
| 43 | 47 | break |
| 44 | 48 | default: |
| ... | ... | @@ -62,6 +66,8 @@ export function agentFromRow(row) { |
| 62 | 66 | level: row.level, |
| 63 | 67 | xp: row.xp, |
| 64 | 68 | daily_spend_limit: row.daily_spend_limit, |
| 69 | + owner_handle: row.owner_handle, | |
| 70 | + full_name: row.owner_handle === null ? row.name : `${row.name}@${row.owner_handle}`, | |
| 65 | 71 | claimed_at: row.claimed_at, |
| 66 | 72 | created_at: row.created_at, |
| 67 | 73 | } |
| ... | ... | @@ -72,6 +78,12 @@ export function agentFromRow(row) { |
| 72 | 78 | */ |
| 73 | 79 | export function createAgents() { |
| 74 | 80 | /** |
| 81 | + * 带主人 id 的 Agent 查询(主人 id 在 vk_owner_handles)。 | |
| 82 | + * @param {import('knex').Knex} runner | |
| 83 | + */ | |
| 84 | + const withHandle = (runner) => runner(`${TABLE} as a`).leftJoin('vk_owner_handles as h', 'h.user_id', 'a.owner_user_id').select('a.*', 'h.handle as owner_handle') | |
| 85 | + | |
| 86 | + /** | |
| 75 | 87 | * 新注册的 Agent:待认领、员工角色、1 级。 |
| 76 | 88 | * @param {object} _ctx |
| 77 | 89 | * @param {import('knex').Knex} runner |
| ... | ... | @@ -101,7 +113,7 @@ export function createAgents() { |
| 101 | 113 | * @returns {Promise<Agent>} |
| 102 | 114 | */ |
| 103 | 115 | async function getById(_ctx, runner, { agent_id }) { |
| 104 | - const row = await runner(TABLE).where({ id: agent_id }).first() | |
| 116 | + const row = await withHandle(runner).where('a.id', agent_id).first() | |
| 105 | 117 | if (row === undefined) throw new Error(`vk_agents row missing, id: ${agent_id}`) |
| 106 | 118 | return agentFromRow(row) |
| 107 | 119 | } |
| ... | ... | @@ -137,7 +149,7 @@ export function createAgents() { |
| 137 | 149 | * @returns {Promise<Agent[]>} |
| 138 | 150 | */ |
| 139 | 151 | async function listByOwner(_ctx, runner, { owner_user_id }) { |
| 140 | - return (await runner(TABLE).where({ owner_user_id }).orderBy('created_at', 'asc')).map(agentFromRow) | |
| 152 | + return (await withHandle(runner).where('a.owner_user_id', owner_user_id).orderBy('a.created_at', 'asc')).map(agentFromRow) | |
| 141 | 153 | } |
| 142 | 154 | |
| 143 | 155 | /** |
| ... | ... | @@ -170,7 +182,7 @@ export function createAgents() { |
| 170 | 182 | * @returns {Promise<Agent>} |
| 171 | 183 | */ |
| 172 | 184 | async function getByWallet(_ctx, runner, { wallet_user_id }) { |
| 173 | - const row = await runner(TABLE).where({ wallet_user_id }).first() | |
| 185 | + const row = await withHandle(runner).where('a.wallet_user_id', wallet_user_id).first() | |
| 174 | 186 | if (row === undefined) throw new Error(`vk_agents row missing for wallet: ${wallet_user_id}`) |
| 175 | 187 | return agentFromRow(row) |
| 176 | 188 | } | ... | ... |
packages/voko-exchange/src/db/tables/vk_agents.cjs
| ... | ... | @@ -21,6 +21,7 @@ function buildTable(t) { |
| 21 | 21 | t.datetime('created_at', { precision: 3 }).notNullable() |
| 22 | 22 | t.datetime('updated_at', { precision: 3 }).nullable() |
| 23 | 23 | t.unique(['wallet_user_id'], { indexName: 'uq_vk_agents_wallet' }) |
| 24 | + t.unique(['owner_user_id', 'name'], { indexName: 'uq_vk_agents_owner_name' }) | |
| 24 | 25 | t.index(['owner_user_id'], 'idx_vk_agents_owner') |
| 25 | 26 | t.index(['guild_id', 'status'], 'idx_vk_agents_guild_status') |
| 26 | 27 | t.check("role in ('company','member')", [], 'ck_vk_agents_role') | ... | ... |
packages/voko-exchange/src/game.js
| ... | ... | @@ -72,10 +72,11 @@ export function createGame({ db }) { |
| 72 | 72 | function earnings({ guild_id, since, until }) { |
| 73 | 73 | const query = db('billing_ledger as l') |
| 74 | 74 | .join('vk_agents as a', 'a.wallet_user_id', 'l.user_id') |
| 75 | + .join('vk_owner_handles as h', 'h.user_id', 'a.owner_user_id') | |
| 75 | 76 | .where('l.source', 'voko') |
| 76 | 77 | .whereIn('l.reason', EARNING_REASONS) |
| 77 | - .groupBy('a.id', 'a.name', 'a.level', 'a.guild_id') | |
| 78 | - .select('a.id as agent_id', 'a.name', 'a.level', 'a.guild_id', db.raw('SUM(l.amount) as points')) | |
| 78 | + .groupBy('a.id', 'a.name', 'h.handle', 'a.level', 'a.guild_id') | |
| 79 | + .select('a.id as agent_id', db.raw("CONCAT(a.name, '@', h.handle) as name"), 'a.level', 'a.guild_id', db.raw('SUM(l.amount) as points')) | |
| 79 | 80 | if (guild_id !== null) query.where('a.guild_id', guild_id) |
| 80 | 81 | if (since !== null) query.where('l.created_at', '>=', since) |
| 81 | 82 | if (until !== null) query.where('l.created_at', '<', until) |
| ... | ... | @@ -104,7 +105,7 @@ export function createGame({ db }) { |
| 104 | 105 | const inGuild = (/** @type {import('knex').Knex.QueryBuilder} */ query, /** @type {string} */ column) => (guild_id === null ? query : query.where(column, guild_id)) |
| 105 | 106 | const [guild, agents, working, asking, titles] = await Promise.all([ |
| 106 | 107 | guild_id === null ? null : db('vk_guilds').where({ id: guild_id }).first('id', 'name', 'level', 'weekly_goal'), |
| 107 | - inGuild(db('vk_agents as a').join('vk_guilds as g', 'g.id', 'a.guild_id').where('a.status', 'active'), 'a.guild_id').orderBy('a.created_at', 'asc').select('a.id', 'a.name', 'a.role', 'a.level', 'a.xp', 'g.name as guild_name'), | |
| 108 | + inGuild(db('vk_agents as a').join('vk_guilds as g', 'g.id', 'a.guild_id').join('vk_owner_handles as h', 'h.user_id', 'a.owner_user_id').where('a.status', 'active'), 'a.guild_id').orderBy('a.created_at', 'asc').select('a.id', db.raw("CONCAT(a.name, '@', h.handle) as name"), 'a.role', 'a.level', 'a.xp', 'g.name as guild_name'), | |
| 108 | 109 | inGuild(db('vk_tasks'), 'guild_id').whereIn('status', ['claimed', 'rework']).select('holder_agent_id'), |
| 109 | 110 | inGuild(db('vk_help_requests as h').join('vk_tasks as t', 't.id', 'h.task_id').where('h.status', 'open'), 't.guild_id').select('h.agent_id'), |
| 110 | 111 | inGuild(db('vk_agent_titles as ti').join('vk_agents as a', 'a.id', 'ti.agent_id'), 'a.guild_id').select('ti.agent_id', 'ti.title'), | ... | ... |
tools/scripts/voko-dev-user.js
| ... | ... | @@ -54,7 +54,13 @@ async function main() { |
| 54 | 54 | role: user.role, |
| 55 | 55 | updated_at: now, |
| 56 | 56 | }) |
| 57 | - console.log(`voko dev user ready: ${normalizedEmail}, role=${role}`) | |
| 57 | + // 主人 id(Agent 全名「名字@主人 id」的后半段):取邮箱 @ 前的部分,已有则保留。 | |
| 58 | + const stored = await db('users').where({ email: normalizedEmail }).first('id') | |
| 59 | + const handle = normalizedEmail.split('@')[0].replace(/[^a-z0-9_-]/g, '-').slice(0, 32) | |
| 60 | + await db('vk_owner_handles').insert({ user_id: stored.id, handle, created_at: now }).onConflict('user_id').ignore() | |
| 61 | + const saved = await db('vk_owner_handles').where({ user_id: stored.id }).first('handle') | |
| 62 | + if (saved === undefined) throw new Error(`voko dev user: id ${handle} 已被别的账号占用,请换一个邮箱前缀`) | |
| 63 | + console.log(`voko dev user ready: ${normalizedEmail}, role=${role}, id=${saved.handle}`) | |
| 58 | 64 | } finally { |
| 59 | 65 | await db.destroy() |
| 60 | 66 | } | ... | ... |
tools/voko-company-agent/agent.mjs
| ... | ... | @@ -14,7 +14,7 @@ |
| 14 | 14 | import { createHash } from 'node:crypto' |
| 15 | 15 | import { readFileSync } from 'node:fs' |
| 16 | 16 | |
| 17 | -const SUPPORTED_SKILL_VERSION = '0.3.1' | |
| 17 | +const SUPPORTED_SKILL_VERSION = '0.3.2' | |
| 18 | 18 | const config = { |
| 19 | 19 | api: required('VOKO_API_BASE').replace(/\/$/, ''), |
| 20 | 20 | key: required('VOKO_API_KEY'), | ... | ... |
tools/voko-member-agent/agent.mjs
| ... | ... | @@ -11,7 +11,7 @@ |
| 11 | 11 | * 没有第三方依赖:Node 20+ 自带的 fetch 即可。大模型走 OpenAI 兼容的 /chat/completions。 |
| 12 | 12 | * 用法:node agent.mjs [--once] 环境变量见 README.md |
| 13 | 13 | */ |
| 14 | -const SUPPORTED_SKILL_VERSION = '0.3.1' | |
| 14 | +const SUPPORTED_SKILL_VERSION = '0.3.2' | |
| 15 | 15 | const config = { |
| 16 | 16 | api: required('VOKO_API_BASE').replace(/\/$/, ''), |
| 17 | 17 | key: required('VOKO_API_KEY'), | ... | ... |