mirror of
https://github.com/complexcaresolutions/telegram-media-bot.git
synced 2026-03-17 15:03:42 +00:00
feat: add download helper and Payload API client with token caching
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a58d6f31fa
commit
829d785c4a
2 changed files with 219 additions and 0 deletions
181
src/payload/client.ts
Normal file
181
src/payload/client.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { config } from '../config.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const log = createLogger('PayloadClient');
|
||||
|
||||
interface MediaUploadOptions {
|
||||
alt: string;
|
||||
tenantId: number;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
interface MediaDoc {
|
||||
id: number;
|
||||
url: string;
|
||||
filename: string;
|
||||
alt: string;
|
||||
mimeType: string;
|
||||
filesize: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
sizes?: Record<string, { url?: string; width?: number; height?: number }>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface MediaListResponse {
|
||||
docs: MediaDoc[];
|
||||
totalDocs: number;
|
||||
}
|
||||
|
||||
interface TenantDoc {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
function decodeJwtExpiry(token: string): number {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return 0;
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
return payload.exp || 0;
|
||||
}
|
||||
|
||||
class PayloadClient {
|
||||
private token: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
private readonly apiUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.apiUrl = config.payload.apiUrl;
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
log.info('Logging in to Payload CMS...');
|
||||
const response = await fetch(`${this.apiUrl}/users/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: config.payload.email,
|
||||
password: config.payload.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Payload login failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.token = data.token;
|
||||
this.tokenExpiry = decodeJwtExpiry(data.token);
|
||||
log.info(`Login successful, token expires at ${new Date(this.tokenExpiry * 1000).toISOString()}`);
|
||||
}
|
||||
|
||||
async getToken(): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const buffer = 300; // 5 min buffer
|
||||
|
||||
if (!this.token || this.tokenExpiry <= now + buffer) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
return this.token!;
|
||||
}
|
||||
|
||||
private async authFetch(url: string, init: RequestInit = {}, retry = true): Promise<Response> {
|
||||
const token = await this.getToken();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Authorization', `JWT ${token}`);
|
||||
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
|
||||
if (response.status === 401 && retry) {
|
||||
log.warn('Got 401, re-authenticating...');
|
||||
this.token = null;
|
||||
return this.authFetch(url, init, false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async uploadMedia(file: Buffer, filename: string, mimeType: string, options: MediaUploadOptions): Promise<MediaDoc> {
|
||||
const formData = new FormData();
|
||||
formData.append('alt', options.alt);
|
||||
if (options.caption) formData.append('caption', options.caption);
|
||||
formData.append('tenant', String(options.tenantId));
|
||||
|
||||
const arrayBuffer = new ArrayBuffer(file.byteLength);
|
||||
new Uint8Array(arrayBuffer).set(file);
|
||||
const blob = new Blob([arrayBuffer], { type: mimeType });
|
||||
formData.append('file', blob, filename);
|
||||
|
||||
log.info(`Uploading ${filename} (${(file.byteLength / 1024).toFixed(1)} KB) to tenant ${options.tenantId}`);
|
||||
|
||||
const response = await this.authFetch(`${this.apiUrl}/media`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
log.info(`Upload successful: ID ${data.doc.id}, ${data.doc.filename}`);
|
||||
return data.doc;
|
||||
}
|
||||
|
||||
async listMedia(tenantId: number, limit = 5): Promise<MediaListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
'where[tenant][equals]': String(tenantId),
|
||||
sort: '-createdAt',
|
||||
limit: String(limit),
|
||||
});
|
||||
|
||||
const response = await this.authFetch(`${this.apiUrl}/media?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`List media failed (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async deleteMedia(mediaId: number): Promise<void> {
|
||||
const response = await this.authFetch(`${this.apiUrl}/media/${mediaId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Delete media failed (${response.status})`);
|
||||
}
|
||||
|
||||
log.info(`Deleted media ID ${mediaId}`);
|
||||
}
|
||||
|
||||
async listTenants(): Promise<TenantDoc[]> {
|
||||
const response = await this.authFetch(`${this.apiUrl}/tenants?limit=50`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`List tenants failed (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.docs;
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.authFetch(`${this.apiUrl}/users/me`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getTokenExpiry(): Date | null {
|
||||
return this.tokenExpiry > 0 ? new Date(this.tokenExpiry * 1000) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const payloadClient = new PayloadClient();
|
||||
export type { MediaDoc, MediaUploadOptions, MediaListResponse, TenantDoc };
|
||||
38
src/utils/download.ts
Normal file
38
src/utils/download.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { createLogger } from './logger.js';
|
||||
|
||||
const log = createLogger('Download');
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
export async function downloadFile(url: string): Promise<{ buffer: Buffer; mimeType: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length') || '0');
|
||||
if (contentLength > MAX_FILE_SIZE) {
|
||||
throw new Error(`Datei zu groß (${(contentLength / 1024 / 1024).toFixed(1)} MB). Maximum: 20 MB`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (arrayBuffer.byteLength > MAX_FILE_SIZE) {
|
||||
throw new Error(`Datei zu groß (${(arrayBuffer.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum: 20 MB`);
|
||||
}
|
||||
|
||||
const mimeType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
log.debug(`Downloaded ${arrayBuffer.byteLength} bytes, type: ${mimeType}`);
|
||||
|
||||
return { buffer: Buffer.from(arrayBuffer), mimeType };
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('Download-Timeout: Server hat nicht innerhalb von 30 Sekunden geantwortet');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue