diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/GifTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/GifTests.cs
index f4badbea..765108f8 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<string[]> GifProviders() => [["tenor"]];
+ public static IEnumerable<string[]> GifProviders() => [["klipy"]];
public static IEnumerable<object[]> 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<List<GifItem>>(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<TrendingGifsResult>(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<List<GifItem>>(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 00000000..8bcc08a6
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+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<GifTrendingCategory[]>(TRENDING_CATEGORIES_CACHE_DURATION);
+ #trendingGifsCache = new SingletonCache<GifsResponse>(TRENDING_GIFS_CACHE_DURATION);
+
+ async init(): Promise<void> {
+ 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<GifsResponse> {
+ 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<GifTrendingCategory[]> {
+ 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<GifsResponse> {
+ 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 11f5cd55..00000000
--- 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 <https://www.gnu.org/licenses/>.
-*/
-
-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<GifTrendingCategory[]>(TRENDING_CATEGORIES_CACHE_DURATION);
- #trendingGifsCache = new SingletonCache<GifsResponse>(TRENDING_GIFS_CACHE_DURATION);
-
- async init(): Promise<void> {
- 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<GifsResponse> {
- 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<GifTrendingCategory[]> {
- 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<GifsResponse> {
- 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 7549b8b6..3790b741 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 00000000..b8339613
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+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 872b590b..ff6d3f09 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";
|