refactor: use base http client for notifications, parser, autocomp

This commit is contained in:
Sam Chau
2026-01-19 02:26:12 +10:30
parent f6d99bc267
commit 8d3e20d3c3
5 changed files with 232 additions and 180 deletions

View File

@@ -1,6 +1,7 @@
import { logger } from '$logger/logger.ts';
import type { Notification } from '../types.ts';
import type { Notifier } from './Notifier.ts';
import { getWebhookClient } from './webhookClient.ts';
/**
* Base class for HTTP-based notification services (webhooks)
@@ -9,7 +10,6 @@ import type { Notifier } from './Notifier.ts';
export abstract class BaseHttpNotifier implements Notifier {
private lastSentAt: Date | null = null;
private readonly minInterval: number = 1000; // 1 second between notifications
private readonly timeout: number = 10000; // 10 second timeout
/**
* Get the webhook URL for this service
@@ -47,43 +47,14 @@ export abstract class BaseHttpNotifier implements Notifier {
const payload = this.formatPayload(notification);
const url = this.getWebhookUrl();
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
await getWebhookClient().sendWebhook(url, payload);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
signal: controller.signal
});
await logger.debug(`Notification sent`, {
source: this.getName(),
meta: { type: notification.type }
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
await logger.debug(`Notification sent`, {
source: this.getName(),
meta: { type: notification.type }
});
this.lastSentAt = new Date();
} catch (error) {
clearTimeout(timeoutId);
// Handle timeout
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Request timeout');
}
throw error;
}
this.lastSentAt = new Date();
} catch (error) {
// Log error but don't throw (fire-and-forget)
await logger.error(`Failed to send notification`, {

View File

@@ -0,0 +1,42 @@
/**
* Shared HTTP client for webhook-based notifications
* Uses BaseHttpClient for connection pooling
*/
import { BaseHttpClient } from '../../utils/http/client.ts';
/**
* Webhook HTTP client
* Extends BaseHttpClient with webhook-specific settings:
* - No retries (webhooks should either work or not)
* - 10 second timeout
*/
class WebhookClient extends BaseHttpClient {
constructor() {
// Empty base URL - we pass full webhook URLs as paths
super('', {
timeout: 10000,
retries: 0
});
}
/**
* POST to a webhook URL
*/
sendWebhook<T = void>(url: string, payload: unknown): Promise<T> {
return this.post<T>(url, payload);
}
}
// Singleton instance - lazy initialized
let webhookClient: WebhookClient | null = null;
/**
* Get the shared webhook client
*/
export function getWebhookClient(): WebhookClient {
if (!webhookClient) {
webhookClient = new WebhookClient();
}
return webhookClient;
}

View File

@@ -1,6 +1,7 @@
import { logger } from '$logger/logger.ts';
import type { DiscordConfig, Notification } from '../../types.ts';
import { Colors, type DiscordEmbed } from './embed.ts';
import { getWebhookClient } from '../../base/webhookClient.ts';
const RATE_LIMIT_DELAY = 1000; // 1 second between messages
@@ -104,30 +105,10 @@ export class DiscordNotifier {
*/
private async sendWebhook(payload: unknown): Promise<void> {
const payloadObj = payload as { embeds?: unknown[] };
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(this.config.webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
await getWebhookClient().sendWebhook(this.config.webhook_url, payload);
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Request timeout');
}
const embedCharCounts = payloadObj.embeds?.map((e, i) => `${i}:${getEmbedCharCount(e as DiscordEmbed)}`).join(', ') || 'none';
await logger.error('Failed to send notification', {
source: this.getName(),

View File

@@ -4,6 +4,7 @@
import { aiSettingsQueries } from '$db/queries/aiSettings.ts';
import { logger } from '$logger/logger.ts';
import { BaseHttpClient } from '../http/client.ts';
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
@@ -25,6 +26,72 @@ interface ChatCompletionResponse {
}>;
}
/**
* AI API HTTP client
* Extends BaseHttpClient for OpenAI-compatible APIs
*/
class AIClient extends BaseHttpClient {
constructor(baseUrl: string, apiKey?: string) {
super(baseUrl, {
timeout: 60000, // AI requests can be slow
retries: 2,
retryDelay: 1000,
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
});
}
/**
* Chat completions API (GPT-4, etc.)
*/
chatCompletions(model: string, messages: ChatMessage[], maxTokens = 100, temperature = 0.3): Promise<ChatCompletionResponse> {
return this.post<ChatCompletionResponse>('/chat/completions', {
model,
messages,
max_tokens: maxTokens,
temperature
});
}
/**
* Responses API (GPT-5)
*/
responses(model: string, instructions: string, input: string): Promise<ChatCompletionResponse> {
return this.post<ChatCompletionResponse>('/responses', {
model,
instructions,
input,
text: { verbosity: 'low' }
});
}
}
// Cached client instance (recreated if settings change)
let cachedClient: AIClient | null = null;
let cachedApiUrl: string | null = null;
let cachedApiKey: string | null = null;
function getClient(): AIClient | null {
const settings = aiSettingsQueries.get();
if (!settings || settings.enabled !== 1 || !settings.api_url) {
return null;
}
// Recreate client if settings changed
if (!cachedClient || cachedApiUrl !== settings.api_url || cachedApiKey !== settings.api_key) {
if (cachedClient) {
cachedClient.close();
}
cachedClient = new AIClient(settings.api_url, settings.api_key || undefined);
cachedApiUrl = settings.api_url;
cachedApiKey = settings.api_key;
}
return cachedClient;
}
/**
* Check if AI is enabled and configured
*/
@@ -43,6 +110,11 @@ export async function generateCommitMessage(diff: string): Promise<string> {
throw new Error('AI is not enabled');
}
const client = getClient();
if (!client) {
throw new Error('AI client not available');
}
const systemPrompt = `Generate a git commit message for database operation files.
File format: "N.operation-entity-name.sql" where operation is create/update/delete.
@@ -65,83 +137,51 @@ For multiple files, combine: "create(custom-format): HDR, DV" or list operations
Output only the commit message, max 72 chars.`;
const userPrompt = diff;
try {
let data: ChatCompletionResponse;
// Use Responses API for GPT-5 models, Chat Completions for others
const isGpt5 = settings.model.startsWith('gpt-5');
// Use Responses API for GPT-5 models, Chat Completions for others
const isGpt5 = settings.model.startsWith('gpt-5');
let response: Response;
if (isGpt5) {
data = await client.responses(settings.model, systemPrompt, diff);
} else {
const messages: ChatMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: diff }
];
data = await client.chatCompletions(settings.model, messages);
}
if (isGpt5) {
// Responses API (recommended for GPT-5)
response = await fetch(`${settings.api_url}/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(settings.api_key ? { 'Authorization': `Bearer ${settings.api_key}` } : {})
},
body: JSON.stringify({
model: settings.model,
instructions: systemPrompt,
input: userPrompt,
text: { verbosity: 'low' }
})
await logger.debug('AI response received', {
source: 'ai/client',
meta: { response: JSON.stringify(data) }
});
} else {
// Chat Completions API (for other models)
const messages: ChatMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
];
response = await fetch(`${settings.api_url}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(settings.api_key ? { 'Authorization': `Bearer ${settings.api_key}` } : {})
},
body: JSON.stringify({
model: settings.model,
messages,
max_tokens: 100,
temperature: 0.3
})
// Handle Responses API format
if (data.output) {
const textOutput = data.output.find(o => o.type === 'message');
const textContent = textOutput?.content?.find(c => c.type === 'output_text');
if (textContent?.text) {
return textContent.text.trim();
}
}
// Handle Chat Completions API format
if (data.choices?.[0]?.message?.content) {
return data.choices[0].message.content.trim();
}
await logger.error('Invalid AI response structure', {
source: 'ai/client',
meta: { response: JSON.stringify(data) }
});
}
if (!response.ok) {
const text = await response.text();
throw new Error('Invalid response from AI');
} catch (error) {
await logger.error('AI request failed', {
source: 'ai/client',
meta: { status: response.status, error: text }
meta: { error: error instanceof Error ? error.message : String(error) }
});
throw new Error(`AI request failed: ${response.status} ${text}`);
throw error;
}
const data = await response.json() as ChatCompletionResponse;
await logger.debug('AI response received', {
source: 'ai/client',
meta: { response: JSON.stringify(data) }
});
// Handle Responses API format
if (data.output) {
const textOutput = data.output.find(o => o.type === 'message');
const textContent = textOutput?.content?.find(c => c.type === 'output_text');
if (textContent?.text) {
return textContent.text.trim();
}
}
// Handle Chat Completions API format
if (data.choices?.[0]?.message?.content) {
return data.choices[0].message.content.trim();
}
await logger.error('Invalid AI response structure', {
source: 'ai/client',
meta: { response: JSON.stringify(data) }
});
throw new Error('Invalid response from AI');
}

View File

@@ -5,6 +5,7 @@
import { config } from '$config';
import { logger } from '$logger/logger.ts';
import { BaseHttpClient } from '../../http/client.ts';
import { parsedReleaseCacheQueries } from '$db/queries/parsedReleaseCache.ts';
import { patternMatchCacheQueries } from '$db/queries/patternMatchCache.ts';
import {
@@ -14,7 +15,6 @@ import {
ReleaseType,
type QualityInfo,
type ParseResult,
type EpisodeInfo,
type Resolution,
type MediaType
} from './types.ts';
@@ -59,23 +59,78 @@ interface ParseResponse {
episode: EpisodeResponse | null;
}
interface HealthResponse {
status: string;
version: string;
}
interface MatchResponse {
results: Record<string, boolean>;
}
interface BatchMatchResponse {
results: Record<string, Record<string, boolean>>;
}
/**
* Parser service HTTP client
* Extends BaseHttpClient with parser-specific methods
*/
class ParserClient extends BaseHttpClient {
constructor(baseUrl: string) {
super(baseUrl, {
timeout: 30000,
retries: 2,
retryDelay: 500
});
}
/**
* Parse a release title
*/
async parse(title: string, type: MediaType): Promise<ParseResponse> {
return this.post<ParseResponse>('/parse', { title, type });
}
/**
* Check health and get version
*/
async health(): Promise<HealthResponse> {
return this.get<HealthResponse>('/health');
}
/**
* Match patterns against text
*/
async match(text: string, patterns: string[]): Promise<MatchResponse> {
return this.post<MatchResponse>('/match', { text, patterns });
}
/**
* Match patterns against multiple texts (batch)
*/
async matchBatch(texts: string[], patterns: string[]): Promise<BatchMatchResponse> {
return this.post<BatchMatchResponse>('/match/batch', { texts, patterns });
}
}
// Singleton client instance - lazy initialized
let parserClient: ParserClient | null = null;
function getClient(): ParserClient {
if (!parserClient) {
parserClient = new ParserClient(config.parserUrl);
}
return parserClient;
}
/**
* Parse a release title - returns quality, resolution, modifier, revision, and languages
* @param title - The release title to parse
* @param type - The media type: 'movie' or 'series'
*/
export async function parse(title: string, type: MediaType): Promise<ParseResult> {
const res = await fetch(`${config.parserUrl}/parse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, type })
});
if (!res.ok) {
throw new Error(`Parser error: ${res.status}`);
}
const data: ParseResponse = await res.json();
const data = await getClient().parse(title, type);
return {
title: data.title,
@@ -134,8 +189,8 @@ export async function parseQuality(title: string, type: MediaType): Promise<Qual
*/
export async function isParserHealthy(): Promise<boolean> {
try {
const res = await fetch(`${config.parserUrl}/health`);
return res.ok;
await getClient().health();
return true;
} catch {
return false;
}
@@ -151,16 +206,7 @@ export async function getParserVersion(): Promise<string | null> {
}
try {
const res = await fetch(`${config.parserUrl}/health`);
if (!res.ok) {
await logger.warn('Parser health check failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { status: string; version: string } = await res.json();
const data = await getClient().health();
cachedParserVersion = data.version;
await logger.debug(`Parser version: ${data.version}`, { source: 'ParserClient' });
return cachedParserVersion;
@@ -333,21 +379,7 @@ export async function matchPatterns(
}
try {
const res = await fetch(`${config.parserUrl}/match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, patterns })
});
if (!res.ok) {
await logger.warn('Pattern match request failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { results: Record<string, boolean> } = await res.json();
const data = await getClient().match(text, patterns);
return new Map(Object.entries(data.results));
} catch (err) {
await logger.warn('Failed to connect to parser for pattern matching', {
@@ -378,21 +410,7 @@ async function fetchPatternMatches(
patterns: string[]
): Promise<Map<string, Map<string, boolean>> | null> {
try {
const res = await fetch(`${config.parserUrl}/match/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ texts, patterns })
});
if (!res.ok) {
await logger.warn('Batch pattern match request failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { results: Record<string, Record<string, boolean>> } = await res.json();
const data = await getClient().matchBatch(texts, patterns);
const result = new Map<string, Map<string, boolean>>();
for (const [text, patternResults] of Object.entries(data.results)) {