help.js
5.57 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
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 }
}