help.js 5.57 KB
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<Date|null>,
 *   mailer: { sendHelp(ctx: object, input: { user_id: string, agent_name: string, task_title: string, question: string }): Promise<void> },
 * }} 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<Array<{ id: string, task_id: string, question: string, answer: string, answered_at: Date }>>}
   */
  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 }
}