feat(chat): handle credit-gated send responses
This commit is contained in:
@@ -38,6 +38,12 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
pendingReplyCount: 1,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
canSendMessage: true,
|
||||
cannotSendReason: null,
|
||||
creditBalance: 0,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
@@ -168,6 +174,96 @@ describe("applyHttpSendOutput", () => {
|
||||
expect(nextState.upgradePromptVisible).toBe(true);
|
||||
expect(nextState.upgradeReason).toBe("weekly_limit");
|
||||
});
|
||||
|
||||
it("stores insufficient credit state and removes failed optimistic messages", () => {
|
||||
const context = makeChatState({
|
||||
messages: [
|
||||
{
|
||||
content: "hello",
|
||||
isFromAI: false,
|
||||
date: "2026-06-25",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nextState = applyHttpSendOutput(context, {
|
||||
response: makeResponse({
|
||||
reply: "",
|
||||
messageId: "",
|
||||
canSendMessage: false,
|
||||
cannotSendReason: "insufficient_credits",
|
||||
creditBalance: 0,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 2,
|
||||
shortfallCredits: 2,
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
showContent: false,
|
||||
showUpgrade: true,
|
||||
reason: "insufficient_credits",
|
||||
hint: "Insufficient credits.",
|
||||
detail: {
|
||||
requiredCredits: 2,
|
||||
currentCredits: 0,
|
||||
shortfallCredits: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
reply: null,
|
||||
});
|
||||
|
||||
expect(nextState.messages).toEqual([]);
|
||||
expect(nextState.isReplyingAI).toBe(false);
|
||||
expect(nextState.upgradePromptVisible).toBe(true);
|
||||
expect(nextState.upgradeReason).toBe("insufficient_credits");
|
||||
expect(nextState.canSendMessage).toBe(false);
|
||||
expect(nextState.cannotSendReason).toBe("insufficient_credits");
|
||||
expect(nextState.requiredCredits).toBe(2);
|
||||
expect(nextState.shortfallCredits).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps a valid reply while marking future sends as unavailable", () => {
|
||||
const context = makeChatState({
|
||||
messages: [
|
||||
{
|
||||
content: "hello",
|
||||
isFromAI: false,
|
||||
date: "2026-06-25",
|
||||
},
|
||||
],
|
||||
});
|
||||
const reply = sendResponseToUiMessage(
|
||||
makeResponse({
|
||||
reply: "This one went through, but you need credits next.",
|
||||
canSendMessage: false,
|
||||
cannotSendReason: "insufficient_credits",
|
||||
creditBalance: 0,
|
||||
requiredCredits: 2,
|
||||
shortfallCredits: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const nextState = applyHttpSendOutput(context, {
|
||||
response: makeResponse({
|
||||
reply: "This one went through, but you need credits next.",
|
||||
canSendMessage: false,
|
||||
cannotSendReason: "insufficient_credits",
|
||||
creditBalance: 0,
|
||||
requiredCredits: 2,
|
||||
shortfallCredits: 2,
|
||||
}),
|
||||
reply,
|
||||
});
|
||||
|
||||
expect(nextState.messages).toHaveLength(2);
|
||||
expect(nextState.messages?.[1]).toMatchObject({
|
||||
content: "This one went through, but you need credits next.",
|
||||
isFromAI: true,
|
||||
});
|
||||
expect(nextState.upgradePromptVisible).toBe(true);
|
||||
expect(nextState.upgradeReason).toBe("insufficient_credits");
|
||||
expect(nextState.canSendMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localMessagesToUi", () => {
|
||||
|
||||
@@ -467,6 +467,11 @@ describe("chatMachine transitions", () => {
|
||||
limit: 3,
|
||||
},
|
||||
},
|
||||
canSendMessage: false,
|
||||
cannotSendReason: "insufficient_credits",
|
||||
creditBalance: 0,
|
||||
requiredCredits: 2,
|
||||
shortfallCredits: 2,
|
||||
}),
|
||||
reply: null,
|
||||
},
|
||||
@@ -474,11 +479,14 @@ describe("chatMachine transitions", () => {
|
||||
|
||||
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(true);
|
||||
expect(actor.getSnapshot().context.upgradeReason).toBe("weekly_limit");
|
||||
expect(actor.getSnapshot().context.canSendMessage).toBe(false);
|
||||
|
||||
actor.send({ type: "ChatPaymentSucceeded" });
|
||||
|
||||
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
|
||||
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
|
||||
expect(actor.getSnapshot().context.canSendMessage).toBe(true);
|
||||
expect(actor.getSnapshot().context.cannotSendReason).toBeNull();
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
@@ -160,8 +160,26 @@ async function sendMessageViaHttp(content: string): Promise<{
|
||||
result.data.lockDetail.locked &&
|
||||
result.data.lockDetail.showUpgrade &&
|
||||
result.data.lockDetail.reason === "weekly_limit";
|
||||
const isInsufficientCredits =
|
||||
result.data.canSendMessage === false &&
|
||||
result.data.cannotSendReason === "insufficient_credits" &&
|
||||
!hasRenderableSendResponse(result.data);
|
||||
return {
|
||||
response: result.data,
|
||||
reply: isMessageLimit ? null : sendResponseToUiMessage(result.data),
|
||||
reply:
|
||||
isMessageLimit || isInsufficientCredits
|
||||
? null
|
||||
: sendResponseToUiMessage(result.data),
|
||||
};
|
||||
}
|
||||
|
||||
function hasRenderableSendResponse(response: ChatSendResponse): boolean {
|
||||
return (
|
||||
response.reply.trim().length > 0 ||
|
||||
response.audioUrl.trim().length > 0 ||
|
||||
Boolean(response.image.url) ||
|
||||
response.lockDetail.reason === "private_message" ||
|
||||
response.lockDetail.reason === "voice_message" ||
|
||||
response.lockDetail.reason === "image_paywall"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
|
||||
import { todayString, Result, Logger } from "@/utils";
|
||||
|
||||
import type { ChatState } from "./chat-state";
|
||||
import type { ChatState, ChatUpgradeReason } from "./chat-state";
|
||||
|
||||
const log = new Logger("StoresChatChatMachineHelpers");
|
||||
|
||||
@@ -170,29 +170,40 @@ export function applyHttpSendOutput(
|
||||
const { response, reply } = output;
|
||||
|
||||
const lockDetail = response.lockDetail;
|
||||
const isMessageLimitBlocked =
|
||||
const isWeeklyLimitBlocked =
|
||||
lockDetail.locked &&
|
||||
lockDetail.showUpgrade &&
|
||||
lockDetail.reason === "weekly_limit";
|
||||
const isInsufficientCredits =
|
||||
response.canSendMessage === false &&
|
||||
response.cannotSendReason === "insufficient_credits";
|
||||
const upgradeReason: ChatUpgradeReason | null = isWeeklyLimitBlocked
|
||||
? "weekly_limit"
|
||||
: isInsufficientCredits
|
||||
? "insufficient_credits"
|
||||
: null;
|
||||
const sendCapabilityState = getSendCapabilityState(response);
|
||||
|
||||
if (isMessageLimitBlocked) {
|
||||
if (upgradeReason) {
|
||||
const lastMessage = context.messages[context.messages.length - 1];
|
||||
const messages =
|
||||
lastMessage && !lastMessage.isFromAI
|
||||
!reply && lastMessage && !lastMessage.isFromAI
|
||||
? context.messages.slice(0, -1)
|
||||
: context.messages;
|
||||
|
||||
return {
|
||||
messages,
|
||||
messages: reply ? [...messages, reply] : messages,
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: true,
|
||||
upgradeReason: "weekly_limit",
|
||||
upgradeReason,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
return {
|
||||
...finishPendingReply(context),
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,6 +213,7 @@ export function applyHttpSendOutput(
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,6 +222,28 @@ export function applyHttpSendOutput(
|
||||
...finishPendingReply(context),
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
...sendCapabilityState,
|
||||
};
|
||||
}
|
||||
|
||||
function getSendCapabilityState(
|
||||
response: ChatSendResponse,
|
||||
): Pick<
|
||||
ChatState,
|
||||
| "canSendMessage"
|
||||
| "cannotSendReason"
|
||||
| "creditBalance"
|
||||
| "creditsCharged"
|
||||
| "requiredCredits"
|
||||
| "shortfallCredits"
|
||||
> {
|
||||
return {
|
||||
canSendMessage: response.canSendMessage,
|
||||
cannotSendReason: response.cannotSendReason,
|
||||
creditBalance: response.creditBalance,
|
||||
creditsCharged: response.creditsCharged,
|
||||
requiredCredits: response.requiredCredits,
|
||||
shortfallCredits: response.shortfallCredits,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,10 @@ export const chatMachine = setup({
|
||||
clearUpgradePrompt: assign(() => ({
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
canSendMessage: true,
|
||||
cannotSendReason: null,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
})),
|
||||
|
||||
markPaymentUnlockPending: assign(() => ({
|
||||
@@ -306,7 +310,8 @@ export const chatMachine = setup({
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
actions: ["appendGuestUserMessage", "enqueueMessage"],
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
guard: ({ context, event }) =>
|
||||
context.canSendMessage && event.content.trim().length > 0,
|
||||
},
|
||||
ChatSendImage: {
|
||||
actions: "appendGuestUserImage",
|
||||
@@ -441,7 +446,8 @@ export const chatMachine = setup({
|
||||
ready: {
|
||||
on: {
|
||||
ChatSendMessage: {
|
||||
guard: ({ event }) => event.content.trim().length > 0,
|
||||
guard: ({ context, event }) =>
|
||||
context.canSendMessage && event.content.trim().length > 0,
|
||||
actions: ["appendUserMessage", "enqueueMessage"],
|
||||
},
|
||||
ChatSendImage: {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
|
||||
export type ChatUpgradeReason = "weekly_limit" | "insufficient_credits";
|
||||
|
||||
export interface ChatState {
|
||||
messages: UiMessage[];
|
||||
isReplyingAI: boolean;
|
||||
pendingReplyCount: number;
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: "weekly_limit" | null;
|
||||
upgradeReason: ChatUpgradeReason | null;
|
||||
canSendMessage: boolean;
|
||||
cannotSendReason: string | null;
|
||||
creditBalance: number;
|
||||
creditsCharged: number;
|
||||
requiredCredits: number;
|
||||
shortfallCredits: number;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
historyOffset: number;
|
||||
@@ -27,6 +35,12 @@ export const initialState: ChatState = {
|
||||
pendingReplyCount: 0,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
canSendMessage: true,
|
||||
cannotSendReason: null,
|
||||
creditBalance: 0,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
|
||||
Reference in New Issue
Block a user