const TABLE = 'vk_agents' /** Agent 的全部状态;消费方 switch 必须穷举。与 DB CHECK `ck_vk_agents_status` 一致。 */ export const AGENT_STATUSES = Object.freeze(['pending_claim', 'active', 'suspended']) export const AGENT_ROLES = Object.freeze(['company', 'member']) /** * @typedef {{ * id: string, * wallet_user_id: string, * owner_user_id: string|null, * guild_id: string|null, * role: 'company'|'member', * status: 'pending_claim'|'active'|'suspended', * name: string, * description: string|null, * skills: string[], * level: number, * xp: number, * daily_spend_limit: number|null, * owner_handle: string|null, * full_name: string, * claimed_at: Date|null, * created_at: Date, * }} Agent pending_claim 时 owner_user_id / guild_id / claimed_at / owner_handle 为 null,其余状态必有。 * `full_name` 是对外显示的全名:已认领为「名字@主人 id」(如 小火子@yibo),未认领时就是名字。 */ /** * DB 行 → Agent 的唯一构造点。按状态保证必有字段,非法即 throw。 * @param {Record} row * @returns {Agent} */ export function agentFromRow(row) { const id = requiredString(row.id, 'id', row.id) if (!AGENT_ROLES.includes(row.role)) throw new Error(`vk_agents.role invalid: ${row.role}, id: ${id}`) switch (row.status) { case 'pending_claim': if (row.owner_user_id !== null || row.guild_id !== null || row.claimed_at !== null) throw new Error(`vk_agents pending_claim row has claim fields, id: ${id}`) break case 'active': case 'suspended': requiredString(row.owner_user_id, 'owner_user_id', id) requiredString(row.guild_id, 'guild_id', id) requiredString(row.owner_handle, 'owner_handle', id) if (!(row.claimed_at instanceof Date)) throw new Error(`vk_agents.claimed_at missing, id: ${id}`) break default: throw new Error(`Unhandled status: ${row.status}, id: ${id}`) } if (!(row.created_at instanceof Date)) throw new Error(`vk_agents.created_at missing, id: ${id}`) if (!Array.isArray(row.skills) || row.skills.some((skill) => typeof skill !== 'string')) throw new Error(`vk_agents.skills invalid, id: ${id}`) if (!Number.isSafeInteger(row.level) || row.level < 1) throw new Error(`vk_agents.level invalid, id: ${id}`) if (!Number.isSafeInteger(row.xp) || row.xp < 0) throw new Error(`vk_agents.xp invalid, id: ${id}`) if (row.daily_spend_limit !== null && (!Number.isSafeInteger(row.daily_spend_limit) || row.daily_spend_limit < 0)) throw new Error(`vk_agents.daily_spend_limit invalid, id: ${id}`) return { id, wallet_user_id: requiredString(row.wallet_user_id, 'wallet_user_id', id), owner_user_id: row.owner_user_id, guild_id: row.guild_id, role: row.role, status: row.status, name: requiredString(row.name, 'name', id), description: row.description === null ? null : requiredString(row.description, 'description', id), skills: row.skills, level: row.level, xp: row.xp, daily_spend_limit: row.daily_spend_limit, owner_handle: row.owner_handle, full_name: row.owner_handle === null ? row.name : `${row.name}@${row.owner_handle}`, claimed_at: row.claimed_at, created_at: row.created_at, } } /** * vk_agents 的读写。写方法的 `runner` 传调用方的事务,使 Agent 行与认领、记账一起提交。 */ export function createAgents() { /** * 带主人 id 的 Agent 查询(主人 id 在 vk_owner_handles)。 * @param {import('knex').Knex} runner */ 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') /** * 新注册的 Agent:待认领、员工角色、1 级。 * @param {object} _ctx * @param {import('knex').Knex} runner * @param {{ id: string, wallet_user_id: string, name: string, description: string|null, skills: string[], owner_email: string|null, created_at: Date }} input */ async function insertPending(_ctx, runner, input) { await runner(TABLE).insert({ id: input.id, wallet_user_id: input.wallet_user_id, role: 'member', status: 'pending_claim', name: input.name, description: input.description, skills: JSON.stringify(input.skills), owner_email: input.owner_email, level: 1, xp: 0, events_cursor: 0, created_at: input.created_at, }) } /** * @param {object} _ctx * @param {import('knex').Knex} runner * @param {{ agent_id: string }} input * @returns {Promise} */ async function getById(_ctx, runner, { agent_id }) { const row = await withHandle(runner).where('a.id', agent_id).first() if (row === undefined) throw new Error(`vk_agents row missing, id: ${agent_id}`) return agentFromRow(row) } /** * 认领:pending_claim → active。行不在 pending_claim 即 throw(调用方已锁认领凭证,不应发生)。 * @param {object} _ctx * @param {import('knex').Knex} runner * @param {{ agent_id: string, owner_user_id: string, guild_id: string, role: 'company'|'member', claimed_at: Date }} input */ async function activate(_ctx, runner, { agent_id, owner_user_id, guild_id, role, claimed_at }) { const updated = await runner(TABLE).where({ id: agent_id, status: 'pending_claim' }).update({ status: 'active', role, owner_user_id, guild_id, claimed_at, updated_at: claimed_at }) if (updated !== 1) throw new Error(`vk_agents activate expected pending_claim row, id: ${agent_id}`) } /** * @param {object} _ctx * @param {import('knex').Knex} runner * @param {{ agent_id: string, description?: string, skills?: string[] }} input */ async function updateProfile(_ctx, runner, { agent_id, description, skills }) { await runner(TABLE).where({ id: agent_id }).update({ ...(description === undefined ? {} : { description }), ...(skills === undefined ? {} : { skills: JSON.stringify(skills) }), updated_at: new Date(), }) } /** * @param {object} _ctx * @param {import('knex').Knex} runner * @param {{ owner_user_id: string }} input * @returns {Promise} */ async function listByOwner(_ctx, runner, { owner_user_id }) { return (await withHandle(runner).where('a.owner_user_id', owner_user_id).orderBy('a.created_at', 'asc')).map(agentFromRow) } /** * 主人停用 / 恢复自己的 Agent。只在 active 与 suspended 之间切换;当前状态不符返回 false。 * @param {object} _ctx * @param {import('knex').Knex} runner * @param {{ agent_id: string, owner_user_id: string, to: 'active'|'suspended' }} input * @returns {Promise} */ async function setStatus(_ctx, runner, { agent_id, owner_user_id, to }) { const from = to === 'active' ? 'suspended' : 'active' return (await runner(TABLE).where({ id: agent_id, owner_user_id, status: from }).update({ status: to, updated_at: new Date() })) === 1 } /** * 单日支出上限;null 表示不限。 * @param {object} _ctx * @param {import('knex').Knex} runner * @param {{ agent_id: string, owner_user_id: string, daily_spend_limit: number|null }} input * @returns {Promise} */ async function setDailySpendLimit(_ctx, runner, { agent_id, owner_user_id, daily_spend_limit }) { return (await runner(TABLE).where({ id: agent_id, owner_user_id }).update({ daily_spend_limit, updated_at: new Date() })) === 1 } /** * @param {object} _ctx * @param {import('knex').Knex} runner * @param {{ wallet_user_id: string }} input * @returns {Promise} */ async function getByWallet(_ctx, runner, { wallet_user_id }) { const row = await withHandle(runner).where('a.wallet_user_id', wallet_user_id).first() if (row === undefined) throw new Error(`vk_agents row missing for wallet: ${wallet_user_id}`) return agentFromRow(row) } return { insertPending, getById, getByWallet, activate, updateProfile, listByOwner, setStatus, setDailySpendLimit } } /** * @param {unknown} value * @param {string} field * @param {unknown} id * @returns {string} */ function requiredString(value, field, id) { if (typeof value === 'string' && value.length > 0) return value throw new Error(`vk_agents.${field} invalid, id: ${String(id)}`) }