mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Questions and review decisions must work in the conversation where a task started. > - Slack omitted question context and did not publish several review kinds. > - Some answers saved in Paperclip also lost their authorized continuation or their final Slack reply. > - A settled card and a successful run did not prove that the user received the result. > - This pull request preserves complete questions and follows the existing answer and delivery receipts through continuation. > - Users can read the request, submit an authorized decision, and receive the resumed task's result in the same conversation. ## Linked Issues or Issue Description Refs #17045 and #17218. **What happened?** Slack question cards could omit instructions and editable starting text. Checkbox reviews, item verdicts, task suggestions, and connection requests had no publication path. Complex question answers submitted in Paperclip could fail continuation authorization. Several review continuations completed without delivering their result to Slack. **Expected behavior** Show the actual question and context. Use native Slack controls when they preserve the form contract. Otherwise provide an explicit Paperclip review link. Persist one authorized response, resume through the existing delivery path, settle the card, and deliver the useful result to its originating conversation. **Steps to reproduce** 1. Start a task in Slack and ask a text or choice question with instructions and help text. 2. Submit native text and choice responses, then submit a complex form through Paperclip. 3. Accept a checklist, submit item decisions, select suggested tasks, or decline a connection request. 4. Inspect the saved answer, continuation run, original Slack card, and final Slack reply independently. ## What Changed - Preserve full prompts, context, descriptions, recommendations, help, and editable starting text. Use direct buttons for short choices, inline searchable selects for longer lists, and native checkboxes with explicit submission for multiple choices. - Add native custom-answer and validated-text modals, ordinary confirmation buttons, checklist decisions, per-item verdicts with reasons, suggested-task creation/dismissal, and connection decline. Keep governed approvals, permission grants, and forms that cannot be represented faithfully in the full Paperclip review surface. - Keep Slack-specific canonical-validation feedback in its owning platform module. Shared callbacks delegate without interpreting provider presentation. - Reuse the existing canonical interaction transactions, callback receipts, publication queue, and continuation delivery. Share ordinary confirmation handling across Telegram and Slack. Recheck current authorization and commit response receipts atomically. - Settle cards with the saved answer and remove stale controls. Ignore passive Slack link callbacks so opening a task does not display a false answer error. - Validate exact multi-select receipt mappings during native continuation. Recover automation-origin connection replies from their published prompt after validating the durable connection receipt. - Keep oversized multi-select handoffs free of unused native tokens, and retain the complete Paperclip form for 101–128 choices plus Other. Make the original-conversation audience explicit before submitting a Slack modal. - Add rollback, provider serialization, callback, continuation, and negative authorization regressions. Update the connector playbook and feature/access maps. No React or application UI changes are included. - Add a durable Q01–Q11 qualification index and provider coverage matrix. Require distinct functional, good-experience, and best-available-control assessments; real unanswered/answering/settled evidence; saved results, continuation and navigation proof; and retained failures. Mark other providers unaudited rather than assuming Slack parity. ## Verification Candidate: `e0017d446b1016a38d65382366cd1408cce33039`. The eleven-core-format live Slack gallery is pinned to `c3d458b2a06b660448940fd8cf33c5906f0058d5`; subsequent boundary fixes and the sharing notice have automated verification, not new live screenshots. - Real Slack UI testing covered all 11 formats: text, closed choice, long single select, multiple choices, choices with a custom answer, pattern-validated text, confirmation, checklist, item verdicts, suggested tasks, and connection requests. Each has unanswered/answering/answered screenshots as applicable, an inspected saved result, one continuation, and an individual experience review in the local HTML gallery. - The dedicated private QA app/channel used signed callbacks to the local candidate. A separately approved password-protected, read-only preview proved connection-review, suggested-task review, and created-task navigation. Anonymous access, writes, and credential routes were denied. No OAuth grant was attempted. - Real testing exposed and repaired a multi-select continuation failure and a misleading error on link-button clicks. Failed attempts remain in the evidence. The final navigation replay opened the exact pending connection request and declined it in Slack without granting access. Suggested-task creation produced the selected child with the correct parent and description. - Final review-fix verification: **32 form tests**, **28 provider/form-rendering tests**, **30 publication tests**, and **171 native continuation/authorization tests** passed. Earlier **3 atomic-resolution rollback**, **9 targeted callback/publication/Telegram**, and **28 review-continuation** cases passed. The final Slack/Teams modal subset also passed. The automation-origin connection cases, 101/128 custom-choice cases, and oversized multi-select continuation reproduced before their fixes and passed afterward; failing logs remain retained. These counts describe relevant runs, not a reliability percentage. - CI on `b5885b85fd` exposed a test fixture that assumed every Slack modal block was an input; the new sharing notice correctly renders a non-input block. The fixture now selects input blocks safely. The exact signed-modal correction/one-continuation test passed locally, together with 14 Slack form/modal tests. The failing CI log and its specific cause remain recorded. - `pnpm -r typecheck` and `pnpm build` passed. Server typechecking was repeated after the final source changes. Map and module-boundary checks and `git diff --check` passed. - The full `pnpm test:run` was attempted and stopped after repeated embedded PostgreSQL semaphore exhaustion on this host. Its 1,395-test chat suite found two stale native-control assertions; both were corrected and re-tested in a focused run. Focused database suites used separate disposable databases on existing local PostgreSQL. The full suite is not claimed green; new-head CI must complete before merge. - Final-head CI passed on `e0017d446b1016a38d65382366cd1408cce33039`: all test/build jobs and required `ci / verify` and `ci / e2e` gates are green ([run](https://github.com/paperclipai/paperclip/actions/runs/40115430194)). Greptile and Ponytail both reviewed this exact head at 5/5, the security scan passed, and all review threads are resolved. - All three QA app callbacks were restored to staging. Both temporary tunnels and local proxies were closed. The local endpoint was archived and its local credential copies removed. The candidate app was stopped. Limits: provider execution used a deterministic Claude protocol fixture, not an LLM. This is local real-provider UX proof, not deployed staging or broader Codie/Claude qualification. The public preview intentionally blocked ancillary artifact routes, so its destination pages could show a partial-history warning. The complete permission-grant flow and every oversized/mixed-form branch were not driven manually. Earlier fixture, test, and provider failures remain recorded. ## Risks - New reply routes require durable origin proof. Incorrect receipt matching could lose a reply or route it incorrectly. Tests cover missing, forged, mismatched, and revoked authority. - Governed forms keep their Paperclip resolution checks. This change does not grant new approval authority to Slack. - Existing completed review results can become deliverable through the repaired publication path without another provider run. - No schema, lockfile, workflow, public task-status model, or runtime-instruction change is included. This branch is not deployed. ## Model Used OpenAI Codex, GPT-6 family, with code execution and tool use. The exact backend model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green on the new head - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups on the new head - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1077 lines
34 KiB
TypeScript
1077 lines
34 KiB
TypeScript
import { projectSafeChatPublicationText } from "./chat-publication-projection.js";
|
|
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,
|
|
CardText,
|
|
Select,
|
|
RadioSelect,
|
|
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" | "slack";
|
|
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" | "slack";
|
|
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 Array.from(value.replace(/[\u0000-\u001f\u007f]/g, " ").trim())
|
|
.slice(0, maximum)
|
|
.join("");
|
|
}
|
|
|
|
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,
|
|
nativeProvider?: "discord" | "slack",
|
|
): "single_select" | "text" | null {
|
|
const source = questionSetQuestion(interaction, question.id);
|
|
if (source) {
|
|
if (nativeProvider === "slack" && source.answerMode === "single_select" && source.customAnswer && interaction.payload.questions.length === 1) {
|
|
// The text modal is the Other action beside native choices. Without all
|
|
// listed choices, keep the complete response path in Paperclip.
|
|
const choiceCount = question.options.filter(option => !option.freeText).length;
|
|
return choiceCount > 0 && choiceCount <= MAX_NATIVE_SELECT_OPTIONS ? "text" : null;
|
|
}
|
|
if (source.answerMode === "text") {
|
|
// Slack can show canonical validation errors without closing the modal.
|
|
// The canonical resolver evaluates patterns in its bounded worker, never
|
|
// synchronously on the webhook event loop.
|
|
return source.textValidation?.pattern && nativeProvider !== "slack" ? 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" | "slack" } = {},
|
|
): 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, options.nativeProvider),
|
|
}));
|
|
if (classified.some(({ kind }) => kind === null)) return null;
|
|
if (
|
|
classified.length === 1 &&
|
|
classified[0]?.kind === "single_select" &&
|
|
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 (canonicalMax < 1 || (source?.initialText?.length ?? 0) > Math.min(canonicalMax, maxTextLength)) return null;
|
|
if (source?.initialText?.trim() && projectSafeChatPublicationText(source.initialText) !== source.initialText.trim()) 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();
|
|
const draft: ChatQuestionFormDraft = {
|
|
...(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),
|
|
};
|
|
return buildChatQuestionFormModal(interaction, draft.submitActionId, {
|
|
version: 1, interactionId: interaction.id, publicationId: "preview",
|
|
formActionId: draft.submitActionId, expiresAt: draft.expiresAt,
|
|
fields: draft.fields, ...(draft.nativeProvider ? { nativeProvider: draft.nativeProvider } : {}),
|
|
}) ? draft : null;
|
|
}
|
|
|
|
/** 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" && value.nativeProvider !== "slack") ||
|
|
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" || value.nativeProvider === "slack")
|
|
? { nativeProvider: value.nativeProvider }
|
|
: {}),
|
|
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 title = interaction.payload.title ?? interaction.title ?? "Input needed";
|
|
const titleLimit = payload.nativeProvider === "discord" ? 45 : 24;
|
|
const modalTitle = Array.from(title).length <= titleLimit ? boundedLabel(title, titleLimit)
|
|
: payload.fields.length === 1 ? "Answer question" : "Answer questions";
|
|
const children: ModalElement["children"] = [];
|
|
const appendContext = (text: string | null | undefined) => {
|
|
if (payload.nativeProvider === "discord" || !text?.trim()) return;
|
|
const points = Array.from(projectSafeChatPublicationText(text));
|
|
for (let offset = 0; offset < points.length; offset += 2000) {
|
|
children.push(CardText(points.slice(offset, offset + 2000).join("")));
|
|
}
|
|
};
|
|
if (payload.nativeProvider === "slack") appendContext("Your answers will be shared in this Slack conversation.");
|
|
if (modalTitle !== title && !interaction.payload.questions.some(question => question.prompt === title)) appendContext(title);
|
|
for (const text of new Set([interaction.summary, interaction.payload.questionSet?.description])) appendContext(text);
|
|
for (const field of payload.fields) {
|
|
const question = canonicalQuestion(interaction, field.questionId);
|
|
if (!question || formKind(interaction, question, payload.nativeProvider) !== field.kind)
|
|
return null;
|
|
const label =
|
|
boundedLabel(
|
|
question.prompt,
|
|
payload.nativeProvider === "discord" ? 45 : 2_000,
|
|
) || "Response";
|
|
const source = questionSetQuestion(interaction, question.id);
|
|
if (source?.header && source.header !== question.helpText) appendContext(source.header);
|
|
if (question.prompt !== label) appendContext(question.prompt);
|
|
appendContext(question.helpText);
|
|
if (field.kind === "text") {
|
|
if ((source?.initialText?.length ?? 0) > field.maxLength) return null;
|
|
if (source?.initialText?.trim() && projectSafeChatPublicationText(source.initialText) !== source.initialText.trim()) return null;
|
|
children.push(
|
|
TextInput({
|
|
...(source?.initialText !== undefined ? { initialValue: source.initialText } : {}),
|
|
id: field.fieldId,
|
|
label: source?.customAnswer?.label ?? label,
|
|
maxLength: field.maxLength,
|
|
multiline: field.inputType === "text",
|
|
optional: !field.required,
|
|
...((source?.customAnswer?.placeholder ?? question.helpText)
|
|
? {
|
|
placeholder: boundedLabel(
|
|
source?.customAnswer?.placeholder ?? question.helpText!,
|
|
payload.nativeProvider === "discord" ? 100 : 150,
|
|
),
|
|
}
|
|
: {}),
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
for (const option of question.options) {
|
|
const recommended = source?.options?.find(item => item.id === option.id)?.recommended;
|
|
if (option.label !== boundedLabel(option.label, 75) || (option.description?.length ?? 0) > 75 || recommended) {
|
|
appendContext(`${option.label}${recommended ? " (recommended)" : ""}${option.description ? `: ${option.description}` : ""}`);
|
|
}
|
|
}
|
|
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(
|
|
(payload.nativeProvider === "slack" && mappedOptions.length <= 5 ? RadioSelect : Select)({
|
|
id: field.fieldId,
|
|
label,
|
|
optional: !field.required,
|
|
options: mappedOptions as SelectOptionElement[],
|
|
placeholder: "Choose one",
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (payload.nativeProvider !== "discord" && children.length > 100) return null;
|
|
return Modal({
|
|
callbackId,
|
|
title: modalTitle || "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, payload.nativeProvider) !== 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;
|
|
}
|