diff --git a/assets/openapi.json b/assets/openapi.json index 2a928a2bc..8941e40c1 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index b72d9cc13..0c883c932 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/src/api/Server.ts b/src/api/Server.ts index 2bf143504..74718dad7 100644 --- a/src/api/Server.ts +++ b/src/api/Server.ts @@ -29,6 +29,7 @@ import { BcryptWorkerPool } from "../util/util/workers/bcrypt/BcryptWorkerPool"; import { Authentication, CORS, ImageProxy, BodyParser, ErrorHandler, initRateLimits, initTranslation } from "./middlewares"; import { initInstance } from "./util/handlers/Instance"; import { route, addPendingPoll } from "./util"; +import { GifProviderManager } from "@spacebar/util/util/integrations/gifProviders/GifProviderManager"; const ASSETS_FOLDER = path.join(__dirname, "..", "..", "assets"); const PUBLIC_ASSETS_FOLDER = path.join(ASSETS_FOLDER, "public"); @@ -64,6 +65,7 @@ export class SpacebarServer extends Server { await initInstance(); WebAuthn.init(); // await BcryptWorkerPool.Init(8); // TODO: make configurable + await GifProviderManager.init(); const logRequests = process.env["LOG_REQUESTS"] != undefined; if (logRequests) { diff --git a/src/api/routes/gifs/search.ts b/src/api/routes/gifs/search.ts index 13c246590..20f778890 100644 --- a/src/api/routes/gifs/search.ts +++ b/src/api/routes/gifs/search.ts @@ -1,6 +1,6 @@ /* Spacebar: A FOSS re-implementation and extension of the Discord.com backend. - Copyright (C) 2023 Spacebar and Spacebar Contributors + Copyright (C) 2026 Spacebar and Spacebar Contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published @@ -17,9 +17,9 @@ */ import { route } from "@spacebar/api/util/handlers/route"; -import { getGifApiKey, parseGifResult } from "@spacebar/util"; import { Request, Response, Router } from "express"; -import { TenorGif, TenorMediaTypes } from "@spacebar/schemas"; +import { GifMediaTypes } from "@spacebar/schemas"; +import { GifProviderManager } from "@spacebar/util/util/integrations/gifProviders/GifProviderManager"; const router = Router({ mergeParams: true }); @@ -35,33 +35,30 @@ router.get( media_format: { type: "string", description: "Media format", - values: Object.keys(TenorMediaTypes).filter((key) => isNaN(Number(key))), + values: Object.keys(GifMediaTypes).filter((key) => isNaN(Number(key))), }, locale: { type: "string", description: "Locale", }, + provider: { + type: "string", + description: "Provider to use", + }, }, responses: { 200: { - body: "TenorGifsResponse", + body: "GifsResponse", }, }, }), async (req: Request, res: Response) => { - // TODO: Custom providers - const { q, media_format, locale } = req.query; + const { provider } = req.query; - const apiKey = getGifApiKey(); + const impl = GifProviderManager.getProvider(provider as string); + const result = await impl.search(req.query as typeof impl.search.arguments); - const response = await fetch(`https://g.tenor.com/v1/search?q=${q}&media_format=${media_format}&locale=${locale}&key=${apiKey}`, { - method: "get", - headers: { "Content-Type": "application/json" }, - }); - - const { results } = (await response.json()) as { results: TenorGif[] }; - - res.json(results.map(parseGifResult)).status(200); + res.json(result).status(200); }, ); diff --git a/src/api/routes/gifs/trending-gifs.ts b/src/api/routes/gifs/trending-gifs.ts index 14d3d4dc1..a79dfc541 100644 --- a/src/api/routes/gifs/trending-gifs.ts +++ b/src/api/routes/gifs/trending-gifs.ts @@ -19,7 +19,7 @@ import { route } from "@spacebar/api/util/handlers/route"; import { getGifApiKey, parseGifResult } from "@spacebar/util"; import { Request, Response, Router } from "express"; -import { TenorGif, TenorMediaTypes } from "@spacebar/schemas"; +import { TenorGif, GifMediaTypes } from "@spacebar/schemas"; const router = Router({ mergeParams: true }); @@ -30,7 +30,7 @@ router.get( media_format: { type: "string", description: "Media format", - values: Object.keys(TenorMediaTypes).filter((key) => isNaN(Number(key))), + values: Object.keys(GifMediaTypes).filter((key) => isNaN(Number(key))), }, locale: { type: "string", diff --git a/src/schemas/responses/Tenor.ts b/src/schemas/responses/Tenor.ts index cd11b6ef6..61d7c2bc9 100644 --- a/src/schemas/responses/Tenor.ts +++ b/src/schemas/responses/Tenor.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -export enum TenorMediaTypes { +export enum GifMediaTypes { gif, mediumgif, tinygif, @@ -41,7 +41,7 @@ export type TenorGif = { created: number; hasaudio: boolean; id: string; - media: { [type in keyof typeof TenorMediaTypes]: TenorMedia }[]; + media: { [type in keyof typeof GifMediaTypes]: TenorMedia }[]; tags: string[]; title: string; itemurl: string; @@ -71,7 +71,7 @@ export type TenorSearchResults = { results: TenorGif[]; }; -export interface TenorGifResponse { +export interface GifResponse { id: string; title: string; url: string; @@ -84,7 +84,7 @@ export interface TenorGifResponse { export interface TenorTrendingResponse { categories: TenorCategoriesResults; - gifs: TenorGifResponse[]; + gifs: GifResponse[]; } -export type TenorGifsResponse = TenorGifResponse[]; +export type GifsResponse = GifResponse[]; diff --git a/src/util/config/types/GifConfiguration.ts b/src/util/config/types/GifConfiguration.ts index 04afb3bce..ffb0d095d 100644 --- a/src/util/config/types/GifConfiguration.ts +++ b/src/util/config/types/GifConfiguration.ts @@ -17,7 +17,10 @@ */ export class GifConfiguration { + /// @deprecated enabled: boolean = true; + // @deprecated provider = "tenor" as const; // more coming soon + // @deprecated apiKey?: string = "LIVDSRZULELA"; } diff --git a/src/util/util/Gifs.ts b/src/util/util/Gifs.ts index a24ada632..22b851686 100644 --- a/src/util/util/Gifs.ts +++ b/src/util/util/Gifs.ts @@ -2,6 +2,7 @@ import { HTTPError } from "lambert-server/HTTPError"; import { Config } from "./Config"; import { TenorGif } from "@spacebar/schemas"; +// @deprecated export function parseGifResult(result: TenorGif) { return { id: result.id, diff --git a/src/util/util/integrations/gifProviders/GifProviderManager.ts b/src/util/util/integrations/gifProviders/GifProviderManager.ts new file mode 100644 index 000000000..8bbfccf52 --- /dev/null +++ b/src/util/util/integrations/gifProviders/GifProviderManager.ts @@ -0,0 +1,51 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import fs from "node:fs/promises"; +import path from "node:path"; +import type { IGifProvider } from "./IGifProvider"; + +export class GifProviderManager { + private static _providers: Map = new Map(); + public static async init() { + console.log("[GifProviderManager] Initialising providers..."); + const providerImports = await Promise.all( + (await fs.readdir(path.join(__dirname, "providers"))) /**/ + .filter((p) => p.endsWith(".js")) + .map((f) => import(path.join(__dirname, "providers", f))), + ); + + console.log("Import tasks:", providerImports); + for (const providerImport of providerImports) { + const provider = new providerImport.default.default() as IGifProvider; + console.log(`[GifProviderManager] Got provider with id ${provider.id}, calling init...`); + await provider.init(); + console.log(`[GifProviderManager] Initialized '${provider.id}':`, provider, " - Available:", provider.available); + if (provider.available) this._providers.set(provider.id, provider); + console.log(`[GifProviderManager] Initialized`, this._providers.size, "/", providerImports.length, "GIF providers..."); + } + + console.log("[GifProviderManager] Ready with", this._providers.size, "available providers!"); + } + + public static getProvider(id: string): IGifProvider { + if (this._providers.has(id)) return this._providers.get(id)!; + + throw new Error(`Unknown GIF provider, or it is not enabled: ${id}, known GIF providers: ${this._providers.keys().toArray().join(", ")}`); + } +} diff --git a/src/util/util/integrations/gifProviders/IGifProvider.ts b/src/util/util/integrations/gifProviders/IGifProvider.ts new file mode 100644 index 000000000..e9726c72b --- /dev/null +++ b/src/util/util/integrations/gifProviders/IGifProvider.ts @@ -0,0 +1,27 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { GifsResponse } from "@spacebar/schemas"; + +export interface IGifProvider { + id: string; + available: boolean; + + init(): Promise; + search(query: { q: string; limit?: number; media_format: string; locale: string }): Promise; +} diff --git a/src/util/util/integrations/gifProviders/providers/TenorGifProvider.ts b/src/util/util/integrations/gifProviders/providers/TenorGifProvider.ts new file mode 100644 index 000000000..ca5635989 --- /dev/null +++ b/src/util/util/integrations/gifProviders/providers/TenorGifProvider.ts @@ -0,0 +1,60 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import type { IGifProvider } from "../IGifProvider"; +import { TenorGif, GifsResponse } from "@spacebar/schemas"; +import { Config, getGifApiKey, parseGifResult } from "@spacebar/util"; + +export default class TenorGifProvider implements IGifProvider { + id = "tenor"; + available = true; + #apiKey: string; + + async init(): Promise { + if (!(Config.get().gif.enabled && Config.get().gif.apiKey)) { + this.available = false; + return; + } + + this.#apiKey = Config.get().gif.apiKey!; + console.log("[TenorGifProvider] Hellorld!"); + } + + async search(query: { q: string; limit?: number; media_format: string; locale: string }): Promise { + const response = await fetch(`https://g.tenor.com/v1/search?q=${query.q}&media_format=${query.media_format}&locale=${query.locale}&key=${this.#apiKey}`, { + method: "get", + headers: { "Content-Type": "application/json" }, + }); + + const { results } = (await response.json()) as { results: TenorGif[] }; + return results.map(parseGifResult); + } + + private convertGifResult(result: TenorGif) { + return { + id: result.id, + title: result.title, + url: result.itemurl, + src: result.media[0].mp4.url, + gif_src: result.media[0].gif.url, + width: result.media[0].mp4.dims[0], + height: result.media[0].mp4.dims[1], + preview: result.media[0].mp4.preview, + }; + } +}