diff --git a/.env b/.env index 5b3e3d41c7..c3f98116a4 100644 --- a/.env +++ b/.env @@ -95,3 +95,5 @@ EMPLOYMENT_AGREEMENT_BUCKET_NAME=other-bucket-name MAPBOX_GEOCODING_URL=https://api.mapbox.com/search/geocode/v6/forward MAPBOX_ACCESS_TOKEN=topsecret + +TENOR_GIF_SEARCH_URL=https://tenor.googleapis.com/v2/search diff --git a/.infra/Pulumi.prod.yaml b/.infra/Pulumi.prod.yaml index 94ce82a05f..c321a99b74 100644 --- a/.infra/Pulumi.prod.yaml +++ b/.infra/Pulumi.prod.yaml @@ -25,7 +25,7 @@ config: api:enablePersonalizedDigest: 'true' api:env: anthropicApiUrl: https://api.anthropic.com/v1/messages - anthropicVersion: "2023-06-01" + anthropicVersion: '2023-06-01' anthropicApiKey: secure: AAABAHEzcSWbWl8xVDhOLgPCopvQnihNX6MIzyG/JbaYeGA3p1qrJ3UEQhOvBg7kMoIbx9u+0CRC102IldakBAlxx0l9l9kCDSE/JqqfCfjiLT5mjdLISRE5q2dQz+MaqqVzqm0MzaIQQkWBmOxeJAxRxQ/+/dWdla8RMWKG+Q7PnrH8vS8PeNKASRQ= accessSecret: @@ -191,6 +191,9 @@ config: mapboxGeocodingUrl: https://api.mapbox.com/search/geocode/v6/forward slackBotToken: secure: AAABAH+UKbv4/Uoc9jYySYeAr7m+W7OCm/kQa9/3LCrKURh3TcPqgNPqF1ugLg31AAfsT4qVafpb0jiZm+ZCfDTYzrCfPmebxLjV0AAkHAy3kHgLK1v6YNGH + tenorApiKey: + secure: AAABAApa5GEV473b7T+WtKazqzPbyQDc7qFT3SZjqmU1LL1n24jwcpn2/bwRINsUz4vwduduqh0/0ey1JpoWfEFJ1LvxlLk= + tenorGifSearchUrl: https://tenor.googleapis.com/v2/search gondulOpportunityServerOrigin: secure: AAABADfUUbSK5WvKYJM6lgpfvaPChqDYdUolX6Kv6/TMy0D7hWJWPbkipmq9W8vXbkuM97XzU2RlJGFB/9eiVfEdB6jBvu2C9iu5GbJ276481jB8Q+lw1do= api:k8s: diff --git a/__tests__/integrations/tenor/client.ts b/__tests__/integrations/tenor/client.ts new file mode 100644 index 0000000000..ccb24159e6 --- /dev/null +++ b/__tests__/integrations/tenor/client.ts @@ -0,0 +1,296 @@ +import nock from 'nock'; +import { TenorClient } from '../../../src/integrations/tenor/clients'; +import { GarmrNoopService } from '../../../src/integrations/garmr'; +import { + deleteKeysByPattern, + getRedisObject, + getRedisObjectExpiry, +} from '../../../src/redis'; + +const TENOR_API_URL = process.env.TENOR_GIF_SEARCH_URL!; +const TENOR_SEARCH_PATH = '/v2/search'; + +describe('TenorClient', () => { + const API_KEY = 'test-api-key'; + let client: TenorClient; + + beforeAll(() => { + process.env.TENOR_GIF_SEARCH_URL = `${TENOR_API_URL}${TENOR_SEARCH_PATH}`; + }); + + beforeEach(async () => { + nock.cleanAll(); + await deleteKeysByPattern('tenor:search:*'); + client = new TenorClient(API_KEY, { garmr: new GarmrNoopService() }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(async () => { + await deleteKeysByPattern('tenor:search:*'); + }); + + describe('search', () => { + const mockTenorResponse = { + results: [ + { + id: 'gif1', + title: 'Funny cat', + content_description: 'A funny cat', + url: 'https://tenor.com/gif1', + media_formats: { + gif: { url: 'https://media.tenor.com/gif1.gif' }, + mediumgif: { url: 'https://media.tenor.com/gif1-medium.gif' }, + }, + }, + { + id: 'gif2', + title: 'Dancing dog', + content_description: 'A dancing dog', + url: 'https://tenor.com/gif2', + media_formats: { + gif: { url: 'https://media.tenor.com/gif2.gif' }, + }, + }, + ], + next: 'next-page-token', + }; + + it('should return empty result for empty query', async () => { + const result = await client.search({ q: '' }); + + expect(result).toEqual({ gifs: [], next: undefined }); + }); + + it('should fetch from API on cache miss', async () => { + const scope = nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'cats', + key: API_KEY, + limit: '10', + }) + .reply(200, mockTenorResponse); + + const result = await client.search({ q: 'cats' }); + + expect(scope.isDone()).toBe(true); + expect(result.gifs).toHaveLength(2); + expect(result.gifs[0]).toEqual({ + id: 'gif1', + url: 'https://media.tenor.com/gif1.gif', + preview: 'https://media.tenor.com/gif1-medium.gif', + title: 'A funny cat', + }); + expect(result.next).toBe('next-page-token'); + }); + + it('should cache results after API call', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'dogs', + key: API_KEY, + limit: '10', + }) + .reply(200, mockTenorResponse); + + await client.search({ q: 'dogs' }); + + const cached = await getRedisObject('tenor:search:dogs:10'); + expect(cached).not.toBeNull(); + + const parsedCache = JSON.parse(cached!); + expect(parsedCache.gifs).toHaveLength(2); + expect(parsedCache.next).toBe('next-page-token'); + }); + + it('should return cached result on cache hit without calling API', async () => { + // First call - should hit API + const scope = nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'birds', + key: API_KEY, + limit: '10', + }) + .reply(200, mockTenorResponse); + + await client.search({ q: 'birds' }); + expect(scope.isDone()).toBe(true); + + // Second call - should use cache, not API + const secondScope = nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'birds', + key: API_KEY, + limit: '10', + }) + .reply(200, { results: [], next: undefined }); + + const result = await client.search({ q: 'birds' }); + + // API should NOT have been called + expect(secondScope.isDone()).toBe(false); + // Should return cached result + expect(result.gifs).toHaveLength(2); + expect(result.next).toBe('next-page-token'); + }); + + it('should cache with 3 hour TTL', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'fish', + key: API_KEY, + limit: '10', + }) + .reply(200, mockTenorResponse); + + await client.search({ q: 'fish' }); + + const ttl = await getRedisObjectExpiry('tenor:search:fish:10'); + const threeHoursInSeconds = 3 * 60 * 60; + + // TTL should be approximately 3 hours (allow 10 seconds tolerance) + expect(ttl).toBeLessThanOrEqual(threeHoursInSeconds); + expect(ttl).toBeGreaterThanOrEqual(threeHoursInSeconds - 10); + }); + + it('should NOT cache rate limited responses', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'ratelimited', + key: API_KEY, + limit: '10', + }) + .reply(429); + + const result = await client.search({ q: 'ratelimited' }); + + expect(result).toEqual({ gifs: [], next: undefined }); + + const cached = await getRedisObject('tenor:search:ratelimited:10'); + expect(cached).toBeNull(); + }); + + it('should preserve pagination position when rate limited', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'test', + key: API_KEY, + limit: '10', + pos: 'page-2', + }) + .reply(429); + + const result = await client.search({ q: 'test', pos: 'page-2' }); + + expect(result).toEqual({ gifs: [], next: 'page-2' }); + }); + + it('should use separate cache keys for different pagination positions', async () => { + // First page + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'animals', + key: API_KEY, + limit: '10', + }) + .reply(200, { + results: [mockTenorResponse.results[0]], + next: 'page-2', + }); + + // Second page + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'animals', + key: API_KEY, + limit: '10', + pos: 'page-2', + }) + .reply(200, { + results: [mockTenorResponse.results[1]], + next: 'page-3', + }); + + const page1 = await client.search({ q: 'animals' }); + const page2 = await client.search({ q: 'animals', pos: 'page-2' }); + + expect(page1.gifs).toHaveLength(1); + expect(page1.gifs[0].id).toBe('gif1'); + expect(page1.next).toBe('page-2'); + + expect(page2.gifs).toHaveLength(1); + expect(page2.gifs[0].id).toBe('gif2'); + expect(page2.next).toBe('page-3'); + + // Verify separate cache keys + const cachedPage1 = await getRedisObject('tenor:search:animals:10'); + const cachedPage2 = await getRedisObject( + 'tenor:search:animals:10:page-2', + ); + + expect(cachedPage1).not.toBeNull(); + expect(cachedPage2).not.toBeNull(); + expect(JSON.parse(cachedPage1!).next).toBe('page-2'); + expect(JSON.parse(cachedPage2!).next).toBe('page-3'); + }); + + it('should use separate cache keys for different limits', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'test', + key: API_KEY, + limit: '5', + }) + .reply(200, mockTenorResponse); + + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'test', + key: API_KEY, + limit: '20', + }) + .reply(200, mockTenorResponse); + + await client.search({ q: 'test', limit: 5 }); + await client.search({ q: 'test', limit: 20 }); + + const cached5 = await getRedisObject('tenor:search:test:5'); + const cached20 = await getRedisObject('tenor:search:test:20'); + + expect(cached5).not.toBeNull(); + expect(cached20).not.toBeNull(); + }); + + it('should throw error on API failure (non-429)', async () => { + nock(TENOR_API_URL) + .get(TENOR_SEARCH_PATH) + .query({ + q: 'error', + key: API_KEY, + limit: '10', + }) + .reply(500, 'Internal Server Error'); + + await expect(client.search({ q: 'error' })).rejects.toThrow( + 'Tenor API error: 500 Internal Server Error', + ); + + // Should not cache error responses + const cached = await getRedisObject('tenor:search:error:10'); + expect(cached).toBeNull(); + }); + }); +}); diff --git a/__tests__/routes/gifs.ts b/__tests__/routes/gifs.ts new file mode 100644 index 0000000000..ec2f41d467 --- /dev/null +++ b/__tests__/routes/gifs.ts @@ -0,0 +1,353 @@ +import appFunc from '../../src'; +import { FastifyInstance } from 'fastify'; +import { authorizeRequest, saveFixtures } from '../helpers'; +import { User } from '../../src/entity'; +import { + UserIntegrationGif, + UserIntegrationType, +} from '../../src/entity/UserIntegration'; +import { usersFixture } from '../fixture'; +import { DataSource } from 'typeorm'; +import createOrGetConnection from '../../src/db'; +import request from 'supertest'; +import { tenorClient } from '../../src/integrations/tenor/clients'; + +let app: FastifyInstance; +let con: DataSource; + +jest.mock('../../src/integrations/tenor/clients', () => ({ + tenorClient: { + search: jest.fn(), + }, +})); + +const mockTenorSearch = tenorClient.search as jest.Mock; + +beforeAll(async () => { + con = await createOrGetConnection(); + app = await appFunc(); + return app.ready(); +}); + +afterAll(() => app.close()); + +beforeEach(async () => { + jest.resetAllMocks(); + await saveFixtures(con, User, usersFixture); +}); + +describe('GET /gifs', () => { + it('should return 401 when user is not authenticated', async () => { + await request(app.server).get('/gifs').expect(401); + }); + + it('should return empty gifs when no query is provided', async () => { + mockTenorSearch.mockResolvedValue({ gifs: [], next: undefined }); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs'), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [], next: undefined }); + expect(mockTenorSearch).toHaveBeenCalledWith({ + q: '', + limit: 10, + pos: undefined, + }); + }); + + it('should return gifs from tenor search', async () => { + const mockGifs = [ + { + id: 'gif1', + url: 'https://tenor.com/gif1.gif', + preview: 'https://tenor.com/gif1-preview.gif', + title: 'Funny cat', + }, + { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }, + ]; + + mockTenorSearch.mockResolvedValue({ + gifs: mockGifs, + next: 'next-page-token', + }); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs').query({ q: 'funny', limit: '20' }), + '1', + ).expect(200); + + expect(body).toEqual({ + gifs: mockGifs, + next: 'next-page-token', + }); + expect(mockTenorSearch).toHaveBeenCalledWith({ + q: 'funny', + limit: 20, + pos: undefined, + }); + }); + + it('should pass pagination position to tenor search', async () => { + mockTenorSearch.mockResolvedValue({ gifs: [], next: undefined }); + + await authorizeRequest( + request(app.server).get('/gifs').query({ q: 'test', pos: 'page-token' }), + '1', + ).expect(200); + + expect(mockTenorSearch).toHaveBeenCalledWith({ + q: 'test', + limit: 10, + pos: 'page-token', + }); + }); + + it('should return empty gifs when tenor search fails', async () => { + mockTenorSearch.mockRejectedValue(new Error('Tenor API error')); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs').query({ q: 'test' }), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [], next: undefined }); + }); + + it('should preserve pagination position when rate limited', async () => { + // When rate limited, the client returns empty gifs but preserves the position + // so the user can retry the same page + mockTenorSearch.mockResolvedValue({ gifs: [], next: 'page-2' }); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs').query({ q: 'test', pos: 'page-2' }), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [], next: 'page-2' }); + }); +}); + +describe('POST /gifs/favorite', () => { + const gifToFavorite = { + id: 'gif1', + url: 'https://tenor.com/gif1.gif', + preview: 'https://tenor.com/gif1-preview.gif', + title: 'Funny cat', + }; + + it('should return 401 when user is not authenticated', async () => { + await request(app.server) + .post('/gifs/favorite') + .send(gifToFavorite) + .expect(401); + }); + + it('should add a gif to favorites for authenticated user', async () => { + const { body } = await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(1); + expect(body.gifs[0]).toMatchObject(gifToFavorite); + + const saved = await con.getRepository(UserIntegrationGif).findOne({ + where: { userId: '1', type: UserIntegrationType.Gif }, + }); + expect(saved?.meta.favorites).toHaveLength(1); + expect(saved?.meta.favorites[0]).toMatchObject(gifToFavorite); + }); + + it('should add multiple gifs to favorites', async () => { + const gif2 = { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }; + + await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + const { body } = await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gif2), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(2); + expect(body.gifs).toEqual( + expect.arrayContaining([ + expect.objectContaining(gifToFavorite), + expect.objectContaining(gif2), + ]), + ); + }); + + it('should remove a gif from favorites when already favorited (toggle)', async () => { + await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + const { body } = await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(0); + + const saved = await con.getRepository(UserIntegrationGif).findOne({ + where: { userId: '1', type: UserIntegrationType.Gif }, + }); + expect(saved?.meta.favorites).toHaveLength(0); + }); + + it('should keep favorites separate per user', async () => { + await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + const gif2 = { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }; + + await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gif2), + '2', + ).expect(200); + + const user1Favorites = await con.getRepository(UserIntegrationGif).findOne({ + where: { userId: '1', type: UserIntegrationType.Gif }, + }); + const user2Favorites = await con.getRepository(UserIntegrationGif).findOne({ + where: { userId: '2', type: UserIntegrationType.Gif }, + }); + + expect(user1Favorites?.meta.favorites).toHaveLength(1); + expect(user1Favorites?.meta.favorites[0].id).toBe('gif1'); + expect(user2Favorites?.meta.favorites).toHaveLength(1); + expect(user2Favorites?.meta.favorites[0].id).toBe('gif2'); + }); + + it('should return empty gifs on database error', async () => { + const repo = con.getRepository(UserIntegrationGif); + jest.spyOn(repo, 'findOne').mockRejectedValueOnce(new Error('DB error')); + + const { body } = await authorizeRequest( + request(app.server).post('/gifs/favorite').send(gifToFavorite), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [] }); + }); +}); + +describe('GET /gifs/favorites', () => { + it('should return 401 when user is not authenticated', async () => { + await request(app.server).get('/gifs/favorites').expect(401); + }); + + it('should return empty array when user has no favorites', async () => { + const { body } = await authorizeRequest( + request(app.server).get('/gifs/favorites'), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [] }); + }); + + it('should return user favorites', async () => { + const gif1 = { + id: 'gif1', + url: 'https://tenor.com/gif1.gif', + preview: 'https://tenor.com/gif1-preview.gif', + title: 'Funny cat', + }; + const gif2 = { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }; + + await con.getRepository(UserIntegrationGif).insert({ + userId: '1', + type: UserIntegrationType.Gif, + meta: { favorites: [gif1, gif2] }, + }); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs/favorites'), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(2); + expect(body.gifs).toEqual( + expect.arrayContaining([ + expect.objectContaining(gif1), + expect.objectContaining(gif2), + ]), + ); + }); + + it('should only return favorites for the authenticated user', async () => { + const gif1 = { + id: 'gif1', + url: 'https://tenor.com/gif1.gif', + preview: 'https://tenor.com/gif1-preview.gif', + title: 'Funny cat', + }; + const gif2 = { + id: 'gif2', + url: 'https://tenor.com/gif2.gif', + preview: 'https://tenor.com/gif2-preview.gif', + title: 'Dancing dog', + }; + + await con.getRepository(UserIntegrationGif).insert([ + { + userId: '1', + type: UserIntegrationType.Gif, + meta: { favorites: [gif1] }, + }, + { + userId: '2', + type: UserIntegrationType.Gif, + meta: { favorites: [gif2] }, + }, + ]); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs/favorites'), + '1', + ).expect(200); + + expect(body.gifs).toHaveLength(1); + expect(body.gifs[0].id).toBe('gif1'); + }); + + it('should return empty gifs on database error', async () => { + const repo = con.getRepository(UserIntegrationGif); + jest.spyOn(repo, 'find').mockRejectedValueOnce(new Error('DB error')); + + const { body } = await authorizeRequest( + request(app.server).get('/gifs/favorites'), + '1', + ).expect(200); + + expect(body).toEqual({ gifs: [] }); + }); +}); diff --git a/src/entity/UserIntegration.ts b/src/entity/UserIntegration.ts index 6250ba9cd8..69bc44ffc1 100644 --- a/src/entity/UserIntegration.ts +++ b/src/entity/UserIntegration.ts @@ -13,6 +13,7 @@ import type { User } from './user/User'; export enum UserIntegrationType { Slack = 'slack', + Gif = 'gif', } export type IntegrationMetaSlack = { @@ -25,6 +26,13 @@ export type IntegrationMetaSlack = { teamName: string; }; +export type Gif = { + id: string; + url: string; + preview: string; + title: string; +}; + @Entity() @TableInheritance({ column: { type: 'text', name: 'type' }, @@ -61,3 +69,11 @@ export class UserIntegrationSlack extends UserIntegration { @Column({ type: 'jsonb', default: {} }) meta: IntegrationMetaSlack; } + +@ChildEntity(UserIntegrationType.Gif) +export class UserIntegrationGif extends UserIntegration { + @Column({ type: 'jsonb', default: {} }) + meta: { + favorites: Gif[]; + }; +} diff --git a/src/integrations/tenor/clients.ts b/src/integrations/tenor/clients.ts new file mode 100644 index 0000000000..33eb5164df --- /dev/null +++ b/src/integrations/tenor/clients.ts @@ -0,0 +1,123 @@ +import fetch from 'node-fetch'; +import { GarmrService, IGarmrService, GarmrNoopService } from '../garmr'; +import type { Gif } from '../../entity/UserIntegration'; +import { + ITenorClient, + TenorSearchParams, + TenorSearchResult, + TenorSearchResponse, + TenorGif, +} from './types'; +import { getRedisObject, setRedisObjectWithExpiry } from '../../redis'; + +const TENOR_CACHE_TTL_SECONDS = 3 * 60 * 60; // 3 hours +const TENOR_CACHE_KEY_PREFIX = 'tenor:search'; + +const generateCacheKey = (params: TenorSearchParams): string => { + const { q, limit = 10, pos } = params; + const parts = [TENOR_CACHE_KEY_PREFIX, q, limit.toString()]; + if (pos) { + parts.push(pos); + } + return parts.join(':'); +}; + +export class TenorClient implements ITenorClient { + private readonly apiKey: string; + public readonly garmr: IGarmrService; + + constructor( + apiKey: string, + options?: { + garmr?: IGarmrService; + }, + ) { + this.apiKey = apiKey; + this.garmr = options?.garmr || new GarmrNoopService(); + } + + async search(params: TenorSearchParams): Promise { + const { q, limit = 10, pos } = params; + + if (!q) { + return { gifs: [], next: undefined }; + } + + const cacheKey = generateCacheKey(params); + + const cached = await getRedisObject(cacheKey); + if (cached) { + return JSON.parse(cached) as TenorSearchResult; + } + + return this.garmr.execute(async () => { + const searchParams = new URLSearchParams({ + q, + key: this.apiKey, + limit: limit.toString(), + }); + + if (pos) { + searchParams.append('pos', pos); + } + + const response = await fetch( + `${process.env.TENOR_GIF_SEARCH_URL}?${searchParams.toString()}`, + ); + + if (response.status === 429) { + // if rate limited, return empty result but preserve pagination position + return { gifs: [], next: pos }; + } + + if (!response.ok) { + throw new Error( + `Tenor API error: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as TenorSearchResponse; + + const gifs: Gif[] = data.results.map((item: TenorGif) => ({ + id: item.id, + url: item.media_formats.gif?.url || '', + preview: + item.media_formats.mediumgif?.url || + item.media_formats.gif?.url || + '', + title: item.content_description || item.title || '', + })); + + const result: TenorSearchResult = { + gifs, + next: data.next, + }; + + await setRedisObjectWithExpiry( + cacheKey, + JSON.stringify(result), + TENOR_CACHE_TTL_SECONDS, + ); + + return result; + }); + } +} + +const garmrTenorService = new GarmrService({ + service: 'tenor', + breakerOpts: { + halfOpenAfter: 5 * 1000, + threshold: 0.1, + duration: 10 * 1000, + minimumRps: 0, + }, + retryOpts: { + maxAttempts: 2, + backoff: 100, + }, +}); + +export const tenorClient = new TenorClient(process.env.TENOR_API_KEY!, { + garmr: garmrTenorService, +}); diff --git a/src/integrations/tenor/types.ts b/src/integrations/tenor/types.ts new file mode 100644 index 0000000000..0a7ea6a815 --- /dev/null +++ b/src/integrations/tenor/types.ts @@ -0,0 +1,34 @@ +import { IGarmrClient } from '../garmr'; +import type { Gif } from '../../entity/UserIntegration'; + +export type TenorMediaFormat = { + url?: string; +}; + +export type TenorGif = { + id: string; + title: string; + media_formats: Record; + content_description: string; + url: string; +}; + +export type TenorSearchResponse = { + results: TenorGif[]; + next?: string; +}; + +export type TenorSearchParams = { + q: string; + limit?: number; + pos?: string; +}; + +export type TenorSearchResult = { + gifs: Gif[]; + next?: string; +}; + +export interface ITenorClient extends IGarmrClient { + search(params: TenorSearchParams): Promise; +} diff --git a/src/routes/gifs.ts b/src/routes/gifs.ts new file mode 100644 index 0000000000..38f2691bd7 --- /dev/null +++ b/src/routes/gifs.ts @@ -0,0 +1,120 @@ +import type { FastifyInstance } from 'fastify'; +import createOrGetConnection from '../db'; +import { + UserIntegrationGif, + UserIntegrationType, + type Gif, +} from '../entity/UserIntegration'; +import { logger } from '../logger'; +import { tenorClient } from '../integrations/tenor/clients'; + +export default async function (fastify: FastifyInstance): Promise { + fastify.get('/', async (req, res) => { + if (!req.userId) { + return res.status(401).send(); + } + + try { + const query = req.query as { q?: string; limit?: string; pos?: string }; + const q = query.q ?? ''; + const limit = parseInt(query.limit ?? '10', 10); + const pos = query.pos; + + const result = await tenorClient.search({ q, limit, pos }); + + return res.send(result); + } catch (err) { + logger.error({ err }, 'Error searching gifs'); + return res.send({ gifs: [], next: undefined }); + } + }); + fastify.post('/favorite', async (req, res) => { + if (!req.userId) { + return res.status(401).send(); + } + + try { + const con = await createOrGetConnection(); + + const existingFavorites = await con + .getRepository(UserIntegrationGif) + .findOne({ + where: { + userId: req.userId, + type: UserIntegrationType.Gif, + }, + }); + + const gifToToggle = req.body as Gif; + const gifs: Gif[] = []; + if (existingFavorites?.meta) { + gifs.push(...existingFavorites.meta.favorites); + } + + const existingIndex = gifs.findIndex((g) => g.id === gifToToggle.id); + + if (existingIndex !== -1) { + gifs.splice(existingIndex, 1); + } else { + gifs.push(gifToToggle); + } + + if (existingFavorites) { + await con.getRepository(UserIntegrationGif).update( + { + userId: req.userId, + type: UserIntegrationType.Gif, + }, + { + meta: { + favorites: gifs, + }, + }, + ); + } else { + await con.getRepository(UserIntegrationGif).insert({ + userId: req.userId, + type: UserIntegrationType.Gif, + meta: { + favorites: gifs, + }, + }); + } + + return res.send({ gifs }); + } catch (err) { + logger.error({ err }, 'Error toggling favorite gif'); + return res.send({ gifs: [] }); + } + }); + fastify.get('/favorites', async (req, res) => { + if (!req.userId) { + return res.status(401).send(); + } + + try { + const con = await createOrGetConnection(); + const existingFavorites = await con + .getRepository(UserIntegrationGif) + .find({ + where: { + userId: req.userId, + type: UserIntegrationType.Gif, + }, + }); + + const favorites: Gif[] = []; + existingFavorites.forEach((fav) => { + if (fav.meta) { + favorites.push(...(fav.meta.favorites as Gif[])); + } + }); + + return res.send({ gifs: favorites }); + } catch (err) { + logger.error({ err }, 'Error getting favorited gifs'); + + return res.send({ gifs: [] }); + } + }); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index f70ee4eb8c..4112d517a6 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -19,6 +19,7 @@ import { UserPersonalizedDigest, UserPersonalizedDigestType } from '../entity'; import { notifyGeneratePersonalizedDigest } from '../common'; import { PersonalizedDigestFeatureConfig } from '../growthbook'; import integrations from './integrations'; +import gifs from './gifs'; import log from './log'; export default async function (fastify: FastifyInstance): Promise { @@ -39,6 +40,7 @@ export default async function (fastify: FastifyInstance): Promise { fastify.register(automations, { prefix: '/auto' }); fastify.register(sitemaps, { prefix: '/sitemaps' }); fastify.register(integrations, { prefix: '/integrations' }); + fastify.register(gifs, { prefix: '/gifs' }); fastify.register(log, { prefix: '/log' }); fastify.get('/robots.txt', (req, res) => {