// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { z } from 'zod'; import type { Simplify } from 'type-fest'; import { strictAssert } from '../../../util/assert'; import { parseUnknown } from '../../../util/schemas'; import { fetchInSegments } from './segments'; const BASE_URL = 'https://tenor.googleapis.com/v2'; const API_KEY = 'AIzaSyBt6SUfSsCQic2P2VkNkLjsGI7HGWZI95g'; /** * Types */ export type TenorNextCursor = string & { TenorHasNextCursor: never }; export type TenorTailCursor = ('0' | '') & { TenorHasEndCursor: never }; export type TenorCursor = TenorNextCursor | TenorTailCursor; const TenorCursorSchema = z.custom( (value: unknown) => { return typeof value === 'string'; }, input => { return { message: `Expected tenor cursor, got: ${input}` }; } ); export type TenorContentFormat = string; export type TenorContentFilter = 'off' | 'low' | 'medium' | 'high'; export type TenorAspectRatioFilter = 'all' | 'wide' | 'standard'; export type TenorSearchFilter = 'sticker' | 'static' | '-static'; export function isTenorTailCursor( cursor: TenorCursor ): cursor is TenorTailCursor { return cursor === '0' || cursor === ''; } /** * Params */ type TenorApiParams = Readonly<{ key: string; client_key?: string; }>; type TenorLocalizationParams = Readonly<{ country?: string; locale?: string; }>; type TenorSearchFilterParams = Readonly<{ searchfilter?: ReadonlyArray; media_filter?: ReadonlyArray; ar_range?: TenorAspectRatioFilter; }>; type TenorContentFilterParams = Readonly<{ contentfilter?: TenorContentFilter; }>; type TenorPaginationParams = Readonly<{ limit?: number; pos?: TenorNextCursor; }>; /** * Response Schemas */ const TenorResponseCategorySchema = z.object({ searchterm: z.string(), path: z.string(), image: z.string(), name: z.string(), }); const TenorResponseMediaSchema = z.object({ url: z.string(), dims: z.array(z.number()), duration: z.number(), size: z.number(), }); const TenorResponseResultSchema = z.object({ created: z.number(), hasaudio: z.boolean(), id: z.string(), media_formats: z.record(TenorResponseMediaSchema), tags: z.array(z.string()), title: z.string(), content_description: z.string(), itemurl: z.string(), hascaption: z.boolean().optional(), flags: z.array(z.string()), bg_color: z.string().optional(), url: z.string(), }); export type TenorResponseCategory = z.infer; export type TenorResponseMedia = z.infer; export type TenorResponseResult = z.infer; export type TenorPaginatedResponse = Readonly<{ next: TenorCursor; results: ReadonlyArray; }>; /** * Endpoints */ type TenorEndpoints = Readonly<{ 'v2/search': { params: Simplify< TenorApiParams & TenorLocalizationParams & TenorSearchFilterParams & TenorContentFilterParams & TenorPaginationParams & Readonly<{ q: string; random?: boolean; }> >; response: TenorPaginatedResponse; }; 'v2/featured': { params: Simplify< TenorApiParams & TenorLocalizationParams & TenorSearchFilterParams & TenorContentFilterParams & TenorPaginationParams >; response: TenorPaginatedResponse; }; 'v2/categories': { params: Simplify< TenorApiParams & TenorLocalizationParams & TenorContentFilterParams & { type: 'featured' | 'trending'; } >; response: { tags: ReadonlyArray; }; }; // ignored // 'v2/search_suggestions' // 'v2/autocomplete' // 'v2/trending_terms' // 'v2/registershare': {}, // 'v2/posts': {}, }>; /** * Response Schemas */ type ResponseSchemaMapType = Readonly<{ [Path in keyof TenorEndpoints]: z.ZodSchema; }>; const ResponseSchemaMap: ResponseSchemaMapType = { 'v2/search': z.object({ next: TenorCursorSchema, results: z.array(TenorResponseResultSchema), }), 'v2/featured': z.object({ next: TenorCursorSchema, results: z.array(TenorResponseResultSchema), }), 'v2/categories': z.object({ tags: z.array(TenorResponseCategorySchema), }), }; /** * Tenor API Client * * ```ts * const response = await tenor('v2/search', { * q: 'hello', * limit: 10, * }); * // >> { next: '...', results: [...] } * ```` */ export async function tenor( path: Path, params: Omit, signal?: AbortSignal ): Promise { const { messaging } = window.textsecure; strictAssert(messaging, 'Missing window.textsecure.messaging'); const schema = ResponseSchemaMap[path]; strictAssert(schema, 'Missing schema'); const url = new URL(path, BASE_URL); // Always add the API key url.searchParams.set('key', API_KEY); for (const [key, value] of Object.entries(params)) { if (value == null) { continue; } // Note: Tenor formats arrays as comma-separated strings const param = Array.isArray(value) ? value.join(',') : `${value}`; url.searchParams.set(key, param); } const response = await messaging.server.fetchJsonViaProxy({ method: 'GET', url: url.toString(), signal, }); const result = parseUnknown(schema, response.data); return result; } export function tenorDownload( tenorCdnUrl: string, signal?: AbortSignal ): Promise { return fetchInSegments(tenorCdnUrl, signal); }