Adds a "Scan for sellers" button to the conversations header, backed by POST /api/conversations/seller-scan. The sweep reuses the existing seller detection rather than inventing a second one: the configured keyword/domain list, the seller-link funnel patterns, and the aiReplyScreening confidences all decide the verdict, so a swept conversation lands in the state the live reply loop would have given it. Being operator-forced, it deliberately ignores filterSellers and aiReplyScreening.enabled. AI is the last resort. Conversations are skipped when already flagged or killed, settled for free by the deterministic rules, or skipped against a durable sellerScreenedKey watermark when nothing new has arrived since the last clean sweep. Only the remainder reaches the model, batched eight at a time through a new screenConversationsForSellers, so the system prompt and round trip are paid per batch instead of per conversation. A classifier outage degrades the sweep instead of failing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
import type { ZodType } from 'zod';
|
|
import {
|
|
ConversationsResponseSchema,
|
|
ConversationDetailResponseSchema,
|
|
ConversationMediaShareOptionsResponseSchema,
|
|
ErrorResponseSchema,
|
|
ProcessedPostsResponseSchema,
|
|
SendReplyRequestSchema,
|
|
ShareConversationMediaRequestSchema,
|
|
StartConversationRequestSchema,
|
|
StartConversationResponseSchema,
|
|
SuccessResponseSchema,
|
|
SuggestedReplyResponseSchema,
|
|
BulkMarkConversationsReadRequestSchema,
|
|
BulkMarkConversationsReadResponseSchema,
|
|
SellerScanResponseSchema,
|
|
MarkConversationReadRequestSchema,
|
|
MarkConversationUnreadRequestSchema,
|
|
SetConversationStatusRequestSchema,
|
|
type ActiveChat,
|
|
type BulkMarkConversationsReadResponse,
|
|
type ConversationDetailResponse,
|
|
type ConversationMediaShareOptions,
|
|
type ConversationTriageStatus,
|
|
type ProcessedPost,
|
|
type SellerScanResponse,
|
|
type SuccessResponse,
|
|
} from '../../../../../packages/api-contracts/src/index.js';
|
|
import { createDashboardRequest } from '../../api/request.js';
|
|
|
|
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
|
const payload: unknown = await response.json();
|
|
if (!response.ok) {
|
|
const parsedError = ErrorResponseSchema.safeParse(payload);
|
|
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
|
|
}
|
|
return schema.parse(payload);
|
|
}
|
|
|
|
function conversationPath(id: string): string {
|
|
return `/api/conversations/${encodeURIComponent(id)}`;
|
|
}
|
|
|
|
export interface ConversationApi {
|
|
list(): Promise<ActiveChat[]>;
|
|
detail(id: string): Promise<ConversationDetailResponse>;
|
|
suggestReply(id: string): Promise<string>;
|
|
suggestMediaAsk(id: string): Promise<string>;
|
|
mediaShareOptions(id: string): Promise<ConversationMediaShareOptions>;
|
|
processedPosts(username: string): Promise<ProcessedPost[]>;
|
|
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
|
shareMedia(id: string, assetId: string): Promise<SuccessResponse>;
|
|
startConversation(accountId: string, username: string, message: string): Promise<ActiveChat>;
|
|
markRead(id: string, throughTimestamp?: number): Promise<SuccessResponse>;
|
|
markUnread(id: string, beforeTimestamp?: number): Promise<SuccessResponse>;
|
|
setStatus(id: string, status: ConversationTriageStatus, snoozeUntil?: number | null): Promise<SuccessResponse>;
|
|
bulkMarkRead(ids: string[]): Promise<BulkMarkConversationsReadResponse>;
|
|
sellerScan(): Promise<SellerScanResponse>;
|
|
}
|
|
|
|
function jsonPost(body: unknown): RequestInit {
|
|
return {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
|
|
const request = createDashboardRequest(fetchImplementation);
|
|
|
|
return {
|
|
async list() {
|
|
return parseResponse(
|
|
await request('/api/conversations?includeImageMetadata=true'),
|
|
ConversationsResponseSchema,
|
|
);
|
|
},
|
|
async detail(id) {
|
|
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
|
|
},
|
|
async suggestReply(id) {
|
|
const response = await parseResponse(
|
|
await request(`${conversationPath(id)}/suggest-reply`, { method: 'POST' }),
|
|
SuggestedReplyResponseSchema,
|
|
);
|
|
return response.suggestedReply;
|
|
},
|
|
async suggestMediaAsk(id) {
|
|
const response = await parseResponse(
|
|
await request(`${conversationPath(id)}/suggest-media-ask`, { method: 'POST' }),
|
|
SuggestedReplyResponseSchema,
|
|
);
|
|
return response.suggestedReply;
|
|
},
|
|
async mediaShareOptions(id) {
|
|
return parseResponse(
|
|
await request(`${conversationPath(id)}/shareable-media`),
|
|
ConversationMediaShareOptionsResponseSchema,
|
|
);
|
|
},
|
|
async processedPosts(username) {
|
|
return parseResponse(
|
|
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
|
|
ProcessedPostsResponseSchema,
|
|
);
|
|
},
|
|
async sendReply(id, message) {
|
|
const body = SendReplyRequestSchema.parse({ message: message.trim() });
|
|
return parseResponse(
|
|
await request(`${conversationPath(id)}/send-reply`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
SuccessResponseSchema,
|
|
);
|
|
},
|
|
async shareMedia(id, assetId) {
|
|
const body = ShareConversationMediaRequestSchema.parse({ assetId });
|
|
return parseResponse(
|
|
await request(`${conversationPath(id)}/share-media`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
SuccessResponseSchema,
|
|
);
|
|
},
|
|
async startConversation(accountId, username, message) {
|
|
const body = StartConversationRequestSchema.parse({
|
|
accountId,
|
|
username: username.trim().replace(/^u\//i, ''),
|
|
message: message.trim(),
|
|
});
|
|
const response = await parseResponse(
|
|
await request('/api/conversations/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
StartConversationResponseSchema,
|
|
);
|
|
return response.chat;
|
|
},
|
|
async markRead(id, throughTimestamp) {
|
|
const body = MarkConversationReadRequestSchema.parse(
|
|
throughTimestamp === undefined ? {} : { throughTimestamp },
|
|
);
|
|
return parseResponse(
|
|
await request(`${conversationPath(id)}/read`, jsonPost(body)),
|
|
SuccessResponseSchema,
|
|
);
|
|
},
|
|
async markUnread(id, beforeTimestamp) {
|
|
const body = MarkConversationUnreadRequestSchema.parse(
|
|
beforeTimestamp === undefined ? {} : { beforeTimestamp },
|
|
);
|
|
return parseResponse(
|
|
await request(`${conversationPath(id)}/unread`, jsonPost(body)),
|
|
SuccessResponseSchema,
|
|
);
|
|
},
|
|
async setStatus(id, status, snoozeUntil) {
|
|
const body = SetConversationStatusRequestSchema.parse(
|
|
snoozeUntil === undefined ? { status } : { status, snoozeUntil },
|
|
);
|
|
return parseResponse(
|
|
await request(`${conversationPath(id)}/status`, jsonPost(body)),
|
|
SuccessResponseSchema,
|
|
);
|
|
},
|
|
async bulkMarkRead(ids) {
|
|
const body = BulkMarkConversationsReadRequestSchema.parse({ conversationIds: ids });
|
|
return parseResponse(
|
|
await request('/api/conversations/bulk-read', jsonPost(body)),
|
|
BulkMarkConversationsReadResponseSchema,
|
|
);
|
|
},
|
|
async sellerScan() {
|
|
return parseResponse(
|
|
await request('/api/conversations/seller-scan', { method: 'POST' }),
|
|
SellerScanResponseSchema,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export const conversationApi = createConversationApi();
|