253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
import { z } from 'zod';
|
|
import { ErrorResponseSchema, RejectedActionResponseSchema } from './common.js';
|
|
import { MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS } from './config.js';
|
|
import { RedditAccountOwnerSchema } from './session.js';
|
|
import { MediaLibraryAssetSchema, MediaLibraryTagSchema } from './media.js';
|
|
|
|
export const ChatMediaSchema = z.strictObject({
|
|
originalUrl: z.string(),
|
|
localPath: z.string(),
|
|
fileName: z.string(),
|
|
});
|
|
export const ChatMessageSchema = z.strictObject({
|
|
eventId: z.string().optional(),
|
|
sender: z.string(),
|
|
text: z.string(),
|
|
timestamp: z.number(),
|
|
isSelf: z.boolean(),
|
|
isAi: z.boolean().optional(),
|
|
media: z.array(ChatMediaSchema).optional(),
|
|
});
|
|
/** Operator-maintained triage state. Independent of Matrix's own unread count. */
|
|
export const ConversationTriageStatusSchema = z.enum(['open', 'snoozed', 'done']);
|
|
|
|
/**
|
|
* Where the reply loop currently sits. Anything other than `active` means the
|
|
* bot has handed the conversation over and replies are being written by hand.
|
|
*/
|
|
export const ConversationAutomationStatusSchema = z.enum([
|
|
'active',
|
|
'engaged',
|
|
'image-received',
|
|
'human-required',
|
|
'boundary',
|
|
'user-overridden',
|
|
'killed',
|
|
]);
|
|
/** The policy snapshotted when the conversation started. `engagement` and `manual` never auto-reply. */
|
|
export const ConversationPolicyModeSchema = z.enum(['legacy', 'engagement', 'r4r', 'r4r-media', 'manual']);
|
|
|
|
export const ActiveChatSchema = z.strictObject({
|
|
username: z.string(),
|
|
roomName: z.string(),
|
|
lastMessageTime: z.number(),
|
|
lastMessageSnippet: z.string(),
|
|
isFromUs: z.boolean(),
|
|
/**
|
|
* Matrix's own notification count. Retained for compatibility but not a
|
|
* reliable signal — the app never sends read receipts, so it never clears.
|
|
* The dashboard reads `hasUnreadInbound` instead.
|
|
*/
|
|
unread: z.boolean(),
|
|
url: z.string().optional(),
|
|
roomId: z.string().optional(),
|
|
messages: z.array(ChatMessageSchema).optional(),
|
|
originMessage: z.string().optional(),
|
|
hasReceivedImages: z.boolean().optional(),
|
|
sellerDetected: z.boolean().optional(),
|
|
/** Last confirmed Reddit profile state for the other participant. */
|
|
recipientAccountStatus: z.enum(['active', 'suspended', 'deleted']).optional(),
|
|
recipientAccountStatusCheckedAt: z.number().optional(),
|
|
owner: RedditAccountOwnerSchema.optional(),
|
|
/** True when the newest message is inbound and newer than the read watermark. */
|
|
hasUnreadInbound: z.boolean().optional(),
|
|
/** True when the newest message is inbound and the conversation is still open. */
|
|
needsReply: z.boolean().optional(),
|
|
triageStatus: ConversationTriageStatusSchema.optional(),
|
|
snoozedUntil: z.number().nullable().optional(),
|
|
/** Timestamp of the newest message the operator has read. */
|
|
lastReadMessageTs: z.number().optional(),
|
|
/** Reply-loop state. Absent when no conversation state has been recorded yet. */
|
|
automationStatus: ConversationAutomationStatusSchema.optional(),
|
|
automationPolicyMode: ConversationPolicyModeSchema.optional(),
|
|
/** Why automation handed over, when it has. */
|
|
automationHandoffReason: z.string().nullable().optional(),
|
|
/**
|
|
* The owning account was removed. Its history is kept and readable, but the
|
|
* app can no longer send into the conversation or sync it.
|
|
*/
|
|
readOnly: z.boolean().optional(),
|
|
});
|
|
export const ChatsResponseSchema = z.array(ActiveChatSchema);
|
|
export const ConversationsResponseSchema = ChatsResponseSchema;
|
|
|
|
export const SyncKindSchema = z.union([z.literal('full'), z.literal('delta')]);
|
|
export const SyncPhaseSchema = z.union([
|
|
z.literal('starting'),
|
|
z.literal('seeding'),
|
|
z.literal('listing-rooms'),
|
|
z.literal('resolving-usernames'),
|
|
z.literal('loading-details'),
|
|
z.literal('finalizing'),
|
|
z.literal('done'),
|
|
]);
|
|
export const SyncProgressSchema = z.strictObject({
|
|
kind: SyncKindSchema,
|
|
phase: SyncPhaseSchema,
|
|
current: z.number(),
|
|
total: z.number(),
|
|
label: z.string().optional(),
|
|
startedAt: z.number(),
|
|
});
|
|
export const ChatSyncStatusSchema = z.strictObject({
|
|
syncing: z.boolean(),
|
|
syncingKind: SyncKindSchema.nullable(),
|
|
lastFullSyncAt: z.number().nullable(),
|
|
lastDeltaSyncAt: z.number().nullable(),
|
|
hasEverFullSynced: z.boolean(),
|
|
cachedChats: z.number(),
|
|
progress: SyncProgressSchema.nullable(),
|
|
});
|
|
|
|
export const ConversationDetailResponseSchema = ActiveChatSchema.extend({
|
|
triggeringPost: z.strictObject({
|
|
id: z.string(),
|
|
subreddit: z.string().optional(),
|
|
title: z.string().optional(),
|
|
url: z.string(),
|
|
imageUrl: z.string().nullable().optional(),
|
|
postedAt: z.number().optional(),
|
|
sentAt: z.number(),
|
|
}).nullable(),
|
|
automation: z.strictObject({
|
|
turnsUsed: z.number().int().min(0).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS),
|
|
remainingTurns: z.number().int().min(0).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS),
|
|
status: ConversationAutomationStatusSchema,
|
|
handoffReason: z.string().nullable(),
|
|
}),
|
|
syncInProgress: z.strictObject({
|
|
kind: SyncKindSchema.nullable(),
|
|
progress: SyncProgressSchema.nullable(),
|
|
staleMessages: z.boolean(),
|
|
}).nullable(),
|
|
});
|
|
export const SuggestedReplyResponseSchema = z.strictObject({
|
|
suggestedReply: z.string(),
|
|
intent: z.unknown().optional(),
|
|
conversationState: z.unknown().optional(),
|
|
});
|
|
export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) });
|
|
export const ConversationMediaShareOptionsResponseSchema = z.strictObject({
|
|
/** What the latest inbound message asked for, if anything. Informational only — never gates sharing. */
|
|
requestKind: z.union([MediaLibraryTagSchema, z.literal('generic')]).nullable(),
|
|
assets: z.array(MediaLibraryAssetSchema),
|
|
/** Set only when there is genuinely nothing to share (no room, or no enabled/unused images). */
|
|
reason: z.string().nullable(),
|
|
/** Non-blocking safety note (seller/boundary flags). The operator can still share. */
|
|
warning: z.string().nullable(),
|
|
});
|
|
export const ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() });
|
|
|
|
export const ConversationImageReplacementTargetSchema = z.strictObject({
|
|
id: z.string().min(1),
|
|
label: z.string().min(1),
|
|
urlPath: z.string().min(1),
|
|
});
|
|
export const ConversationImageReplacementOptionsResponseSchema = z.strictObject({
|
|
sources: z.array(MediaLibraryAssetSchema),
|
|
targets: z.array(ConversationImageReplacementTargetSchema),
|
|
reason: z.string().nullable(),
|
|
});
|
|
export const GenerateConversationImageReplacementRequestSchema = z.strictObject({
|
|
sourceAssetId: z.string().uuid(),
|
|
targetId: z.string().min(1),
|
|
});
|
|
export const GenerateConversationImageReplacementResponseSchema = z.strictObject({
|
|
imageDataUrl: z.string().startsWith('data:image/jpeg;base64,'),
|
|
mimeType: z.literal('image/jpeg'),
|
|
fileName: z.string().min(1),
|
|
width: z.number().int().positive(),
|
|
height: z.number().int().positive(),
|
|
});
|
|
|
|
export const MarkConversationReadRequestSchema = z.strictObject({
|
|
/** Defaults to the conversation's newest message when omitted. */
|
|
throughTimestamp: z.number().int().nonnegative().optional(),
|
|
});
|
|
export const MarkConversationUnreadRequestSchema = z.strictObject({
|
|
/** Defaults to the conversation's newest message when omitted. */
|
|
beforeTimestamp: z.number().int().nonnegative().optional(),
|
|
});
|
|
export const SetConversationStatusRequestSchema = z.strictObject({
|
|
status: ConversationTriageStatusSchema,
|
|
/** Required when status is `snoozed`. */
|
|
snoozeUntil: z.number().int().positive().nullable().optional(),
|
|
});
|
|
export const BulkMarkConversationsReadRequestSchema = z.strictObject({
|
|
conversationIds: z.array(z.string().min(1)).min(1).max(500),
|
|
});
|
|
export const BulkMarkConversationsReadResponseSchema = z.strictObject({
|
|
success: z.literal(true),
|
|
updated: z.number().int().nonnegative(),
|
|
failed: z.array(z.string()),
|
|
});
|
|
/** One conversation an operator-forced seller sweep decided to flag. */
|
|
export const SellerScanFlagSchema = z.strictObject({
|
|
conversationId: z.string(),
|
|
username: z.string(),
|
|
/** Which rule settled it. Everything but `ai` costs no tokens. */
|
|
source: z.enum(['keyword', 'domain', 'funnel', 'off-platform', 'ai']),
|
|
reason: z.string(),
|
|
/** Present for AI verdicts only. */
|
|
confidence: z.number().min(0).max(1).optional(),
|
|
/** True when the sweep also ended the conversation, per the kill policy. */
|
|
killed: z.boolean(),
|
|
});
|
|
export const SellerScanResponseSchema = z.strictObject({
|
|
success: z.literal(true),
|
|
scanned: z.number().int().nonnegative(),
|
|
flagged: z.array(SellerScanFlagSchema),
|
|
alreadyFlagged: z.number().int().nonnegative(),
|
|
unchanged: z.number().int().nonnegative(),
|
|
deterministic: z.number().int().nonnegative(),
|
|
aiScreened: z.number().int().nonnegative(),
|
|
aiRequests: z.number().int().nonnegative(),
|
|
budgetSkipped: z.number().int().nonnegative(),
|
|
emptySkipped: z.number().int().nonnegative(),
|
|
/** True when a classifier request failed and part of the sweep was skipped. */
|
|
degraded: z.boolean(),
|
|
});
|
|
export const StartConversationRequestSchema = z.strictObject({
|
|
accountId: z.string().min(1),
|
|
username: z.string().min(1),
|
|
message: z.string().min(1),
|
|
});
|
|
export const StartConversationResponseSchema = z.strictObject({
|
|
success: z.literal(true),
|
|
chat: ActiveChatSchema,
|
|
});
|
|
export const ChatSyncTriggerResponseSchema = z.union([
|
|
z.strictObject({ success: z.literal(true), kind: SyncKindSchema }),
|
|
RejectedActionResponseSchema,
|
|
ErrorResponseSchema,
|
|
]);
|
|
export const FetchAllChatsResponseSchema = z.strictObject({
|
|
success: z.literal(true),
|
|
count: z.number(),
|
|
});
|
|
|
|
export type ActiveChat = z.infer<typeof ActiveChatSchema>;
|
|
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
|
|
export type ConversationTriageStatus = z.infer<typeof ConversationTriageStatusSchema>;
|
|
export type ConversationAutomationStatus = z.infer<typeof ConversationAutomationStatusSchema>;
|
|
export type ConversationPolicyMode = z.infer<typeof ConversationPolicyModeSchema>;
|
|
export type BulkMarkConversationsReadResponse = z.infer<typeof BulkMarkConversationsReadResponseSchema>;
|
|
export type SellerScanFlag = z.infer<typeof SellerScanFlagSchema>;
|
|
export type SellerScanResponse = z.infer<typeof SellerScanResponseSchema>;
|
|
export type ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
|
|
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
|
|
export type ConversationMediaShareOptions = z.infer<typeof ConversationMediaShareOptionsResponseSchema>;
|
|
export type ConversationImageReplacementTarget = z.infer<typeof ConversationImageReplacementTargetSchema>;
|
|
export type ConversationImageReplacementOptions = z.infer<typeof ConversationImageReplacementOptionsResponseSchema>;
|
|
export type ConversationImageReplacementResult = z.infer<typeof GenerateConversationImageReplacementResponseSchema>;
|