From 73fe4f4cd088223adaa51a24ffa7d3deef5ef671 Mon Sep 17 00:00:00 2001 From: Rory& Date: Tue, 7 Jul 2026 19:32:26 +0200 Subject: [PATCH] Convert tenor to klipy outright since tenor shut down last night... --- .../Tests/Spacebar.Tests/Tests/GifTests.cs | 7 +- .../gifs/providers/KlipyGifProvider.ts | 168 ++++++++++++++++++ .../gifs/providers/TenorGifProvider.ts | 138 -------------- src/util/config/Config.ts | 2 + .../config/types/IntegrationConfiguration.ts | 31 ++++ src/util/config/types/index.ts | 1 + 6 files changed, 207 insertions(+), 140 deletions(-) create mode 100644 src/integrations/gifs/providers/KlipyGifProvider.ts delete mode 100644 src/integrations/gifs/providers/TenorGifProvider.ts create mode 100644 src/util/config/types/IntegrationConfiguration.ts diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/GifTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/GifTests.cs index f4badbea3..765108f8d 100644 --- a/extra/admin-api/Tests/Spacebar.Tests/Tests/GifTests.cs +++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/GifTests.cs @@ -29,7 +29,7 @@ public class GifTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : } // wish this could just be a single string.... MEmberData requires returning an array spanning the argument count... - public static IEnumerable GifProviders() => [["tenor"]]; + public static IEnumerable GifProviders() => [["klipy"]]; public static IEnumerable GifSearchTestMatrix() { foreach (var query in (string[])["meow", "meowmeow"]) @@ -42,6 +42,7 @@ public class GifTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : var resp = await Assert.HttpSuccess(await Client.ApiHttpClient.GetAsync($"gifs/search?q={query}&provider={provider}", TestContext.Current.CancellationToken)); var respContent = await resp.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); + _testOutputHelper.WriteLine($"Got {respContent!.Count} results"); Assert.True(respContent!.Count > 0, "respContent.Count > 0"); Assert.All(respContent, gif => { Assert.StringNotNullOrWhitespace(gif.Id); @@ -59,7 +60,8 @@ public class GifTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : var resp = await Assert.HttpSuccess(await Client.ApiHttpClient.GetAsync($"gifs/trending?provider={provider}", TestContext.Current.CancellationToken)); var respContent = await resp.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken); - Assert.True(respContent!.Categories.Count > 0, "respContent.Categories.Count > 0"); + _testOutputHelper.WriteLine($"Got {respContent!.Categories.Count} categories and {respContent.Gifs.Count} gifs"); + Assert.True(respContent.Categories.Count > 0, "respContent.Categories.Count > 0"); Assert.True(respContent.Gifs.Count > 0, "respContent.Gifs.Count > 0"); Assert.All(respContent.Categories, cat => { @@ -83,6 +85,7 @@ public class GifTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : var resp = await Assert.HttpSuccess(await Client.ApiHttpClient.GetAsync($"gifs/trending-gifs?provider={provider}", TestContext.Current.CancellationToken)); var respContent = await resp.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); + _testOutputHelper.WriteLine($"Got {respContent!.Count} results"); Assert.True(respContent!.Count > 0, "respContent.Count > 0"); Assert.All(respContent, gif => { Assert.StringNotNullOrWhitespace(gif.Id); diff --git a/src/integrations/gifs/providers/KlipyGifProvider.ts b/src/integrations/gifs/providers/KlipyGifProvider.ts new file mode 100644 index 000000000..8bcc08a6d --- /dev/null +++ b/src/integrations/gifs/providers/KlipyGifProvider.ts @@ -0,0 +1,168 @@ +/* + 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 type { GifsResponse, GifTrendingCategory, GifMediaTypes, GifResponse } from "@spacebar/schemas"; +import { Config } from "@spacebar/util"; +import { SingletonCache, TimeSpan } from "@spacebar/extensions"; +import type { IGifProvider } from "../IGifProvider"; + +const TRENDING_CATEGORIES_CACHE_DURATION = TimeSpan.fromSeconds(24 * 60 * 60); // 1 day +const TRENDING_GIFS_CACHE_DURATION = TimeSpan.fromSeconds(60 * 60); // 1 hour + +// Klipy v1 API +export default class KlipyGifProvider implements IGifProvider { + id = "klipy"; + available = true; + #apiKey: string; + #trendingCategoryCache = new SingletonCache(TRENDING_CATEGORIES_CACHE_DURATION); + #trendingGifsCache = new SingletonCache(TRENDING_GIFS_CACHE_DURATION); + + async init(): Promise { + if (!Config.get().integrations.gifs.klipy.enabled) { + this.available = false; + return; + } + + let apiKey = Config.get().integrations.gifs.klipy.apiKey; + if (!apiKey) { + const path = Config.get().integrations.gifs.klipy.apiKeyPath; + if (!(path && (await fs.stat(path)))) { + console.warn("[KlipyGifProvider] Klipy integration is enabled but no API key was provided, disabling..."); + this.available = false; + return; + } + apiKey = (await fs.readFile(path, "utf-8")).trim(); + } + + this.#apiKey = apiKey; + console.log("[KlipyGifProvider] Hellorld!"); + } + + async search(query: { q: string; limit?: number; media_format: string; locale: string }): Promise { + query.media_format ??= "gif"; + query.locale ??= "en"; + const response = await fetch(`https://api.klipy.com/api/v1/${this.#apiKey}/gifs/search?q=${query.q}&locale=${query.locale}`, { + method: "get", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) console.log(response, await response.text()); + const responseData = (await response.json()) as KlipyTrendingResponse; + return responseData.data.data.map(this.convertGifResult); + } + + async getTrendingCategories(query: { locale: string }): Promise { + return await this.#trendingCategoryCache.getOrUpdate(async () => { + // query.media_format ??= "gif"; + query.locale ??= "en"; + const response = await fetch(`https://api.klipy.com/api/v1/${this.#apiKey}/gifs/categories?locale=${query.locale}`, { + method: "get", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) console.log(response, await response.text()); + const responseData = (await response.json()) as KlipyCategoriesResponse; + return responseData.data.categories.map((x) => ({ + name: x.query, + src: x.preview_url, + })) satisfies GifTrendingCategory[]; + }); + } + + async getTrendingGifs(query: { media_format: string; locale: string }): Promise { + return await this.#trendingGifsCache.getOrUpdate(async () => { + // query.media_format ??= "gif"; + query.locale ??= "en"; + const response = await fetch(`https://api.klipy.com/api/v1/${this.#apiKey}/gifs/trending?locale=${query.locale}`, { + method: "get", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + if (!response.ok) console.log(response, await response.text()); + const responseData = (await response.json()) as KlipyTrendingResponse; + return responseData.data.data.map(this.convertGifResult); + }); + } + + private convertGifResult(result: KlipyMediaItem) { + return { + id: result.id.toString(), + title: result.title, + url: result.slug, //? + src: result.file.hd.mp4.url, + gif_src: result.file.hd.gif.url, + width: result.file.hd.gif.width, + height: result.file.hd.gif.height, + preview: result.file.hd.gif.url, + } satisfies GifResponse; + } +} + +// API response types + +interface KlipyCategoriesResponse { + result: boolean; + data: { locale: string; categories: KlipyCategory[] }; +} + +interface KlipyCategory { + category: string; + query: string; + preview_url: string; +} + +interface KlipyTrendingResponse { + result: boolean; + data: { current_page: number; per_page: number; has_next: boolean; data: KlipyMediaItem[] }; +} + +interface KlipyMediaItem { + id: number; + slug: string; + title: string; + file: KlipyFile; + tags: string[]; + type: string; + blur_preview: string; +} + +interface KlipyFile { + hd: KlipyFileSize; + md: KlipyFileSize; + sm: KlipyFileSize; + xs: KlipyFileSize; +} + +interface KlipyFileSize { + gif: KlipyFileInfo; + webp: KlipyFileInfo; + jpg: KlipyFileInfo; + mp4: KlipyFileInfo; + webm: KlipyFileInfo; +} + +interface KlipyFileInfo { + url: string; + width: number; + height: number; + size: number; +} diff --git a/src/integrations/gifs/providers/TenorGifProvider.ts b/src/integrations/gifs/providers/TenorGifProvider.ts deleted file mode 100644 index 11f5cd553..000000000 --- a/src/integrations/gifs/providers/TenorGifProvider.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* - 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 { GifsResponse, GifTrendingCategory, GifMediaTypes } from "@spacebar/schemas"; -import { Config } from "@spacebar/util"; -import { SingletonCache, TimeSpan } from "@spacebar/extensions"; - -const TRENDING_CATEGORIES_CACHE_DURATION = TimeSpan.fromSeconds(24 * 60 * 60); // 1 day -const TRENDING_GIFS_CACHE_DURATION = TimeSpan.fromSeconds(60 * 60); // 1 hour - -// Tenor V1 API... Not yet shut down as of writing, but is going soon... -export default class TenorGifProvider implements IGifProvider { - id = "tenor"; - available = true; - #apiKey: string; - #trendingCategoryCache = new SingletonCache(TRENDING_CATEGORIES_CACHE_DURATION); - #trendingGifsCache = new SingletonCache(TRENDING_GIFS_CACHE_DURATION); - - 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 responseData = (await response.json()) as TenorSearchResults; - return responseData.results.map(this.convertGifResult); - } - - async getTrendingCategories(query: { media_format: string; locale: string }): Promise { - return await this.#trendingCategoryCache.getOrUpdate(async () => { - const response = await fetch(`https://g.tenor.com/v1/categories?locale=${query.locale}&key=${this.#apiKey}`, { - method: "get", - headers: { "Content-Type": "application/json" }, - }); - - const responseData = (await response.json()) as TenorCategoriesResults; - return responseData.tags.map((x) => ({ - name: x.searchterm, - src: x.image, - })) satisfies GifTrendingCategory[]; - }); - } - - async getTrendingGifs(query: { media_format: string; locale: string }): Promise { - return await this.#trendingGifsCache.getOrUpdate(async () => { - const response = await fetch(`https://g.tenor.com/v1/trending?media_format=${query.media_format}&locale=${query.locale}&key=${this.#apiKey}`, { - method: "get", - headers: { "Content-Type": "application/json" }, - }); - - console.log(response); - const responseData = (await response.json()) as TenorTrendingResults; - return responseData.results.map(this.convertGifResult); - }); - } - - 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, - }; - } -} - -// API response types - -type TenorGif = { - created: number; - hasaudio: boolean; - id: string; - media: { [type in keyof typeof GifMediaTypes]: TenorMedia }[]; - tags: string[]; - title: string; - itemurl: string; - hascaption: boolean; - url: string; -}; - -type TenorMedia = { - preview: string; - url: string; - dims: number[]; - size: number; -}; - -type TenorCategory = { - searchterm: string; - path: string; - image: string; - name: string; -}; - -type TenorCategoriesResults = { - tags: TenorCategory[]; -}; - -type TenorTrendingResults = { - next: string; - results: TenorGif[]; - locale: string; -}; - -type TenorSearchResults = { - next: string; - results: TenorGif[]; -}; diff --git a/src/util/config/Config.ts b/src/util/config/Config.ts index 7549b8b6a..3790b7416 100644 --- a/src/util/config/Config.ts +++ b/src/util/config/Config.ts @@ -28,6 +28,7 @@ import { GeneralConfiguration, GifConfiguration, GuildConfiguration, + IntegrationConfiguration, LimitsConfiguration, LoginConfiguration, OffloadConfiguration, @@ -63,4 +64,5 @@ export class ConfigValue { offload: OffloadConfiguration = new OffloadConfiguration(); components = new ComponentConfiguration(); embeds = new EmbedConfiguration(); + integrations = new IntegrationConfiguration(); } diff --git a/src/util/config/types/IntegrationConfiguration.ts b/src/util/config/types/IntegrationConfiguration.ts new file mode 100644 index 000000000..b83396135 --- /dev/null +++ b/src/util/config/types/IntegrationConfiguration.ts @@ -0,0 +1,31 @@ +/* + 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 . +*/ + +export class IntegrationConfiguration { + gifs: GifIntegrationConfiguration = new GifIntegrationConfiguration(); +} + +export class GifIntegrationConfiguration { + klipy: GenericGifIntegrationConfiguration = new GenericGifIntegrationConfiguration(); +} + +export class GenericGifIntegrationConfiguration { + enabled: boolean = false; + apiKey?: string; + apiKeyPath?: string; +} diff --git a/src/util/config/types/index.ts b/src/util/config/types/index.ts index 872b590b2..ff6d3f095 100644 --- a/src/util/config/types/index.ts +++ b/src/util/config/types/index.ts @@ -25,6 +25,7 @@ export * from "./ExternalTokensConfiguration"; export * from "./GeneralConfiguration"; export * from "./GifConfiguration"; export * from "./GuildConfiguration"; +export * from "./IntegrationConfiguration"; export * from "./LimitConfigurations"; export * from "./OffloadConfiguration"; export * from "./LoginConfiguration";