diff --git a/src/app.ts b/src/app.ts index 9fb67ced..8831f188 100644 --- a/src/app.ts +++ b/src/app.ts @@ -109,6 +109,7 @@ import { trendingStatusesController, trendingTagsController, } from '@/controllers/api/trends.ts'; +import { translateController } from '@/controllers/api/translate.ts'; import { errorHandler } from '@/controllers/error.ts'; import { frontendController } from '@/controllers/frontend.ts'; import { metricsController } from '@/controllers/metrics.ts'; @@ -126,6 +127,8 @@ import { requireSigner } from '@/middleware/requireSigner.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; +import { DittoTranslator } from '@/translators/translator.ts'; +import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; interface AppEnv extends HonoEnv { Variables: { @@ -141,6 +144,8 @@ interface AppEnv extends HonoEnv { pagination: { since?: number; until?: number; limit: number }; /** Normalized list pagination params. */ listPagination: { offset: number; limit: number }; + /** Translation service. */ + translator?: DittoTranslator; }; } @@ -220,6 +225,7 @@ app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requireSigner, bookmarkC app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requireSigner, unbookmarkController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requireSigner, pinController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requireSigner, unpinController); +app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/translate', requireSigner, translatorMiddleware, translateController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requireSigner, reblogStatusController); app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requireSigner, unreblogStatusController); app.post('/api/v1/statuses', requireSigner, createStatusController); diff --git a/src/controllers/api/translate.ts b/src/controllers/api/translate.ts new file mode 100644 index 00000000..86aaa1f4 --- /dev/null +++ b/src/controllers/api/translate.ts @@ -0,0 +1,89 @@ +import { LanguageCode } from 'iso-639-1'; +import { z } from 'zod'; + +import { AppController } from '@/app.ts'; +import { languageSchema } from '@/schema.ts'; +import { Storages } from '@/storages.ts'; +import { dittoTranslations, dittoTranslationsKey } from '@/translators/translator.ts'; +import { parseBody } from '@/utils/api.ts'; +import { getEvent } from '@/queries.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; + +const translateSchema = z.object({ + //lang: languageSchema, // Correct property name, as stated by Mastodon docs + target_language: languageSchema, // Property name soapbox sends +}); + +const translateController: AppController = async (c) => { + const result = translateSchema.safeParse(await parseBody(c.req.raw)); + const { signal } = c.req.raw; + + if (!result.success) { + return c.json({ error: 'Bad request.', schema: result.error }, 422); + } + + const translator = c.get('translator'); + if (!translator) { + return c.json({ error: 'No translator configured.' }, 500); + } + + const { target_language } = result.data; + const targetLang = target_language; + const id = c.req.param('id'); + + const event = await getEvent(id, { signal }); + if (!event) { + return c.json({ error: 'Record not found' }, 400); + } + + const viewerPubkey = await c.get('signer')?.getPublicKey(); + + const kysely = await Storages.kysely(); + + let sourceLang = (await kysely + .selectFrom('nostr_events') + .select('language').where('id', '=', id) + .limit(1) + .execute())[0]?.language as LanguageCode | undefined; + if (!sourceLang) { + sourceLang = undefined; + } + + if (targetLang.toLowerCase() === sourceLang?.toLowerCase()) { + return c.json({ error: 'Source and target languages are the same. No translation needed.' }, 400); + } + + const status = await renderStatus(event, { viewerPubkey }); + + const translatedId = `${target_language}-${id}` as dittoTranslationsKey; + const translationCache = dittoTranslations.get(translatedId); + + if (translationCache) { + return c.json(translationCache.data, 200); + } + + const mediaAttachments = status?.media_attachments.map((value) => { + return { + id: value.id, + description: value.description ?? '', + }; + }) ?? []; + + try { + const translation = await translator.translate( + status?.content ?? '', + status?.spoiler_text ?? '', + mediaAttachments, + null, + sourceLang, + targetLang, + { signal }, + ); + dittoTranslations.set(translatedId, translation); + return c.json(translation.data, 200); + } catch (_) { + return c.json({ error: 'Service Unavailable' }, 503); + } +}; + +export { translateController };