Merge pull request #479 from gitroomhq/feat/plugs

Plugs
This commit is contained in:
Nevo David 2024-12-09 16:43:56 +07:00 committed by GitHub
commit b17727bccc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1484 additions and 49 deletions

View File

@ -1,13 +1,5 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseFilters,
Body, Controller, Delete, Get, Param, Post, Put, Query, UseFilters
} from '@nestjs/common';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { ConnectIntegrationDto } from '@gitroom/nestjs-libraries/dtos/integrations/connect.integration.dto';
@ -30,6 +22,7 @@ import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/po
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
import {
NotEnoughScopes,
RefreshToken,
@ -538,4 +531,35 @@ export class IntegrationsController {
return this._integrationService.deleteChannel(org.id, id);
}
@Get('/plug/list')
async getPlugList() {
return { plugs: this._integrationManager.getAllPlugs() };
}
@Get('/:id/plugs')
async getPlugsByIntegrationId(
@Param('id') id: string,
@GetOrgFromRequest() org: Organization
) {
return this._integrationService.getPlugsByIntegrationId(org.id, id);
}
@Post('/:id/plugs')
async postPlugsByIntegrationId(
@Param('id') id: string,
@GetOrgFromRequest() org: Organization,
@Body() body: PlugDto
) {
return this._integrationService.createOrUpdatePlug(org.id, id, body);
}
@Put('/plugs/:id/activate')
async changePlugActivation(
@Param('id') id: string,
@GetOrgFromRequest() org: Organization,
@Body('status') status: boolean
) {
return this._integrationService.changePlugActivation(org.id, id, status);
}
}

View File

@ -0,0 +1,19 @@
import { Plugs } from '@gitroom/frontend/components/plugs/plugs';
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' : 'Gitroom'} Plugs`,
description: '',
};
export default async function Index() {
return (
<>
<Plugs />
</>
);
}

View File

@ -44,14 +44,9 @@ export const useMenuItems = () => {
]
: []),
{
name: 'Marketplace',
icon: 'marketplace',
path: '/marketplace',
},
{
name: 'Messages',
icon: 'messages',
path: '/messages',
name: 'Plugs',
icon: 'plugs',
path: '/plugs',
},
{
name: 'Billing',

View File

@ -0,0 +1,316 @@
'use client';
import {
PlugSettings,
PlugsInterface,
usePlugs,
} from '@gitroom/frontend/components/plugs/plugs.context';
import { Button } from '@gitroom/react/form/button';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR, { mutate } from 'swr';
import { useModals } from '@mantine/modals';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import {
FormProvider,
SubmitHandler,
useForm,
useFormContext,
} from 'react-hook-form';
import { Input } from '@gitroom/react/form/input';
import { CopilotTextarea } from '@copilotkit/react-textarea';
import clsx from 'clsx';
import { string, object } from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { Slider } from '@gitroom/react/form/slider';
import { useToaster } from '@gitroom/react/toaster/toaster';
export function convertBackRegex(s: string) {
const matches = s.match(/\/(.*)\/([a-z]*)/);
const pattern = matches?.[1] || '';
const flags = matches?.[2] || '';
return new RegExp(pattern, flags);
}
export const TextArea: FC<{ name: string; placeHolder: string }> = (props) => {
const form = useFormContext();
const { onChange, onBlur, ...all } = form.register(props.name);
const value = form.watch(props.name);
return (
<>
<textarea className="hidden" {...all}></textarea>
<CopilotTextarea
disableBranding={true}
placeholder={props.placeHolder}
value={value}
className={clsx(
'!min-h-40 !max-h-80 p-[24px] overflow-hidden bg-customColor2 outline-none rounded-[4px] border-fifth border'
)}
onChange={(e) => {
onChange({ target: { name: props.name, value: e.target.value } });
}}
autosuggestionsConfig={{
textareaPurpose: `Assist me in writing social media posts.`,
chatApiConfigs: {},
}}
/>
<div className="text-red-400 text-[12px]">
{form?.formState?.errors?.[props.name]?.message as string}
</div>
</>
);
};
export const PlugPop: FC<{
plug: PlugsInterface;
settings: PlugSettings;
data?: {
activated: boolean;
data: string;
id: string;
integrationId: string;
organizationId: string;
plugFunction: string;
};
}> = (props) => {
const { plug, settings, data } = props;
const { closeAll } = useModals();
const fetch = useFetch();
const toaster = useToaster();
const values = useMemo(() => {
if (!data?.data) {
return {};
}
return JSON.parse(data.data).reduce((acc: any, current: any) => {
return {
...acc,
[current.name]: current.value,
};
}, {} as any);
}, []);
const yupSchema = useMemo(() => {
return object(
plug.fields.reduce((acc, field) => {
return {
...acc,
[field.name]: field.validation
? string().matches(convertBackRegex(field.validation), {
message: 'Invalid value',
})
: null,
};
}, {})
);
}, []);
const form = useForm({
resolver: yupResolver(yupSchema),
values,
mode: 'all',
});
const submit: SubmitHandler<any> = useCallback(async (data) => {
await fetch(`/integrations/${settings.providerId}/plugs`, {
method: 'POST',
body: JSON.stringify({
func: plug.methodName,
fields: Object.keys(data).map((key) => ({
name: key,
value: data[key],
})),
}),
});
toaster.show('Plug updated', 'success');
closeAll();
}, []);
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<div className="fixed left-0 top-0 bg-primary/80 z-[300] w-full min-h-full p-4 md:p-[60px] animate-fade">
<div className="max-w-[1000px] w-full h-full bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative mx-auto">
<div className="flex flex-col">
<div className="flex-1">
<TopTitle title={`Auto Plug: ${plug.title}`} />
</div>
<button
onClick={closeAll}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
<div className="my-[20px]">{plug.description}</div>
<div>
{plug.fields.map((field) => (
<div key={field.name}>
{field.type === 'richtext' ? (
<TextArea
name={field.name}
placeHolder={field.placeholder}
/>
) : (
<Input
name={field.name}
label={field.description}
className="w-full mt-[8px] p-[8px] border border-tableBorder rounded-md text-black"
placeholder={field.placeholder}
type={field.type}
/>
)}
</div>
))}
</div>
<div className="mt-[20px]">
<Button type="submit">Activate</Button>
</div>
</div>
</div>
</form>
</FormProvider>
);
};
export const PlugItem: FC<{
plug: PlugsInterface;
addPlug: (data: any) => void;
data?: {
activated: boolean;
data: string;
id: string;
integrationId: string;
organizationId: string;
plugFunction: string;
};
}> = (props) => {
const { plug, addPlug, data } = props;
const [activated, setActivated] = useState(!!data?.activated);
useEffect(() => {
setActivated(!!data?.activated);
}, [data?.activated]);
const fetch = useFetch();
const changeActivated = useCallback(
async (status: 'on' | 'off') => {
await fetch(`/integrations/plugs/${data?.id}/activate`, {
body: JSON.stringify({
status: status === 'on',
}),
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
});
setActivated(status === 'on');
},
[activated]
);
return (
<div
onClick={() => addPlug(data)}
key={plug.title}
className="w-full h-[300px] bg-customColor48 hover:bg-customColor2 hover:border-customColor48 hover:border"
>
<div key={plug.title} className="p-[16px] h-full flex flex-col flex-1">
<div className="flex">
<div className="text-[20px] mb-[8px] flex-1">{plug.title}</div>
{!!data && (
<div onClick={(e) => e.stopPropagation()}>
<Slider
value={activated ? 'on' : 'off'}
onChange={changeActivated}
fill={true}
/>
</div>
)}
</div>
<div className="flex-1">{plug.description}</div>
<Button>{!data ? 'Set Plug' : 'Edit Plug'}</Button>
</div>
</div>
);
};
export const Plug = () => {
const plug = usePlugs();
const modals = useModals();
const fetch = useFetch();
const load = useCallback(async () => {
return (await fetch(`/integrations/${plug.providerId}/plugs`)).json();
}, [plug.providerId]);
const { data, isLoading, mutate } = useSWR(`plugs-${plug.providerId}`, load);
const addEditPlug = useCallback(
(p: PlugsInterface) =>
(data?: {
activated: boolean;
data: string;
id: string;
integrationId: string;
organizationId: string;
plugFunction: string;
}) => {
modals.openModal({
classNames: {
modal: 'bg-transparent text-textColor',
},
withCloseButton: false,
onClose() {
mutate();
},
size: '100%',
children: (
<PlugPop
plug={p}
data={data}
settings={{
identifier: plug.identifier,
providerId: plug.providerId,
name: plug.name,
}}
/>
),
});
},
[data]
);
if (isLoading) {
return null;
}
return (
<div className="grid grid-cols-3 gap-[30px]">
{plug.plugs.map((p) => (
<PlugItem
key={p.title + '-' + plug.providerId}
addPlug={addEditPlug(p)}
plug={p}
data={data?.find((a: any) => a.plugFunction === p.methodName)}
/>
))}
</div>
);
};

View File

@ -0,0 +1,46 @@
'use client';
import { createContext, useContext } from 'react';
export interface PlugSettings {
providerId: string;
name: string;
identifier: string;
}
export interface PlugInterface extends PlugSettings {
plugs: PlugsInterface[];
}
export interface FieldsInterface {
name: string;
type: string;
validation: string;
placeholder: string;
description: string;
}
export interface PlugsInterface {
title: string;
description: string;
runEveryMilliseconds: number;
methodName: string;
fields: FieldsInterface[];
}
export const PlugsContext = createContext<PlugInterface>({
providerId: '',
name: '',
identifier: '',
plugs: [
{
title: '',
description: '',
runEveryMilliseconds: 0,
methodName: '',
fields: [{ name: '', type: '', placeholder: '', description: '', validation: '' }],
},
],
});
export const usePlugs = () => useContext(PlugsContext);

View File

@ -0,0 +1,173 @@
'use client';
import useSWR from 'swr';
import { useCallback, useMemo, useState } from 'react';
import { capitalize, orderBy } from 'lodash';
import clsx from 'clsx';
import ImageWithFallback from '@gitroom/react/helpers/image.with.fallback';
import Image from 'next/image';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Select } from '@gitroom/react/form/select';
import { Button } from '@gitroom/react/form/button';
import { useRouter } from 'next/navigation';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { PlugsContext } from '@gitroom/frontend/components/plugs/plugs.context';
import { Plug } from '@gitroom/frontend/components/plugs/plug';
export const Plugs = () => {
const fetch = useFetch();
const router = useRouter();
const [current, setCurrent] = useState(0);
const [refresh, setRefresh] = useState(false);
const toaster = useToaster();
const load = useCallback(async () => {
return (await (await fetch('/integrations/list')).json()).integrations;
}, []);
const load2 = useCallback(async (path: string) => {
return await (await fetch(path)).json();
}, []);
const { data: plugList, isLoading: plugLoading } = useSWR(
'/integrations/plug/list',
load2,
{
fallbackData: [],
}
);
const { data, isLoading } = useSWR('analytics-list', load, {
fallbackData: [],
});
const sortedIntegrations = useMemo(() => {
return orderBy(
data.filter((integration: any) =>
plugList?.plugs?.some(
(f: any) => f.identifier === integration.identifier
)
),
// data.filter((integration) => !integration.disabled),
['type', 'disabled', 'identifier'],
['desc', 'asc', 'asc']
);
}, [data, plugList]);
const currentIntegration = useMemo(() => {
return sortedIntegrations[current];
}, [current, sortedIntegrations]);
const currentIntegrationPlug = useMemo(() => {
const plug = plugList?.plugs?.find(
(f: any) => f?.identifier === currentIntegration?.identifier
);
if (!plug) {
return null;
}
return {
providerId: currentIntegration.id,
...plug,
};
}, [currentIntegration, plugList]);
if (isLoading || plugLoading) {
return null;
}
if (!sortedIntegrations.length && !isLoading) {
return (
<div className="flex flex-col items-center mt-[100px] gap-[27px] text-center">
<div>
<img src="/peoplemarketplace.svg" />
</div>
<div className="text-[48px]">
There are not plugs matching your channels
<br />
You have to add: X or LinkedIn or Threads
</div>
<Button onClick={() => router.push('/launches')}>
Go to the calendar to add channels
</Button>
</div>
);
}
return (
<div className="flex gap-[30px] flex-1">
<div className="p-[16px] bg-customColor48 overflow-hidden flex w-[220px]">
<div className="flex gap-[16px] flex-col overflow-hidden">
<div className="text-[20px] mb-[8px]">Channels</div>
{sortedIntegrations.map((integration, index) => (
<div
key={integration.id}
onClick={() => {
if (integration.refreshNeeded) {
toaster.show(
'Please refresh the integration from the calendar',
'warning'
);
return;
}
setRefresh(true);
setTimeout(() => {
setRefresh(false);
}, 10);
setCurrent(index);
}}
className={clsx(
'flex gap-[8px] items-center',
currentIntegration.id !== integration.id &&
'opacity-20 hover:opacity-100 cursor-pointer'
)}
>
<div
className={clsx(
'relative w-[34px] h-[34px] rounded-full flex justify-center items-center bg-fifth',
integration.disabled && 'opacity-50'
)}
>
{(integration.inBetweenSteps || integration.refreshNeeded) && (
<div className="absolute left-0 top-0 w-[39px] h-[46px] cursor-pointer">
<div className="bg-red-500 w-[15px] h-[15px] rounded-full left-0 -top-[5px] absolute z-[200] text-[10px] flex justify-center items-center">
!
</div>
<div className="bg-primary/60 w-[39px] h-[46px] left-0 top-0 absolute rounded-full z-[199]" />
</div>
)}
<ImageWithFallback
fallbackSrc={`/icons/platforms/${integration.identifier}.png`}
src={integration.picture}
className="rounded-full"
alt={integration.identifier}
width={32}
height={32}
/>
<Image
src={`/icons/platforms/${integration.identifier}.png`}
className="rounded-full absolute z-10 -bottom-[5px] -right-[5px] border border-fifth"
alt={integration.identifier}
width={20}
height={20}
/>
</div>
<div
className={clsx(
'flex-1 whitespace-nowrap text-ellipsis overflow-hidden',
integration.disabled && 'opacity-50'
)}
>
{integration.name}
</div>
</div>
))}
</div>
</div>
<div className="flex-1 flex flex-col gap-[14px]">
<PlugsContext.Provider value={currentIntegrationPlug}>
<Plug />
</PlugsContext.Provider>
</div>
</div>
);
};

View File

@ -1,14 +1,19 @@
import { Module } from '@nestjs/common';
import { StarsController } from './stars.controller';
import {DatabaseModule} from "@gitroom/nestjs-libraries/database/prisma/database.module";
import {TrendingService} from "@gitroom/nestjs-libraries/services/trending.service";
import {PostsController} from "@gitroom/workers/app/posts.controller";
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { TrendingService } from '@gitroom/nestjs-libraries/services/trending.service';
import { PostsController } from '@gitroom/workers/app/posts.controller';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { PlugsController } from '@gitroom/workers/app/plugs.controller';
@Module({
imports: [DatabaseModule, BullMqModule],
controllers: [...!process.env.IS_GENERAL ? [StarsController] : [], PostsController],
controllers: [
...(!process.env.IS_GENERAL ? [StarsController] : []),
PostsController,
PlugsController,
],
providers: [TrendingService],
})
export class AppModule {}

View File

@ -0,0 +1,21 @@
import { Controller } from '@nestjs/common';
import { EventPattern, Transport } from '@nestjs/microservices';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
@Controller()
export class PlugsController {
constructor(
private _integrationService: IntegrationService
) {}
@EventPattern('plugs', Transport.REDIS)
async plug(data: {
plugId: string;
postId: string;
delay: number;
totalRuns: number;
currentRun: number;
}) {
return this._integrationService.processPlugs(data);
}
}

View File

@ -12,7 +12,7 @@ export class PostsController {
}
@EventPattern('submit', Transport.REDIS)
async payout(data: { id: string, releaseURL: string }) {
async payout(data: { id: string; releaseURL: string }) {
return this._postsService.payout(data.id, data.releaseURL);
}
}

View File

@ -0,0 +1,27 @@
import 'reflect-metadata';
export function Plug(params: {
identifier: string;
title: string;
description: string;
runEveryMilliseconds: number;
totalRuns: number;
fields: {
name: string;
description: string;
type: string;
placeholder: string;
validation?: RegExp;
}[];
}) {
return function (target: Object, propertyKey: string | symbol, descriptor: any) {
// Retrieve existing metadata or initialize an empty array
const existingMetadata = Reflect.getMetadata('custom:plug', target) || [];
// Add the metadata information for this method
existingMetadata.push({ methodName: propertyKey, ...params });
// Define metadata on the class prototype (so it can be retrieved from the class)
Reflect.defineMetadata('custom:plug', existingMetadata, target);
};
}

View File

@ -40,8 +40,8 @@ export class BullMqClient extends ClientProxy {
const job = await queue.add(packet.pattern, packet.data, {
jobId: packet.data.id ?? v4(),
...packet.data.options,
removeOnComplete: true,
removeOnFail: true,
removeOnComplete: !packet.data.options.attempts,
removeOnFail: !packet.data.options.attempts,
});
try {

View File

@ -5,14 +5,17 @@ import { Integration } from '@prisma/client';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
@Injectable()
export class IntegrationRepository {
private storage = UploadFactory.createStorage();
constructor(
private _integration: PrismaRepository<'integration'>,
private _customers: PrismaRepository<'customer'>,
private _posts: PrismaRepository<'post'>
private _posts: PrismaRepository<'post'>,
private _plugs: PrismaRepository<'plugs'>,
private _exisingPlugData: PrismaRepository<'exisingPlugData'>,
private _customers: PrismaRepository<'customer'>
) {}
async setTimes(org: string, id: string, times: IntegrationTimeDto) {
@ -30,6 +33,35 @@ export class IntegrationRepository {
});
}
getPlug(plugId: string) {
return this._plugs.model.plugs.findFirst({
where: {
id: plugId,
},
include: {
integration: true
}
});
}
async getPlugs(orgId: string, integrationId: string) {
return this._plugs.model.plugs.findMany({
where: {
integrationId,
organizationId: orgId,
activated: true,
},
include: {
integration: {
select: {
id: true,
providerIdentifier: true,
},
},
},
});
}
async updateIntegration(id: string, params: Partial<Integration>) {
if (
params.picture &&
@ -378,4 +410,80 @@ export class IntegrationRepository {
});
}
}
getPlugsByIntegrationId(org: string, id: string) {
return this._plugs.model.plugs.findMany({
where: {
organizationId: org,
integrationId: id,
},
});
}
createOrUpdatePlug(org: string, integrationId: string, body: PlugDto) {
return this._plugs.model.plugs.upsert({
where: {
organizationId: org,
plugFunction_integrationId: {
integrationId,
plugFunction: body.func,
},
},
create: {
integrationId,
organizationId: org,
plugFunction: body.func,
data: JSON.stringify(body.fields),
activated: true,
},
update: {
data: JSON.stringify(body.fields),
},
select: {
activated: true,
},
});
}
changePlugActivation(orgId: string, plugId: string, status: boolean) {
return this._plugs.model.plugs.update({
where: {
organizationId: orgId,
id: plugId,
},
data: {
activated: !!status,
},
});
}
async loadExisingData(
methodName: string,
integrationId: string,
id: string[]
) {
return this._exisingPlugData.model.exisingPlugData.findMany({
where: {
integrationId,
methodName,
value: {
in: id,
},
},
});
}
async saveExisingData(
methodName: string,
integrationId: string,
value: string[]
) {
return this._exisingPlugData.model.exisingPlugData.createMany({
data: value.map((p) => ({
integrationId,
methodName,
value: p,
})),
});
}
}

View File

@ -1,13 +1,12 @@
import {
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider';
import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider';
import { AnalyticsData, SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import {
AnalyticsData,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { Integration, Organization } from '@prisma/client';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider';
@ -17,6 +16,9 @@ import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import { difference } from 'lodash';
@Injectable()
export class IntegrationService {
@ -24,10 +26,15 @@ export class IntegrationService {
constructor(
private _integrationRepository: IntegrationRepository,
private _integrationManager: IntegrationManager,
private _notificationService: NotificationService
private _notificationService: NotificationService,
private _workerServiceProducer: BullMqClient
) {}
async setTimes(orgId: string, integrationId: string, times: IntegrationTimeDto) {
async setTimes(
orgId: string,
integrationId: string,
times: IntegrationTimeDto
) {
return this._integrationRepository.setTimes(orgId, integrationId, times);
}
@ -47,7 +54,9 @@ export class IntegrationService {
timezone?: number,
customInstanceDetails?: string
) {
const uploadedPicture = picture ? await this.storage.uploadSimple(picture) : undefined;
const uploadedPicture = picture
? await this.storage.uploadSimple(picture)
: undefined;
return this._integrationRepository.createOrUpdateIntegration(
org,
name,
@ -295,7 +304,12 @@ export class IntegrationService {
return { success: true };
}
async checkAnalytics(org: Organization, integration: string, date: string, forceRefresh = false): Promise<AnalyticsData[]> {
async checkAnalytics(
org: Organization,
integration: string,
date: string,
forceRefresh = false
): Promise<AnalyticsData[]> {
const getIntegration = await this.getIntegrationById(org.id, integration);
if (!getIntegration) {
@ -310,7 +324,10 @@ export class IntegrationService {
getIntegration.providerIdentifier
);
if (dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || forceRefresh) {
if (
dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) ||
forceRefresh
) {
const { accessToken, expiresIn, refreshToken } =
await integrationProvider.refreshToken(getIntegration.refreshToken!);
@ -374,4 +391,113 @@ export class IntegrationService {
customers(orgId: string) {
return this._integrationRepository.customers(orgId);
}
getPlugsByIntegrationId(org: string, integrationId: string) {
return this._integrationRepository.getPlugsByIntegrationId(
org,
integrationId
);
}
async processPlugs(data: {
plugId: string;
postId: string;
delay: number;
totalRuns: number;
currentRun: number;
}) {
const getPlugById = await this._integrationRepository.getPlug(data.plugId);
if (!getPlugById) {
return ;
}
const integration = this._integrationManager.getSocialIntegration(
getPlugById.integration.providerIdentifier
);
const findPlug = this._integrationManager
.getAllPlugs()
.find(
(p) => p.identifier === getPlugById.integration.providerIdentifier
)!;
console.log(data.postId);
// @ts-ignore
const process = await integration[getPlugById.plugFunction](
getPlugById.integration,
data.postId,
JSON.parse(getPlugById.data).reduce((all: any, current: any) => {
all[current.name] = current.value;
return all;
}, {})
);
if (process) {
return ;
}
if (data.totalRuns === data.currentRun) {
return ;
}
this._workerServiceProducer.emit('plugs', {
id: 'plug_' + data.postId + '_' + findPlug.identifier,
options: {
delay: 0, // runPlug.runEveryMilliseconds,
},
payload: {
plugId: data.plugId,
postId: data.postId,
delay: data.delay,
totalRuns: data.totalRuns,
currentRun: data.currentRun + 1,
},
});
}
async createOrUpdatePlug(
orgId: string,
integrationId: string,
body: PlugDto
) {
const { activated } = await this._integrationRepository.createOrUpdatePlug(
orgId,
integrationId,
body
);
return {
activated,
};
}
async changePlugActivation(orgId: string, plugId: string, status: boolean) {
const { id, integrationId, plugFunction } =
await this._integrationRepository.changePlugActivation(
orgId,
plugId,
status
);
return { id };
}
async getPlugs(orgId: string, integrationId: string) {
return this._integrationRepository.getPlugs(orgId, integrationId);
}
async loadExisingData(
methodName: string,
integrationId: string,
id: string[]
) {
const exisingData = await this._integrationRepository.loadExisingData(
methodName,
integrationId,
id
);
const loadOnlyIds = exisingData.map((p) => p.value);
return difference(id, loadOnlyIds);
}
}

View File

@ -299,6 +299,13 @@ export class PostsService {
true
);
await this.checkPlugs(
integration.organizationId,
getIntegration.identifier,
integration.id,
publishedPosts[0].postId
);
return {
postId: publishedPosts[0].postId,
releaseURL: publishedPosts[0].releaseURL,
@ -312,6 +319,42 @@ export class PostsService {
}
}
private async checkPlugs(
orgId: string,
providerName: string,
integrationId: string,
postId: string
) {
const loadAllPlugs = this._integrationManager.getAllPlugs();
const getPlugs = await this._integrationService.getPlugs(
orgId,
integrationId
);
const currentPlug = loadAllPlugs.find((p) => p.identifier === providerName);
for (const plug of getPlugs) {
const runPlug = currentPlug?.plugs?.find((p: any) => p.methodName === plug.plugFunction)!;
if (!runPlug) {
continue;
}
this._workerServiceProducer.emit('plugs', {
id: 'plug_' + postId + '_' + runPlug.identifier,
options: {
delay: runPlug.runEveryMilliseconds,
},
payload: {
plugId: plug.id,
postId,
delay: runPlug.runEveryMilliseconds,
totalRuns: runPlug.totalRuns,
currentRun: 1,
},
});
}
}
private async postArticle(integration: Integration, posts: Post[]) {
const getIntegration = this._integrationManager.getArticlesIntegration(
integration.providerIdentifier

View File

@ -29,6 +29,7 @@ model Organization {
buyerOrganization MessagesGroup[]
usedCodes UsedCodes[]
credits Credits[]
plugs Plugs[]
customers Customer[]
}
@ -280,6 +281,8 @@ model Integration {
customInstanceDetails String?
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
plugs Plugs[]
exisingPlugData ExisingPlugData[]
@@index([updatedAt])
@@index([deletedAt])
@ -455,6 +458,30 @@ model Messages {
@@index([deletedAt])
}
model Plugs {
id String @id @default(uuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
plugFunction String
data String
integrationId String
integration Integration @relation(fields: [integrationId], references: [id])
activated Boolean @default(true)
@@unique([plugFunction, integrationId])
@@index([organizationId])
}
model ExisingPlugData {
id String @id @default(uuid())
integrationId String
integration Integration @relation(fields: [integrationId], references: [id])
methodName String
value String
@@unique([integrationId, methodName, value])
}
enum OrderStatus {
PENDING
ACCEPTED

View File

@ -0,0 +1,23 @@
import { IsDefined, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FieldsDto {
@IsString()
@IsDefined()
name: string;
@IsString()
@IsDefined()
value: string;
}
export class PlugDto {
@IsString()
@IsDefined()
func: string;
@Type(() => FieldsDto)
@ValidateNested({ each: true })
@IsDefined()
fields: FieldsDto[];
}

View File

@ -1,3 +1,5 @@
import 'reflect-metadata';
import { Injectable } from '@nestjs/common';
import { XProvider } from '@gitroom/nestjs-libraries/integrations/social/x.provider';
import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
@ -64,6 +66,27 @@ export class IntegrationManager {
})),
};
}
getAllPlugs() {
return socialIntegrationList
.map((p) => {
return {
name: p.name,
identifier: p.identifier,
plugs: (
Reflect.getMetadata('custom:plug', p.constructor.prototype) || []
).map((p: any) => ({
...p,
fields: p.fields.map((c: any) => ({
...c,
validation: c?.validation?.toString(),
})),
})),
};
})
.filter((f) => f.plugs.length);
}
getAllowedSocialsIntegrations() {
return socialIntegrationList.map((p) => p.identifier);
}

View File

@ -11,6 +11,8 @@ import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import sharp from 'sharp';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { timer } from '@gitroom/helpers/utils/timer';
export class BlueskyProvider extends SocialAbstract implements SocialProvider {
identifier = 'bluesky';
@ -116,7 +118,7 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
let loadCid = '';
let loadUri = '';
const cidUrl = [] as { cid: string; url: string, rev: string }[];
const cidUrl = [] as { cid: string; url: string; rev: string }[];
for (const post of postDetails) {
const images = await Promise.all(
post.media?.map(async (p) => {
@ -134,9 +136,9 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
const rt = new RichText({
text: post.message,
})
});
await rt.detectFacets(agent)
await rt.detectFacets(agent);
// @ts-ignore
const { cid, uri, commit } = await agent.post({
@ -179,9 +181,143 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
return postDetails.map((p, index) => ({
id: p.id,
postId: cidUrl[index].cid,
postId: cidUrl[index].url,
status: 'completed',
releaseURL: `https://bsky.app/profile/${id}/post/${cidUrl[index].url.split('/').pop()}`,
releaseURL: `https://bsky.app/profile/${id}/post/${cidUrl[index].url
.split('/')
.pop()}`,
}));
}
@Plug({
identifier: 'bluesky-autoRepostPost',
title: 'Auto Repost Posts',
description:
'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
],
})
async autoRepostPost(
integration: Integration,
id: string,
fields: { likesAmount: string }
) {
const body = JSON.parse(
AuthService.fixedDecryption(integration.customInstanceDetails!)
);
const agent = new BskyAgent({
service: body.service,
});
await agent.login({
identifier: body.identifier,
password: body.password,
});
const getThread = await agent.getPostThread({
uri: id,
depth: 0,
});
// @ts-ignore
if (getThread.data.thread.post?.likeCount >= +fields.likesAmount) {
await timer(2000);
await agent.repost(
// @ts-ignore
getThread.data.thread.post?.uri,
// @ts-ignore
getThread.data.thread.post?.cid
);
return true;
}
return true;
}
@Plug({
identifier: 'bluesky-autoPlugPost',
title: 'Auto plug post',
description:
'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
{
name: 'post',
type: 'richtext',
placeholder: 'Post to plug',
description: 'Message content to plug',
validation: /^[\s\S]{3,}$/g,
},
],
})
async autoPlugPost(
integration: Integration,
id: string,
fields: { likesAmount: string; post: string }
) {
const body = JSON.parse(
AuthService.fixedDecryption(integration.customInstanceDetails!)
);
const agent = new BskyAgent({
service: body.service,
});
await agent.login({
identifier: body.identifier,
password: body.password,
});
const getThread = await agent.getPostThread({
uri: id,
depth: 0,
});
// @ts-ignore
if (getThread.data.thread.post?.likeCount >= +fields.likesAmount) {
await timer(2000);
const rt = new RichText({
text: fields.post,
});
await agent.post({
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
reply: {
root: {
// @ts-ignore
uri: getThread.data.thread.post?.uri,
// @ts-ignore
cid: getThread.data.thread.post?.cid,
},
parent: {
// @ts-ignore
uri: getThread.data.thread.post?.uri,
// @ts-ignore
cid: getThread.data.thread.post?.cid,
},
},
});
return true;
}
return true;
}
}

View File

@ -9,6 +9,8 @@ import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { timer } from '@gitroom/helpers/utils/timer';
export class LinkedinPageProvider
extends LinkedinProvider
@ -97,7 +99,7 @@ export class LinkedinPageProvider
}
async companies(accessToken: string) {
const { elements } = await (
const { elements, ...all } = await (
await fetch(
'https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&projection=(elements*(organizationalTarget~(localizedName,vanityName,logoV2(original~:playableStreams))))',
{
@ -360,6 +362,150 @@ export class LinkedinPageProvider
percentageChange: 5,
}));
}
@Plug({
identifier: 'linkedin-page-autoRepostPost',
title: 'Auto Repost Posts',
description:
'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
],
})
async autoRepostPost(
integration: Integration,
id: string,
fields: { likesAmount: string }
) {
const {
likesSummary: { totalLikes },
} = await (
await this.fetch(
`https://api.linkedin.com/v2/socialActions/${encodeURIComponent(id)}`,
{
method: 'GET',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${integration.token}`,
},
}
)
).json();
if (totalLikes >= +fields.likesAmount) {
await timer(2000);
await this.fetch(`https://api.linkedin.com/rest/posts`, {
body: JSON.stringify({
author: `urn:li:organization:${integration.internalId}`,
commentary: '',
visibility: 'PUBLIC',
distribution: {
feedDistribution: 'MAIN_FEED',
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: 'PUBLISHED',
isReshareDisabledByAuthor: false,
reshareContext: {
parent: id,
},
}),
method: 'POST',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${integration.token}`,
},
});
return true;
}
return false;
}
@Plug({
identifier: 'linkedin-page-autoPlugPost',
title: 'Auto plug post',
description:
'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
{
name: 'post',
type: 'richtext',
placeholder: 'Post to plug',
description: 'Message content to plug',
validation: /^[\s\S]{3,}$/g,
},
],
})
async autoPlugPost(
integration: Integration,
id: string,
fields: { likesAmount: string; post: string }
) {
const {
likesSummary: { totalLikes },
} = await (
await this.fetch(
`https://api.linkedin.com/v2/socialActions/${encodeURIComponent(id)}`,
{
method: 'GET',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202402',
Authorization: `Bearer ${integration.token}`,
},
}
)
).json();
if (totalLikes >= fields.likesAmount) {
await timer(2000);
await this.fetch(
`https://api.linkedin.com/v2/socialActions/${decodeURIComponent(
id
)}/comments`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${integration.token}`,
},
body: JSON.stringify({
actor: `urn:li:organization:${integration.internalId}`,
object: id,
message: {
text: this.fixText(fields.post)
},
}),
}
);
return true;
}
return false;
}
}
export interface Root {

View File

@ -14,7 +14,6 @@ import {
SocialAbstract,
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Integration } from '@prisma/client';
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
identifier = 'linkedin';
name = 'LinkedIn';
@ -22,6 +21,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
scopes = ['openid', 'profile', 'w_member_social', 'r_basicprofile'];
refreshWait = true;
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const {
access_token: accessToken,
@ -282,7 +282,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
}
}
private fixText(text: string) {
protected fixText(text: string) {
const pattern = /@\[.+?]\(urn:li:organization.+?\)/g;
const matches = text.match(pattern) || [];
const splitAll = text.split(pattern);
@ -431,7 +431,9 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
? `urn:li:person:${id}`
: `urn:li:organization:${id}`,
object: topPostId,
message: this.fixText(post.message),
message: {
text: this.fixText(post.message)
},
}),
}
)

View File

@ -10,6 +10,8 @@ import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { capitalize, chunk } from 'lodash';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { Integration } from '@prisma/client';
export class ThreadsProvider extends SocialAbstract implements SocialProvider {
identifier = 'threads';
@ -152,7 +154,6 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
let globalThread = '';
let link = '';
if (firstPost?.media?.length! <= 1) {
const type = !firstPost?.media?.[0]?.url
? undefined
@ -345,4 +346,73 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
})) || []
);
}
@Plug({
identifier: 'threads-autoPlugPost',
title: 'Auto plug post',
description:
'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
{
name: 'post',
type: 'richtext',
placeholder: 'Post to plug',
description: 'Message content to plug',
validation: /^[\s\S]{3,}$/g,
},
],
})
async autoPlugPost(
integration: Integration,
id: string,
fields: { likesAmount: string; post: string }
) {
const { data } = await (
await fetch(
`https://graph.threads.net/v1.0/${id}/insights?metric=likes&access_token=${integration.token}`
)
).json();
const {
values: [value],
} = data.find((p: any) => p.name === 'likes');
if (value.value >= fields.likesAmount) {
await timer(2000);
const form = new FormData();
form.append('media_type', 'TEXT');
form.append('text', fields.post);
form.append('reply_to_id', id);
form.append('access_token', integration.token);
const { id: replyId } = await (
await this.fetch('https://graph.threads.net/v1.0/me/threads', {
method: 'POST',
body: form,
})
).json();
await (
await this.fetch(
`https://graph.threads.net/v1.0/${integration.internalId}/threads_publish?creation_id=${replyId}&access_token=${integration.token}`,
{
method: 'POST',
}
)
).json();
return true;
}
return false;
}
}

View File

@ -10,6 +10,9 @@ import sharp from 'sharp';
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
import removeMd from 'remove-markdown';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { Integration } from '@prisma/client';
import { timer } from '@gitroom/helpers/utils/timer';
export class XProvider extends SocialAbstract implements SocialProvider {
identifier = 'x';
@ -17,10 +20,112 @@ export class XProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
scopes = [];
@Plug({
identifier: 'x-autoRepostPost',
title: 'Auto Repost Posts',
description:
'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
],
})
async autoRepostPost(
integration: Integration,
id: string,
fields: { likesAmount: string }
) {
// @ts-ignore
// eslint-disable-next-line prefer-rest-params
const [accessTokenSplit, accessSecretSplit] = integration.token.split(':');
const client = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: accessTokenSplit,
accessSecret: accessSecretSplit,
});
if (
(await client.v2.tweetLikedBy(id)).meta.result_count >=
+fields.likesAmount
) {
await timer(2000);
await client.v2.retweet(integration.internalId, id);
return true;
}
return false;
}
@Plug({
identifier: 'x-autoPlugPost',
title: 'Auto plug post',
description:
'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion',
runEveryMilliseconds: 21600000,
totalRuns: 3,
fields: [
{
name: 'likesAmount',
type: 'number',
placeholder: 'Amount of likes',
description: 'The amount of likes to trigger the repost',
validation: /^\d+$/,
},
{
name: 'post',
type: 'richtext',
placeholder: 'Post to plug',
description: 'Message content to plug',
validation: /^[\s\S]{3,}$/g,
},
],
})
async autoPlugPost(
integration: Integration,
id: string,
fields: { likesAmount: string; post: string }
) {
// @ts-ignore
// eslint-disable-next-line prefer-rest-params
const [accessTokenSplit, accessSecretSplit] = integration.token.split(':');
const client = new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: accessTokenSplit,
accessSecret: accessSecretSplit,
});
if (
(await client.v2.tweetLikedBy(id)).meta.result_count >=
+fields.likesAmount
) {
await timer(2000);
await client.v2.tweet({
text: removeMd(fields.post.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢')).replace(
'𝔫𝔢𝔴𝔩𝔦𝔫𝔢',
'\n'
),
reply: { in_reply_to_tweet_id: id },
});
return true;
}
return false;
}
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
const startingClient = new TwitterApi({
clientId: process.env.TWITTER_CLIENT_ID! || process.env.X_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET! || process.env.X_CLIENT_SECRET!,
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
});
const {
accessToken,