Files
sausage/extensions/questions/server.ts
T

110 lines
4.6 KiB
TypeScript

/** User actions that answer or dismiss question cards through the messages service. */
import { defineExtension, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context, withAbortSignal } from '@earendil-works/chord/context'
import type { Host } from '../../host/js/extensions.ts'
import { Messages, MessagesApi } from '../messages/api.ts'
import type { Message, Question } from '../messages/api.ts'
import { implementAction } from '../goal/compat.ts'
import { AnswerAction, DismissAction } from './refs.ts'
const open = (message: Message) =>
message.to === 'user' && message.reply && message.state === 'delivered'
const NOT_OPEN = 'These questions are no longer open'
function validAnswers(value: unknown): value is { question: string; answer: string }[] {
return (
Array.isArray(value) &&
value.every(
(a: { question?: unknown; answer?: unknown } | null) =>
a && typeof a.question === 'string' && typeof a.answer === 'string' && a.answer.trim(),
)
)
}
function checkAnswers(answers: { question: string }[], questions: Question[]) {
if (
new Set(answers.map((a) => a.question)).size !== answers.length ||
answers.some((a) => !questions.some((q) => q.text === a.question))
)
throw new Error('Unknown question')
}
/** The keys of the chosen options, in option order: "a", "a,c"; none when an answer names no option. */
function keysOf(question: Question, answer: string) {
const labels = question.kind === 'multi' ? answer.split(', ') : [answer]
const keys = (question.options ?? []).flatMap((o) =>
o.key && labels.includes(o.label) ? [o.key] : [],
)
return keys.length === labels.length ? keys.join(',') : ''
}
/**
* The reply naming each answer by the message's own numbering: `1a. Topic: label` for an option (`2a,c.` for
* several), `3: text` for typed text or a rating, `Topic: answer` when the message numbered nothing; a note from the
* card's field follows the answers.
*/
export function answerText(
answers: { question: string; answer: string }[],
questions: Question[],
note?: string,
) {
const lines = answers.map(({ question, answer }) => {
const q = questions.find((candidate) => candidate.text === question)!
const keys = keysOf(q, answer)
if (!q.ref) return `${q.text}: ${answer}`
return keys ? `${q.ref}${keys}. ${q.text}: ${answer}` : `${q.ref}: ${answer}`
})
if (note?.trim()) lines.push(answers.length ? `Note: ${note.trim()}` : note.trim())
return lines.join('\n\n')
}
export const userActions = ['answer', 'dismiss']
export const actions = {
/** Answer an open message from its questions, with a note, or as free text: a user reply to its sender. */
answer: implementAction(AnswerAction, async (args, { host, signal, device }) => {
const data = args as {
messageId: number
answers?: { question: string; answer: string }[]
note?: unknown
text?: unknown
}
const text = typeof data?.text === 'string' ? data.text.trim() : undefined
const note = typeof data?.note === 'string' ? data.note.trim() : undefined
const answers = data?.answers ?? []
if (
!data ||
!Number.isSafeInteger(data.messageId) ||
(text === undefined
? !validAnswers(answers) || (!answers.length && !note)
: !text || data.answers !== undefined || note)
)
throw new Error('Invalid answers')
const reply = await host.harness().commit(async (tx) => {
const log = await tx.doc(Messages, ROOT_CONVERSATION_ID)
const message = log.messages.find((m) => m.id === data.messageId)
if (!message || !open(message)) throw new Error(NOT_OPEN)
if (text !== undefined) return text
checkAnswers(answers, message.questions ?? [])
return answerText(answers, message.questions ?? [], note)
}, context)
return {
id: await host
.need(MessagesApi)
.answer(data.messageId, reply, withAbortSignal(signal, context), device),
}
}),
/** Close an open message to the user unanswered; its sender gets a one-way note. */
dismiss: implementAction(DismissAction, async (args, { host, signal, device }) => {
const data = args as { messageId: number }
if (!data || !Number.isSafeInteger(data.messageId)) throw new Error('Invalid message')
return {
id: await host
.need(MessagesApi)
.dismiss(data.messageId, withAbortSignal(signal, context), device),
}
}),
}
export default function questionsExtension(host: Host) {
// Core, always on; resolved where it is called, declared here so the catalogue lists it.
host.need(MessagesApi)
return defineExtension({ name: 'questions' })
}