fix(chat): sync paid voice audio unlock flow
This commit is contained in:
@@ -13,10 +13,6 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
creditsCharged: 10,
|
creditsCharged: 10,
|
||||||
requiredCredits: 10,
|
requiredCredits: 10,
|
||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
lockDetail: {
|
|
||||||
locked: false,
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.unlocked).toBe(true);
|
expect(response.unlocked).toBe(true);
|
||||||
@@ -27,7 +23,6 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
expect(response.creditsCharged).toBe(10);
|
expect(response.creditsCharged).toBe(10);
|
||||||
expect(response.requiredCredits).toBe(10);
|
expect(response.requiredCredits).toBe(10);
|
||||||
expect(response.shortfallCredits).toBe(0);
|
expect(response.shortfallCredits).toBe(0);
|
||||||
expect(response.lockDetail.locked).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes backend ids, audio aliases, and unlocked images", () => {
|
it("normalizes backend ids, audio aliases, and unlocked images", () => {
|
||||||
@@ -43,10 +38,6 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
type: "promotion",
|
type: "promotion",
|
||||||
url: "https://example.com/image.jpg",
|
url: "https://example.com/image.jpg",
|
||||||
},
|
},
|
||||||
lockDetail: {
|
|
||||||
locked: false,
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.messageId).toBe("backend-message-1");
|
expect(response.messageId).toBe("backend-message-1");
|
||||||
@@ -66,10 +57,6 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
creditsCharged: 0,
|
creditsCharged: 0,
|
||||||
requiredCredits: 10,
|
requiredCredits: 10,
|
||||||
shortfallCredits: 5,
|
shortfallCredits: 5,
|
||||||
lockDetail: {
|
|
||||||
locked: true,
|
|
||||||
reason: "voice_message",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.unlocked).toBe(false);
|
expect(response.unlocked).toBe(false);
|
||||||
@@ -78,10 +65,6 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
expect(response.creditsCharged).toBe(0);
|
expect(response.creditsCharged).toBe(0);
|
||||||
expect(response.requiredCredits).toBe(10);
|
expect(response.requiredCredits).toBe(10);
|
||||||
expect(response.shortfallCredits).toBe(5);
|
expect(response.shortfallCredits).toBe(5);
|
||||||
expect(response.lockDetail).toEqual({
|
|
||||||
locked: true,
|
|
||||||
reason: "voice_message",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults nullable content to an empty string", () => {
|
it("defaults nullable content to an empty string", () => {
|
||||||
@@ -90,10 +73,6 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
content: null,
|
content: null,
|
||||||
audioUrl: null,
|
audioUrl: null,
|
||||||
reason: "insufficient_balance",
|
reason: "insufficient_balance",
|
||||||
lockDetail: {
|
|
||||||
locked: true,
|
|
||||||
reason: "private_message",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.content).toBe("");
|
expect(response.content).toBe("");
|
||||||
@@ -109,10 +88,6 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
creditsCharged: null,
|
creditsCharged: null,
|
||||||
requiredCredits: null,
|
requiredCredits: null,
|
||||||
shortfallCredits: null,
|
shortfallCredits: null,
|
||||||
lockDetail: {
|
|
||||||
locked: true,
|
|
||||||
reason: "image_paywall",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.creditBalance).toBe(0);
|
expect(response.creditBalance).toBe(0);
|
||||||
@@ -121,21 +96,7 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
expect(response.shortfallCredits).toBe(0);
|
expect(response.shortfallCredits).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults nullable lock detail through schema helpers", () => {
|
it("strips legacy lock detail fields", () => {
|
||||||
const response = UnlockPrivateResponse.from({
|
|
||||||
unlocked: false,
|
|
||||||
content: "",
|
|
||||||
reason: "insufficient_balance",
|
|
||||||
lockDetail: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.lockDetail).toEqual({
|
|
||||||
locked: false,
|
|
||||||
reason: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips legacy lock detail fields while preserving lock state and type", () => {
|
|
||||||
const response = UnlockPrivateResponse.fromJson({
|
const response = UnlockPrivateResponse.fromJson({
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
@@ -148,9 +109,7 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.lockDetail).toEqual({
|
expect(response.toJson()).not.toHaveProperty("lockDetail");
|
||||||
locked: true,
|
expect(response).not.toHaveProperty("lockDetail");
|
||||||
reason: "private_message",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
type UnlockPrivateResponseData,
|
type UnlockPrivateResponseData,
|
||||||
type UnlockPrivateResponseInput,
|
type UnlockPrivateResponseInput,
|
||||||
} from "@/data/schemas/chat/response/unlock_private_response";
|
} from "@/data/schemas/chat/response/unlock_private_response";
|
||||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
|
||||||
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +22,6 @@ export class UnlockPrivateResponse {
|
|||||||
declare readonly creditsCharged: number;
|
declare readonly creditsCharged: number;
|
||||||
declare readonly requiredCredits: number;
|
declare readonly requiredCredits: number;
|
||||||
declare readonly shortfallCredits: number;
|
declare readonly shortfallCredits: number;
|
||||||
declare readonly lockDetail: ChatLockDetailData;
|
|
||||||
|
|
||||||
private constructor(input: UnlockPrivateResponseInput) {
|
private constructor(input: UnlockPrivateResponseInput) {
|
||||||
const data = UnlockPrivateResponseSchema.parse(input);
|
const data = UnlockPrivateResponseSchema.parse(input);
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ function makeResponse(
|
|||||||
): ChatSendResponse {
|
): ChatSendResponse {
|
||||||
return ChatSendResponse.from({
|
return ChatSendResponse.from({
|
||||||
reply: "",
|
reply: "",
|
||||||
audioUrl: "",
|
|
||||||
messageId: "msg-1",
|
messageId: "msg-1",
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
timestamp: 1782356425363,
|
timestamp: 1782356425363,
|
||||||
@@ -92,6 +91,33 @@ describe("chat media cache helpers", () => {
|
|||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not cache audio from a locked send response", () => {
|
||||||
|
expect(
|
||||||
|
getSendResponseMediaTargets(
|
||||||
|
makeResponse({
|
||||||
|
audioUrl: "https://example.com/locked.mp3",
|
||||||
|
lockDetail: { locked: true, reason: "voice_message" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches audio from an unlocked send response", () => {
|
||||||
|
expect(
|
||||||
|
getSendResponseMediaTargets(
|
||||||
|
makeResponse({
|
||||||
|
audioUrl: "https://example.com/unlocked.mp3",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
messageId: "msg-1",
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: "https://example.com/unlocked.mp3",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("deduplicates media targets", () => {
|
it("deduplicates media targets", () => {
|
||||||
expect(
|
expect(
|
||||||
uniqueMediaTargets([
|
uniqueMediaTargets([
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ export function getSendResponseMediaTargets(
|
|||||||
kind: "image",
|
kind: "image",
|
||||||
remoteUrl: response.image.url,
|
remoteUrl: response.image.url,
|
||||||
});
|
});
|
||||||
|
if (!response.lockDetail.locked) {
|
||||||
pushMediaTarget(targets, {
|
pushMediaTarget(targets, {
|
||||||
messageId: response.messageId,
|
messageId: response.messageId,
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
remoteUrl: response.audioUrl,
|
remoteUrl: response.audioUrl,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
stringOrEmpty,
|
stringOrEmpty,
|
||||||
stringOrNull,
|
stringOrNull,
|
||||||
} from "../../nullable-defaults";
|
} from "../../nullable-defaults";
|
||||||
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
|
import { ChatImageSchema } from "../chat_payloads";
|
||||||
import { ChatLockTypeSchema } from "../chat_lock_type";
|
import { ChatLockTypeSchema } from "../chat_lock_type";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +31,6 @@ export const UnlockPrivateResponseSchema = z
|
|||||||
creditsCharged: numberOrZero,
|
creditsCharged: numberOrZero,
|
||||||
requiredCredits: numberOrZero,
|
requiredCredits: numberOrZero,
|
||||||
shortfallCredits: numberOrZero,
|
shortfallCredits: numberOrZero,
|
||||||
lockDetail: ChatLockDetailSchema,
|
|
||||||
})
|
})
|
||||||
.transform(({ reply, message_id, audio_url, ...data }) => ({
|
.transform(({ reply, message_id, audio_url, ...data }) => ({
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ function makeResponse(
|
|||||||
): ChatSendResponse {
|
): ChatSendResponse {
|
||||||
return ChatSendResponse.from({
|
return ChatSendResponse.from({
|
||||||
reply: "AI reply",
|
reply: "AI reply",
|
||||||
audioUrl: "",
|
|
||||||
messageId: "msg-1",
|
messageId: "msg-1",
|
||||||
isGuest: false,
|
isGuest: false,
|
||||||
timestamp: 1782356425363,
|
timestamp: 1782356425363,
|
||||||
@@ -62,7 +61,6 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
it("maps locked voice messages without treating them as private text", () => {
|
it("maps locked voice messages without treating them as private text", () => {
|
||||||
const message = sendResponseToUiMessage(
|
const message = sendResponseToUiMessage(
|
||||||
makeResponse({
|
makeResponse({
|
||||||
audioUrl: "",
|
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
reason: "voice_message",
|
reason: "voice_message",
|
||||||
@@ -78,6 +76,34 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
expect(message.privateMessageHint).toBeUndefined();
|
expect(message.privateMessageHint).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not expose an audio URL from a locked send response", () => {
|
||||||
|
const message = sendResponseToUiMessage(
|
||||||
|
makeResponse({
|
||||||
|
audioUrl: "https://example.com/locked-voice.mp3",
|
||||||
|
lockDetail: {
|
||||||
|
locked: true,
|
||||||
|
reason: "voice_message",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(message.audioUrl).toBeUndefined();
|
||||||
|
expect(message.locked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an audio URL from an unlocked send response", () => {
|
||||||
|
const message = sendResponseToUiMessage(
|
||||||
|
makeResponse({
|
||||||
|
audioUrl: "https://example.com/unlocked-voice.mp3",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(message.audioUrl).toBe(
|
||||||
|
"https://example.com/unlocked-voice.mp3",
|
||||||
|
);
|
||||||
|
expect(message.locked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps locked private text messages as private messages", () => {
|
it("maps locked private text messages as private messages", () => {
|
||||||
const message = sendResponseToUiMessage(
|
const message = sendResponseToUiMessage(
|
||||||
makeResponse({
|
makeResponse({
|
||||||
@@ -227,7 +253,7 @@ describe("localMessagesToUi", () => {
|
|||||||
type: "voice",
|
type: "voice",
|
||||||
content: "hidden voice transcript",
|
content: "hidden voice transcript",
|
||||||
createdAt: "2026-06-25T12:00:00.000Z",
|
createdAt: "2026-06-25T12:00:00.000Z",
|
||||||
audioUrl: null,
|
audioUrl: "https://example.com/locked-history-voice.mp3",
|
||||||
image: { type: null, url: null },
|
image: { type: null, url: null },
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
|
|||||||
@@ -57,10 +57,6 @@ function makeUnlockPrivateResponse(
|
|||||||
creditsCharged: 10,
|
creditsCharged: 10,
|
||||||
requiredCredits: 10,
|
requiredCredits: 10,
|
||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
lockDetail: {
|
|
||||||
locked: false,
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -822,10 +818,6 @@ describe("chatMachine transitions", () => {
|
|||||||
creditsCharged: 0,
|
creditsCharged: 0,
|
||||||
requiredCredits: 10,
|
requiredCredits: 10,
|
||||||
shortfallCredits: 7,
|
shortfallCredits: 7,
|
||||||
lockDetail: {
|
|
||||||
locked: true,
|
|
||||||
reason: "voice_message",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -57,10 +57,6 @@ describe("chat promotion", () => {
|
|||||||
type: "promotion",
|
type: "promotion",
|
||||||
url: "https://example.com/unlocked.jpg",
|
url: "https://example.com/unlocked.jpg",
|
||||||
},
|
},
|
||||||
lockDetail: {
|
|
||||||
locked: false,
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export function localMessagesToUi(
|
|||||||
}),
|
}),
|
||||||
isFromAI: m.role === "assistant",
|
isFromAI: m.role === "assistant",
|
||||||
date: messageDateFromCreatedAt(m.createdAt),
|
date: messageDateFromCreatedAt(m.createdAt),
|
||||||
...(m.audioUrl ? { audioUrl: m.audioUrl } : {}),
|
...(m.audioUrl && m.lockDetail?.locked !== true
|
||||||
|
? { audioUrl: m.audioUrl }
|
||||||
|
: {}),
|
||||||
...deriveUiLockFields(m.lockDetail, m.image?.url),
|
...deriveUiLockFields(m.lockDetail, m.image?.url),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -46,7 +48,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
|||||||
}),
|
}),
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: todayString(new Date(response.timestamp)),
|
date: todayString(new Date(response.timestamp)),
|
||||||
...(response.audioUrl ? { audioUrl: response.audioUrl } : {}),
|
...(response.audioUrl && !response.lockDetail.locked
|
||||||
|
? { audioUrl: response.audioUrl }
|
||||||
|
: {}),
|
||||||
...deriveUiLockFields(response.lockDetail, response.image.url),
|
...deriveUiLockFields(response.lockDetail, response.image.url),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ function hasRenderableSendResponse(response: ChatSendResponse): boolean {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
response.reply.trim().length > 0 ||
|
response.reply.trim().length > 0 ||
|
||||||
response.audioUrl.trim().length > 0 ||
|
(!response.lockDetail.locked && response.audioUrl.trim().length > 0) ||
|
||||||
Boolean(response.image.url) ||
|
Boolean(response.image.url) ||
|
||||||
isLockedPaidMessage
|
isLockedPaidMessage
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const unlockMessageActor = fromPromise<
|
|||||||
...(unlockResult.data.image.url
|
...(unlockResult.data.image.url
|
||||||
? { image: unlockResult.data.image }
|
? { image: unlockResult.data.image }
|
||||||
: {}),
|
: {}),
|
||||||
lockDetail: unlockResult.data.lockDetail,
|
lockDetail: { locked: false, reason: null },
|
||||||
},
|
},
|
||||||
cacheIdentityResult.data,
|
cacheIdentityResult.data,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,27 +48,15 @@ export function applySingleUnlockOutput(
|
|||||||
content: resolvedContent,
|
content: resolvedContent,
|
||||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||||
imageUrl: resolvedImageUrl,
|
imageUrl: resolvedImageUrl,
|
||||||
locked: output.response.lockDetail.locked,
|
locked: false,
|
||||||
lockReason: output.response.lockDetail.reason,
|
lockReason: null,
|
||||||
imagePaywalled: resolvedImageUrl
|
imagePaywalled: resolvedImageUrl ? false : undefined,
|
||||||
? isLockedImage(output.response.lockDetail)
|
|
||||||
: undefined,
|
|
||||||
lockedPrivate: false,
|
lockedPrivate: false,
|
||||||
privateMessageHint: null,
|
privateMessageHint: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLockedImage(lockDetail: {
|
|
||||||
locked: boolean;
|
|
||||||
reason: string | null;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
lockDetail.locked &&
|
|
||||||
(lockDetail.reason === "image_paywall" || lockDetail.reason === "image")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUnlockedAudioUrl(
|
function getUnlockedAudioUrl(
|
||||||
message: UiMessage,
|
message: UiMessage,
|
||||||
response: UnlockPrivateResponse,
|
response: UnlockPrivateResponse,
|
||||||
|
|||||||
Reference in New Issue
Block a user