diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index aef9a805..e1d47314 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -34,6 +34,7 @@ import { AutopostController } from '@gitroom/backend/api/routes/autopost.control import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service'; import { McpController } from '@gitroom/backend/api/routes/mcp.controller'; import { SetsController } from '@gitroom/backend/api/routes/sets.controller'; +import { ThirdPartyController } from '@gitroom/backend/api/routes/third-party.controller'; const authenticatedController = [ UsersController, @@ -52,6 +53,7 @@ const authenticatedController = [ SignatureController, AutopostController, SetsController, + ThirdPartyController, ]; @Module({ imports: [UploadModule], diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index 7e91f4fe..c07f3b5e 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -38,7 +38,7 @@ export class PostsController { private _starsService: StarsService, private _messagesService: MessagesService, private _agentGraphService: AgentGraphService, - private _shortLinkService: ShortLinkService + private _shortLinkService: ShortLinkService, ) {} @Get('/:id/statistics') diff --git a/apps/backend/src/api/routes/third-party.controller.ts b/apps/backend/src/api/routes/third-party.controller.ts new file mode 100644 index 00000000..26f5b794 --- /dev/null +++ b/apps/backend/src/api/routes/third-party.controller.ts @@ -0,0 +1,160 @@ +import { + Body, + Controller, + Get, + HttpException, + Param, + Post, + Delete, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ThirdPartyManager } from '@gitroom/nestjs-libraries/3rdparties/thirdparty.manager'; +import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; +import { Organization } from '@prisma/client'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; +import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; + +@ApiTags('Third Party') +@Controller('/third-party') +export class ThirdPartyController { + private storage = UploadFactory.createStorage(); + + constructor( + private _thirdPartyManager: ThirdPartyManager, + private _mediaService: MediaService, + ) {} + + @Get('/list') + async getThirdPartyList() { + return this._thirdPartyManager.getAllThirdParties(); + } + + @Get('/') + async getSavedThirdParty(@GetOrgFromRequest() organization: Organization) { + return Promise.all( + ( + await this._thirdPartyManager.getAllThirdPartiesByOrganization( + organization.id + ) + ).map((thirdParty) => { + const { description, fields, position, title, identifier } = + this._thirdPartyManager.getThirdPartyByName(thirdParty.identifier); + return { + ...thirdParty, + title, + position, + fields, + description, + }; + }) + ); + } + + @Delete('/:id') + deleteById( + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string + ) { + return this._thirdPartyManager.deleteIntegration(organization.id, id); + } + + @Post('/:id/submit') + async generate( + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string, + @Body() data: any + ) { + const thirdParty = await this._thirdPartyManager.getIntegrationById( + organization.id, + id + ); + + if (!thirdParty) { + throw new HttpException('Integration not found', 404); + } + + const thirdPartyInstance = this._thirdPartyManager.getThirdPartyByName( + thirdParty.identifier + ); + + if (!thirdPartyInstance) { + throw new HttpException('Invalid identifier', 400); + } + + const loadedData = await thirdPartyInstance?.instance?.sendData( + AuthService.fixedDecryption(thirdParty.apiKey), + data + ); + + const file = await this.storage.uploadSimple(loadedData); + return this._mediaService.saveFile(organization.id, file.split('/').pop(), file); + } + + @Post('/function/:id/:functionName') + async callFunction( + @GetOrgFromRequest() organization: Organization, + @Param('id') id: string, + @Param('functionName') functionName: string, + @Body() data: any + ) { + const thirdParty = await this._thirdPartyManager.getIntegrationById( + organization.id, + id + ); + + if (!thirdParty) { + throw new HttpException('Integration not found', 404); + } + + const thirdPartyInstance = this._thirdPartyManager.getThirdPartyByName( + thirdParty.identifier + ); + + if (!thirdPartyInstance) { + throw new HttpException('Invalid identifier', 400); + } + + return thirdPartyInstance?.instance?.[functionName]( + AuthService.fixedDecryption(thirdParty.apiKey), + data + ); + } + + @Post('/:identifier') + async addApiKey( + @GetOrgFromRequest() organization: Organization, + @Param('identifier') identifier: string, + @Body('api') api: string + ) { + const thirdParty = this._thirdPartyManager.getThirdPartyByName(identifier); + if (!thirdParty) { + throw new HttpException('Invalid identifier', 400); + } + + const connect = await thirdParty.instance.checkConnection(api); + if (!connect) { + throw new HttpException('Invalid API key', 400); + } + + try { + const save = await this._thirdPartyManager.saveIntegration( + organization.id, + identifier, + api, + { + name: connect.name, + username: connect.username, + id: connect.id, + } + ); + + return { + id: save.id, + }; + } catch (e) { + console.log(e); + throw new HttpException('Integration Already Exists', 400); + } + } +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index a8000b3e..60967129 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { ThrottlerBehindProxyGuard } from '@gitroom/nestjs-libraries/throttler/t import { ThrottlerModule } from '@nestjs/throttler'; import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module'; import { McpModule } from '@gitroom/backend/mcp/mcp.module'; +import { ThirdPartyModule } from '@gitroom/nestjs-libraries/3rdparties/thirdparty.module'; @Global() @Module({ @@ -22,6 +23,7 @@ import { McpModule } from '@gitroom/backend/mcp/mcp.module'; PublicApiModule, AgentModule, McpModule, + ThirdPartyModule, ThrottlerModule.forRoot([ { ttl: 3600000, diff --git a/apps/frontend/public/icons/third-party/heygen.png b/apps/frontend/public/icons/third-party/heygen.png new file mode 100644 index 00000000..811603be Binary files /dev/null and b/apps/frontend/public/icons/third-party/heygen.png differ diff --git a/apps/frontend/src/app/(app)/(site)/third-party/page.tsx b/apps/frontend/src/app/(app)/(site)/third-party/page.tsx new file mode 100644 index 00000000..fef12c61 --- /dev/null +++ b/apps/frontend/src/app/(app)/(site)/third-party/page.tsx @@ -0,0 +1,14 @@ +import { ThirdPartyComponent } from '@gitroom/frontend/components/third-parties/third-party.component'; + +export const dynamic = 'force-dynamic'; +import { Metadata } from 'next'; +import { isGeneralServerSide } from '@gitroom/helpers/utils/is.general.server.side'; +export const metadata: Metadata = { + title: `${ + isGeneralServerSide() ? 'Postiz Integrations' : 'Gitroom Integrations' + }`, + description: '', +}; +export default async function Index() { + return ; +} diff --git a/apps/frontend/src/components/launches/add.edit.model.tsx b/apps/frontend/src/components/launches/add.edit.model.tsx index 044c1aec..efe8ff1a 100644 --- a/apps/frontend/src/components/launches/add.edit.model.tsx +++ b/apps/frontend/src/components/launches/add.edit.model.tsx @@ -558,7 +558,6 @@ export const AddEditModal: FC<{ // @ts-ignore for (const item of clipboardItems) { - console.log(item); if (item.kind === 'file') { const file = item.getAsFile(); if (file) { @@ -779,6 +778,7 @@ Here are the things you can do:
( // @ts-ignore for (const item of clipboardItems) { - console.log(item); if (item.kind === 'file') { const file = item.getAsFile(); if (file) { @@ -567,6 +566,7 @@ export const withProvider = function (
{ icon: 'plugs', path: '/plugs', }, + { + name: t('integrations', 'Integrations'), + icon: 'integrations', + path: '/third-party', + }, { name: t('billing', 'Billing'), icon: 'billing', @@ -80,7 +85,7 @@ export const TopMenu: FC = () => { const menuItems = useMenuItems(); return (
-
    +
      {menuItems .filter((f) => { if (f.hide) { diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index acd0d98b..64951a83 100644 --- a/apps/frontend/src/components/media/media.component.tsx +++ b/apps/frontend/src/components/media/media.component.tsx @@ -28,6 +28,7 @@ import Image from 'next/image'; import { DropFiles } from '@gitroom/frontend/components/layout/drop.files'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { ThirdPartyMedia } from '@gitroom/frontend/components/third-parties/third-party.media'; const Polonto = dynamic( () => import('@gitroom/frontend/components/launches/polonto') ); @@ -452,6 +453,14 @@ export const MediaBox: FC<{ export const MultiMediaComponent: FC<{ label: string; description: string; + allData: { + content: string; + id?: string; + image?: Array<{ + id: string; + path: string; + }>; + }[]; value?: Array<{ path: string; id: string; @@ -471,7 +480,7 @@ export const MultiMediaComponent: FC<{ }; }) => void; }> = (props) => { - const { onOpen, onClose, name, error, text, onChange, value } = props; + const { onOpen, onClose, name, error, text, onChange, value, allData } = props; const user = useUser(); useEffect(() => { if (value) { @@ -598,6 +607,8 @@ export const MultiMediaComponent: FC<{
+ + {!!user?.tier?.ai && ( )} diff --git a/apps/frontend/src/components/third-parties/providers/heygen.provider.tsx b/apps/frontend/src/components/third-parties/providers/heygen.provider.tsx new file mode 100644 index 00000000..25a2ebc1 --- /dev/null +++ b/apps/frontend/src/components/third-parties/providers/heygen.provider.tsx @@ -0,0 +1,232 @@ +import { thirdPartyWrapper } from '@gitroom/frontend/components/third-parties/third-party.wrapper'; +import { + useThirdPartyFunction, + useThirdPartyFunctionSWR, + useThirdPartySubmit, +} from '@gitroom/frontend/components/third-parties/third-party.function'; +import { useThirdParty } from '@gitroom/frontend/components/third-parties/third-party.media'; +import { useForm, FormProvider, SubmitHandler } from 'react-hook-form'; +import { Textarea } from '@gitroom/react/form/textarea'; +import { Button } from '@gitroom/react/form/button'; +import { FC, useCallback, useState } from 'react'; +import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; +import clsx from 'clsx'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { object, string } from 'zod'; +import { Select } from '@gitroom/react/form/select'; +import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; + +const aspectRatio = [ + { key: 'portrait', value: 'Portrait' }, + { key: 'story', value: 'Story' }, +]; + +const generateCaptions = [ + { key: 'yes', value: 'Yes' }, + { key: 'no', value: 'No' }, +]; + +const SelectAvatarComponent: FC<{ + avatarList: any[]; + onChange: (id: string) => void; +}> = (props) => { + const [current, setCurrent] = useState({}); + const { avatarList, onChange } = props; + + return ( +
+ {avatarList?.map((p) => ( +
{ + setCurrent(p.avatar_id === current?.avatar_id ? undefined : p); + onChange(p.avatar_id === current?.avatar_id ? {} : p.avatar_id); + }} + key={p.avatar_id} + className={clsx( + 'w-full h-full p-[20px] min-h-[100px] text-[14px] hover:bg-input transition-all text-textColor relative flex flex-col gap-[15px] cursor-pointer', + current?.avatar_id === p.avatar_id + ? 'bg-input border border-red-500' + : 'bg-third' + )} + > +
+ +
+
{p.avatar_name}
+
+ ))} +
+ ); +}; + +const SelectVoiceComponent: FC<{ + voiceList: any[]; + onChange: (id: string) => void; +}> = (props) => { + const [current, setCurrent] = useState({}); + const { voiceList, onChange } = props; + + return ( +
+ {voiceList?.map((p) => ( +
{ + setCurrent(p.voice_id === current?.voice_id ? undefined : p); + onChange(p.voice_id === current?.voice_id ? {} : p.voice_id); + }} + key={p.avatar_id} + className={clsx( + 'w-full h-full p-[20px] min-h-[100px] text-[14px] hover:bg-input transition-all text-textColor relative flex flex-col gap-[15px] cursor-pointer', + current?.voice_id === p.voice_id + ? 'bg-input border border-red-500' + : 'bg-third' + )} + > +
{p.name}
+
{p.language}
+
+ ))} +
+ ); +}; + +const HeygenProviderComponent = () => { + const thirdParty = useThirdParty(); + const load = useThirdPartyFunction('EVERYTIME'); + const { data } = useThirdPartyFunctionSWR('LOAD_ONCE', 'avatars'); + const { data: voices } = useThirdPartyFunctionSWR('LOAD_ONCE', 'voices'); + const send = useThirdPartySubmit(); + const [hideVoiceGenerator, setHideVoiceGenerator] = useState(false); + const [voiceLoading, setVoiceLoading] = useState(false); + + const form = useForm({ + values: { + voice: '', + avatar: '', + aspect_ratio: '', + captions: '', + selectedVoice: '', + }, + mode: 'all', + resolver: zodResolver( + object({ + voice: string().min(20, 'Voice must be at least 20 characters long'), + avatar: string().min(1, 'Avatar is required'), + selectedVoice: string().min(1, 'Voice is required'), + aspect_ratio: string().min(1, 'Aspect ratio is required'), + captions: string().min(1, 'Captions is required'), + }) + ), + }); + + const generateVoice = useCallback(async () => { + if ( + !(await deleteDialog('Are you sure? it will delete the current text')) + ) { + return; + } + + setVoiceLoading(true); + + form.setValue( + 'voice', + ( + await load('generateVoice', { + text: thirdParty.data.map((p) => p.content).join('\n'), + }) + ).voice + ); + + setVoiceLoading(false); + setHideVoiceGenerator(true); + }, [thirdParty]); + + const submit: SubmitHandler<{ voice: string; avatar: string }> = useCallback( + async (params) => { + thirdParty.onChange(await send(params)); + thirdParty.close(); + }, + [] + ); + + return ( +
+ {form.formState.isSubmitting && ( +
+ Grab a coffee and relax, this may take a while... +
+ You can also track the progress directly in HeyGen Dashboard. +
+ DO NOT CLOSE THIS WINDOW! +
+ +
+ )} + + +
+ + + + +
Voice to generate
+ {!hideVoiceGenerator && ( + + )} +