Bot
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
export type TokenResponse = {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
export class CoreApiClientError extends Error {
|
||||
public status?: number;
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = 'CoreApiClientError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class CoreApiClient {
|
||||
private apiUrl: string;
|
||||
private clientId: string;
|
||||
private clientSecret: string;
|
||||
private timeoutMs: number;
|
||||
|
||||
private cachedToken: string | null = null;
|
||||
private tokenExpiry: number = 0; // epoch ms
|
||||
private tokenMarginSec = 60; // safety margin in seconds
|
||||
|
||||
constructor(options?: { apiUrl?: string; clientId?: string; clientSecret?: string; timeoutMs?: number }) {
|
||||
this.apiUrl = options?.apiUrl ?? process.env.RAYLAB_CORE_API_URL ?? 'http://localhost:3000';
|
||||
this.clientId = options?.clientId ?? process.env.RAYLAB_BOT_CLIENT_ID ?? '';
|
||||
this.clientSecret = options?.clientSecret ?? process.env.RAYLAB_BOT_CLIENT_SECRET ?? '';
|
||||
this.timeoutMs = options?.timeoutMs ?? 5000;
|
||||
|
||||
if (!this.clientId || !this.clientSecret) {
|
||||
// keep construction allowed but operations will fail with clear errors
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(url: string, init: RequestInit) {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
return res;
|
||||
} finally {
|
||||
clearTimeout(id);
|
||||
}
|
||||
}
|
||||
|
||||
private isTokenValid() {
|
||||
return !!this.cachedToken && Date.now() < this.tokenExpiry;
|
||||
}
|
||||
|
||||
public async requestAccessToken(): Promise<TokenResponse> {
|
||||
if (!this.clientId || !this.clientSecret) {
|
||||
throw new CoreApiClientError('Missing client credentials');
|
||||
}
|
||||
|
||||
const url = `${this.apiUrl.replace(/\/$/, '')}/api/v1/auth/token`;
|
||||
|
||||
const body = JSON.stringify({ client_id: this.clientId, client_secret: this.clientSecret, grant_type: 'client_credentials' });
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new CoreApiClientError('Request timeout');
|
||||
}
|
||||
throw new CoreApiClientError('Network error');
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
throw new CoreApiClientError('Invalid client credentials', 401);
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
throw new CoreApiClientError(`Token request failed with status ${res.status}`, res.status);
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
// Expecting { success: true, data: { access_token, token_type, expires_in }, ... }
|
||||
const data = payload?.data;
|
||||
if (!data || !data.access_token) {
|
||||
throw new CoreApiClientError('Invalid token response');
|
||||
}
|
||||
|
||||
// Cache token
|
||||
this.cachedToken = data.access_token;
|
||||
const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : parseInt(data.expires_in, 10) || 3600;
|
||||
const effective = Math.max(0, expiresIn - this.tokenMarginSec);
|
||||
this.tokenExpiry = Date.now() + effective * 1000;
|
||||
|
||||
return { access_token: data.access_token, token_type: data.token_type ?? 'Bearer', expires_in: expiresIn };
|
||||
}
|
||||
|
||||
public async getToken(): Promise<string> {
|
||||
if (this.isTokenValid()) {
|
||||
return this.cachedToken as string;
|
||||
}
|
||||
|
||||
const tokenResp = await this.requestAccessToken();
|
||||
return tokenResp.access_token;
|
||||
}
|
||||
|
||||
public async authenticatedRequest(input: RequestInfo, init?: RequestInit, retry = true): Promise<Response> {
|
||||
const token = await this.getToken();
|
||||
|
||||
const headers = new Headers(init?.headers as HeadersInit);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
headers.set('Accept', 'application/json');
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchWithTimeout(typeof input === 'string' ? input : (input as Request).url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new CoreApiClientError('Request timeout');
|
||||
}
|
||||
throw new CoreApiClientError('Network error');
|
||||
}
|
||||
|
||||
if (res.status === 401 && retry) {
|
||||
// invalidate token and retry once
|
||||
this.cachedToken = null;
|
||||
this.tokenExpiry = 0;
|
||||
try {
|
||||
const newToken = await this.getToken();
|
||||
const headers2 = new Headers(init?.headers as HeadersInit);
|
||||
headers2.set('Authorization', `Bearer ${newToken}`);
|
||||
headers2.set('Accept', 'application/json');
|
||||
const res2 = await this.fetchWithTimeout(typeof input === 'string' ? input : (input as Request).url, {
|
||||
...init,
|
||||
headers: headers2,
|
||||
});
|
||||
return res2;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user