Skip to content
Source
Sign inSign up

GuestShareDecider.ts

255 lines9.0 KB

1import {
2 GUEST_PENDING_ATTACHMENT_BYTES_LIMIT,
3 GUEST_PENDING_MESSAGE_LIMIT,
4 GUEST_RESOLVED_MESSAGE_LIMIT,
5 type GuestMessageAttribution,
6 type GuestMessageId,
7 type OrchestrationMessageContext,
8 type OrchestrationV2AppThread,
9 type OrchestrationV2ServerCommand,
10 type ThreadGuestMessage,
11 type ThreadGuestShare,
12} from "@t3tools/contracts";
13import * as DateTime from "effect/DateTime";
14import * as Result from "effect/Result";
15​
16export type GuestShareCommand = Extract<
17 OrchestrationV2ServerCommand,
18 {
19 readonly type:
20 | "thread.guest-share.open"
21 | "thread.guest-share.redeem"
22 | "thread.guest-share.close"
23 | "thread.guest-message.submit"
24 | "thread.guest-message.dismiss";
25 }
26>;
27​
28/** Failures a guest may be told about. Any other dispatch failure reaches them as "rejected". */
29export const GUEST_FAILURE = {
30 inactive: "This guest link is no longer active.",
31 used: "This guest link was already used.",
32 busy: "Wait for the owner to answer your earlier messages.",
33 filesBusy: "Wait for the owner to answer your earlier messages before you attach more files.",
34} as const;
35​
36const isExpired = (share: ThreadGuestShare, now: DateTime.Utc) =>
37 DateTime.toEpochMillis(now) >= Date.parse(share.expiresAt);
38​
39const pendingAttachments = (share: ThreadGuestShare | null) =>
40 share?.messages.flatMap((message) =>
41 message.status === "pending" ? (message.attachments ?? []) : [],
42 ) ?? [];
43​
44/**
45 * Stored files a share change leaves behind: those of waiting messages that were dismissed or
46 * dropped with the share. A sent message keeps its files, which its thread message now holds.
47 */
48export function releasedGuestAttachmentIds(
49 before: ThreadGuestShare | null,
50 after: ThreadGuestShare | null,
51): ReadonlyArray<string> {
52 const kept = new Set(
53 after !== null && after.id === before?.id
54 ? after.messages.flatMap((message) =>
55 message.status === "dismissed"
56 ? []
57 : (message.attachments ?? []).map((attachment) => attachment.id),
58 )
59 : [],
60 );
61 return pendingAttachments(before)
62 .map((attachment) => attachment.id)
63 .filter((id) => !kept.has(id));
65​
66/** Keeps every pending message and only the newest resolved ones. */
67function trimResolved(messages: ReadonlyArray<ThreadGuestMessage>): Array<ThreadGuestMessage> {
68 let resolvedBudget = GUEST_RESOLVED_MESSAGE_LIMIT;
69 const kept: Array<ThreadGuestMessage> = [];
70 for (const message of messages.toReversed()) {
71 if (message.status !== "pending") {
72 if (resolvedBudget === 0) continue;
73 resolvedBudget -= 1;
74 }
75 kept.push(message);
76 }
77 return kept.toReversed();
79​
80const resolveMessage = (
81 share: ThreadGuestShare,
82 guestMessageId: GuestMessageId,
83 status: "sent" | "dismissed",
84): Result.Result<ThreadGuestShare, string> => {
85 const message = share.messages.find((candidate) => candidate.id === guestMessageId);
86 if (message === undefined || message.status !== "pending") {
87 return Result.fail(`Guest message ${guestMessageId} is not pending.`);
88 }
89 return Result.succeed({
90 ...share,
91 messages: trimResolved(
92 share.messages.map((candidate) =>
93 candidate.id === guestMessageId ? { ...candidate, status } : candidate,
94 ),
95 ),
96 });
97};
98​
99/**
100 * Applies a guest share command to the thread. The orchestrator runs commands one at a time, so
101 * checking state here is what makes a link redeem once and a message send once.
102 */
103export function decideGuestShareCommand(input: {
104 readonly thread: OrchestrationV2AppThread;
105 readonly command: GuestShareCommand;
106 readonly now: DateTime.Utc;
107}): Result.Result<OrchestrationV2AppThread, string> {
108 const { thread, command, now } = input;
109 const share = thread.guestShare ?? null;
110 const withShare = (guestShare: ThreadGuestShare | null) =>
111 Result.succeed({ ...thread, guestShare, updatedAt: now });
112​
113 switch (command.type) {
114 case "thread.guest-share.open": {
115 if (thread.archivedAt !== null) return Result.fail("Archived threads cannot be shared.");
116 return withShare({
117 id: command.shareId,
118 host: command.host,
119 createdAt: DateTime.formatIso(now),
120 expiresAt: command.expiresAt,
121 guestName: null,
122 messages: [],
123 });
124 }
125 case "thread.guest-share.close":
126 return share === null ? Result.succeed(thread) : withShare(null);
127 case "thread.guest-share.redeem": {
128 if (share === null || share.id !== command.shareId || isExpired(share, now)) {
129 return Result.fail(GUEST_FAILURE.inactive);
130 }
131 if (share.guestName !== null) return Result.fail(GUEST_FAILURE.used);
132 return withShare({ ...share, guestName: command.guestName });
133 }
134 case "thread.guest-message.submit": {
135 if (
136 share === null ||
137 share.id !== command.shareId ||
138 share.guestName === null ||
139 isExpired(share, now)
140 ) {
141 return Result.fail(GUEST_FAILURE.inactive);
142 }
143 if (
144 share.messages.filter((message) => message.status === "pending").length >=
145 GUEST_PENDING_MESSAGE_LIMIT
146 ) {
147 return Result.fail(GUEST_FAILURE.busy);
148 }
149 const attachments = command.attachments ?? [];
150 const pendingBytes = [...pendingAttachments(share), ...attachments].reduce(
151 (total, attachment) => total + attachment.sizeBytes,
152 0,
153 );
154 if (attachments.length > 0 && pendingBytes > GUEST_PENDING_ATTACHMENT_BYTES_LIMIT) {
155 return Result.fail(GUEST_FAILURE.filesBusy);
156 }
157 const guestShare: ThreadGuestShare = {
158 ...share,
159 messages: trimResolved([
160 ...share.messages,
161 {
162 id: command.guestMessageId,
163 text: command.text,
164 status: "pending",
165 submittedAt: DateTime.formatIso(now),
166 ...(attachments.length === 0 ? {} : { attachments }),
167 },
168 ]),
169 };
170 // A request needs the owner, so a settled thread comes back as a new message brings it.
171 return thread.settledOverride === "settled"
172 ? Result.succeed({
173 ...thread,
174 guestShare,
175 settledOverride: null,
176 settledAt: null,
177 unsettledAt: now,
178 updatedAt: now,
179 })
180 : withShare(guestShare);
181 }
182 case "thread.guest-message.dismiss": {
183 if (share === null) return Result.fail("This thread has no guest link.");
184 return Result.map(resolveMessage(share, command.guestMessageId, "dismissed"), (next) => ({
185 ...thread,
186 guestShare: next,
187 updatedAt: now,
188 }));
189 }
190 }
192​
193/**
194 * Marks a guest message sent. `text` is what the agent receives: it names the guest so the agent
195 * does not mistake it for the owner's words, and so a guest cannot start a slash command.
196 * `guest` lets clients show the guest's own text with their name beside it.
197 */
198export function decideGuestMessageSend(input: {
199 readonly thread: OrchestrationV2AppThread;
200 readonly guestMessageId: GuestMessageId;
201 readonly now: DateTime.Utc;
202}): Result.Result<
203 {
204 readonly thread: OrchestrationV2AppThread;
205 readonly text: string;
206 readonly guest: GuestMessageAttribution;
207 },
208 string
209> {
210 const share = input.thread.guestShare ?? null;
211 if (share === null) return Result.fail("This thread has no guest link.");
212 const message = share.messages.find((candidate) => candidate.id === input.guestMessageId);
213 const name = share.guestName ?? "Guest";
214 const guestText = message?.text ?? "";
215 return Result.map(resolveMessage(share, input.guestMessageId, "sent"), (next) => ({
216 thread: { ...input.thread, guestShare: next, updatedAt: input.now },
217 text: `Message from guest ${name}, sent by the owner:\n\n${guestText}`,
218 guest: { guestMessageId: input.guestMessageId, name, text: guestText, shareId: share.id },
219 }));
221​
222/**
223 * The owner cancelled a queued send, so the agent never got the message. It shows as dismissed,
224 * which the guest can see, instead of sent with no reply ever coming. A share that changed since
225 * the send is left alone.
226 */
227export function guestShareAfterCancelledSend(
228 thread: OrchestrationV2AppThread,
229 guest: Pick<GuestMessageAttribution, "guestMessageId" | "shareId">,
230 now: DateTime.Utc,
231): OrchestrationV2AppThread {
232 const share = thread.guestShare ?? null;
233 if (share === null || share.id !== guest.shareId) return thread;
234 const message = share.messages.find((candidate) => candidate.id === guest.guestMessageId);
235 if (message?.status !== "sent") return thread;
236 return {
237 ...thread,
238 guestShare: {
239 ...share,
240 messages: share.messages.map((candidate) =>
241 candidate.id === guest.guestMessageId ? { ...candidate, status: "dismissed" } : candidate,
242 ),
243 },
244 updatedAt: now,
245 };
247​
248/** Guest attribution comes only from `decideGuestMessageSend`; a client cannot claim it. */
249export function withoutGuestAttribution<
250 C extends { readonly context?: OrchestrationMessageContext | undefined },
251>(command: C): C {
252 if (command.context?.guest === undefined) return command;
253 const { guest: _guest, ...context } = command.context;
254 return { ...command, context };