feat: add agent API and operational analytics

This commit is contained in:
jjbrunton committed 2026-08-08 22:08:14 +01:00
1 parent 720ea0652a
commit e8b730b592
65 files changed
+2650 -1214

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
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<Omit<ApiOperation, 'operationId' | 'method' | 'path' | 'summary' | 'tags'>> = {},
): 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('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('startConversation', 'POST', '/api/conversations/start', 'Start a new conversation', ['Conversations']),
operation('getMedia', 'GET', '/api/media', 'List indexed 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('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('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<string, unknown> | undefined {
return schema ? z.toJSONSchema(schema, { target: 'draft-7' }) as Record<string, unknown> : undefined;
}
export function createOpenApiDocument(): Record<string, unknown> {
const paths: Record<string, Record<string, unknown>> = {};
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),
})),
};
}