refactor(chat): centralize machine actions
This commit is contained in:
@@ -64,7 +64,9 @@ function makeUnlockPrivateResponse(
|
||||
function createTestChatMachine(
|
||||
options: {
|
||||
historyMessages?: UiMessage[];
|
||||
sendMessageHttpError?: Error;
|
||||
unlockHistoryOutput?: UnlockHistoryOutput;
|
||||
unlockHistoryError?: Error;
|
||||
unlockMessageOutput?: TestUnlockMessageOutput;
|
||||
} = {},
|
||||
) {
|
||||
@@ -74,10 +76,15 @@ function createTestChatMachine(
|
||||
sendMessageHttp: fromPromise<
|
||||
SendMessageHttpOutput,
|
||||
{ content: string }
|
||||
>(async () => ({
|
||||
response: makeChatSendResponse(),
|
||||
reply: null,
|
||||
})),
|
||||
>(async () => {
|
||||
if (options.sendMessageHttpError) {
|
||||
throw options.sendMessageHttpError;
|
||||
}
|
||||
return {
|
||||
response: makeChatSendResponse(),
|
||||
reply: null,
|
||||
};
|
||||
}),
|
||||
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
||||
receive((event) => {
|
||||
if (event.type !== "ChatSendMessage") return;
|
||||
@@ -92,13 +99,18 @@ function createTestChatMachine(
|
||||
});
|
||||
return () => undefined;
|
||||
}),
|
||||
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
shortfallCredits: 0,
|
||||
messages: [],
|
||||
...options.unlockHistoryOutput,
|
||||
})),
|
||||
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => {
|
||||
if (options.unlockHistoryError) {
|
||||
throw options.unlockHistoryError;
|
||||
}
|
||||
return {
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
shortfallCredits: 0,
|
||||
messages: [],
|
||||
...options.unlockHistoryOutput,
|
||||
};
|
||||
}),
|
||||
unlockMessage: fromPromise<
|
||||
MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest
|
||||
@@ -366,6 +378,56 @@ describe("chatMachine transitions", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("applies direct HTTP actor output with a type-bound action", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
|
||||
actor.send({ type: "ChatGuestLogin" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ guestSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "ChatSendImage",
|
||||
imageBase64: "data:image/png;base64,image",
|
||||
});
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ guestSession: "ready" }) &&
|
||||
snapshot.context.pendingReplyCount === 0,
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ content: "[Image]", isFromAI: false },
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("restores ready state when the direct HTTP actor fails", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
sendMessageHttpError: new Error("send failed"),
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatGuestLogin" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ guestSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({
|
||||
type: "ChatSendImage",
|
||||
imageBase64: "data:image/png;base64,image",
|
||||
});
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ guestSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.isReplyingAI).toBe(false);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("tracks replying state by queued batch instead of user message count", async () => {
|
||||
const machine = chatMachine.provide({
|
||||
actors: {
|
||||
@@ -568,6 +630,49 @@ describe("chatMachine transitions", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("restores the unlock prompt when the history actor fails", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-06-29",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
],
|
||||
unlockHistoryError: new Error("unlock failed"),
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
actor.send({ type: "ChatUnlockHistoryConfirmed" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
unlockHistoryError: "Failed to unlock messages. Please try again.",
|
||||
});
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("unlocks a single private message without navigating to payment", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { assign as xAssign } from "xstate";
|
||||
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import type { ChatState } from "./chat-state";
|
||||
|
||||
export type ChatActionArgs = { context: ChatState; event: ChatEvent };
|
||||
export type ChatContextArgs = { context: ChatState };
|
||||
|
||||
export const chatAssign = (
|
||||
assignment: Parameters<
|
||||
typeof xAssign<ChatState, ChatEvent, undefined, ChatEvent, never>
|
||||
>[0],
|
||||
) => xAssign<ChatState, ChatEvent, undefined, ChatEvent, never>(assignment);
|
||||
@@ -0,0 +1,499 @@
|
||||
import {
|
||||
setup,
|
||||
type ActionFunction,
|
||||
type DoneActorEvent,
|
||||
type ErrorActorEvent,
|
||||
type EventObject,
|
||||
} from "xstate";
|
||||
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { todayString } from "@/utils/date";
|
||||
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import {
|
||||
applyHistoryLoadedOutput,
|
||||
applyHttpSendOutput,
|
||||
applyNetworkHistoryLoadedOutput,
|
||||
applyPromotionUnlockOutput,
|
||||
applySingleUnlockOutput,
|
||||
beginPendingReply,
|
||||
countLockedHistoryMessages,
|
||||
createChatPromotionState,
|
||||
finishPendingReply,
|
||||
type HttpSendOutput,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-machine.helpers";
|
||||
import { baseChatMachineSetup } from "./chat-machine.setup";
|
||||
import { initialState, type ChatState } from "./chat-state";
|
||||
import type { UnlockHistoryOutput } from "./chat-unlock-flow";
|
||||
|
||||
const sendLog = new Logger("StoresChatChatSendFlow");
|
||||
const mediaLog = new Logger("StoresChatChatMediaFlow");
|
||||
|
||||
const enqueueMessageAction = baseChatMachineSetup.sendTo(
|
||||
"messageQueue",
|
||||
({ event }) => event,
|
||||
);
|
||||
|
||||
const startGuestSessionAction = baseChatMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
}),
|
||||
);
|
||||
|
||||
const startUserSessionAction = baseChatMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearChatSessionAction = baseChatMachineSetup.assign(() => ({
|
||||
...initialState,
|
||||
}));
|
||||
|
||||
const injectPromotionAction = baseChatMachineSetup.assign(({ event }) => {
|
||||
if (event.type !== "ChatPromotionInjected") return {};
|
||||
return {
|
||||
promotion: createChatPromotionState(
|
||||
event.promotion,
|
||||
event.messageId,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const clearPromotionAction = baseChatMachineSetup.assign({ promotion: null });
|
||||
|
||||
const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign(
|
||||
({ event }) => {
|
||||
if (event.type !== "ChatLocalHistoryLoaded") return {};
|
||||
return applyHistoryLoadedOutput(event.output);
|
||||
},
|
||||
);
|
||||
|
||||
const applyNetworkHistoryLoadedAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
return applyNetworkHistoryLoadedOutput(context, event.output);
|
||||
},
|
||||
);
|
||||
|
||||
const applyNetworkHistoryLoadedAndClearPaymentAction =
|
||||
baseChatMachineSetup.assign(({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
return {
|
||||
...applyNetworkHistoryLoadedOutput(context, event.output),
|
||||
paymentUnlockPending: false,
|
||||
};
|
||||
});
|
||||
|
||||
const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
const nextHistory = applyNetworkHistoryLoadedOutput(context, event.output);
|
||||
return {
|
||||
...nextHistory,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages),
|
||||
unlockHistoryError: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const markHistoryLoadFailedAction = baseChatMachineSetup.assign({
|
||||
historyLoaded: true,
|
||||
});
|
||||
|
||||
const appendGuestUserMessageAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
const today = todayString();
|
||||
sendLog.debug("[chat-machine] appendGuestUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
quotaMode: "server controlled",
|
||||
isReplyingAI: context.isReplyingAI,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
},
|
||||
],
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const appendUserMessageAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
const today = todayString();
|
||||
sendLog.debug("[chat-machine] appendUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
isReplyingAI: context.isReplyingAI,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
},
|
||||
],
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const appendQueuedSendErrorMessageAction = baseChatMachineSetup.assign(
|
||||
({ context }) => {
|
||||
const messages = [
|
||||
...context.messages,
|
||||
{
|
||||
content: "Something went wrong. Try sending again?",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
},
|
||||
];
|
||||
return { messages, ...finishPendingReply(context) };
|
||||
},
|
||||
);
|
||||
|
||||
const markQueuedSendStartedAction = baseChatMachineSetup.assign(
|
||||
({ context }) => beginPendingReply(context),
|
||||
);
|
||||
|
||||
const applyQueuedHttpOutputAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatQueuedHttpDone") return {};
|
||||
return applyHttpSendOutput(context, event.output);
|
||||
},
|
||||
);
|
||||
|
||||
const clearUpgradePromptAction = baseChatMachineSetup.assign(() => ({
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
canSendMessage: true,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
}));
|
||||
|
||||
const appendGuestUserImageAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
|
||||
const today = todayString();
|
||||
mediaLog.debug("[chat-machine] appendGuestUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
quotaMode: "server controlled",
|
||||
});
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
imageUrl: event.imageBase64,
|
||||
},
|
||||
],
|
||||
...beginPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const appendUserImageAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
|
||||
const today = todayString();
|
||||
mediaLog.debug("[chat-machine] appendUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
imageUrl: event.imageBase64,
|
||||
},
|
||||
],
|
||||
...beginPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const markPaymentUnlockPendingAction = baseChatMachineSetup.assign(() => ({
|
||||
paymentUnlockPending: true,
|
||||
unlockHistoryError: null,
|
||||
}));
|
||||
|
||||
const showUnlockHistoryPromptAction = baseChatMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
lockedHistoryCount: countLockedHistoryMessages(context.messages),
|
||||
unlockHistoryError: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const dismissUnlockHistoryPromptAction = baseChatMachineSetup.assign(() => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
unlockHistoryError: null,
|
||||
}));
|
||||
|
||||
const markUnlockHistoryStartedAction = baseChatMachineSetup.assign(() => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
isUnlockingHistory: true,
|
||||
unlockHistoryError: null,
|
||||
}));
|
||||
|
||||
const markUnlockHistoryFailedAction = baseChatMachineSetup.assign(() => ({
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
unlockHistoryError: "Failed to unlock messages. Please try again.",
|
||||
}));
|
||||
|
||||
const markUnlockMessageStartedAction = baseChatMachineSetup.assign(
|
||||
({ event }) => {
|
||||
if (event.type !== "ChatUnlockMessageRequested") return {};
|
||||
return {
|
||||
isUnlockingMessage: true,
|
||||
unlockingMessageId: event.messageId,
|
||||
unlockingMessageKind: event.kind,
|
||||
unlockingRemoteMessageId:
|
||||
event.remoteMessageId ?? (event.lockType ? null : event.messageId),
|
||||
unlockingLockType: event.lockType ?? null,
|
||||
unlockingClientLockId: event.clientLockId ?? null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const applyUnlockMessageSucceededAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
const { output } = event as unknown as DoneActorEvent<UnlockMessageOutput>;
|
||||
return {
|
||||
messages: applySingleUnlockOutput(context.messages, output),
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
creditBalance: output.response.creditBalance,
|
||||
creditsCharged: output.response.creditsCharged,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const requestUnlockPaymentFromOutputAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
const { output } = event as unknown as DoneActorEvent<UnlockMessageOutput>;
|
||||
return {
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: output.response.reason,
|
||||
unlockPaywallRequest: {
|
||||
displayMessageId:
|
||||
output.response.messageId || output.displayMessageId,
|
||||
...(output.response.messageId || output.request.messageId
|
||||
? {
|
||||
messageId:
|
||||
output.response.messageId || output.request.messageId,
|
||||
}
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind ?? "private",
|
||||
...(output.request.lockType
|
||||
? { lockType: output.request.lockType }
|
||||
: {}),
|
||||
...(output.request.clientLockId
|
||||
? { clientLockId: output.request.clientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === output.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: output.response.reason,
|
||||
creditBalance: output.response.creditBalance,
|
||||
requiredCredits: output.response.requiredCredits,
|
||||
shortfallCredits: output.response.shortfallCredits,
|
||||
},
|
||||
creditBalance: output.response.creditBalance,
|
||||
requiredCredits: output.response.requiredCredits,
|
||||
shortfallCredits: output.response.shortfallCredits,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const requestUnlockPaymentFromErrorAction = baseChatMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
isUnlockingMessage: false,
|
||||
unlockMessageError: "unlock_failed",
|
||||
unlockPaywallRequest:
|
||||
context.unlockingMessageId && context.unlockingMessageKind
|
||||
? {
|
||||
displayMessageId: context.unlockingMessageId,
|
||||
...(context.unlockingRemoteMessageId
|
||||
? { messageId: context.unlockingRemoteMessageId }
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind,
|
||||
...(context.unlockingLockType
|
||||
? { lockType: context.unlockingLockType }
|
||||
: {}),
|
||||
...(context.unlockingClientLockId
|
||||
? { clientLockId: context.unlockingClientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === context.unlockingMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: "unlock_failed",
|
||||
creditBalance: context.creditBalance,
|
||||
requiredCredits: context.requiredCredits,
|
||||
shortfallCredits: context.shortfallCredits,
|
||||
}
|
||||
: null,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearUnlockPaywallRequestAction = baseChatMachineSetup.assign(() => ({
|
||||
unlockPaywallRequest: null,
|
||||
}));
|
||||
|
||||
type ChatActorAction<TEvent extends EventObject> = ActionFunction<
|
||||
ChatState,
|
||||
TEvent,
|
||||
ChatEvent,
|
||||
undefined,
|
||||
never,
|
||||
never,
|
||||
never,
|
||||
never,
|
||||
never
|
||||
>;
|
||||
|
||||
function createChatActorActionSetup<TEvent extends EventObject>() {
|
||||
const actorActionSetup = setup({
|
||||
types: {
|
||||
context: {} as ChatState,
|
||||
events: {} as TEvent,
|
||||
},
|
||||
});
|
||||
return {
|
||||
assign(
|
||||
assignment: Parameters<typeof actorActionSetup.assign>[0],
|
||||
): ChatActorAction<TEvent> {
|
||||
// Actor lifecycle events are expression events, not public machine events.
|
||||
return actorActionSetup.assign(assignment) as unknown as ChatActorAction<TEvent>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const sendMessageDoneActionSetup =
|
||||
createChatActorActionSetup<DoneActorEvent<HttpSendOutput>>();
|
||||
const unlockHistoryDoneActionSetup =
|
||||
createChatActorActionSetup<DoneActorEvent<UnlockHistoryOutput>>();
|
||||
const actorErrorActionSetup = createChatActorActionSetup<ErrorActorEvent>();
|
||||
|
||||
export const applyHttpSendOutputAction = sendMessageDoneActionSetup.assign(
|
||||
({ context, event }) => applyHttpSendOutput(context, event.output),
|
||||
);
|
||||
|
||||
export const markHttpSendFailedAction = actorErrorActionSetup.assign({
|
||||
isReplyingAI: false,
|
||||
});
|
||||
|
||||
export const applyUnlockHistoryOutputAction =
|
||||
unlockHistoryDoneActionSetup.assign(({ event }) => {
|
||||
const lockedHistoryCount = countLockedHistoryMessages(
|
||||
event.output.messages,
|
||||
);
|
||||
const insufficientBalance =
|
||||
!event.output.unlocked &&
|
||||
event.output.reason === "insufficient_balance";
|
||||
return {
|
||||
messages: event.output.messages,
|
||||
historyLoaded: true,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: insufficientBalance,
|
||||
lockedHistoryCount,
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryError: insufficientBalance
|
||||
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
export const chatMachineSetup = baseChatMachineSetup.extend({
|
||||
actions: {
|
||||
enqueueMessage: enqueueMessageAction,
|
||||
startGuestSession: startGuestSessionAction,
|
||||
startUserSession: startUserSessionAction,
|
||||
clearChatSession: clearChatSessionAction,
|
||||
injectPromotion: injectPromotionAction,
|
||||
clearPromotion: clearPromotionAction,
|
||||
applyLocalHistoryLoaded: applyLocalHistoryLoadedAction,
|
||||
applyNetworkHistoryLoaded: applyNetworkHistoryLoadedAction,
|
||||
applyNetworkHistoryLoadedAndClearPayment:
|
||||
applyNetworkHistoryLoadedAndClearPaymentAction,
|
||||
showUnlockHistoryPromptFromNetwork:
|
||||
showUnlockHistoryPromptFromNetworkAction,
|
||||
markHistoryLoadFailed: markHistoryLoadFailedAction,
|
||||
appendGuestUserMessage: appendGuestUserMessageAction,
|
||||
appendUserMessage: appendUserMessageAction,
|
||||
appendQueuedSendErrorMessage: appendQueuedSendErrorMessageAction,
|
||||
markQueuedSendStarted: markQueuedSendStartedAction,
|
||||
applyQueuedHttpOutput: applyQueuedHttpOutputAction,
|
||||
clearUpgradePrompt: clearUpgradePromptAction,
|
||||
appendGuestUserImage: appendGuestUserImageAction,
|
||||
appendUserImage: appendUserImageAction,
|
||||
markPaymentUnlockPending: markPaymentUnlockPendingAction,
|
||||
showUnlockHistoryPrompt: showUnlockHistoryPromptAction,
|
||||
dismissUnlockHistoryPrompt: dismissUnlockHistoryPromptAction,
|
||||
markUnlockHistoryStarted: markUnlockHistoryStartedAction,
|
||||
markUnlockHistoryFailed: markUnlockHistoryFailedAction,
|
||||
markUnlockMessageStarted: markUnlockMessageStartedAction,
|
||||
applyUnlockMessageSucceeded: applyUnlockMessageSucceededAction,
|
||||
requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction,
|
||||
requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction,
|
||||
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { setup } from "xstate";
|
||||
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import {
|
||||
httpMessageQueueActor,
|
||||
loadHistoryActor,
|
||||
sendMessageHttpActor,
|
||||
unlockHistoryActor,
|
||||
unlockMessageActor,
|
||||
} from "./chat-machine.actors";
|
||||
import type { ChatState } from "./chat-state";
|
||||
|
||||
export const baseChatMachineSetup = setup({
|
||||
types: {
|
||||
context: {} as ChatState,
|
||||
events: {} as ChatEvent,
|
||||
},
|
||||
actors: {
|
||||
loadHistory: loadHistoryActor,
|
||||
sendMessageHttp: sendMessageHttpActor,
|
||||
httpMessageQueue: httpMessageQueueActor,
|
||||
unlockHistory: unlockHistoryActor,
|
||||
unlockMessage: unlockMessageActor,
|
||||
},
|
||||
});
|
||||
+13
-194
@@ -1,177 +1,18 @@
|
||||
/**
|
||||
* Chat 状态机(XState v5)
|
||||
*
|
||||
* 登录态流转约束:
|
||||
* - 未登录:可以进入游客登录 / 其他登录。
|
||||
* - 游客登录:只可以升级为其他登录,不响应退出。
|
||||
* - 其他登录:可以退出,不响应游客登录 / 重复其他登录。
|
||||
*
|
||||
* 状态结构(parent state 模式):
|
||||
* - `idle`:屏没挂 / 登出
|
||||
* - `guestSession`(parent):游客会话
|
||||
* - `userSession`(parent):其他登录用户会话
|
||||
*
|
||||
* init 任务:
|
||||
* - guestSession.initializing:loadHistory
|
||||
* - userSession.initializing:loadHistory
|
||||
* - 每个任务都是独立 actor,不互相等待(除非 `always` barrier)
|
||||
*
|
||||
* 消息发送路径(业务事实):
|
||||
* - `ChatSendMessage` 只负责即时追加用户消息 + 入队,不进入 sending 子状态
|
||||
* - guestSession / userSession:队列串行走 HTTP,限制以后端 `lockDetail` 为准
|
||||
* - `pendingReplyCount` 跟踪待回复数量,`isReplyingAI` 由计数派生
|
||||
*
|
||||
* 发送能力:
|
||||
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
||||
* - 后端返回 `canSendMessage=false` 时展示积分不足充值引导。
|
||||
*
|
||||
*/
|
||||
|
||||
import { setup, assign, sendTo } from "xstate";
|
||||
|
||||
import { ChatState, initialState } from "./chat-state";
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
applyHistoryLoadedOutput,
|
||||
applyNetworkHistoryLoadedOutput,
|
||||
countLockedHistoryMessages,
|
||||
shouldPromptUnlockHistory,
|
||||
createChatPromotionState,
|
||||
} from "./chat-machine.helpers";
|
||||
import { loadHistoryActor } from "./chat-history-flow";
|
||||
import {
|
||||
appendGuestUserImageAction,
|
||||
appendUserImageAction,
|
||||
} from "./chat-media-flow";
|
||||
import {
|
||||
appendGuestUserMessageAction,
|
||||
appendQueuedSendErrorMessageAction,
|
||||
appendUserMessageAction,
|
||||
applyQueuedHttpOutputAction,
|
||||
clearUpgradePromptAction,
|
||||
httpMessageQueueActor,
|
||||
markQueuedSendStartedAction,
|
||||
sendMessageHttpActor,
|
||||
} from "./chat-send-flow";
|
||||
import {
|
||||
applyUnlockMessageSucceededAction,
|
||||
clearUnlockPaywallRequestAction,
|
||||
dismissUnlockHistoryPromptAction,
|
||||
markPaymentUnlockPendingAction,
|
||||
markUnlockHistoryFailedAction,
|
||||
markUnlockHistoryStartedAction,
|
||||
markUnlockMessageStartedAction,
|
||||
requestUnlockPaymentFromErrorAction,
|
||||
requestUnlockPaymentFromOutputAction,
|
||||
showUnlockHistoryPromptAction,
|
||||
unlockHistoryActor,
|
||||
unlockMessageActor,
|
||||
} from "./chat-unlock-flow";
|
||||
applyHttpSendOutputAction,
|
||||
applyUnlockHistoryOutputAction,
|
||||
chatMachineSetup,
|
||||
markHttpSendFailedAction,
|
||||
} from "./chat-machine.actions";
|
||||
import { shouldPromptUnlockHistory } from "./chat-machine.helpers";
|
||||
import { initialState } from "./chat-state";
|
||||
|
||||
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
|
||||
export type { ChatState } from "./chat-state";
|
||||
export { initialState } from "./chat-state";
|
||||
export type { ChatEvent } from "./chat-events";
|
||||
|
||||
// ============================================================
|
||||
// Machine
|
||||
// ============================================================
|
||||
export const chatMachine = setup({
|
||||
types: {
|
||||
context: {} as ChatState,
|
||||
events: {} as ChatEvent,
|
||||
},
|
||||
actors: {
|
||||
loadHistory: loadHistoryActor,
|
||||
sendMessageHttp: sendMessageHttpActor,
|
||||
httpMessageQueue: httpMessageQueueActor,
|
||||
unlockHistory: unlockHistoryActor,
|
||||
unlockMessage: unlockMessageActor,
|
||||
},
|
||||
actions: {
|
||||
appendGuestUserMessage: appendGuestUserMessageAction,
|
||||
appendUserMessage: appendUserMessageAction,
|
||||
appendQueuedSendErrorMessage: appendQueuedSendErrorMessageAction,
|
||||
markQueuedSendStarted: markQueuedSendStartedAction,
|
||||
applyQueuedHttpOutput: applyQueuedHttpOutputAction,
|
||||
clearUpgradePrompt: clearUpgradePromptAction,
|
||||
appendGuestUserImage: appendGuestUserImageAction,
|
||||
appendUserImage: appendUserImageAction,
|
||||
markPaymentUnlockPending: markPaymentUnlockPendingAction,
|
||||
showUnlockHistoryPrompt: showUnlockHistoryPromptAction,
|
||||
dismissUnlockHistoryPrompt: dismissUnlockHistoryPromptAction,
|
||||
markUnlockHistoryStarted: markUnlockHistoryStartedAction,
|
||||
markUnlockHistoryFailed: markUnlockHistoryFailedAction,
|
||||
markUnlockMessageStarted: markUnlockMessageStartedAction,
|
||||
applyUnlockMessageSucceeded: applyUnlockMessageSucceededAction,
|
||||
requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction,
|
||||
requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction,
|
||||
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||
|
||||
startGuestSession: assign(({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
})),
|
||||
|
||||
startUserSession: assign(({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
})),
|
||||
|
||||
clearChatSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
injectPromotion: assign(({ event }) => {
|
||||
if (event.type !== "ChatPromotionInjected") return {};
|
||||
return {
|
||||
promotion: createChatPromotionState(
|
||||
event.promotion,
|
||||
event.messageId,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
clearPromotion: assign({ promotion: null }),
|
||||
|
||||
applyLocalHistoryLoaded: assign(({ event }) => {
|
||||
if (event.type !== "ChatLocalHistoryLoaded") return {};
|
||||
return applyHistoryLoadedOutput(event.output);
|
||||
}),
|
||||
|
||||
applyNetworkHistoryLoaded: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
return applyNetworkHistoryLoadedOutput(context, event.output);
|
||||
}),
|
||||
|
||||
applyNetworkHistoryLoadedAndClearPayment: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
return {
|
||||
...applyNetworkHistoryLoadedOutput(context, event.output),
|
||||
paymentUnlockPending: false,
|
||||
};
|
||||
}),
|
||||
|
||||
showUnlockHistoryPromptFromNetwork: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
const nextHistory = applyNetworkHistoryLoadedOutput(
|
||||
context,
|
||||
event.output,
|
||||
);
|
||||
return {
|
||||
...nextHistory,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages),
|
||||
unlockHistoryError: null,
|
||||
};
|
||||
}),
|
||||
|
||||
markHistoryLoadFailed: assign({ historyLoaded: true }),
|
||||
},
|
||||
}).createMachine({
|
||||
export const chatMachine = chatMachineSetup.createMachine({
|
||||
id: "chat",
|
||||
initial: "idle",
|
||||
context: initialState,
|
||||
@@ -265,13 +106,11 @@ export const chatMachine = setup({
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) =>
|
||||
applyHttpSendOutput(context, event.output),
|
||||
),
|
||||
actions: applyHttpSendOutputAction,
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isReplyingAI: false }),
|
||||
actions: markHttpSendFailedAction,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -410,13 +249,11 @@ export const chatMachine = setup({
|
||||
}),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) =>
|
||||
applyHttpSendOutput(context, event.output),
|
||||
),
|
||||
actions: applyHttpSendOutputAction,
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: assign({ isReplyingAI: false }),
|
||||
actions: markHttpSendFailedAction,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -426,25 +263,7 @@ export const chatMachine = setup({
|
||||
src: "unlockHistory",
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => {
|
||||
const lockedHistoryCount = countLockedHistoryMessages(
|
||||
event.output.messages,
|
||||
);
|
||||
const insufficientBalance =
|
||||
!event.output.unlocked &&
|
||||
event.output.reason === "insufficient_balance";
|
||||
return {
|
||||
messages: event.output.messages,
|
||||
historyLoaded: true,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: insufficientBalance,
|
||||
lockedHistoryCount,
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryError: insufficientBalance
|
||||
? `Insufficient credits. You still need ${event.output.shortfallCredits} credits.`
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
actions: applyUnlockHistoryOutputAction,
|
||||
},
|
||||
onError: {
|
||||
target: "ready",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { todayString } from "@/utils/date";
|
||||
|
||||
import { chatAssign, type ChatActionArgs } from "./chat-flow-actions";
|
||||
import { beginPendingReply } from "./chat-machine.helpers";
|
||||
|
||||
const log = new Logger("StoresChatChatMediaFlow");
|
||||
|
||||
export const appendGuestUserImageAction = chatAssign(
|
||||
({ context, event }: ChatActionArgs) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendGuestUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
quotaMode: "server controlled",
|
||||
});
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
imageUrl: event.imageBase64,
|
||||
},
|
||||
],
|
||||
...beginPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const appendUserImageAction = chatAssign(
|
||||
({ context, event }: ChatActionArgs) => {
|
||||
if (event.type !== "ChatSendImage") return {};
|
||||
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendUserImage", {
|
||||
oldMessagesCount: context.messages.length,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: "[Image]",
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
imageUrl: event.imageBase64,
|
||||
},
|
||||
],
|
||||
...beginPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -7,20 +7,9 @@ import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
import { todayString } from "@/utils/date";
|
||||
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
import {
|
||||
chatAssign,
|
||||
type ChatActionArgs,
|
||||
type ChatContextArgs,
|
||||
} from "./chat-flow-actions";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
beginPendingReply,
|
||||
finishPendingReply,
|
||||
sendResponseToUiMessage,
|
||||
} from "./chat-machine.helpers";
|
||||
import { sendResponseToUiMessage } from "./chat-machine.helpers";
|
||||
|
||||
const log = new Logger("StoresChatChatSendFlow");
|
||||
|
||||
@@ -39,93 +28,6 @@ export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
||||
},
|
||||
);
|
||||
|
||||
export const appendGuestUserMessageAction = chatAssign(
|
||||
({ context, event }: ChatActionArgs) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendGuestUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
quotaMode: "server controlled",
|
||||
isReplyingAI: context.isReplyingAI,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
},
|
||||
],
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const appendUserMessageAction = chatAssign(
|
||||
({ context, event }: ChatActionArgs) => {
|
||||
if (event.type !== "ChatSendMessage") return {};
|
||||
|
||||
const today = todayString();
|
||||
log.debug("[chat-machine] appendUserMessage", {
|
||||
contentLength: event.content.length,
|
||||
contentPreview: event.content.slice(0, 50),
|
||||
isReplyingAI: context.isReplyingAI,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...context.messages,
|
||||
{
|
||||
content: event.content,
|
||||
isFromAI: false,
|
||||
date: today,
|
||||
},
|
||||
],
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const appendQueuedSendErrorMessageAction = chatAssign(
|
||||
({ context }: ChatContextArgs) => {
|
||||
const messages = [
|
||||
...context.messages,
|
||||
{
|
||||
content: "Something went wrong. Try sending again?",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
},
|
||||
];
|
||||
return { messages, ...finishPendingReply(context) };
|
||||
},
|
||||
);
|
||||
|
||||
export const markQueuedSendStartedAction = chatAssign(
|
||||
({ context }: ChatContextArgs) =>
|
||||
beginPendingReply(context),
|
||||
);
|
||||
|
||||
export const applyQueuedHttpOutputAction = chatAssign(
|
||||
({ context, event }: ChatActionArgs) => {
|
||||
if (event.type !== "ChatQueuedHttpDone") return {};
|
||||
return applyHttpSendOutput(context, event.output);
|
||||
},
|
||||
);
|
||||
|
||||
export const clearUpgradePromptAction = chatAssign(() => ({
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
canSendMessage: true,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
}));
|
||||
|
||||
function createMessageQueueActor(
|
||||
sendBack: (event: ChatEvent) => void,
|
||||
receive: (listener: (event: ChatEvent) => void) => void,
|
||||
|
||||
@@ -5,16 +5,8 @@ import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identit
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import {
|
||||
chatAssign,
|
||||
type ChatActionArgs,
|
||||
type ChatContextArgs,
|
||||
} from "./chat-flow-actions";
|
||||
import { readAndSyncHistory } from "./chat-history-sync";
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
applyPromotionUnlockOutput,
|
||||
countLockedHistoryMessages,
|
||||
type UnlockMessageRequest,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-machine.helpers";
|
||||
@@ -23,12 +15,14 @@ const log = new Logger("StoresChatChatUnlockFlow");
|
||||
|
||||
type UiMessage = import("@/data/dto/chat").UiMessage;
|
||||
|
||||
export const unlockHistoryActor = fromPromise<{
|
||||
export interface UnlockHistoryOutput {
|
||||
unlocked: boolean;
|
||||
reason: string;
|
||||
shortfallCredits: number;
|
||||
messages: UiMessage[];
|
||||
}>(async () => {
|
||||
}
|
||||
|
||||
export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
||||
const chatRepo = getChatRepository();
|
||||
const unlockResult = await chatRepo.unlockHistory();
|
||||
if (Result.isErr(unlockResult)) {
|
||||
@@ -100,159 +94,4 @@ export const unlockMessageActor = fromPromise<
|
||||
};
|
||||
});
|
||||
|
||||
export const markPaymentUnlockPendingAction = chatAssign(() => ({
|
||||
paymentUnlockPending: true,
|
||||
unlockHistoryError: null,
|
||||
}));
|
||||
|
||||
export const showUnlockHistoryPromptAction = chatAssign(
|
||||
({ context }: ChatContextArgs) => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
lockedHistoryCount: countLockedHistoryMessages(context.messages),
|
||||
unlockHistoryError: null,
|
||||
}),
|
||||
);
|
||||
|
||||
export const dismissUnlockHistoryPromptAction = chatAssign(() => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
unlockHistoryError: null,
|
||||
}));
|
||||
|
||||
export const markUnlockHistoryStartedAction = chatAssign(() => ({
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
isUnlockingHistory: true,
|
||||
unlockHistoryError: null,
|
||||
}));
|
||||
|
||||
export const markUnlockHistoryFailedAction = chatAssign(() => ({
|
||||
isUnlockingHistory: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
unlockHistoryError: "Failed to unlock messages. Please try again.",
|
||||
}));
|
||||
|
||||
export const markUnlockMessageStartedAction = chatAssign(
|
||||
({ event }: ChatActionArgs) => {
|
||||
if (event.type !== "ChatUnlockMessageRequested") return {};
|
||||
return {
|
||||
isUnlockingMessage: true,
|
||||
unlockingMessageId: event.messageId,
|
||||
unlockingMessageKind: event.kind,
|
||||
unlockingRemoteMessageId:
|
||||
event.remoteMessageId ?? (event.lockType ? null : event.messageId),
|
||||
unlockingLockType: event.lockType ?? null,
|
||||
unlockingClientLockId: event.clientLockId ?? null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const applyUnlockMessageSucceededAction = chatAssign(
|
||||
({ context, event }: ChatActionArgs) => {
|
||||
const output = (event as { output?: UnlockMessageOutput }).output;
|
||||
if (!output) return {};
|
||||
return {
|
||||
messages: applySingleUnlockOutput(context.messages, output),
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
creditBalance: output.response.creditBalance,
|
||||
creditsCharged: output.response.creditsCharged,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const requestUnlockPaymentFromOutputAction = chatAssign(
|
||||
({ context, event }: ChatActionArgs) => {
|
||||
const output = (event as { output?: UnlockMessageOutput }).output;
|
||||
if (!output) return {};
|
||||
return {
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: output.response.reason,
|
||||
unlockPaywallRequest: {
|
||||
displayMessageId:
|
||||
output.response.messageId || output.displayMessageId,
|
||||
...(output.response.messageId || output.request.messageId
|
||||
? {
|
||||
messageId:
|
||||
output.response.messageId || output.request.messageId,
|
||||
}
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind ?? "private",
|
||||
...(output.request.lockType
|
||||
? { lockType: output.request.lockType }
|
||||
: {}),
|
||||
...(output.request.clientLockId
|
||||
? { clientLockId: output.request.clientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === output.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: output.response.reason,
|
||||
creditBalance: output.response.creditBalance,
|
||||
requiredCredits: output.response.requiredCredits,
|
||||
shortfallCredits: output.response.shortfallCredits,
|
||||
},
|
||||
creditBalance: output.response.creditBalance,
|
||||
requiredCredits: output.response.requiredCredits,
|
||||
shortfallCredits: output.response.shortfallCredits,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const requestUnlockPaymentFromErrorAction = chatAssign(
|
||||
({ context }: ChatContextArgs) => ({
|
||||
isUnlockingMessage: false,
|
||||
unlockMessageError: "unlock_failed",
|
||||
unlockPaywallRequest:
|
||||
context.unlockingMessageId && context.unlockingMessageKind
|
||||
? {
|
||||
displayMessageId: context.unlockingMessageId,
|
||||
...(context.unlockingRemoteMessageId
|
||||
? { messageId: context.unlockingRemoteMessageId }
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind,
|
||||
...(context.unlockingLockType
|
||||
? { lockType: context.unlockingLockType }
|
||||
: {}),
|
||||
...(context.unlockingClientLockId
|
||||
? { clientLockId: context.unlockingClientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === context.unlockingMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: "unlock_failed",
|
||||
creditBalance: context.creditBalance,
|
||||
requiredCredits: context.requiredCredits,
|
||||
shortfallCredits: context.shortfallCredits,
|
||||
}
|
||||
: null,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
}),
|
||||
);
|
||||
|
||||
export const clearUnlockPaywallRequestAction = chatAssign(() => ({
|
||||
unlockPaywallRequest: null,
|
||||
}));
|
||||
export type { UnlockMessageOutput } from "./chat-machine.helpers";
|
||||
|
||||
Reference in New Issue
Block a user