mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
Add dormant provider contracts, qualified patched adapters, tenant-scoped persistence and lifecycle ownership without activating chat routes. Preserve the experimental integration as dependent PR #13038. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1039 lines
31 KiB
TypeScript
1039 lines
31 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { chatActions } from "@paperclipai/db";
|
|
import type {
|
|
AskUserQuestionsAnswer,
|
|
AskUserQuestionsInteraction,
|
|
AskUserQuestionsQuestion,
|
|
PaperclipQuestionSetQuestion,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
Modal,
|
|
Select,
|
|
SelectOption,
|
|
TextInput,
|
|
type ModalElement,
|
|
type ModalResponse,
|
|
type SelectOptionElement,
|
|
} from "chat";
|
|
|
|
const TOKEN_BYTES = 16;
|
|
const OPEN_ACTION_PREFIX = "pcf:";
|
|
const SUBMIT_ACTION_PREFIX = "pcfs:";
|
|
const FIELD_PREFIX = "pcff:";
|
|
const OPTION_PREFIX = "pcfo:";
|
|
const MAX_NATIVE_FORM_QUESTIONS = 64;
|
|
const MAX_NATIVE_SELECT_OPTIONS = 100;
|
|
const MAX_NATIVE_TEXT_LENGTH = 3_000;
|
|
|
|
export const CHAT_QUESTION_FORM_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
|
|
type ChatQuestionFormDb = Pick<Db, "select">;
|
|
type ChatQuestionFormMutationDb = Pick<Db, "update">;
|
|
|
|
interface ChatQuestionFormTextField {
|
|
fieldId: string;
|
|
kind: "text";
|
|
maxLength: number;
|
|
minLength: number;
|
|
inputType: "integer" | "number" | "text";
|
|
maximum?: number;
|
|
minimum?: number;
|
|
questionId: string;
|
|
required: boolean;
|
|
}
|
|
|
|
interface ChatQuestionFormSelectOption {
|
|
optionId: string;
|
|
value: string;
|
|
}
|
|
|
|
interface ChatQuestionFormSelectField {
|
|
fieldId: string;
|
|
kind: "single_select";
|
|
options: ChatQuestionFormSelectOption[];
|
|
questionId: string;
|
|
required: boolean;
|
|
}
|
|
|
|
export type ChatQuestionFormField =
|
|
ChatQuestionFormTextField | ChatQuestionFormSelectField;
|
|
|
|
export interface ChatQuestionFormDraft {
|
|
nativeProvider?: "discord";
|
|
expiresAt: string;
|
|
fields: ChatQuestionFormField[];
|
|
interactionId: string;
|
|
openActionId: string;
|
|
submitActionId: string;
|
|
}
|
|
|
|
export interface ChatQuestionFormOpenTokenPayload {
|
|
expiresAt: string;
|
|
formActionId: string;
|
|
interactionId: string;
|
|
publicationId: string;
|
|
version: 1;
|
|
}
|
|
|
|
export interface ChatQuestionFormSubmitTokenPayload {
|
|
nativeProvider?: "discord";
|
|
expiresAt: string;
|
|
fields: ChatQuestionFormField[];
|
|
formActionId: string;
|
|
interactionId: string;
|
|
publicationId: string;
|
|
version: 1;
|
|
}
|
|
|
|
export interface ChatQuestionFormActionRecordContext {
|
|
companyId: string;
|
|
conversationId: string;
|
|
endpointId: string;
|
|
publicationId: string;
|
|
}
|
|
|
|
export interface ResolvedChatQuestionFormOpen {
|
|
interactionId: string;
|
|
modal: ModalElement;
|
|
openActionId: string;
|
|
openActionRowId: string;
|
|
publicationId: string;
|
|
submitActionId: string;
|
|
submitActionRowId: string;
|
|
}
|
|
|
|
export interface ResolvedChatQuestionFormSubmission {
|
|
actionRowId: string;
|
|
conversationId: string;
|
|
interactionId: string;
|
|
payload: ChatQuestionFormSubmitTokenPayload;
|
|
publicationId: string;
|
|
validation: ChatQuestionFormValidationResult;
|
|
}
|
|
|
|
export interface LoadedChatQuestionFormSubmissionToken {
|
|
actionRowId: string;
|
|
conversationId: string;
|
|
interactionId: string;
|
|
principalId: string | null;
|
|
payload: ChatQuestionFormSubmitTokenPayload;
|
|
publicationId: string;
|
|
result: Record<string, unknown> | null;
|
|
status: "issued" | "processed";
|
|
}
|
|
|
|
export type ChatQuestionFormValidationResult =
|
|
| { ok: true; answers: AskUserQuestionsAnswer[] }
|
|
| {
|
|
ok: false;
|
|
code:
|
|
"expired" | "invalid_callback" | "invalid_form" | "stale_interaction";
|
|
fieldErrors: Record<string, string>;
|
|
};
|
|
|
|
const CHAT_QUESTION_FORM_DENIAL_MESSAGE =
|
|
"This form is no longer authorized. Close it and open the linked Paperclip task.";
|
|
|
|
/**
|
|
* Provider callbacks must be acknowledged after Paperclip has durably denied a
|
|
* stale or unauthorized submission. When the opaque form token is known, keep
|
|
* the modal open with a safe field-level explanation; otherwise clear the
|
|
* untrusted view without reflecting any callback details.
|
|
*/
|
|
export function chatQuestionFormDenialResponse(
|
|
payload?: ChatQuestionFormSubmitTokenPayload | null,
|
|
): ModalResponse {
|
|
const fieldId = payload?.fields[0]?.fieldId;
|
|
return fieldId
|
|
? {
|
|
action: "errors",
|
|
errors: { [fieldId]: CHAT_QUESTION_FORM_DENIAL_MESSAGE },
|
|
}
|
|
: { action: "clear" };
|
|
}
|
|
|
|
function opaqueToken(prefix: string): string {
|
|
return `${prefix}${randomBytes(TOKEN_BYTES).toString("base64url")}`;
|
|
}
|
|
|
|
function boundedLabel(value: string, maximum: number): string {
|
|
return value
|
|
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
.trim()
|
|
.slice(0, maximum);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|
|
|
|
function isOpaqueToken(value: unknown, prefix: string): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
value.startsWith(prefix) &&
|
|
/^[A-Za-z0-9_-]{22}$/.test(value.slice(prefix.length))
|
|
);
|
|
}
|
|
|
|
export function isChatQuestionFormOpenActionId(
|
|
value: unknown,
|
|
): value is string {
|
|
return isOpaqueToken(value, OPEN_ACTION_PREFIX);
|
|
}
|
|
|
|
export function isChatQuestionFormSubmitActionId(
|
|
value: unknown,
|
|
): value is string {
|
|
return isOpaqueToken(value, SUBMIT_ACTION_PREFIX);
|
|
}
|
|
|
|
function canonicalQuestion(
|
|
interaction: AskUserQuestionsInteraction,
|
|
questionId: string,
|
|
): AskUserQuestionsQuestion | null {
|
|
return (
|
|
interaction.payload.questions.find(
|
|
(question) => question.id === questionId,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function questionSetQuestion(
|
|
interaction: AskUserQuestionsInteraction,
|
|
questionId: string,
|
|
): PaperclipQuestionSetQuestion | null {
|
|
return (
|
|
interaction.payload.questionSet?.questions.find(
|
|
(question) => question.id === questionId,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function formKind(
|
|
interaction: AskUserQuestionsInteraction,
|
|
question: AskUserQuestionsQuestion,
|
|
): "single_select" | "text" | null {
|
|
const source = questionSetQuestion(interaction, question.id);
|
|
if (source) {
|
|
if (source.answerMode === "text") {
|
|
// Regex evaluation supplied by an agent can be computationally unsafe.
|
|
// Keep those uncommon forms in Paperclip, whose canonical validator owns
|
|
// that policy, instead of evaluating a pattern inside a webhook callback.
|
|
return source.textValidation?.pattern ? null : "text";
|
|
}
|
|
return source.answerMode === "single_select" && !source.customAnswer
|
|
? "single_select"
|
|
: null;
|
|
}
|
|
|
|
const freeTextOptions = question.options.filter(
|
|
(option) => option.freeText === true,
|
|
);
|
|
if (
|
|
question.selectionMode === "single" &&
|
|
freeTextOptions.length === 1 &&
|
|
question.options.length === 1
|
|
) {
|
|
return "text";
|
|
}
|
|
if (
|
|
question.selectionMode === "single" &&
|
|
question.allowOther !== true &&
|
|
freeTextOptions.length === 0
|
|
) {
|
|
return "single_select";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Creates the provider-neutral opaque field map for a native Slack/Teams form.
|
|
* A lone closed select deliberately stays on the compact button path. Native
|
|
* forms are used when text input or a multi-question response needs them.
|
|
*/
|
|
export function createChatQuestionFormDraft(
|
|
interaction: AskUserQuestionsInteraction,
|
|
options: { now?: Date; nativeProvider?: "discord" } = {},
|
|
): ChatQuestionFormDraft | null {
|
|
const maxTextLength =
|
|
options.nativeProvider === "discord" ? 4000 : MAX_NATIVE_TEXT_LENGTH;
|
|
if (
|
|
interaction.status !== "pending" ||
|
|
interaction.payload.questions.length === 0 ||
|
|
interaction.payload.questions.length >
|
|
(options.nativeProvider === "discord" ? 5 : MAX_NATIVE_FORM_QUESTIONS)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const classified = interaction.payload.questions.map((question) => ({
|
|
question,
|
|
kind: formKind(interaction, question),
|
|
}));
|
|
if (classified.some(({ kind }) => kind === null)) return null;
|
|
if (
|
|
classified.length === 1 &&
|
|
classified[0]?.kind === "single_select" &&
|
|
(options.nativeProvider !== "discord" ||
|
|
classified[0].question.options.length <= 12)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const fields: ChatQuestionFormField[] = [];
|
|
for (const { question, kind } of classified) {
|
|
const fieldId = opaqueToken(FIELD_PREFIX);
|
|
if (kind === "single_select") {
|
|
const options = question.options.filter((option) => !option.freeText);
|
|
if (
|
|
options.length === 0 ||
|
|
options.length >
|
|
(maxTextLength === 4000 ? 25 : MAX_NATIVE_SELECT_OPTIONS)
|
|
) {
|
|
return null;
|
|
}
|
|
fields.push({
|
|
fieldId,
|
|
kind,
|
|
questionId: question.id,
|
|
required: question.required !== false,
|
|
options: options.map((option) => ({
|
|
optionId: option.id,
|
|
value: opaqueToken(OPTION_PREFIX),
|
|
})),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const source = questionSetQuestion(interaction, question.id);
|
|
const validation = source?.textValidation;
|
|
const canonicalMax = validation?.maxLength ?? 100_000;
|
|
const canonicalMin = validation?.minLength ?? 0;
|
|
if (options.nativeProvider === "discord" && canonicalMax < 1) return null;
|
|
if (canonicalMin > maxTextLength) return null;
|
|
fields.push({
|
|
fieldId,
|
|
kind: "text",
|
|
questionId: question.id,
|
|
required: question.required !== false,
|
|
minLength: canonicalMin,
|
|
maxLength: Math.min(canonicalMax, maxTextLength),
|
|
inputType: validation?.inputType ?? "text",
|
|
...(validation?.minimum !== undefined
|
|
? { minimum: validation.minimum }
|
|
: {}),
|
|
...(validation?.maximum !== undefined
|
|
? { maximum: validation.maximum }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
const now = options.now ?? new Date();
|
|
return {
|
|
...(options.nativeProvider
|
|
? { nativeProvider: options.nativeProvider }
|
|
: {}),
|
|
expiresAt: new Date(
|
|
now.getTime() + CHAT_QUESTION_FORM_TOKEN_TTL_MS,
|
|
).toISOString(),
|
|
fields,
|
|
interactionId: interaction.id,
|
|
openActionId: opaqueToken(OPEN_ACTION_PREFIX),
|
|
submitActionId: opaqueToken(SUBMIT_ACTION_PREFIX),
|
|
};
|
|
}
|
|
|
|
/** Values ready for one durable chat_actions insert. */
|
|
export function chatQuestionFormActionRecords(
|
|
draft: ChatQuestionFormDraft,
|
|
context: ChatQuestionFormActionRecordContext,
|
|
) {
|
|
const shared = {
|
|
companyId: context.companyId,
|
|
endpointId: context.endpointId,
|
|
conversationId: context.conversationId,
|
|
status: "issued",
|
|
} as const;
|
|
return [
|
|
{
|
|
...shared,
|
|
kind: "question_form_open",
|
|
providerActionId: draft.openActionId,
|
|
payload: {
|
|
version: 1,
|
|
publicationId: context.publicationId,
|
|
interactionId: draft.interactionId,
|
|
formActionId: draft.submitActionId,
|
|
expiresAt: draft.expiresAt,
|
|
} satisfies ChatQuestionFormOpenTokenPayload,
|
|
},
|
|
{
|
|
...shared,
|
|
kind: "question_form_submit",
|
|
providerActionId: draft.submitActionId,
|
|
payload: {
|
|
version: 1,
|
|
publicationId: context.publicationId,
|
|
interactionId: draft.interactionId,
|
|
formActionId: draft.submitActionId,
|
|
fields: draft.fields,
|
|
...(draft.nativeProvider
|
|
? { nativeProvider: draft.nativeProvider }
|
|
: {}),
|
|
expiresAt: draft.expiresAt,
|
|
} satisfies ChatQuestionFormSubmitTokenPayload,
|
|
},
|
|
];
|
|
}
|
|
|
|
export function parseChatQuestionFormOpenTokenPayload(
|
|
value: unknown,
|
|
): ChatQuestionFormOpenTokenPayload | null {
|
|
if (
|
|
!isRecord(value) ||
|
|
value.version !== 1 ||
|
|
typeof value.publicationId !== "string" ||
|
|
typeof value.interactionId !== "string" ||
|
|
!isChatQuestionFormSubmitActionId(value.formActionId) ||
|
|
typeof value.expiresAt !== "string" ||
|
|
!Number.isFinite(Date.parse(value.expiresAt))
|
|
) {
|
|
return null;
|
|
}
|
|
return value as unknown as ChatQuestionFormOpenTokenPayload;
|
|
}
|
|
|
|
function parseFormField(
|
|
value: unknown,
|
|
maxTextLength = MAX_NATIVE_TEXT_LENGTH,
|
|
): ChatQuestionFormField | null {
|
|
if (
|
|
!isRecord(value) ||
|
|
!isOpaqueToken(value.fieldId, FIELD_PREFIX) ||
|
|
typeof value.questionId !== "string" ||
|
|
typeof value.required !== "boolean"
|
|
) {
|
|
return null;
|
|
}
|
|
if (value.kind === "text") {
|
|
if (
|
|
typeof value.minLength !== "number" ||
|
|
!Number.isSafeInteger(value.minLength) ||
|
|
value.minLength < 0 ||
|
|
typeof value.maxLength !== "number" ||
|
|
!Number.isSafeInteger(value.maxLength) ||
|
|
value.maxLength < value.minLength ||
|
|
value.maxLength > maxTextLength ||
|
|
!["text", "number", "integer"].includes(String(value.inputType)) ||
|
|
(value.minimum !== undefined &&
|
|
(typeof value.minimum !== "number" ||
|
|
!Number.isFinite(value.minimum))) ||
|
|
(value.maximum !== undefined &&
|
|
(typeof value.maximum !== "number" || !Number.isFinite(value.maximum)))
|
|
) {
|
|
return null;
|
|
}
|
|
return value as unknown as ChatQuestionFormTextField;
|
|
}
|
|
if (
|
|
value.kind !== "single_select" ||
|
|
!Array.isArray(value.options) ||
|
|
value.options.length === 0 ||
|
|
value.options.length > MAX_NATIVE_SELECT_OPTIONS
|
|
) {
|
|
return null;
|
|
}
|
|
const options: ChatQuestionFormSelectOption[] = [];
|
|
const values = new Set<string>();
|
|
for (const option of value.options) {
|
|
if (
|
|
!isRecord(option) ||
|
|
typeof option.optionId !== "string" ||
|
|
!isOpaqueToken(option.value, OPTION_PREFIX) ||
|
|
values.has(option.value)
|
|
) {
|
|
return null;
|
|
}
|
|
values.add(option.value);
|
|
options.push({ optionId: option.optionId, value: option.value });
|
|
}
|
|
return {
|
|
fieldId: value.fieldId,
|
|
kind: "single_select",
|
|
options,
|
|
questionId: value.questionId,
|
|
required: value.required,
|
|
};
|
|
}
|
|
|
|
export function parseChatQuestionFormSubmitTokenPayload(
|
|
value: unknown,
|
|
): ChatQuestionFormSubmitTokenPayload | null {
|
|
if (
|
|
!isRecord(value) ||
|
|
value.version !== 1 ||
|
|
(value.nativeProvider !== undefined &&
|
|
value.nativeProvider !== "discord") ||
|
|
typeof value.publicationId !== "string" ||
|
|
typeof value.interactionId !== "string" ||
|
|
!isChatQuestionFormSubmitActionId(value.formActionId) ||
|
|
typeof value.expiresAt !== "string" ||
|
|
!Number.isFinite(Date.parse(value.expiresAt)) ||
|
|
!Array.isArray(value.fields) ||
|
|
value.fields.length === 0 ||
|
|
value.fields.length >
|
|
(value.nativeProvider === "discord" ? 5 : MAX_NATIVE_FORM_QUESTIONS)
|
|
) {
|
|
return null;
|
|
}
|
|
const fields = value.fields.map((field) =>
|
|
parseFormField(
|
|
field,
|
|
value.nativeProvider === "discord" ? 4000 : MAX_NATIVE_TEXT_LENGTH,
|
|
),
|
|
);
|
|
if (fields.some((field) => field === null)) return null;
|
|
if (
|
|
value.nativeProvider === "discord" &&
|
|
fields.some(
|
|
(field) => field?.kind === "single_select" && field.options.length > 25,
|
|
)
|
|
)
|
|
return null;
|
|
const fieldIds = new Set(fields.map((field) => field!.fieldId));
|
|
const questionIds = new Set(fields.map((field) => field!.questionId));
|
|
if (fieldIds.size !== fields.length || questionIds.size !== fields.length) {
|
|
return null;
|
|
}
|
|
return {
|
|
...(value.nativeProvider === "discord"
|
|
? { nativeProvider: "discord" as const }
|
|
: {}),
|
|
version: 1,
|
|
publicationId: value.publicationId,
|
|
interactionId: value.interactionId,
|
|
formActionId: value.formActionId,
|
|
expiresAt: value.expiresAt,
|
|
fields: fields as ChatQuestionFormField[],
|
|
};
|
|
}
|
|
|
|
/** Build a safe modal only from a durable token map and current interaction. */
|
|
export function buildChatQuestionFormModal(
|
|
interaction: AskUserQuestionsInteraction,
|
|
callbackId: string,
|
|
payload: ChatQuestionFormSubmitTokenPayload,
|
|
): ModalElement | null {
|
|
if (
|
|
!isChatQuestionFormSubmitActionId(callbackId) ||
|
|
callbackId !== payload.formActionId ||
|
|
payload.interactionId !== interaction.id
|
|
) {
|
|
return null;
|
|
}
|
|
if (
|
|
payload.interactionId !== interaction.id ||
|
|
interaction.status !== "pending"
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const children = [];
|
|
for (const field of payload.fields) {
|
|
const question = canonicalQuestion(interaction, field.questionId);
|
|
if (!question || formKind(interaction, question) !== field.kind)
|
|
return null;
|
|
const label =
|
|
boundedLabel(
|
|
question.prompt,
|
|
payload.nativeProvider === "discord" ? 45 : 2_000,
|
|
) || "Response";
|
|
if (field.kind === "text") {
|
|
children.push(
|
|
TextInput({
|
|
id: field.fieldId,
|
|
label,
|
|
maxLength: field.maxLength,
|
|
multiline: field.inputType === "text",
|
|
optional: !field.required,
|
|
...(question.helpText
|
|
? {
|
|
placeholder: boundedLabel(
|
|
question.helpText,
|
|
payload.nativeProvider === "discord" ? 100 : 150,
|
|
),
|
|
}
|
|
: {}),
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
const mappedOptions = field.options.map((mapping) => {
|
|
const option = question.options.find(
|
|
(candidate) =>
|
|
candidate.id === mapping.optionId && candidate.freeText !== true,
|
|
);
|
|
return option
|
|
? SelectOption({
|
|
label: boundedLabel(option.label, 75),
|
|
value: mapping.value,
|
|
...(option.description
|
|
? { description: boundedLabel(option.description, 75) }
|
|
: {}),
|
|
})
|
|
: null;
|
|
});
|
|
if (mappedOptions.some((option) => option === null)) return null;
|
|
children.push(
|
|
Select({
|
|
id: field.fieldId,
|
|
label,
|
|
optional: !field.required,
|
|
options: mappedOptions as SelectOptionElement[],
|
|
placeholder: "Choose one",
|
|
}),
|
|
);
|
|
}
|
|
|
|
return Modal({
|
|
callbackId,
|
|
title:
|
|
boundedLabel(
|
|
interaction.payload.title ?? interaction.title ?? "Input needed",
|
|
payload.nativeProvider === "discord" ? 45 : 24,
|
|
) || "Input needed",
|
|
submitLabel:
|
|
boundedLabel(interaction.payload.submitLabel ?? "Submit", 24) || "Submit",
|
|
closeLabel: "Cancel",
|
|
privateMetadata: callbackId,
|
|
children,
|
|
});
|
|
}
|
|
|
|
function invalid(
|
|
code: Exclude<ChatQuestionFormValidationResult, { ok: true }>["code"],
|
|
fieldErrors: Record<string, string> = {},
|
|
): ChatQuestionFormValidationResult {
|
|
return { ok: false, code, fieldErrors };
|
|
}
|
|
|
|
/**
|
|
* Converts opaque submitted values to canonical Paperclip answers. Provider
|
|
* labels, question ids, option ids, private metadata, and extra form fields are
|
|
* never trusted as canonical identifiers.
|
|
*/
|
|
export function validateChatQuestionFormSubmission(args: {
|
|
callbackId: string;
|
|
interaction: AskUserQuestionsInteraction;
|
|
now?: Date;
|
|
payload: ChatQuestionFormSubmitTokenPayload;
|
|
privateMetadata?: string;
|
|
values: Record<string, string>;
|
|
}): ChatQuestionFormValidationResult {
|
|
const { callbackId, interaction, payload, values } = args;
|
|
if (
|
|
!isChatQuestionFormSubmitActionId(callbackId) ||
|
|
callbackId !== payload.formActionId ||
|
|
// Teams task modules in Chat SDK 4.39 do not round-trip privateMetadata.
|
|
// When a provider does return it (Slack), it must match the opaque durable
|
|
// callback token. The callback id itself remains mandatory everywhere.
|
|
(args.privateMetadata !== undefined && args.privateMetadata !== callbackId)
|
|
) {
|
|
return invalid("invalid_callback");
|
|
}
|
|
if (
|
|
payload.interactionId !== interaction.id ||
|
|
interaction.status !== "pending"
|
|
) {
|
|
return invalid("stale_interaction");
|
|
}
|
|
const now = args.now ?? new Date();
|
|
if (Date.parse(payload.expiresAt) <= now.getTime()) return invalid("expired");
|
|
|
|
const expectedFields = new Set(payload.fields.map((field) => field.fieldId));
|
|
if (Object.keys(values).some((fieldId) => !expectedFields.has(fieldId))) {
|
|
return invalid("invalid_form");
|
|
}
|
|
|
|
const answers: AskUserQuestionsAnswer[] = [];
|
|
const fieldErrors: Record<string, string> = {};
|
|
for (const field of payload.fields) {
|
|
const question = canonicalQuestion(interaction, field.questionId);
|
|
if (!question || formKind(interaction, question) !== field.kind) {
|
|
return invalid("stale_interaction");
|
|
}
|
|
const submitted = values[field.fieldId] ?? "";
|
|
if (field.kind === "single_select") {
|
|
if (!submitted) {
|
|
if (field.required) fieldErrors[field.fieldId] = "Choose an option";
|
|
else answers.push({ questionId: question.id, optionIds: [] });
|
|
continue;
|
|
}
|
|
const mapping = field.options.find(
|
|
(option) => option.value === submitted,
|
|
);
|
|
const option = mapping
|
|
? question.options.find(
|
|
(candidate) =>
|
|
candidate.id === mapping.optionId && candidate.freeText !== true,
|
|
)
|
|
: null;
|
|
if (!mapping || !option) {
|
|
fieldErrors[field.fieldId] = "Choose a valid option";
|
|
continue;
|
|
}
|
|
answers.push({ questionId: question.id, optionIds: [option.id] });
|
|
continue;
|
|
}
|
|
|
|
const text = submitted.trim();
|
|
if (!text) {
|
|
if (field.required) {
|
|
fieldErrors[field.fieldId] = "Enter a response";
|
|
} else {
|
|
answers.push({ questionId: question.id, optionIds: [] });
|
|
}
|
|
continue;
|
|
}
|
|
if (text.length < field.minLength) {
|
|
fieldErrors[field.fieldId] =
|
|
`Enter at least ${field.minLength} characters`;
|
|
continue;
|
|
}
|
|
if (text.length > field.maxLength) {
|
|
fieldErrors[field.fieldId] =
|
|
`Enter no more than ${field.maxLength} characters`;
|
|
continue;
|
|
}
|
|
if (text && field.inputType !== "text") {
|
|
const number = Number(text);
|
|
if (
|
|
!Number.isFinite(number) ||
|
|
(field.inputType === "integer" && !Number.isInteger(number))
|
|
) {
|
|
fieldErrors[field.fieldId] =
|
|
field.inputType === "integer"
|
|
? "Enter a whole number"
|
|
: "Enter a number";
|
|
continue;
|
|
}
|
|
if (field.minimum !== undefined && number < field.minimum) {
|
|
fieldErrors[field.fieldId] = `Enter ${field.minimum} or more`;
|
|
continue;
|
|
}
|
|
if (field.maximum !== undefined && number > field.maximum) {
|
|
fieldErrors[field.fieldId] = `Enter ${field.maximum} or less`;
|
|
continue;
|
|
}
|
|
}
|
|
answers.push({
|
|
questionId: question.id,
|
|
optionIds: [],
|
|
...(text ? { otherText: text } : {}),
|
|
});
|
|
}
|
|
return Object.keys(fieldErrors).length
|
|
? invalid("invalid_form", fieldErrors)
|
|
: { ok: true, answers };
|
|
}
|
|
|
|
/**
|
|
* Teams replaces a dialog on errors, unlike Slack's inline field errors.
|
|
* Rebuild only an invalid-but-current form, after the caller's source and actor
|
|
* authorization. Neither provider input nor a stale token can refresh a form.
|
|
*/
|
|
export function chatQuestionFormValidationResponse(
|
|
args: Parameters<typeof validateChatQuestionFormSubmission>[0] & {
|
|
provider: "slack" | "microsoft-teams";
|
|
},
|
|
): ModalResponse {
|
|
const validation = validateChatQuestionFormSubmission(args);
|
|
if (validation.ok || validation.code !== "invalid_form") {
|
|
return chatQuestionFormDenialResponse();
|
|
}
|
|
if (args.provider === "slack") {
|
|
return { action: "errors", errors: validation.fieldErrors };
|
|
}
|
|
if (args.provider !== "microsoft-teams")
|
|
return chatQuestionFormDenialResponse();
|
|
const modal = buildChatQuestionFormModal(
|
|
args.interaction,
|
|
args.callbackId,
|
|
args.payload,
|
|
);
|
|
if (!modal) return chatQuestionFormDenialResponse();
|
|
|
|
const children: ModalElement["children"] = [
|
|
{
|
|
type: "text",
|
|
content: "Please check the answers below and submit again.",
|
|
},
|
|
];
|
|
for (const child of modal.children) {
|
|
if (child.type !== "text_input" && child.type !== "select") {
|
|
children.push(child);
|
|
continue;
|
|
}
|
|
const error = validation.fieldErrors[child.id];
|
|
if (error)
|
|
children.push({ type: "text", content: `${child.label}: ${error}` });
|
|
const value = args.values[child.id];
|
|
if (child.type === "text_input") {
|
|
// Keep over-field-limit drafts editable, but never reflect unbounded
|
|
// provider text. If the global native-form ceiling trims it, say so.
|
|
const initialValue = value?.slice(0, MAX_NATIVE_TEXT_LENGTH);
|
|
if (value && value.length > MAX_NATIVE_TEXT_LENGTH) {
|
|
children.push({
|
|
type: "text",
|
|
content: `${child.label}: This draft was shortened to ${MAX_NATIVE_TEXT_LENGTH} characters.`,
|
|
});
|
|
}
|
|
children.push({
|
|
...child,
|
|
...(initialValue !== undefined ? { initialValue } : {}),
|
|
});
|
|
} else {
|
|
const option = child.options.find(
|
|
(candidate) => candidate.value === value,
|
|
);
|
|
children.push({
|
|
...child,
|
|
...(option ? { initialOption: option.value } : {}),
|
|
});
|
|
}
|
|
}
|
|
return { action: "update", modal: { ...modal, children } };
|
|
}
|
|
|
|
/**
|
|
* Lookup helper for onAction after actor/resource/message authorization. It
|
|
* verifies both durable rows and rebuilds the modal from the current pending
|
|
* interaction; it does not consume either token.
|
|
*/
|
|
export async function resolveChatQuestionFormOpen(
|
|
db: ChatQuestionFormDb,
|
|
args: {
|
|
companyId: string;
|
|
conversationId: string;
|
|
endpointId: string;
|
|
interaction: AskUserQuestionsInteraction;
|
|
now?: Date;
|
|
openActionId: string;
|
|
},
|
|
): Promise<ResolvedChatQuestionFormOpen | null> {
|
|
if (!isChatQuestionFormOpenActionId(args.openActionId)) return null;
|
|
const open = await db
|
|
.select()
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, args.companyId),
|
|
eq(chatActions.endpointId, args.endpointId),
|
|
eq(chatActions.conversationId, args.conversationId),
|
|
eq(chatActions.kind, "question_form_open"),
|
|
eq(chatActions.providerActionId, args.openActionId),
|
|
eq(chatActions.status, "issued"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
const openPayload = parseChatQuestionFormOpenTokenPayload(open?.payload);
|
|
if (
|
|
!open ||
|
|
!openPayload ||
|
|
openPayload.interactionId !== args.interaction.id ||
|
|
Date.parse(openPayload.expiresAt) <= (args.now ?? new Date()).getTime()
|
|
) {
|
|
return null;
|
|
}
|
|
const submit = await db
|
|
.select()
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, args.companyId),
|
|
eq(chatActions.endpointId, args.endpointId),
|
|
eq(chatActions.conversationId, args.conversationId),
|
|
eq(chatActions.kind, "question_form_submit"),
|
|
eq(chatActions.providerActionId, openPayload.formActionId),
|
|
eq(chatActions.status, "issued"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
const submitPayload = parseChatQuestionFormSubmitTokenPayload(
|
|
submit?.payload,
|
|
);
|
|
if (
|
|
!submit ||
|
|
!submitPayload ||
|
|
submitPayload.publicationId !== openPayload.publicationId ||
|
|
submitPayload.interactionId !== openPayload.interactionId ||
|
|
submitPayload.formActionId !== openPayload.formActionId ||
|
|
submitPayload.expiresAt !== openPayload.expiresAt
|
|
) {
|
|
return null;
|
|
}
|
|
const modal = buildChatQuestionFormModal(
|
|
args.interaction,
|
|
submit.providerActionId,
|
|
submitPayload,
|
|
);
|
|
return modal
|
|
? {
|
|
interactionId: openPayload.interactionId,
|
|
modal,
|
|
openActionId: open.providerActionId,
|
|
openActionRowId: open.id,
|
|
publicationId: openPayload.publicationId,
|
|
submitActionId: submit.providerActionId,
|
|
submitActionRowId: submit.id,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Lookup and pure-validation wrapper for onModalSubmit. Call this only after
|
|
* actor, resource, conversation, and source-publication authorization. An
|
|
* invalid user value is returned as fieldErrors so the provider can keep the
|
|
* modal open; missing/tampered durable state returns null.
|
|
*/
|
|
export async function resolveChatQuestionFormSubmission(
|
|
db: ChatQuestionFormDb,
|
|
args: {
|
|
callbackId: string;
|
|
companyId: string;
|
|
conversationId?: string;
|
|
endpointId: string;
|
|
interaction: AskUserQuestionsInteraction;
|
|
now?: Date;
|
|
privateMetadata?: string;
|
|
values: Record<string, string>;
|
|
},
|
|
): Promise<ResolvedChatQuestionFormSubmission | null> {
|
|
const loaded = await loadChatQuestionFormSubmissionToken(db, {
|
|
callbackId: args.callbackId,
|
|
companyId: args.companyId,
|
|
endpointId: args.endpointId,
|
|
});
|
|
if (
|
|
!loaded ||
|
|
(args.conversationId !== undefined &&
|
|
loaded.conversationId !== args.conversationId)
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
...loaded,
|
|
validation: validateChatQuestionFormSubmission({
|
|
callbackId: args.callbackId,
|
|
privateMetadata: args.privateMetadata,
|
|
interaction: args.interaction,
|
|
payload: loaded.payload,
|
|
values: args.values,
|
|
...(args.now ? { now: args.now } : {}),
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Loads an opaque submit token without depending on Chat SDK's relatedThread.
|
|
* This matters after a provider-side validation retry: Chat SDK 4.39 consumes
|
|
* its short-lived modal context on the first submit, while this durable record
|
|
* remains the authoritative conversation/publication binding.
|
|
*/
|
|
export async function loadChatQuestionFormSubmissionToken(
|
|
db: ChatQuestionFormDb,
|
|
args: {
|
|
callbackId: string;
|
|
companyId: string;
|
|
endpointId: string;
|
|
includeProcessed?: boolean;
|
|
},
|
|
): Promise<LoadedChatQuestionFormSubmissionToken | null> {
|
|
if (!isChatQuestionFormSubmitActionId(args.callbackId)) return null;
|
|
const action = await db
|
|
.select()
|
|
.from(chatActions)
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, args.companyId),
|
|
eq(chatActions.endpointId, args.endpointId),
|
|
eq(chatActions.kind, "question_form_submit"),
|
|
eq(chatActions.providerActionId, args.callbackId),
|
|
args.includeProcessed
|
|
? inArray(chatActions.status, ["issued", "processed"])
|
|
: eq(chatActions.status, "issued"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
const payload = parseChatQuestionFormSubmitTokenPayload(action?.payload);
|
|
if (!action || !payload || !action.conversationId) return null;
|
|
return {
|
|
actionRowId: action.id,
|
|
conversationId: action.conversationId,
|
|
interactionId: payload.interactionId,
|
|
principalId: action.principalId,
|
|
payload,
|
|
publicationId: payload.publicationId,
|
|
result: action.result,
|
|
status: action.status as "issued" | "processed",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Atomic replay barrier for a validated modal response. Run this in the same
|
|
* database transaction as the canonical interaction answer whenever possible.
|
|
* Raw submitted field values are intentionally not copied into chat_actions.
|
|
*/
|
|
export async function claimChatQuestionFormSubmission(
|
|
db: ChatQuestionFormMutationDb,
|
|
args: {
|
|
actionRowId: string;
|
|
principalId: string;
|
|
},
|
|
): Promise<boolean> {
|
|
const claimed = await db
|
|
.update(chatActions)
|
|
.set({
|
|
principalId: args.principalId,
|
|
status: "processing",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatActions.id, args.actionRowId),
|
|
eq(chatActions.kind, "question_form_submit"),
|
|
eq(chatActions.status, "issued"),
|
|
),
|
|
)
|
|
.returning({ id: chatActions.id });
|
|
return claimed.length === 1;
|
|
}
|
|
|
|
/** Complete the durable form token without duplicating the answer payload. */
|
|
export async function completeChatQuestionFormSubmission(
|
|
db: ChatQuestionFormMutationDb,
|
|
args: { actionRowId: string; interactionId: string },
|
|
): Promise<boolean> {
|
|
const completed = await db
|
|
.update(chatActions)
|
|
.set({
|
|
status: "processed",
|
|
result: {
|
|
code: "question_form_answered",
|
|
interactionId: args.interactionId,
|
|
},
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatActions.id, args.actionRowId),
|
|
eq(chatActions.kind, "question_form_submit"),
|
|
eq(chatActions.status, "processing"),
|
|
),
|
|
)
|
|
.returning({ id: chatActions.id });
|
|
return completed.length === 1;
|
|
}
|