import { z, type ZodType } from 'zod'; import { AgentJobRequestSchema, AgentJobSchema, CapabilitiesResponseSchema, ControlRequestSchema, ControlResponseSchema, HealthResponseSchema, OverviewResponseSchema, ReadinessResponseSchema, } from './agent-api-contracts.js'; export type ApiAccess = 'read' | 'control' | 'admin'; export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; export interface ApiOperation { operationId: string; method: ApiMethod; path: string; summary: string; access: ApiAccess; mutates: boolean; streaming?: boolean; requestSchema?: ZodType; responseSchema?: ZodType; successStatus?: number; tags: string[]; } const operation = ( operationId: string, method: ApiMethod, path: string, summary: string, tags: string[], options: Partial> = {}, ): ApiOperation => ({ operationId, method, path, summary, tags, access: options.access ?? (method === 'GET' ? 'read' : 'admin'), mutates: options.mutates ?? method !== 'GET', streaming: options.streaming ?? false, requestSchema: options.requestSchema, responseSchema: options.responseSchema, successStatus: options.successStatus, }); /** * Machine-readable catalogue for every HTTP operation exposed by the host. * Legacy dashboard routes remain here during the v1 migration so discovery * never depends on scraping Express source or stale prose documentation. */ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('health', 'GET', '/healthz', 'Process liveness', ['Operations'], { responseSchema: HealthResponseSchema }), operation('getOverview', 'GET', '/api/v1/overview', 'Consolidated operational snapshot', ['Agent'], { responseSchema: OverviewResponseSchema }), operation('getReadiness', 'GET', '/api/v1/readyz', 'Component readiness', ['Agent'], { responseSchema: ReadinessResponseSchema }), operation('getCapabilities', 'GET', '/api/v1/capabilities', 'Discover API operations and safety attributes', ['Agent'], { responseSchema: CapabilitiesResponseSchema }), operation('getOpenApi', 'GET', '/api/openapi.json', 'OpenAPI 3.1 description', ['Discovery']), operation('getControlStatus', 'GET', '/api/v1/control', 'Get bot running state', ['Control'], { responseSchema: ControlResponseSchema }), operation('setControl', 'POST', '/api/v1/control', 'Set bot running state', ['Control'], { access: 'control', requestSchema: ControlRequestSchema, responseSchema: ControlResponseSchema }), operation('startBotV1', 'POST', '/api/v1/control/start', 'Start bot processing', ['Control'], { access: 'control', responseSchema: ControlResponseSchema }), operation('stopBotV1', 'POST', '/api/v1/control/stop', 'Stop bot processing', ['Control'], { access: 'control', responseSchema: ControlResponseSchema }), operation('listJobs', 'GET', '/api/v1/jobs', 'List recent asynchronous jobs', ['Jobs'], { responseSchema: z.array(AgentJobSchema) }), operation('getJob', 'GET', '/api/v1/jobs/{id}', 'Get asynchronous job state', ['Jobs'], { responseSchema: AgentJobSchema }), operation('createJob', 'POST', '/api/v1/jobs', 'Start chat or media synchronisation', ['Jobs'], { access: 'control', requestSchema: AgentJobRequestSchema, responseSchema: AgentJobSchema, successStatus: 202 }), operation('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']), operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']), operation('getActivity', 'GET', '/api/activity', 'Get recent activity', ['Dashboard']), operation('getPostsByAuthor', 'GET', '/api/posts/by-author/{username}', 'Get processed posts by author', ['Dashboard']), operation('getMessages', 'GET', '/api/messages', 'Get recent sent messages', ['Dashboard']), operation('getSuccessful', 'GET', '/api/successful', 'Get successful message examples', ['Dashboard']), operation('getChats', 'GET', '/api/chats', 'Get active chats', ['Chats']), operation('getChatSync', 'GET', '/api/chats/sync', 'Get chat sync state', ['Chats']), operation('triggerDeltaChatSync', 'POST', '/api/chats/sync/delta', 'Trigger incremental chat sync', ['Chats']), operation('triggerFullChatSync', 'POST', '/api/chats/sync/full', 'Trigger full chat sync', ['Chats']), operation('fetchAllChats', 'POST', '/api/chats/fetch-all', 'Fetch all chats synchronously', ['Chats']), operation('getConversations', 'GET', '/api/conversations', 'List conversations', ['Conversations']), operation('getConversation', 'GET', '/api/conversations/{id}', 'Get conversation detail', ['Conversations']), operation('suggestReply', 'POST', '/api/conversations/{id}/suggest-reply', 'Generate a reply suggestion', ['Conversations']), operation('suggestMediaAsk', 'POST', '/api/conversations/{id}/suggest-media-ask', 'Generate a media request suggestion', ['Conversations']), operation('sendReply', 'POST', '/api/conversations/{id}/send-reply', 'Send a conversation reply', ['Conversations']), operation('getConversationMediaOptions', 'GET', '/api/conversations/{id}/shareable-media', 'List every enabled exchange-library image not yet shared in this conversation', ['Conversations']), operation('shareConversationMedia', 'POST', '/api/conversations/{id}/share-media', 'Send a selected exchange-library image', ['Conversations']), operation('startConversation', 'POST', '/api/conversations/start', 'Start a new conversation', ['Conversations']), operation('markConversationRead', 'POST', '/api/conversations/{id}/read', 'Advance the operator read watermark', ['Conversations'], { requestSchema: z.strictObject({ throughTimestamp: z.number().int().nonnegative().optional() }), responseSchema: z.strictObject({ success: z.literal(true) }), }), operation('markConversationUnread', 'POST', '/api/conversations/{id}/unread', 'Return a conversation to the unread queue', ['Conversations'], { requestSchema: z.strictObject({ beforeTimestamp: z.number().int().nonnegative().optional() }), responseSchema: z.strictObject({ success: z.literal(true) }), }), operation('setConversationStatus', 'POST', '/api/conversations/{id}/status', 'Snooze, resolve or reopen a conversation', ['Conversations'], { requestSchema: z.strictObject({ status: z.enum(['open', 'snoozed', 'done']), snoozeUntil: z.number().int().positive().nullable().optional(), }), responseSchema: z.strictObject({ success: z.literal(true) }), }), operation('bulkMarkConversationsRead', 'POST', '/api/conversations/bulk-read', 'Mark many conversations read in one request', ['Conversations'], { requestSchema: z.strictObject({ conversationIds: z.array(z.string().min(1)).min(1).max(500) }), responseSchema: z.strictObject({ success: z.literal(true), updated: z.number().int().nonnegative(), failed: z.array(z.string()), }), }), operation('getMedia', 'GET', '/api/media', 'List received private media', ['Media']), operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']), operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']), operation('triggerFullMediaSync', 'POST', '/api/media/sync/full', 'Trigger full media sync', ['Media']), operation('listMediaLibrary', 'GET', '/api/media-library', 'List operator-owned exchange images', ['Media Library']), operation('uploadMediaLibraryImage', 'POST', '/api/media-library', 'Upload and classify an operator-owned exchange image', ['Media Library'], { successStatus: 201 }), operation('updateMediaLibraryImage', 'PATCH', '/api/media-library/{id}', 'Correct tags or enable an exchange image', ['Media Library'], { requestSchema: z.strictObject({ tags: z.array(z.enum(['penis', 'chest', 'selfie', 'topless', 'feet'])).min(1), safety: z.enum(['sfw', 'nsfw']), enabled: z.boolean(), }), }), operation('deleteMediaLibraryImage', 'DELETE', '/api/media-library/{id}', 'Delete an exchange image', ['Media Library']), operation('startBot', 'POST', '/api/start', 'Start bot processing (legacy)', ['Control']), operation('stopBot', 'POST', '/api/stop', 'Stop bot processing (legacy)', ['Control']), operation('getTimers', 'GET', '/api/timers', 'Get rate-limit timers', ['Analytics']), operation('getSubredditAnalytics', 'GET', '/api/analytics/by-subreddit', 'Get performance by subreddit', ['Analytics']), operation('getStrategyAnalytics', 'GET', '/api/analytics/by-strategy', 'Get performance by strategy', ['Analytics']), operation('getFunnelAnalytics', 'GET', '/api/analytics/funnel', 'Get mature message outcome funnel', ['Analytics']), operation('getAnalyticsTrend', 'GET', '/api/analytics/trend', 'Get daily performance trend', ['Analytics']), operation('getAiUsageAnalytics', 'GET', '/api/analytics/ai-usage', 'Get OpenRouter cost and model reliability', ['Analytics']), operation('getAttention', 'GET', '/api/attention', 'Get operator attention items', ['Operations']), operation('getOutreachFunnel', 'GET', '/api/outreach', 'Get active outreach candidates and funnel stages', ['Operations']), operation('getConfig', 'GET', '/api/config', 'Get non-secret application configuration', ['Configuration']), operation('updateConfig', 'PUT', '/api/config', 'Update application configuration', ['Configuration']), operation('getLoginStatus', 'GET', '/api/login/status', 'Get Reddit session state', ['Accounts']), operation('importSession', 'POST', '/api/login/session-import', 'Import Reddit and Matrix credentials', ['Accounts']), operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']), operation('activateAccount', 'POST', '/api/accounts/{accountId}/activate', 'Activate a Reddit account', ['Accounts']), operation('streamLogs', 'GET', '/api/logs/stream', 'Stream legacy log events', ['Events'], { streaming: true }), operation('streamEvents', 'GET', '/api/events/stream', 'Stream typed application events', ['Events'], { streaming: true }), operation('getLogs', 'GET', '/api/logs', 'Get recent structured logs', ['Events']), operation('getCookieDiagnostics', 'GET', '/api/debug/cookies', 'Get redacted cookie diagnostics when enabled', ['Debug'], { access: 'admin' }), operation('checkResponses', 'POST', '/api/debug/check-responses', 'Run response check when enabled', ['Debug']), ]; function jsonSchema(schema: ZodType | undefined): Record | undefined { return schema ? z.toJSONSchema(schema, { target: 'draft-7' }) as Record : undefined; } export function createOpenApiDocument(): Record { const paths: Record> = {}; for (const item of API_OPERATIONS) { const path = paths[item.path] ?? {}; const request = jsonSchema(item.requestSchema); const response = jsonSchema(item.responseSchema) ?? {}; path[item.method.toLowerCase()] = { operationId: item.operationId, summary: item.summary, tags: item.tags, security: item.path === '/healthz' ? [] : [{ bearerAuth: [] }, { cookieAuth: [] }], 'x-access': item.access, 'x-mutates': item.mutates, 'x-streaming': item.streaming, ...(request ? { requestBody: { required: true, content: { 'application/json': { schema: request } } } } : {}), responses: { [String(item.successStatus ?? 200)]: { description: 'Successful response', content: item.streaming ? { 'text/event-stream': { schema: { type: 'string' } } } : { 'application/json': { schema: response } }, }, '401': { description: 'Authentication required' }, '403': { description: 'Credential lacks the required access' }, }, }; paths[item.path] = path; } return { openapi: '3.1.0', info: { title: 'RedditBot API', version: '1.0.0', description: 'Dashboard and agent API. Prefer /api/v1 operations for automation.', }, servers: [{ url: '/' }], paths, components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' }, cookieAuth: { type: 'apiKey', in: 'cookie', name: 'reddit_bot_dashboard_session' }, }, }, }; } export function capabilities() { return { apiVersion: 'v1' as const, authentication: { schemes: ['Bearer token', 'HttpOnly dashboard session'], agentScopes: ['read', 'control', 'admin'] as const, }, operations: API_OPERATIONS.map(({ operationId, method, path, summary, access, mutates, streaming }) => ({ operationId, method, path, summary, access, mutates, streaming: Boolean(streaming), })), }; }