feat(characters): support character-scoped conversations
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export const DEFAULT_CHARACTER_ID = "character_elio";
|
||||
export const DEFAULT_CHARACTER_SLUG = "elio";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Character } from "@/data/dto/character";
|
||||
|
||||
describe("Character", () => {
|
||||
it("keeps only the four public character fields", () => {
|
||||
const character = Character.fromJson({
|
||||
id: "character_elio",
|
||||
slug: "elio",
|
||||
displayName: " Elio Silvestri ",
|
||||
avatarUrl: "https://cdn.example.com/elio.jpg",
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
});
|
||||
|
||||
expect(character.toJson()).toEqual({
|
||||
id: "character_elio",
|
||||
slug: "elio",
|
||||
displayName: "Elio Silvestri",
|
||||
avatarUrl: "https://cdn.example.com/elio.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe slugs and non-HTTPS avatars", () => {
|
||||
expect(() =>
|
||||
Character.from({
|
||||
id: "character_aria",
|
||||
slug: "Aria Profile",
|
||||
displayName: "Aria",
|
||||
avatarUrl: "https://cdn.example.com/aria.jpg",
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
Character.from({
|
||||
id: "character_aria",
|
||||
slug: "aria",
|
||||
displayName: "Aria",
|
||||
avatarUrl: "http://cdn.example.com/aria.jpg",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
CharacterSchema,
|
||||
type CharacterData,
|
||||
type CharacterInput,
|
||||
} from "@/data/schemas/character";
|
||||
|
||||
export class Character {
|
||||
declare readonly id: string;
|
||||
declare readonly slug: string;
|
||||
declare readonly displayName: string;
|
||||
declare readonly avatarUrl: string;
|
||||
|
||||
private constructor(input: CharacterInput) {
|
||||
Object.assign(this, CharacterSchema.parse(input));
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: CharacterInput): Character {
|
||||
return new Character(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): Character {
|
||||
return Character.from(json as CharacterInput);
|
||||
}
|
||||
|
||||
toJson(): CharacterData {
|
||||
return CharacterSchema.parse(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
CharactersResponseSchema,
|
||||
type CharactersResponseData,
|
||||
type CharactersResponseInput,
|
||||
} from "@/data/schemas/character";
|
||||
|
||||
import { Character } from "./character";
|
||||
|
||||
export class CharactersResponse {
|
||||
declare readonly items: Character[];
|
||||
|
||||
private constructor(input: CharactersResponseInput) {
|
||||
const data = CharactersResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
items: data.items.map((item) => Character.from(item)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: CharactersResponseInput): CharactersResponse {
|
||||
return new CharactersResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): CharactersResponse {
|
||||
return CharactersResponse.from(json as CharactersResponseInput);
|
||||
}
|
||||
|
||||
toJson(): CharactersResponseData {
|
||||
return CharactersResponseSchema.parse({
|
||||
items: this.items.map((item) => item.toJson()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./character";
|
||||
export * from "./characters_response";
|
||||
@@ -1,21 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { UnlockPrivateRequest } from "@/data/dto/chat";
|
||||
|
||||
describe("UnlockPrivateRequest", () => {
|
||||
it("serializes an existing backend message unlock", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({ messageId: "message-1" }).toJson(),
|
||||
).toEqual({ messageId: "message-1" });
|
||||
UnlockPrivateRequest.from({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
}).toJson(),
|
||||
).toEqual({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes a temporary promotion lock without a fake message id", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
}).toJson(),
|
||||
).toEqual({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
});
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./send_message_request";
|
||||
export * from "./unlock_private_request";
|
||||
export * from "./unlock_history_request";
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/data/schemas/chat/request/send_message_request";
|
||||
|
||||
export class SendMessageRequest {
|
||||
declare readonly characterId: string;
|
||||
declare readonly message: string;
|
||||
declare readonly image: string;
|
||||
declare readonly imageId: string;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
UnlockHistoryRequestSchema,
|
||||
type UnlockHistoryRequestData,
|
||||
type UnlockHistoryRequestInput,
|
||||
} from "@/data/schemas/chat/request/unlock_history_request";
|
||||
|
||||
export class UnlockHistoryRequest {
|
||||
declare readonly characterId: string;
|
||||
|
||||
private constructor(input: UnlockHistoryRequestInput) {
|
||||
Object.assign(this, UnlockHistoryRequestSchema.parse(input));
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockHistoryRequestInput): UnlockHistoryRequest {
|
||||
return new UnlockHistoryRequest(input);
|
||||
}
|
||||
|
||||
toJson(): UnlockHistoryRequestData {
|
||||
return UnlockHistoryRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/data/schemas/chat/request/unlock_private_request";
|
||||
|
||||
export class UnlockPrivateRequest {
|
||||
declare readonly characterId: string;
|
||||
declare readonly messageId?: string;
|
||||
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
||||
declare readonly clientLockId?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"message": "Look at this sunset.",
|
||||
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
|
||||
"imageId": "img_mock_001",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"message": "I missed you today.",
|
||||
"image": "",
|
||||
"imageId": "",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"characterId": "character_elio",
|
||||
"messageId": "msg_private_locked_001"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { LoginStatus } from "@/data/dto/auth";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { resolveChatCacheOwnerKey } from "../chat_cache_identity";
|
||||
import {
|
||||
buildChatConversationKey,
|
||||
resolveChatCacheOwnerKey,
|
||||
} from "../chat_cache_identity";
|
||||
|
||||
function createStorageState(input: {
|
||||
provider: (typeof LoginStatus)[keyof typeof LoginStatus] | null;
|
||||
@@ -22,6 +25,15 @@ function createStorageState(input: {
|
||||
}
|
||||
|
||||
describe("chat cache identity", () => {
|
||||
it("isolates the same owner by character", () => {
|
||||
const elio = buildChatConversationKey("user:account-1", "character_elio");
|
||||
const aria = buildChatConversationKey("user:account-1", "character_aria");
|
||||
|
||||
expect(elio).toBe("user:account-1::character:character_elio");
|
||||
expect(aria).toBe("user:account-1::character:character_aria");
|
||||
expect(elio).not.toBe(aria);
|
||||
});
|
||||
|
||||
it("uses the current user id for authenticated and guest sessions", async () => {
|
||||
const authenticated = createStorageState({
|
||||
provider: LoginStatus.Email,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { ChatMediaCacheCoordinator } from "../chat_media_cache_coordinator";
|
||||
@@ -22,13 +23,43 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
);
|
||||
|
||||
const result = await coordinator.getCachedMedia({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
expect(result).toEqual(Result.ok(null));
|
||||
expect(getMedia).toHaveBeenCalledTimes(1);
|
||||
expect(getMedia).toHaveBeenCalledWith("user:account-a:message-1:image");
|
||||
expect(getMedia).toHaveBeenCalledWith(
|
||||
"user:account-a::character:character_elio:message-1:image",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not share a media key between characters", async () => {
|
||||
const getMedia = vi.fn(async () => Result.ok(null));
|
||||
const coordinator = new ChatMediaCacheCoordinator(
|
||||
{
|
||||
getMedia,
|
||||
saveMedia: vi.fn(async () => Result.ok(undefined)),
|
||||
},
|
||||
async () => Result.ok("user:account-a"),
|
||||
);
|
||||
|
||||
await coordinator.getCachedMedia({
|
||||
characterId: "character_elio",
|
||||
messageId: "shared-message-id",
|
||||
kind: "image",
|
||||
});
|
||||
await coordinator.getCachedMedia({
|
||||
characterId: "character_aria",
|
||||
messageId: "shared-message-id",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
expect(getMedia.mock.calls).toEqual([
|
||||
["user:account-a::character:character_elio:shared-message-id:image"],
|
||||
["user:account-a::character:character_aria:shared-message-id:image"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not fall back to an anonymous cache without an identity", async () => {
|
||||
@@ -45,6 +76,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
);
|
||||
|
||||
const result = await coordinator.getCachedMedia({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
kind: "audio",
|
||||
});
|
||||
@@ -75,11 +107,12 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
||||
|
||||
const result = await coordinator.cacheRemoteMedia(
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-expired",
|
||||
kind: "image",
|
||||
remoteUrl: "https://media.example/expired.jpg",
|
||||
},
|
||||
"user:account-a",
|
||||
"user:account-a::character:character_elio",
|
||||
);
|
||||
|
||||
expect(Result.isErr(result)).toBe(true);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { ChatMessage, ChatSendResponse } from "@/data/dto/chat";
|
||||
|
||||
import {
|
||||
@@ -64,14 +65,17 @@ describe("chat media cache helpers", () => {
|
||||
audioUrl: "https://example.com/a.mp3",
|
||||
image: { type: "jpg", url: "https://example.com/a.jpg" },
|
||||
}),
|
||||
DEFAULT_CHARACTER_ID,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "msg-1",
|
||||
kind: "image",
|
||||
remoteUrl: "https://example.com/a.jpg",
|
||||
},
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "msg-1",
|
||||
kind: "audio",
|
||||
remoteUrl: "https://example.com/a.mp3",
|
||||
@@ -87,6 +91,7 @@ describe("chat media cache helpers", () => {
|
||||
audioUrl: "https://example.com/a.mp3",
|
||||
image: { type: "png", url: "data:image/png;base64,abc" },
|
||||
}),
|
||||
DEFAULT_CHARACTER_ID,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
@@ -98,6 +103,7 @@ describe("chat media cache helpers", () => {
|
||||
audioUrl: "https://example.com/locked.mp3",
|
||||
lockDetail: { locked: true, reason: "voice_message" },
|
||||
}),
|
||||
DEFAULT_CHARACTER_ID,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
@@ -108,9 +114,11 @@ describe("chat media cache helpers", () => {
|
||||
makeResponse({
|
||||
audioUrl: "https://example.com/unlocked.mp3",
|
||||
}),
|
||||
DEFAULT_CHARACTER_ID,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "msg-1",
|
||||
kind: "audio",
|
||||
remoteUrl: "https://example.com/unlocked.mp3",
|
||||
@@ -121,13 +129,38 @@ describe("chat media cache helpers", () => {
|
||||
it("deduplicates media targets", () => {
|
||||
expect(
|
||||
uniqueMediaTargets([
|
||||
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
||||
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
||||
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "msg-1",
|
||||
kind: "image",
|
||||
remoteUrl: "https://x/a.jpg",
|
||||
},
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "msg-1",
|
||||
kind: "image",
|
||||
remoteUrl: "https://x/a.jpg",
|
||||
},
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "msg-1",
|
||||
kind: "audio",
|
||||
remoteUrl: "https://x/a.jpg",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{ messageId: "msg-1", kind: "image", remoteUrl: "https://x/a.jpg" },
|
||||
{ messageId: "msg-1", kind: "audio", remoteUrl: "https://x/a.jpg" },
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "msg-1",
|
||||
kind: "image",
|
||||
remoteUrl: "https://x/a.jpg",
|
||||
},
|
||||
{
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "msg-1",
|
||||
kind: "audio",
|
||||
remoteUrl: "https://x/a.jpg",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CharactersResponse } from "@/data/dto/character";
|
||||
import type { ICharacterRepository } from "@/data/repositories/interfaces";
|
||||
import { CharacterApi, characterApi } from "@/data/services/api/character_api";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
|
||||
export class CharacterRepository implements ICharacterRepository {
|
||||
constructor(private readonly api: CharacterApi) {}
|
||||
|
||||
getCharacters(): Promise<Result<CharactersResponse>> {
|
||||
return Result.wrap(() => this.api.getCharacters());
|
||||
}
|
||||
}
|
||||
|
||||
export const getCharacterRepository = createLazySingleton<ICharacterRepository>(
|
||||
() => new CharacterRepository(characterApi),
|
||||
);
|
||||
@@ -4,6 +4,9 @@ import { UserStorage, type IUserStorage } from "@/data/storage/user";
|
||||
import { Result, type Result as ResultT } from "@/utils/result";
|
||||
|
||||
export type ChatCacheIdentityResolver = () => Promise<ResultT<string>>;
|
||||
export type ChatConversationKeyResolver = (
|
||||
characterId: string,
|
||||
) => Promise<ResultT<string>>;
|
||||
|
||||
type ChatCacheAuthStorage = Pick<
|
||||
IAuthStorage,
|
||||
@@ -49,3 +52,21 @@ export async function resolveChatCacheOwnerKey(
|
||||
|
||||
return Result.err(new Error("Chat cache identity is unavailable."));
|
||||
}
|
||||
|
||||
export function buildChatConversationKey(
|
||||
ownerKey: string,
|
||||
characterId: string,
|
||||
): string {
|
||||
if (ownerKey.trim().length === 0 || characterId.trim().length === 0) {
|
||||
throw new Error("Chat owner and character identities must not be empty.");
|
||||
}
|
||||
return `${ownerKey}::character:${encodeURIComponent(characterId)}`;
|
||||
}
|
||||
|
||||
export async function resolveChatConversationKey(
|
||||
characterId: string,
|
||||
): Promise<ResultT<string>> {
|
||||
const ownerResult = await resolveChatCacheOwnerKey();
|
||||
if (Result.isErr(ownerResult)) return ownerResult;
|
||||
return Result.ok(buildChatConversationKey(ownerResult.data, characterId));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
uniqueMediaTargets,
|
||||
} from "./chat_media_cache_helpers";
|
||||
import {
|
||||
buildChatConversationKey,
|
||||
resolveChatCacheOwnerKey,
|
||||
type ChatCacheIdentityResolver,
|
||||
} from "./chat_cache_identity";
|
||||
@@ -59,7 +60,7 @@ export class ChatMediaCacheCoordinator {
|
||||
input: ChatMediaLookupInput,
|
||||
): Promise<Result<LocalChatMediaRow | null>> {
|
||||
return Result.wrap(async () => {
|
||||
const ownerKey = await this._requireIdentity();
|
||||
const ownerKey = await this._requireIdentity(input.characterId);
|
||||
const cacheKey = this._buildMediaCacheKey(ownerKey, input);
|
||||
const result = await this.mediaStorage.getMedia(cacheKey);
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
@@ -73,7 +74,10 @@ export class ChatMediaCacheCoordinator {
|
||||
): Promise<Result<LocalChatMediaRow>> {
|
||||
return Result.wrap(
|
||||
async () => {
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const ownerKey = await this._requireIdentity(
|
||||
input.characterId,
|
||||
identity,
|
||||
);
|
||||
return this._cacheRemoteMediaOrThrow(input, ownerKey);
|
||||
},
|
||||
{
|
||||
@@ -85,32 +89,39 @@ export class ChatMediaCacheCoordinator {
|
||||
|
||||
async prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
characterId: string,
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(
|
||||
messages.flatMap((message) => getMessageMediaTargets(message)),
|
||||
messages.flatMap((message) =>
|
||||
getMessageMediaTargets(message, characterId),
|
||||
),
|
||||
characterId,
|
||||
identity,
|
||||
);
|
||||
}
|
||||
|
||||
async prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
characterId: string,
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this._prefetchMediaTargets(
|
||||
getSendResponseMediaTargets(response),
|
||||
getSendResponseMediaTargets(response, characterId),
|
||||
characterId,
|
||||
identity,
|
||||
);
|
||||
}
|
||||
|
||||
private async _prefetchMediaTargets(
|
||||
targets: readonly CacheRemoteChatMediaInput[],
|
||||
characterId: string,
|
||||
identity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
const uniqueTargets = uniqueMediaTargets(targets);
|
||||
if (uniqueTargets.length === 0) return;
|
||||
const ownerKey = await this._requireIdentity(identity);
|
||||
const ownerKey = await this._requireIdentity(characterId, identity);
|
||||
for (const target of uniqueTargets) {
|
||||
try {
|
||||
await this._cacheRemoteMediaOrThrow(target, ownerKey);
|
||||
@@ -199,7 +210,10 @@ export class ChatMediaCacheCoordinator {
|
||||
return savedResult.data;
|
||||
}
|
||||
|
||||
private async _requireIdentity(identity?: string): Promise<string> {
|
||||
private async _requireIdentity(
|
||||
characterId: string,
|
||||
identity?: string,
|
||||
): Promise<string> {
|
||||
if (identity !== undefined) {
|
||||
if (identity.length === 0) {
|
||||
throw new Error("Chat cache identity must not be empty.");
|
||||
@@ -211,7 +225,7 @@ export class ChatMediaCacheCoordinator {
|
||||
if (result.data.length === 0) {
|
||||
throw new Error("Chat cache identity must not be empty.");
|
||||
}
|
||||
return result.data;
|
||||
return buildChatConversationKey(result.data, characterId);
|
||||
}
|
||||
|
||||
private _buildMediaCacheKey(
|
||||
|
||||
@@ -18,14 +18,17 @@ export function buildChatMediaCacheKey(input: {
|
||||
|
||||
export function getMessageMediaTargets(
|
||||
message: ChatMessage,
|
||||
characterId: string,
|
||||
): CacheRemoteChatMediaInput[] {
|
||||
const targets: CacheRemoteChatMediaInput[] = [];
|
||||
pushMediaTarget(targets, {
|
||||
characterId,
|
||||
messageId: message.id,
|
||||
kind: "image",
|
||||
remoteUrl: message.image.url,
|
||||
});
|
||||
pushMediaTarget(targets, {
|
||||
characterId,
|
||||
messageId: message.id,
|
||||
kind: "audio",
|
||||
remoteUrl: message.audioUrl,
|
||||
@@ -35,15 +38,18 @@ export function getMessageMediaTargets(
|
||||
|
||||
export function getSendResponseMediaTargets(
|
||||
response: ChatSendResponse,
|
||||
characterId: string,
|
||||
): CacheRemoteChatMediaInput[] {
|
||||
const targets: CacheRemoteChatMediaInput[] = [];
|
||||
pushMediaTarget(targets, {
|
||||
characterId,
|
||||
messageId: response.messageId,
|
||||
kind: "image",
|
||||
remoteUrl: response.image.url,
|
||||
});
|
||||
if (!response.lockDetail.locked) {
|
||||
pushMediaTarget(targets, {
|
||||
characterId,
|
||||
messageId: response.messageId,
|
||||
kind: "audio",
|
||||
remoteUrl: response.audioUrl,
|
||||
@@ -61,7 +67,7 @@ export function uniqueMediaTargets(
|
||||
): CacheRemoteChatMediaInput[] {
|
||||
const seen = new Set<string>();
|
||||
return targets.filter((target) => {
|
||||
const key = `${target.messageId}:${target.kind}:${target.remoteUrl}`;
|
||||
const key = `${target.characterId}:${target.messageId}:${target.kind}:${target.remoteUrl}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
@@ -71,6 +77,7 @@ export function uniqueMediaTargets(
|
||||
function pushMediaTarget(
|
||||
targets: CacheRemoteChatMediaInput[],
|
||||
input: {
|
||||
characterId: string;
|
||||
messageId: string;
|
||||
kind: ChatMediaKind;
|
||||
remoteUrl: string | null;
|
||||
@@ -84,6 +91,7 @@ function pushMediaTarget(
|
||||
return;
|
||||
}
|
||||
targets.push({
|
||||
characterId: input.characterId,
|
||||
messageId: input.messageId,
|
||||
kind: input.kind,
|
||||
remoteUrl: input.remoteUrl,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ChatHistoryResponse,
|
||||
ChatSendResponse,
|
||||
SendMessageRequest,
|
||||
UnlockHistoryRequest,
|
||||
UnlockHistoryResponse,
|
||||
UnlockPrivateRequest,
|
||||
UnlockPrivateResponse,
|
||||
@@ -14,11 +15,13 @@ export class ChatRemoteDataSource {
|
||||
constructor(private readonly api: ChatApi) {}
|
||||
|
||||
async sendMessage(
|
||||
characterId: string,
|
||||
message: string,
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
): Promise<Result<ChatSendResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const request = SendMessageRequest.from({
|
||||
characterId,
|
||||
message,
|
||||
image: options?.image ?? "",
|
||||
useWebSocket: options?.useWebSocket ?? false,
|
||||
@@ -28,10 +31,11 @@ export class ChatRemoteDataSource {
|
||||
}
|
||||
|
||||
async getHistory(
|
||||
characterId: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<Result<ChatHistoryResponse>> {
|
||||
return Result.wrap(() => this.api.getHistory(limit, offset));
|
||||
return Result.wrap(() => this.api.getHistory(characterId, limit, offset));
|
||||
}
|
||||
|
||||
async unlockPrivateMessage(
|
||||
@@ -42,7 +46,11 @@ export class ChatRemoteDataSource {
|
||||
);
|
||||
}
|
||||
|
||||
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
||||
return Result.wrap(() => this.api.unlockHistory());
|
||||
async unlockHistory(
|
||||
characterId: string,
|
||||
): Promise<Result<UnlockHistoryResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockHistory(UnlockHistoryRequest.from({ characterId })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,20 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
/** 发送一条消息。 */
|
||||
async sendMessage(
|
||||
characterId: string,
|
||||
message: string,
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
): Promise<Result<ChatSendResponse>> {
|
||||
return this.remote.sendMessage(message, options);
|
||||
return this.remote.sendMessage(characterId, message, options);
|
||||
}
|
||||
|
||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||
async getHistory(
|
||||
characterId: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<Result<ChatHistoryResponse>> {
|
||||
return this.remote.getHistory(limit, offset);
|
||||
return this.remote.getHistory(characterId, limit, offset);
|
||||
}
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
@@ -58,8 +60,8 @@ export class ChatRepository implements IChatRepository {
|
||||
}
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
|
||||
return this.remote.unlockHistory();
|
||||
async unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>> {
|
||||
return this.remote.unlockHistory(characterId);
|
||||
}
|
||||
|
||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||
@@ -133,17 +135,24 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
async prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
characterId: string,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this.mediaCache.prefetchMediaForMessages(messages, cacheIdentity);
|
||||
return this.mediaCache.prefetchMediaForMessages(
|
||||
messages,
|
||||
characterId,
|
||||
cacheIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
async prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
characterId: string,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>> {
|
||||
return this.mediaCache.prefetchMediaForSendResponse(
|
||||
response,
|
||||
characterId,
|
||||
cacheIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export * from "./auth_repository";
|
||||
export * from "./chat_repository";
|
||||
export * from "./character_repository";
|
||||
export * from "./feedback_repository";
|
||||
export * from "./metrics_repository";
|
||||
export * from "./payment_repository";
|
||||
@@ -11,6 +12,7 @@ export * from "./private_room_repository";
|
||||
export * from "./user_repository";
|
||||
export * from "./interfaces/iauth_repository";
|
||||
export * from "./interfaces/ichat_repository";
|
||||
export * from "./interfaces/icharacter_repository";
|
||||
export * from "./interfaces/ifeedback_repository";
|
||||
export * from "./interfaces/imetrics_repository";
|
||||
export * from "./interfaces/ipayment_repository";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CharactersResponse } from "@/data/dto/character";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export interface ICharacterRepository {
|
||||
getCharacters(): Promise<Result<CharactersResponse>>;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
||||
|
||||
export interface ChatMediaLookupInput {
|
||||
characterId: string;
|
||||
messageId: string;
|
||||
kind: ChatMediaKind;
|
||||
}
|
||||
@@ -34,6 +35,7 @@ export interface UnlockedPrivateMessageLocalPatch {
|
||||
}
|
||||
|
||||
export interface UnlockPrivateMessageInput {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
@@ -42,12 +44,17 @@ export interface UnlockPrivateMessageInput {
|
||||
export interface IChatRepository {
|
||||
/** 发送一条消息。 */
|
||||
sendMessage(
|
||||
characterId: string,
|
||||
message: string,
|
||||
options?: { image?: string; useWebSocket?: boolean },
|
||||
): Promise<Result<ChatSendResponse>>;
|
||||
|
||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
|
||||
getHistory(
|
||||
characterId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<Result<ChatHistoryResponse>>;
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
unlockPrivateMessage(
|
||||
@@ -55,7 +62,7 @@ export interface IChatRepository {
|
||||
): Promise<Result<UnlockPrivateResponse>>;
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
|
||||
unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>>;
|
||||
|
||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||
markPrivateMessageUnlockedInLocal(
|
||||
@@ -99,12 +106,14 @@ export interface IChatRepository {
|
||||
/** 后台预缓存历史消息中的图片 / 音频。失败不影响聊天主流程。 */
|
||||
prefetchMediaForMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
characterId: string,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>>;
|
||||
|
||||
/** 后台预缓存发送响应中的图片 / 音频。失败不影响聊天主流程。 */
|
||||
prefetchMediaForSendResponse(
|
||||
response: ChatSendResponse,
|
||||
characterId: string,
|
||||
cacheIdentity?: string,
|
||||
): Promise<Result<void>>;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export * from "./iauth_repository";
|
||||
export * from "./ichat_repository";
|
||||
export * from "./icharacter_repository";
|
||||
export * from "./ifeedback_repository";
|
||||
export * from "./imetrics_repository";
|
||||
export * from "./ipayment_repository";
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export interface GetPrivateAlbumsInput {
|
||||
character?: string;
|
||||
characterId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CharacterSchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
avatarUrl: z.url().refine((url) => url.startsWith("https://"), {
|
||||
message: "avatarUrl must use HTTPS",
|
||||
}),
|
||||
});
|
||||
|
||||
export type CharacterInput = z.input<typeof CharacterSchema>;
|
||||
export type CharacterData = z.output<typeof CharacterSchema>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CharacterSchema } from "./character";
|
||||
|
||||
export const CharactersResponseSchema = z.object({
|
||||
items: z.array(CharacterSchema).default([]),
|
||||
});
|
||||
|
||||
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
|
||||
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./character";
|
||||
export * from "./characters_response";
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
export * from "./send_message_request";
|
||||
export * from "./unlock_private_request";
|
||||
export * from "./unlock_history_request";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../nullable-defaults";
|
||||
|
||||
export const SendMessageRequestSchema = z.object({
|
||||
characterId: z.string().min(1),
|
||||
message: stringOrEmpty,
|
||||
image: stringOrEmpty,
|
||||
imageId: stringOrEmpty,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UnlockHistoryRequestSchema = z.object({
|
||||
characterId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type UnlockHistoryRequestInput = z.input<
|
||||
typeof UnlockHistoryRequestSchema
|
||||
>;
|
||||
export type UnlockHistoryRequestData = z.output<
|
||||
typeof UnlockHistoryRequestSchema
|
||||
>;
|
||||
@@ -7,6 +7,7 @@ import { ChatLockTypeSchema } from "../chat_lock_type";
|
||||
|
||||
export const UnlockPrivateRequestSchema = z
|
||||
.object({
|
||||
characterId: z.string().min(1),
|
||||
messageId: z.string().min(1).optional(),
|
||||
lockType: ChatLockTypeSchema.optional(),
|
||||
clientLockId: z.string().min(1).optional(),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const httpClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../http_client", () => ({
|
||||
httpClient: httpClientMock,
|
||||
}));
|
||||
|
||||
import { CharacterApi } from "../character_api";
|
||||
|
||||
describe("CharacterApi", () => {
|
||||
beforeEach(() => {
|
||||
httpClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("loads characters and preserves the backend order", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: "character_aria",
|
||||
slug: "aria",
|
||||
displayName: "Aria",
|
||||
avatarUrl: "https://cdn.example.com/aria.jpg",
|
||||
},
|
||||
{
|
||||
id: "character_elio",
|
||||
slug: "elio",
|
||||
displayName: "Elio Silvestri",
|
||||
avatarUrl: "https://cdn.example.com/elio.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await new CharacterApi().getCharacters();
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/characters");
|
||||
expect(response.items.map((character) => character.id)).toEqual([
|
||||
"character_aria",
|
||||
"character_elio",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SendMessageRequest,
|
||||
UnlockHistoryRequest,
|
||||
UnlockPrivateRequest,
|
||||
} from "@/data/dto/chat";
|
||||
|
||||
const httpClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../http_client", () => ({
|
||||
httpClient: httpClientMock,
|
||||
}));
|
||||
|
||||
import { ChatApi } from "../chat_api";
|
||||
import { PrivateRoomApi } from "../private_room_api";
|
||||
|
||||
const CHARACTER_ID = "character_elio";
|
||||
|
||||
describe("multi-character API contract", () => {
|
||||
beforeEach(() => {
|
||||
httpClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("sends characterId with chat messages", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
reply: "Hello",
|
||||
messageId: "message-1",
|
||||
image: { type: null, url: null },
|
||||
lockDetail: { locked: false, reason: null },
|
||||
},
|
||||
});
|
||||
|
||||
await new ChatApi().sendMessage(
|
||||
SendMessageRequest.from({
|
||||
characterId: CHARACTER_ID,
|
||||
message: "Hello",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/send", {
|
||||
method: "POST",
|
||||
body: expect.objectContaining({ characterId: CHARACTER_ID }),
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes history to characterId", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: { messages: [], total: 0, limit: 50, offset: 0 },
|
||||
});
|
||||
|
||||
await new ChatApi().getHistory(CHARACTER_ID, 50, 10);
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/history", {
|
||||
query: { characterId: CHARACTER_ID, limit: 50, offset: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends characterId with private and history unlocks", async () => {
|
||||
const api = new ChatApi();
|
||||
httpClientMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
unlocked: false,
|
||||
reason: "not_found",
|
||||
image: { type: null, url: null },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { unlocked: false, reason: "no_locked_messages" },
|
||||
});
|
||||
|
||||
await api.unlockPrivateMessage(
|
||||
UnlockPrivateRequest.from({
|
||||
characterId: CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
}),
|
||||
);
|
||||
await api.unlockHistory(
|
||||
UnlockHistoryRequest.from({ characterId: CHARACTER_ID }),
|
||||
);
|
||||
|
||||
expect(httpClientMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/chat/unlock-private",
|
||||
{
|
||||
method: "POST",
|
||||
body: expect.objectContaining({ characterId: CHARACTER_ID }),
|
||||
},
|
||||
);
|
||||
expect(httpClientMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/chat/unlock-history",
|
||||
{
|
||||
method: "POST",
|
||||
body: { characterId: CHARACTER_ID },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes private-room albums to characterId", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: { items: [], creditBalance: 0 },
|
||||
});
|
||||
|
||||
await new PrivateRoomApi().getAlbums({
|
||||
characterId: CHARACTER_ID,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/private-room/albums", {
|
||||
query: { characterId: CHARACTER_ID, limit: 20 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,5 +24,6 @@
|
||||
"privateRoomAlbumUnlock": { "method": "post", "path": "/api/private-room/albums/{albumId}/unlock" },
|
||||
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
|
||||
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
|
||||
"feedback": { "method": "post", "path": "/api/feedback" }
|
||||
"feedback": { "method": "post", "path": "/api/feedback" },
|
||||
"characters": { "method": "get", "path": "/api/characters" }
|
||||
}
|
||||
|
||||
@@ -98,4 +98,7 @@ export class ApiPath {
|
||||
|
||||
// ============ 用户反馈相关 ============
|
||||
static readonly feedback = apiContract.feedback.path;
|
||||
|
||||
// ============ 角色相关 ============
|
||||
static readonly characters = apiContract.characters.path;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CharactersResponse } from "@/data/dto/character";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
import { type ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export class CharacterApi {
|
||||
async getCharacters(): Promise<CharactersResponse> {
|
||||
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.characters);
|
||||
return CharactersResponse.fromJson(unwrap(envelope));
|
||||
}
|
||||
}
|
||||
|
||||
export const characterApi = new CharacterApi();
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChatHistoryResponse,
|
||||
ChatSendResponse,
|
||||
SendMessageRequest,
|
||||
UnlockHistoryRequest,
|
||||
UnlockHistoryResponse,
|
||||
UnlockPrivateRequest,
|
||||
UnlockPrivateResponse,
|
||||
@@ -31,9 +32,13 @@ export class ChatApi {
|
||||
/**
|
||||
* 获取聊天历史
|
||||
*/
|
||||
async getHistory(limit = 50, offset = 0): Promise<ChatHistoryResponse> {
|
||||
async getHistory(
|
||||
characterId: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<ChatHistoryResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
||||
query: { limit, offset },
|
||||
query: { characterId, limit, offset },
|
||||
});
|
||||
return ChatHistoryResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>
|
||||
@@ -61,11 +66,14 @@ export class ChatApi {
|
||||
/**
|
||||
* 一键解锁历史锁定消息
|
||||
*/
|
||||
async unlockHistory(): Promise<UnlockHistoryResponse> {
|
||||
async unlockHistory(
|
||||
body: UnlockHistoryRequest,
|
||||
): Promise<UnlockHistoryResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.chatUnlockHistory,
|
||||
{
|
||||
method: "POST",
|
||||
body: body.toJson(),
|
||||
},
|
||||
);
|
||||
return UnlockHistoryResponse.fromJson(
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./api_path";
|
||||
export * from "./api_result";
|
||||
export * from "./auth_api";
|
||||
export * from "./chat_api";
|
||||
export * from "./character_api";
|
||||
export * from "./feedback_api";
|
||||
export * from "./http_client";
|
||||
export * from "./metrics_api";
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
PrivateAlbumUnlockResponse,
|
||||
UnlockPrivateAlbumRequest,
|
||||
} from "@/data/dto/private-room";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
import { type ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export interface GetPrivateAlbumsInput {
|
||||
character?: string;
|
||||
characterId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -21,7 +22,7 @@ export class PrivateRoomApi {
|
||||
ApiPath.privateRoomAlbums,
|
||||
{
|
||||
query: {
|
||||
character: input.character ?? "elio",
|
||||
characterId: input.characterId ?? DEFAULT_CHARACTER_ID,
|
||||
limit: input.limit ?? 20,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user