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!
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default thirdPartyWrapper('heygen', HeygenProviderComponent);
diff --git a/apps/frontend/src/components/third-parties/third-party.component.tsx b/apps/frontend/src/components/third-parties/third-party.component.tsx
new file mode 100644
index 00000000..1e86db15
--- /dev/null
+++ b/apps/frontend/src/components/third-parties/third-party.component.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import clsx from 'clsx';
+import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
+import { useT } from '@gitroom/react/translation/get.transation.service.client';
+import { ThirdPartyListComponent } from '@gitroom/frontend/components/third-parties/third-party.list.component';
+import React, { FC, useCallback, useState } from 'react';
+import useSWR from 'swr';
+import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
+import interClass from '@gitroom/react/helpers/inter.font';
+import { useToaster } from '@gitroom/react/toaster/toaster';
+import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
+
+export const ThirdPartyMenuComponent: FC<{
+ reload: () => void;
+ tParty: { id: string };
+}> = (props) => {
+ const { tParty, reload } = props;
+ const fetch = useFetch();
+ const [show, setShow] = useState(false);
+ const t = useT();
+ const toaster = useToaster();
+
+ const changeShow = () => {
+ setShow((prev) => !prev);
+ };
+
+ const deleteChannel = (id: string) => async () => {
+ setShow(false);
+ if (
+ !(await deleteDialog('Are you sure you want to delete this integration?'))
+ ) {
+ return;
+ }
+
+ const res = await fetch(`/third-party/${id}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ toaster.show('Integration deleted successfully', 'success');
+ reload();
+ } else {
+ const error = await res.json();
+ console.error('Error deleting integration:', error);
+ }
+ };
+
+ return (
+
+
+
+
+ {show && (
+
e.stopPropagation()}
+ className={`absolute top-[100%] start-0 p-[8px] px-[20px] bg-fifth flex flex-col gap-[16px] z-[100] rounded-[8px] border border-tableBorder ${interClass} text-nowrap`}
+ >
+
+
+
+ {t('delete_integration', 'Delete Integration')}
+
+
+
+ )}
+
+ );
+};
+
+export const ThirdPartyComponent = () => {
+ const t = useT();
+ const fetch = useFetch();
+
+ const integrations = useCallback(async () => {
+ return (await fetch('/third-party')).json();
+ }, []);
+
+ const { data, isLoading, mutate } = useSWR('third-party', integrations);
+
+ return (
+
+
+
+
+
{t('integrations')}
+
+
+
+ {!isLoading && !data?.length ? (
+
No Integrations Yet
+ ) : (
+ data?.map((p: any) => (
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/frontend/src/components/third-parties/third-party.function.tsx b/apps/frontend/src/components/third-parties/third-party.function.tsx
new file mode 100644
index 00000000..368fb5c0
--- /dev/null
+++ b/apps/frontend/src/components/third-parties/third-party.function.tsx
@@ -0,0 +1,88 @@
+import { useThirdParty } from '@gitroom/frontend/components/third-parties/third-party.media';
+import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import useSWR from 'swr';
+
+export const useThirdPartySubmit = () => {
+ const thirdParty = useThirdParty();
+ const fetch = useFetch();
+
+ return useCallback(async (data?: any) => {
+ if (!thirdParty.id) {
+ return;
+ }
+
+ const response = await fetch(`/third-party/${thirdParty.id}/submit`, {
+ body: JSON.stringify(data),
+ method: 'POST',
+ });
+
+ return response.json();
+ }, []);
+};
+
+export const useThirdPartyFunction = (type: 'EVERYTIME' | 'ONCE') => {
+ const thirdParty = useThirdParty();
+ const data = useRef(undefined);
+ const fetch = useFetch();
+
+ return useCallback(
+ async (functionName: string, sendData?: any) => {
+ if (data.current && type === 'ONCE') {
+ return data.current;
+ }
+
+ data.current = await (
+ await fetch(`/third-party/function/${thirdParty.id}/${functionName}`, {
+ ...(data ? { body: JSON.stringify(sendData) } : {}),
+ method: 'POST',
+ })
+ ).json();
+
+ return data.current;
+ },
+ [thirdParty, data]
+ );
+};
+
+export const useThirdPartyFunctionSWR = (
+ type: 'SWR' | 'LOAD_ONCE',
+ functionName: string,
+ data?: any
+) => {
+ const thirdParty = useThirdParty();
+ const fetch = useFetch();
+
+ const callBack = useCallback(
+ async (functionName: string, data?: any) => {
+ return (
+ await fetch(`/third-party/function/${thirdParty.id}/${functionName}`, {
+ ...(data ? { body: JSON.stringify(data) } : {}),
+ method: 'POST',
+ })
+ ).json();
+ },
+ [thirdParty]
+ );
+
+ return useSWR(
+ `function-${thirdParty.id}-${functionName}`,
+ () => {
+ // @ts-ignore
+ return callBack(functionName, { ...data });
+ },
+ {
+ ...(type === 'LOAD_ONCE'
+ ? {
+ revalidateOnMount: true,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ refreshWhenOffline: false,
+ revalidateIfStale: false,
+ }
+ : {}),
+ }
+ );
+};
diff --git a/apps/frontend/src/components/third-parties/third-party.list.component.tsx b/apps/frontend/src/components/third-parties/third-party.list.component.tsx
new file mode 100644
index 00000000..df4b920f
--- /dev/null
+++ b/apps/frontend/src/components/third-parties/third-party.list.component.tsx
@@ -0,0 +1,164 @@
+'use client';
+
+import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
+import useSWR from 'swr';
+import React, { FC, useCallback, useState } from 'react';
+import { Button } from '@gitroom/react/form/button';
+import { string } from 'yup';
+import { useRouter } from 'next/navigation';
+import { useModals } from '@mantine/modals';
+import { FieldValues, FormProvider, useForm } from 'react-hook-form';
+import { useT } from '@gitroom/react/translation/get.transation.service.client';
+import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
+import { Input } from '@gitroom/react/form/input';
+import { useToaster } from '@gitroom/react/toaster/toaster';
+
+export const ApiModal: FC<{
+ identifier: string;
+ title: string;
+ update: () => void;
+}> = (props) => {
+ const { title, identifier, update } = props;
+ const fetch = useFetch();
+ const router = useRouter();
+ const modal = useModals();
+ const toaster = useToaster();
+ const [loading, setLoading] = useState(false);
+ const closePopup = useCallback(() => {
+ modal.closeAll();
+ }, []);
+
+ const methods = useForm({
+ mode: 'onChange',
+ });
+
+ const close = useCallback(() => {
+ if (closePopup) {
+ return closePopup();
+ }
+ modal.closeAll();
+ }, []);
+
+ const submit = useCallback(async (data: FieldValues) => {
+ setLoading(true);
+ const add = await fetch(`/third-party/${identifier}`, {
+ method: 'POST',
+ body: JSON.stringify({
+ api: data.api,
+ }),
+ });
+
+ if (add.ok) {
+ toaster.show('Integration added successfully', 'success');
+ if (closePopup) {
+ closePopup();
+ } else {
+ modal.closeAll();
+ }
+ router.refresh();
+ if (update) update();
+ return;
+ }
+
+ const {message} = await add.json();
+
+ methods.setError('api', {
+ message,
+ });
+
+ setLoading(false);
+ }, [props]);
+
+ const t = useT();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('add_integration', 'Add Integration')}
+
+
+
+
+ );
+};
+
+export const ThirdPartyListComponent: FC<{reload: () => void}> = (props) => {
+ const fetch = useFetch();
+ const modals = useModals();
+ const { reload } = props;
+
+ const integrationsList = useCallback(async () => {
+ return (await fetch('/third-party/list')).json();
+ }, []);
+
+ const { data } = useSWR('third-party-list', integrationsList);
+
+ const addApiKey = useCallback((title: string, identifier: string) => () => {
+ modals.openModal({
+ title: '',
+ withCloseButton: false,
+ classNames: {
+ modal: 'bg-transparent text-textColor',
+ },
+ children: (
+
+ ),
+ });
+ }, []);
+
+
+ return (
+
+ {data?.map((p: any) => (
+
+
+
+
+
+ {p.title}
+
+
{p.description}
+
+ Add
+
+
+ ))}
+
+ );
+};
diff --git a/apps/frontend/src/components/third-parties/third-party.media.tsx b/apps/frontend/src/components/third-parties/third-party.media.tsx
new file mode 100644
index 00000000..b12e6f91
--- /dev/null
+++ b/apps/frontend/src/components/third-parties/third-party.media.tsx
@@ -0,0 +1,233 @@
+'use client';
+
+import { Button } from '@gitroom/react/form/button';
+import clsx from 'clsx';
+import { useT } from '@gitroom/react/translation/get.transation.service.client';
+import React, {
+ createContext,
+ FC,
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
+import useSWR from 'swr';
+import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
+import './providers/heygen.provider';
+import { thirdPartyList } from '@gitroom/frontend/components/third-parties/third-party.wrapper';
+
+const ThirdPartyContext = createContext({
+ id: '',
+ name: '',
+ title: '',
+ identifier: '',
+ description: '',
+ close: () => {},
+ onChange: (data: any) => {},
+ fields: [],
+ data: [
+ {
+ content: '',
+ id: '',
+ image: [
+ {
+ id: '',
+ path: '',
+ },
+ ],
+ },
+ ],
+});
+export const useThirdParty = () => React.useContext(ThirdPartyContext);
+const EmptyComponent: FC = () => null;
+
+export const ThirdPartyPopup: FC<{
+ closeModal: () => void;
+ thirdParties: any[];
+ onChange: (data: any) => void;
+ allData: {
+ content: string;
+ id?: string;
+ image?: Array<{
+ id: string;
+ path: string;
+ }>;
+ }[];
+}> = (props) => {
+ const { closeModal, thirdParties, allData, onChange } = props;
+ const [thirdParty, setThirdParty] = useState(null);
+
+ const Component = useMemo(() => {
+ if (!thirdParty) {
+ return EmptyComponent;
+ }
+
+ return (
+ thirdPartyList.find((p) => p.identifier === thirdParty.identifier)
+ ?.Component || EmptyComponent
+ );
+ }, [thirdParty]);
+
+ const close = useCallback(() => {
+ setThirdParty(null);
+ closeModal();
+ }, [setThirdParty, closeModal]);
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+ {!thirdParty && (
+
+ {thirdParties.map((p: any) => (
+
{
+ setThirdParty(p);
+ }}
+ key={p.identifier}
+ className="w-full h-full p-[20px] min-h-[100px] text-[14px] bg-third hover:bg-input transition-all text-textColor relative flex flex-col gap-[15px] cursor-pointer"
+ >
+
+
+
+
+ {p.title}: {p.name}
+
+
+ {p.description}
+
+
+ Use
+
+
+ ))}
+
+ )}
+ {thirdParty && (
+ <>
+
+
setThirdParty(null)}
+ >
+ {'<'} Back
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ );
+};
+
+export const ThirdPartyMedia: FC<{
+ onChange: (data: any) => void;
+ allData: {
+ content: string;
+ id?: string;
+ image?: Array<{
+ id: string;
+ path: string;
+ }>;
+ }[];
+}> = (props) => {
+ const { allData, onChange } = props;
+ const t = useT();
+ const fetch = useFetch();
+ const [popup, setPopup] = useState(false);
+
+ const thirdParties = useCallback(async () => {
+ return (await (await fetch('/third-party')).json()).filter(
+ (f: any) => f.position === 'media'
+ );
+ }, []);
+
+ const { data, isLoading, mutate } = useSWR('third-party', thirdParties);
+
+ if (isLoading || !data.length) {
+ return null;
+ }
+
+ return (
+ <>
+ {popup && (
+ setPopup(false)}
+ allData={allData}
+ onChange={onChange}
+ />
+ )}
+
+
setPopup(true)}
+ >
+
+
+
+ {t('integrations', 'Integrations')}
+
+
+
+
+ >
+ );
+};
diff --git a/apps/frontend/src/components/third-parties/third-party.wrapper.tsx b/apps/frontend/src/components/third-parties/third-party.wrapper.tsx
new file mode 100644
index 00000000..59991720
--- /dev/null
+++ b/apps/frontend/src/components/third-parties/third-party.wrapper.tsx
@@ -0,0 +1,16 @@
+import { FC } from 'react';
+
+export const thirdPartyList: {identifier: string, Component: FC}[] = [];
+
+export const thirdPartyWrapper = (identifier: string, Component: any): null => {
+ if (thirdPartyList.map(p => p.identifier).includes(identifier)) {
+ return null;
+ }
+
+ thirdPartyList.push({
+ identifier,
+ Component
+ });
+
+ return null;
+}
\ No newline at end of file
diff --git a/libraries/nestjs-libraries/src/3rdparties/heygen/heygen.provider.ts b/libraries/nestjs-libraries/src/3rdparties/heygen/heygen.provider.ts
new file mode 100644
index 00000000..593df310
--- /dev/null
+++ b/libraries/nestjs-libraries/src/3rdparties/heygen/heygen.provider.ts
@@ -0,0 +1,176 @@
+import {
+ ThirdParty,
+ ThirdPartyAbstract,
+} from '@gitroom/nestjs-libraries/3rdparties/thirdparty.interface';
+import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
+import { timer } from '@gitroom/helpers/utils/timer';
+
+@ThirdParty({
+ identifier: 'heygen',
+ title: 'HeyGen',
+ description: 'HeyGen is a platform for creating AI-generated avatars videos.',
+ position: 'media',
+ fields: [],
+})
+export class HeygenProvider extends ThirdPartyAbstract<{
+ voice: string;
+ avatar: string;
+ aspect_ratio: string;
+ captions: string;
+}> {
+ // @ts-ignore
+ constructor(private _openaiService: OpenaiService) {
+ super();
+ }
+
+ async checkConnection(
+ apiKey: string
+ ): Promise {
+ const list = await fetch('https://api.heygen.com/v1/user/me', {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ 'x-api-key': apiKey,
+ },
+ });
+
+ if (!list.ok) {
+ return false;
+ }
+
+ const { data } = await list.json();
+
+ return {
+ name: data.first_name + ' ' + data.last_name,
+ username: data.username,
+ id: data.username,
+ };
+ }
+
+ async generateVoice(apiKey: string, data: { text: string }) {
+ return {
+ voice: await this._openaiService.generateVoiceFromText(data.text),
+ };
+ }
+
+ async voices(apiKey: string) {
+ const {
+ data: { voices },
+ } = await (
+ await fetch('https://api.heygen.com/v2/voices', {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ 'x-api-key': apiKey,
+ },
+ })
+ ).json();
+
+ return voices.slice(0, 20);
+ }
+
+ async avatars(apiKey: string) {
+ const {
+ data: { avatar_group_list },
+ } = await (
+ await fetch(
+ 'https://api.heygen.com/v2/avatar_group.list?include_public=false',
+ {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ 'x-api-key': apiKey,
+ },
+ }
+ )
+ ).json();
+
+ const loadedAvatars = [];
+ for (const avatar of avatar_group_list) {
+ const {
+ data: { avatar_list },
+ } = await (
+ await fetch(
+ `https://api.heygen.com/v2/avatar_group/${avatar.id}/avatars`,
+ {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ 'x-api-key': apiKey,
+ },
+ }
+ )
+ ).json();
+
+ loadedAvatars.push(...avatar_list);
+ }
+
+ return loadedAvatars;
+ }
+
+ async sendData(
+ apiKey: string,
+ data: {
+ voice: string;
+ avatar: string;
+ aspect_ratio: string;
+ captions: string;
+ selectedVoice: string;
+ }
+ ): Promise {
+ const {data: {video_id}} = await (
+ await fetch(`https://api.heygen.com/v2/video/generate`, {
+ method: 'POST',
+ body: JSON.stringify({
+ caption: data.captions === 'yes',
+ video_inputs: [
+ {
+ character: {
+ type: 'avatar',
+ avatar_id: data.avatar,
+ },
+ voice: {
+ type: 'text',
+ input_text: data.voice,
+ voice_id: data.selectedVoice,
+ },
+ },
+ ],
+ dimension:
+ data.aspect_ratio === 'story'
+ ? {
+ width: 720,
+ height: 1280,
+ }
+ : {
+ width: 1280,
+ height: 720,
+ },
+ }),
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ 'x-api-key': apiKey,
+ },
+ })
+ ).json();
+
+ while (true) {
+ const {data: {status, video_url}} = await (await fetch(`https://api.heygen.com/v1/video_status.get?video_id=${video_id}`, {
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ 'x-api-key': apiKey,
+ },
+ })).json();
+
+ if (status === 'completed') {
+ return video_url;
+ } else if (status === 'failed') {
+ throw new Error('Video generation failed');
+ }
+
+ await timer(3000);
+ }
+ }
+}
diff --git a/libraries/nestjs-libraries/src/3rdparties/thirdparty.interface.ts b/libraries/nestjs-libraries/src/3rdparties/thirdparty.interface.ts
new file mode 100644
index 00000000..0f5d4780
--- /dev/null
+++ b/libraries/nestjs-libraries/src/3rdparties/thirdparty.interface.ts
@@ -0,0 +1,40 @@
+import { Injectable } from '@nestjs/common';
+
+export abstract class ThirdPartyAbstract {
+ abstract checkConnection(
+ apiKey: string
+ ): Promise;
+ abstract sendData(apiKey: string, data: T): Promise;
+ [key: string]: ((apiKey: string, data?: any) => Promise) | undefined;
+}
+
+export interface ThirdPartyParams {
+ identifier: string;
+ title: string;
+ description: string;
+ position: 'media' | 'webhook';
+ fields: {
+ name: string;
+ description: string;
+ type: string;
+ placeholder: string;
+ validation?: RegExp;
+ }[];
+}
+
+export function ThirdParty(params: ThirdPartyParams) {
+ return function (target: any) {
+ // Apply @Injectable decorator to the target class
+ Injectable()(target);
+
+ // Retrieve existing metadata or initialize an empty array
+ const existingMetadata =
+ Reflect.getMetadata('third:party', ThirdPartyAbstract) || [];
+
+ // Add the metadata information for this method
+ existingMetadata.push({ target, ...params });
+
+ // Define metadata on the class prototype (so it can be retrieved from the class)
+ Reflect.defineMetadata('third:party', existingMetadata, ThirdPartyAbstract);
+ };
+}
diff --git a/libraries/nestjs-libraries/src/3rdparties/thirdparty.manager.ts b/libraries/nestjs-libraries/src/3rdparties/thirdparty.manager.ts
new file mode 100644
index 00000000..2f367fa9
--- /dev/null
+++ b/libraries/nestjs-libraries/src/3rdparties/thirdparty.manager.ts
@@ -0,0 +1,62 @@
+import { Injectable } from '@nestjs/common';
+import {
+ ThirdPartyAbstract,
+ ThirdPartyParams,
+} from '@gitroom/nestjs-libraries/3rdparties/thirdparty.interface';
+import { ModuleRef } from '@nestjs/core';
+import { ThirdPartyService } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.service';
+
+@Injectable()
+export class ThirdPartyManager {
+ constructor(
+ private _moduleRef: ModuleRef,
+ private _thirdPartyService: ThirdPartyService
+ ) {}
+
+ getAllThirdParties(): any[] {
+ return (Reflect.getMetadata('third:party', ThirdPartyAbstract) || []).map(
+ (p: any) => ({
+ identifier: p.identifier,
+ title: p.title,
+ description: p.description,
+ fields: p.fields || [],
+ })
+ );
+ }
+
+ getThirdPartyByName(
+ identifier: string
+ ): (ThirdPartyParams & { instance: ThirdPartyAbstract }) | undefined {
+ const thirdParty = (
+ Reflect.getMetadata('third:party', ThirdPartyAbstract) || []
+ ).find((p: any) => p.identifier === identifier);
+
+ return { ...thirdParty, instance: this._moduleRef.get(thirdParty.target) };
+ }
+
+ deleteIntegration(org: string, id: string) {
+ return this._thirdPartyService.deleteIntegration(org, id);
+ }
+
+ getIntegrationById(org: string, id: string) {
+ return this._thirdPartyService.getIntegrationById(org, id);
+ }
+
+ getAllThirdPartiesByOrganization(org: string) {
+ return this._thirdPartyService.getAllThirdPartiesByOrganization(org);
+ }
+
+ saveIntegration(
+ org: string,
+ identifier: string,
+ apiKey: string,
+ data: { name: string; username: string; id: string }
+ ) {
+ return this._thirdPartyService.saveIntegration(
+ org,
+ identifier,
+ apiKey,
+ data
+ );
+ }
+}
diff --git a/libraries/nestjs-libraries/src/3rdparties/thirdparty.module.ts b/libraries/nestjs-libraries/src/3rdparties/thirdparty.module.ts
new file mode 100644
index 00000000..0f435a96
--- /dev/null
+++ b/libraries/nestjs-libraries/src/3rdparties/thirdparty.module.ts
@@ -0,0 +1,12 @@
+import { Global, Module } from '@nestjs/common';
+import { HeygenProvider } from '@gitroom/nestjs-libraries/3rdparties/heygen/heygen.provider';
+import { ThirdPartyManager } from '@gitroom/nestjs-libraries/3rdparties/thirdparty.manager';
+
+@Global()
+@Module({
+ providers: [HeygenProvider, ThirdPartyManager],
+ get exports() {
+ return this.providers;
+ },
+})
+export class ThirdPartyModule {}
diff --git a/libraries/nestjs-libraries/src/database/prisma/database.module.ts b/libraries/nestjs-libraries/src/database/prisma/database.module.ts
index fda39f4e..031c29e0 100644
--- a/libraries/nestjs-libraries/src/database/prisma/database.module.ts
+++ b/libraries/nestjs-libraries/src/database/prisma/database.module.ts
@@ -37,6 +37,8 @@ import { AutopostRepository } from '@gitroom/nestjs-libraries/database/prisma/au
import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service';
import { SetsService } from '@gitroom/nestjs-libraries/database/prisma/sets/sets.service';
import { SetsRepository } from '@gitroom/nestjs-libraries/database/prisma/sets/sets.repository';
+import { ThirdPartyRepository } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.repository';
+import { ThirdPartyService } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.service';
@Global()
@Module({
@@ -82,6 +84,8 @@ import { SetsRepository } from '@gitroom/nestjs-libraries/database/prisma/sets/s
ShortLinkService,
SetsService,
SetsRepository,
+ ThirdPartyRepository,
+ ThirdPartyService,
],
get exports() {
return this.providers;
diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma
index f7558af6..edc2262b 100644
--- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma
+++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma
@@ -38,6 +38,7 @@ model Organization {
signatures Signatures[]
autoPost AutoPost[]
sets Sets[]
+ thirdParty ThirdParty[]
}
model Tags {
@@ -612,6 +613,23 @@ model Sets {
@@index([organizationId])
}
+model ThirdParty {
+ id String @id @default(uuid())
+ organizationId String
+ organization Organization @relation(fields: [organizationId], references: [id])
+ identifier String
+ name String
+ internalId String
+ apiKey String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ deletedAt DateTime?
+
+ @@index([organizationId])
+ @@index([deletedAt])
+ @@unique([organizationId, internalId])
+}
+
enum OrderStatus {
PENDING
ACCEPTED
diff --git a/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.repository.ts b/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.repository.ts
new file mode 100644
index 00000000..48a9933e
--- /dev/null
+++ b/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.repository.ts
@@ -0,0 +1,64 @@
+import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
+import { Injectable } from '@nestjs/common';
+import { AuthService } from '@gitroom/helpers/auth/auth.service';
+
+@Injectable()
+export class ThirdPartyRepository {
+ constructor(private _thirdParty: PrismaRepository<'thirdParty'>) {}
+
+ getAllThirdPartiesByOrganization(org: string) {
+ return this._thirdParty.model.thirdParty.findMany({
+ where: { organizationId: org, deletedAt: null },
+ select: {
+ id: true,
+ name: true,
+ identifier: true,
+ },
+ });
+ }
+
+ deleteIntegration(org: string, id: string) {
+ return this._thirdParty.model.thirdParty.update({
+ where: { id, organizationId: org },
+ data: { deletedAt: new Date() },
+ });
+ }
+
+ getIntegrationById(org: string, id: string) {
+ return this._thirdParty.model.thirdParty.findFirst({
+ where: { id, organizationId: org, deletedAt: null },
+ });
+ }
+
+ saveIntegration(
+ org: string,
+ identifier: string,
+ apiKey: string,
+ data: { name: string; username: string; id: string }
+ ) {
+ return this._thirdParty.model.thirdParty.upsert({
+ where: {
+ organizationId_internalId: {
+ internalId: data.id,
+ organizationId: org,
+ },
+ },
+ create: {
+ organizationId: org,
+ name: data.name,
+ internalId: data.id,
+ identifier,
+ apiKey: AuthService.fixedEncryption(apiKey),
+ deletedAt: null,
+ },
+ update: {
+ organizationId: org,
+ name: data.name,
+ internalId: data.id,
+ identifier,
+ apiKey: AuthService.fixedEncryption(apiKey),
+ deletedAt: null,
+ },
+ });
+ }
+}
diff --git a/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.service.ts b/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.service.ts
new file mode 100644
index 00000000..3fd888b8
--- /dev/null
+++ b/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.service.ts
@@ -0,0 +1,28 @@
+import { Injectable } from '@nestjs/common';
+import { ThirdPartyRepository } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.repository';
+
+@Injectable()
+export class ThirdPartyService {
+ constructor(private _thirdPartyRepository: ThirdPartyRepository) {}
+
+ getAllThirdPartiesByOrganization(org: string) {
+ return this._thirdPartyRepository.getAllThirdPartiesByOrganization(org);
+ }
+
+ deleteIntegration(org: string, id: string) {
+ return this._thirdPartyRepository.deleteIntegration(org, id);
+ }
+
+ getIntegrationById(org: string, id: string) {
+ return this._thirdPartyRepository.getIntegrationById(org, id);
+ }
+
+ saveIntegration(
+ org: string,
+ identifier: string,
+ apiKey: string,
+ data: { name: string; username: string; id: string }
+ ) {
+ return this._thirdPartyRepository.saveIntegration(org, identifier, apiKey, data);
+ }
+}
diff --git a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
index 58674827..07476470 100644
--- a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts
@@ -24,7 +24,7 @@ export class FarcasterProvider
name = 'Warpcast';
isBetweenSteps = false;
isWeb3 = true;
- scopes = [];
+ scopes = [] as string[];
async refreshToken(refresh_token: string): Promise {
return {
diff --git a/libraries/nestjs-libraries/src/openai/openai.service.ts b/libraries/nestjs-libraries/src/openai/openai.service.ts
index 1ccdbfd8..fe0f5182 100644
--- a/libraries/nestjs-libraries/src/openai/openai.service.ts
+++ b/libraries/nestjs-libraries/src/openai/openai.service.ts
@@ -12,6 +12,10 @@ const PicturePrompt = z.object({
prompt: z.string(),
});
+const VoicePrompt = z.object({
+ voice: z.string(),
+});
+
@Injectable()
export class OpenaiService {
async generateImage(prompt: string, isUrl: boolean) {
@@ -47,6 +51,27 @@ export class OpenaiService {
);
}
+ async generateVoiceFromText(prompt: string) {
+ return (
+ (
+ await openai.beta.chat.completions.parse({
+ model: 'gpt-4.1',
+ messages: [
+ {
+ role: 'system',
+ content: `You are an assistant that takes a social media post and convert it to a normal human voice, to be later added to a character, when a person talk they don\'t use "-", and sometimes they add pause with "..." to make it sounds more natural, make sure you use a lot of pauses and make it sound like a real person`,
+ },
+ {
+ role: 'user',
+ content: `prompt: ${prompt}`,
+ },
+ ],
+ response_format: zodResponseFormat(VoicePrompt, 'voice'),
+ })
+ ).choices[0].message.parsed?.voice || ''
+ );
+ }
+
async generatePosts(content: string) {
const posts = (
await Promise.all([
@@ -142,7 +167,9 @@ export class OpenaiService {
messages: [
{
role: 'system',
- content: `You are an assistant that take a social media post and break it to a thread, each post must be minimum ${len - 10} and maximum ${len} characters, keeping the exact wording and break lines, however make sure you split posts based on context`,
+ content: `You are an assistant that take a social media post and break it to a thread, each post must be minimum ${
+ len - 10
+ } and maximum ${len} characters, keeping the exact wording and break lines, however make sure you split posts based on context`,
},
{
role: 'user',
diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json
index f52fad35..a085748a 100644
--- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json
+++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json
@@ -186,6 +186,8 @@
"please_add_the_following_command_in_your_chat": "Please add the following command in your chat:",
"copy": "Copy",
"settings": "Settings",
+ "integrations": "Integrations",
+ "add_integration": "Add Integration",
"you_are_now_editing_only": "You are now editing only",
"tag_a_company": "Tag a company",
"video_length_is_invalid_must_be_up_to": "Video length is invalid, must be up to",
@@ -486,5 +488,6 @@
"post_as_images_carousel": "Post as images carousel",
"save_set": "Save Set",
"separate_post": "Separate post to multiple posts",
- "label_who_can_reply_to_this_post": "Who can reply to this post?"
+ "label_who_can_reply_to_this_post": "Who can reply to this post?",
+ "delete_integration": "Delete Integration"
}