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; export type ChatMessage = z.infer; export type ConversationTriageStatus = z.infer; export type ConversationAutomationStatus = z.infer; export type ConversationPolicyMode = z.infer; export type BulkMarkConversationsReadResponse = z.infer; export type SellerScanFlag = z.infer; export type SellerScanResponse = z.infer; export type ChatSyncStatus = z.infer; export type ConversationDetailResponse = z.infer; export type ConversationMediaShareOptions = z.infer; export type ConversationImageReplacementTarget = z.infer; export type ConversationImageReplacementOptions = z.infer; export type ConversationImageReplacementResult = z.infer;