import { randomUUID } from 'node:crypto' import { taskFromRow } from './task.js' import { insertEvent, insertJob } from './tasks.js' const HELP = 'vk_help_requests' /** * 求助主人:Agent 卡住时提问,主人在网页收件箱回复,Agent 下次 `/home` 拿到答复。 * * @param {{ * db: import('knex').Knex, * sendAfter(ctx: object, input: { user_id: string, now: Date }): Promise, * mailer: { sendHelp(ctx: object, input: { user_id: string, agent_name: string, task_title: string, question: string }): Promise }, * }} deps `sendAfter` 给出通知主人的时刻(安静时段内推迟),主人关闭了求助邮件时返回 null。 */ export function createHelp({ db, sendAfter, mailer }) { if (typeof db !== 'function') throw new Error('createHelp: db is required') /** * @param {object} ctx * @param {{ agent: import('./agents.js').Agent, task_id: string, question: string }} input * @returns {Promise<{ ok: true, help_id: string } | { ok: false, code: 'not_found'|'forbidden'|'state_conflict', hint: string }>} */ async function request(ctx, { agent, task_id, question }) { const now = new Date() const owner = /** @type {string} */ (agent.owner_user_id) const notifyAt = await sendAfter(ctx, { user_id: owner, now }) return db.transaction(async (trx) => { const row = await trx('vk_tasks').where({ id: task_id }).forUpdate().first() if (row === undefined) return refuse('not_found', `Task not found: ${task_id}`) const task = taskFromRow(row) if (task.holder_agent_id !== agent.id) return refuse('forbidden', 'Only the current holder can ask for help on a task.') switch (task.status) { case 'claimed': case 'rework': break case 'pending_approval': case 'open': case 'in_contest': case 'submitted': case 'reviewing': case 'disputed': case 'settled': case 'cancelled': case 'refunded': return refuse('state_conflict', `Task is ${task.status}; help is for work in progress.`) default: throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) } const id = `hp_${randomUUID()}` await trx(HELP).insert({ id, task_id, agent_id: agent.id, owner_user_id: owner, question, status: 'open', created_at: now }) await insertEvent(trx, task, 'help.requested', null, now) if (notifyAt !== null) await insertJob(trx, id, 'help_notify', notifyAt, now) return { ok: true, help_id: id } }) } /** * worker:给主人发求助邮件;已经回复过的不再打扰。 * @param {object} ctx * @param {{ help_id: string }} input */ async function notify(ctx, { help_id }) { const help = await db(HELP).where({ id: help_id }).first() if (help === undefined) throw new Error(`vk_help_requests row missing, id: ${help_id}`) if (help.status !== 'open') return const [agent, task] = await Promise.all([db('vk_agents').where({ id: help.agent_id }).first('name'), db('vk_tasks').where({ id: help.task_id }).first('title')]) await mailer.sendHelp(ctx, { user_id: help.owner_user_id, agent_name: agent.name, task_title: task.title, question: help.question }) } /** * 主人的求助收件箱:未回复的在前。 * @param {object} _ctx * @param {{ user_id: string }} input */ async function listForOwner(_ctx, { user_id }) { return db(`${HELP} as h`) .join('vk_agents as a', 'a.id', 'h.agent_id') .join('vk_tasks as t', 't.id', 'h.task_id') .where('h.owner_user_id', user_id) .orderBy([{ column: 'h.status', order: 'desc' }, { column: 'h.created_at', order: 'desc' }]) .limit(100) .select('h.id', 'h.task_id', 'h.agent_id', 'h.question', 'h.status', 'h.answer', 'h.answered_at', 'h.created_at', 'a.name as agent_name', 't.title as task_title') } /** * 主人回复。只能回复自己名下 Agent 的、尚未回复的求助。 * @param {object} _ctx * @param {{ user_id: string, help_id: string, answer: string }} input * @returns {Promise<{ ok: true } | { ok: false, code: 'not_found'|'state_conflict', hint: string }>} */ async function answer(_ctx, { user_id, help_id, answer: text }) { const now = new Date() return db.transaction(async (trx) => { const help = await trx(HELP).where({ id: help_id, owner_user_id: user_id }).forUpdate().first() if (help === undefined) return refuse('not_found', `Help request not found: ${help_id}`) if (help.status !== 'open') return refuse('state_conflict', 'This help request has already been answered.') await trx(HELP).where({ id: help_id }).update({ status: 'answered', answer: text, answered_at: now }) const task = taskFromRow(await trx('vk_tasks').where({ id: help.task_id }).first()) await insertEvent(trx, task, 'help.answered', help.agent_id, now) return { ok: true } }) } /** * `/home` 用:这个 Agent 最近收到的答复。 * @param {object} _ctx * @param {{ agent_id: string }} input * @returns {Promise>} */ async function answersFor(_ctx, { agent_id }) { return db(HELP).where({ agent_id, status: 'answered' }).orderBy('answered_at', 'desc').limit(10).select('id', 'task_id', 'question', 'answer', 'answered_at') } return { request, notify, listForOwner, answer, answersFor } } /** * @template {string} C * @param {C} code * @param {string} hint * @returns {{ ok: false, code: C, hint: string }} */ function refuse(code, hint) { return { ok: false, code, hint } }