From d3ea4413a6343066465cb8de74199e4173c17740 Mon Sep 17 00:00:00 2001 From: Kervan Aslan Date: Fri, 13 Dec 2024 20:32:51 +0300 Subject: [PATCH 1/2] Development has been made to allow X users to generate and use their own APIKEY and Access Token. By setting ENABLE_X_SELF="true" in the .env file, each user can use their own API Key. --- .env.example | 4 + .../frontend/public/icons/platforms/xself.png | Bin 0 -> 1712 bytes .../launches/providers/show.all.providers.tsx | 98 +++--- .../providers/xself/xself.provider.tsx | 52 +++ .../src/integrations/integration.manager.ts | 3 +- .../src/integrations/social/xself.provider.ts | 304 ++++++++++++++++++ 6 files changed, 420 insertions(+), 41 deletions(-) create mode 100644 apps/frontend/public/icons/platforms/xself.png create mode 100644 apps/frontend/src/components/launches/providers/xself/xself.provider.tsx create mode 100644 libraries/nestjs-libraries/src/integrations/social/xself.provider.ts diff --git a/.env.example b/.env.example index 7ca10d13..7870234f 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,7 @@ STRIPE_SIGNING_KEY_CONNECT="" # Developer Settings NX_ADD_PLUGINS=false IS_GENERAL="true" # required for now + +#Enable X Integration with Self Generated Tokens. +ENABLE_X_SELF="true" + diff --git a/apps/frontend/public/icons/platforms/xself.png b/apps/frontend/public/icons/platforms/xself.png new file mode 100644 index 0000000000000000000000000000000000000000..59799be46b5e9d7fb61b4bb19e045ce403e04cb6 GIT binary patch literal 1712 zcmV;h22c5kP)m$uQi+GK9e^Zj3(ze7^@ z)|(gpuC{Z|_jf+edCvKr-#HJZlnMj_vXG4Dc}gk$*BEoy!Wt*9En)>@i&(+f0>u*f zr~}|z98_?b>CLZj?zja&Bod{kr>Cc zlgZ>uOXo6Hm6eq>Iy!2%+btH$Xu3fvjySo7t z7Z*oGMfujwTSo|qii#>NEd^k+*_xV~YHMrj>+2gE8%IV)c%C;JjjUN9OlfIpFJ8O= zaP8VPrBdl%<=f-(<;(Bhy$hhCqJmPYP$=Ybc}`9a0EfeI;=~CbL+*8wl$6xk+6usE zG_G8^(yzon79m8Z(^)K*;o;%T%uH4i9UXo4>{$RKBO_^PX+HWOgv7+eum^kg?Af|? z>jGi=ziZ#s;o;%Oj~@r{jV`t)h1)A{4akDQzwkw_FE zQ=nLc5RFDNHa2FpS`QpJzzQQHBMS=)0hrC^jEsz#e%Nn=!C_DwT?{#>K@oH#Y<5=;(-xiz73_*p3}LMn^{hl$4Z6B$A+*0>xtO zWHQ;+t5*TszI~gO2n2$)YuApCk8>PXUS2K~3K`SJjT>LTehr|yx;i#CHdv-CgUl}gp#-VWg2y?Y9UA_S%&uvk-(NVI3q9u^wb zuV2qt#bWXG>(>GF^z>-8+WYtK19<-Y`Knc`0$+NeW3kp!skE}P62PNJkGvCEsZjN-2IJjxkCU$ZOfhlM#CRnjzMPFav)YR0eQ>U0*AP{6{XS-Z309LCtGc%LT zv(T7A#1aaHdcEG`@!0M5EnBvD$u3;Dz;WD{FJI(x`L8gAg2glviR9e5a{xL!J7qE% zn?o|0th2KdKz)7vk|j(2>S%do2?+`9?d=@LUAlCMOq*7#{rdGQfJ284&9^E2SuBA- zpwVc)ef#Eex%Tee>m@sR@}$evpF?2l}=|6H*VYj(A?a-Y}v9ui^Zl+c6PSM<8eBjES$2Pk?lDxEiK-7k(ijs zf_`CPq2Dt_=vb^}Mn=ZO#Dv@JzIgGXw~$hroSZy1HU^-msECCILdd>-`z#iV$K&xP z+`k!%IaRCGy}i8v8XFsDZ+V9gA9gyO9LH(3TJPplU0n^Jt*wn^X}=STF{xB4mh`o? zwXvlBr#6(*J9qBzJpc0L%f!S)rjkmfPoF*oP+D3V9UUFSC~ z6~OM@yV}6x^$^n zEGC3(*s$U4+qVF6b94Q^N?*@S2-&)I>-hLM&+}DPRe>;hwR*jNVqyZoty{M$Dk>@~ zE6d8t`uqC<^!E0$gga;~#X0PR$ zHt)5YNF)Mq^XAQuA3r{R{Mh61c>D8flu~wK)ZN`Jkw`d>n^`Lq3Y|`8SXh{E5A${w z)cu`r`J5exi1iO`fnMn@Vg+N1SizXD&jr73em^+OXa4~fg%}nP>d>VC0000; + selectedProvider?: Integrations; +}> = (props) => { + const { integrations, value, selectedProvider } = props; + return ( + <> + {integrations.map((integration) => { + const { component: ProviderComponent } = Providers.find( + (provider) => provider.identifier === integration.identifier + ) || { component: null }; + if ( + !ProviderComponent || + integrations.map((p) => p.id).indexOf(selectedProvider?.id!) === -1 + ) { + return null; + } + return ( + + ); + })} + + ); +}; -export const ShowAllProviders: FC<{integrations: Integrations[], value: Array<{content: string, id?: string}>, selectedProvider?: Integrations}> = (props) => { - const {integrations, value, selectedProvider} = props; - return ( - <> - {integrations.map((integration) => { - const {component: ProviderComponent} = Providers.find(provider => provider.identifier === integration.identifier) || {component: null}; - if (!ProviderComponent || integrations.map(p => p.id).indexOf(selectedProvider?.id!) === -1) { - return null; - } - return ; - })} - - ) -} \ No newline at end of file diff --git a/apps/frontend/src/components/launches/providers/xself/xself.provider.tsx b/apps/frontend/src/components/launches/providers/xself/xself.provider.tsx new file mode 100644 index 00000000..a9ffc5fe --- /dev/null +++ b/apps/frontend/src/components/launches/providers/xself/xself.provider.tsx @@ -0,0 +1,52 @@ +import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; +export default withProvider( + null, + undefined, + undefined, + async (posts) => { + if (posts.some((p) => p.length > 4)) { + return 'There can be maximum 4 pictures in a post.'; + } + + if ( + posts.some( + (p) => p.some((m) => m.path.indexOf('mp4') > -1) && p.length > 1 + ) + ) { + return 'There can be maximum 1 video in a post.'; + } + + for (const load of posts.flatMap((p) => p.flatMap((a) => a.path))) { + if (load.indexOf('mp4') > -1) { + const isValid = await checkVideoDuration(load); + if (!isValid) { + return 'Video duration must be less than or equal to 140 seconds.'; + } + } + } + return true; + }, + 280 +); + +const checkVideoDuration = async (url: string): Promise => { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.src = url; + video.preload = 'metadata'; + + video.onloadedmetadata = () => { + // Check if the duration is less than or equal to 140 seconds + const duration = video.duration; + if (duration <= 140) { + resolve(true); // Video duration is acceptable + } else { + resolve(false); // Video duration exceeds 140 seconds + } + }; + + video.onerror = () => { + reject(new Error('Failed to load video metadata.')); + }; + }); +}; diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index 09756576..91499b59 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -21,10 +21,11 @@ import { DiscordProvider } from '@gitroom/nestjs-libraries/integrations/social/d import { SlackProvider } from '@gitroom/nestjs-libraries/integrations/social/slack.provider'; import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider'; import { BlueskyProvider } from '@gitroom/nestjs-libraries/integrations/social/bluesky.provider'; +import { XSelfProvider } from '@gitroom/nestjs-libraries/integrations/social/xself.provider'; // import { MastodonCustomProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.custom.provider'; const socialIntegrationList: SocialProvider[] = [ - new XProvider(), + process.env.ENABLE_X_SELF === 'true' ? new XSelfProvider() : new XProvider(), new LinkedinProvider(), new LinkedinPageProvider(), new RedditProvider(), diff --git a/libraries/nestjs-libraries/src/integrations/social/xself.provider.ts b/libraries/nestjs-libraries/src/integrations/social/xself.provider.ts new file mode 100644 index 00000000..7298c1ab --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/xself.provider.ts @@ -0,0 +1,304 @@ +import { TwitterApi } from 'twitter-api-v2'; +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { lookup } from 'mime-types'; +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'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { text } from 'stream/consumers'; + +export class XSelfProvider extends SocialAbstract implements SocialProvider { + identifier = 'xself'; + name = 'X Self API Token'; + isBetweenSteps = false; + scopes = []; + async customFields() { + return [ + { + key: 'apiKey', + label: 'API Key', + defaultValue: '', + validation: `/^.{3,}$/`, + type: 'text' as const, + }, + { + key: 'apiSecretKey', + label: 'API Secret Key', + validation: `/^.{3,}$/`, + type: 'text' as const, + }, + { + key: 'accessToken', + label: 'Access Token', + validation: `/^.{3,}$/`, + type: 'text' as const, + }, + { + key: 'accessTokenSecret', + label: 'Access Token Secret', + validation: `/^.{3,}$/`, + type: 'text' as const, + }, + ]; + } + + @Plug({ + identifier: 'xself-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 [ + apiKeySplit, + apiSecretKeySplit, + accessTokenSplit, + accessTokenSecretSplit, + ] = integration.token.split(':'); + const client = new TwitterApi({ + appKey: apiKeySplit, + appSecret: apiSecretKeySplit, + accessToken: accessTokenSplit, + accessSecret: accessTokenSecretSplit, + }); + 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: 'xself-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 [ + apiKeySplit, + apiSecretKeySplit, + accessTokenSplit, + accessTokenSecretSplit, + ] = integration.token.split(':'); + const client = new TwitterApi({ + appKey: apiKeySplit, + appSecret: apiSecretKeySplit, + accessToken: accessTokenSplit, + accessSecret: accessTokenSecretSplit, + }); + + 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 { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: '', + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { code: string; codeVerifier: string }) { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()); + + const { code, codeVerifier } = params; + const [oauth_token, oauth_token_secret] = codeVerifier.split(':'); + + const startingClient = new TwitterApi({ + appKey: body.apiKey, + appSecret: body.apiSecretKey, + accessToken: body.accessToken, + accessSecret: body.accessTokenSecret, + }); + + const { id, name, profile_image_url_https, screen_name } = + await startingClient.currentUser(true); + + return { + id: String(id), + accessToken: + body.apiKey + + ':' + + body.apiSecretKey + + ':' + + body.accessToken + + ':' + + body.accessTokenSecret, + + name, + refreshToken: '', + expiresIn: 999999999, + picture: profile_image_url_https, + username: screen_name, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [ + apiKeySplit, + apiSecretKeySplit, + accessTokenSplit, + accessTokenSecretSplit, + ] = accessToken.split(':'); + const client = new TwitterApi({ + appKey: apiKeySplit, + appSecret: apiSecretKeySplit, + accessToken: accessTokenSplit, + accessSecret: accessTokenSecretSplit, + }); + + const { name, profile_image_url_https, screen_name } = + await client.currentUser(true); + + // upload everything before, you don't want it to fail between the posts + const uploadAll = ( + await Promise.all( + postDetails.flatMap((p) => + p?.media?.flatMap(async (m) => { + return { + id: await client.v1.uploadMedia( + m.url.indexOf('mp4') > -1 + ? Buffer.from(await readOrFetch(m.url)) + : await sharp(await readOrFetch(m.url), { + animated: lookup(m.url) === 'image/gif', + }) + .resize({ + width: 1000, + }) + .gif() + .toBuffer(), + { + mimeType: lookup(m.url) || '', + } + ), + postId: p.id, + }; + }) + ) + ) + ).reduce((acc, val) => { + if (!val?.id) { + return acc; + } + + acc[val.postId] = acc[val.postId] || []; + acc[val.postId].push(val.id); + + return acc; + }, {} as Record); + + const ids: Array<{ postId: string; id: string; releaseURL: string }> = []; + for (const post of postDetails) { + const media_ids = (uploadAll[post.id] || []).filter((f) => f); + // @ts-ignore + const { data }: { data: { id: string } } = await client.v2.tweet({ + text: removeMd(post.message.replace('\n', '𝔫𝔢𝔴𝔩𝔦𝔫𝔢')).replace( + '𝔫𝔢𝔴𝔩𝔦𝔫𝔢', + '\n' + ), + ...(media_ids.length ? { media: { media_ids } } : {}), + ...(ids.length + ? { reply: { in_reply_to_tweet_id: ids[ids.length - 1].postId } } + : {}), + }); + + console.log('GGG DATA', data); + + ids.push({ + postId: data.id, + id: post.id, + releaseURL: `https://twitter.com/${screen_name}/status/${data.id}`, + }); + } + + return ids.map((p) => ({ + ...p, + status: 'posted', + })); + } +} From 1eb30afa71f13d58f856206314064ee5d00127b0 Mon Sep 17 00:00:00 2001 From: Kervan Aslan Date: Sun, 15 Dec 2024 08:35:19 +0300 Subject: [PATCH 2/2] New feature: Use ENABLE_OPENAI_SELF="true" to display a field on the user profile page where users can enter their own OpenAI API key. This allows users to use their own OpenAI key for accessing OpenAI services. --- .env.example | 1 + .../src/api/routes/copilot.controller.ts | 31 +- .../src/api/routes/users.controller.ts | 11 +- apps/frontend/src/app/layout.tsx | 7 +- .../src/components/layout/layout.settings.tsx | 7 +- .../components/layout/settings.component.tsx | 21 +- .../integrations/integration.service.ts | 8 +- .../migration.sql | 727 ++++++++++++++++++ .../prisma/migrations/migration_lock.toml | 3 + .../src/database/prisma/schema.prisma | 3 +- .../database/prisma/users/users.repository.ts | 2 + .../src/dtos/users/user.details.dto.ts | 14 +- .../src/upload/cloudflare.storage.ts | 12 +- .../src/helpers/variable.context.tsx | 8 +- 14 files changed, 824 insertions(+), 31 deletions(-) create mode 100644 libraries/nestjs-libraries/src/database/prisma/migrations/20241213183825_add_open_aiapi_key_to_user/migration.sql create mode 100644 libraries/nestjs-libraries/src/database/prisma/migrations/migration_lock.toml diff --git a/.env.example b/.env.example index 7870234f..32b1f4c5 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,7 @@ MASTODON_CLIENT_ID="" MASTODON_CLIENT_SECRET="" # Misc Settings +ENABLE_OPENAI_SELF="false" OPENAI_API_KEY="" NEXT_PUBLIC_DISCORD_SUPPORT="" NEXT_PUBLIC_POLOTNO="" diff --git a/apps/backend/src/api/routes/copilot.controller.ts b/apps/backend/src/api/routes/copilot.controller.ts index 65928b20..e304c4a1 100644 --- a/apps/backend/src/api/routes/copilot.controller.ts +++ b/apps/backend/src/api/routes/copilot.controller.ts @@ -5,23 +5,44 @@ import { copilotRuntimeNestEndpoint, } from '@copilotkit/runtime'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; -import { Organization } from '@prisma/client'; +import { Organization, User } from '@prisma/client'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request'; +import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; +import OpenAI from 'openai'; @Controller('/copilot') export class CopilotController { - constructor(private _subscriptionService: SubscriptionService) {} + constructor( + private _subscriptionService: SubscriptionService, + private _userService: UsersService + ) {} @Post('/chat') - chat(@Req() req: Request, @Res() res: Response) { - if (process.env.OPENAI_API_KEY === undefined || process.env.OPENAI_API_KEY === '') { + async chat( + @Req() req: Request, + @Res() res: Response, + @GetUserFromRequest() user: User + ) { + let openAIAPIKey = ''; + + //Check if OPEN AI is enabled for Self tokens. + if (process.env.ENABLE_OPENAI_SELF === 'true') { + const userPersonal = await this._userService.getPersonal(user.id); + openAIAPIKey = userPersonal.openAIAPIKey; + } else { + openAIAPIKey = process.env.OPENAI_API_KEY; + } + + if (openAIAPIKey === undefined || openAIAPIKey === '') { Logger.warn('OpenAI API key not set, chat functionality will not work'); - return + return; } const copilotRuntimeHandler = copilotRuntimeNestEndpoint({ endpoint: '/copilot/chat', runtime: new CopilotRuntime(), serviceAdapter: new OpenAIAdapter({ + openai: new OpenAI({ apiKey: openAIAPIKey }), model: // @ts-ignore req?.body?.variables?.data?.metadata?.requestType === diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 85311535..d334b6cd 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -42,7 +42,7 @@ export class UsersController { async getSelf( @GetUserFromRequest() user: User, @GetOrgFromRequest() organization: Organization, - @Req() req: Request, + @Req() req: Request ) { if (!organization) { throw new HttpForbiddenException(); @@ -52,9 +52,14 @@ export class UsersController { ...user, orgId: organization.id, // @ts-ignore - totalChannels: organization?.subscription?.totalChannels || pricing.FREE.channel, + totalChannels: + // @ts-ignore + organization?.subscription?.totalChannels || pricing.FREE.channel, // @ts-ignore - tier: organization?.subscription?.subscriptionTier || (!process.env.STRIPE_PUBLISHABLE_KEY ? 'ULTIMATE' : 'FREE'), + tier: + // @ts-ignore + organization?.subscription?.subscriptionTier || + (!process.env.STRIPE_PUBLISHABLE_KEY ? 'ULTIMATE' : 'FREE'), // @ts-ignore role: organization?.users[0]?.role, // @ts-ignore diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index 9a99ad77..2fe81b96 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -25,11 +25,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { return ( - + diff --git a/apps/frontend/src/components/layout/layout.settings.tsx b/apps/frontend/src/components/layout/layout.settings.tsx index 38ce4d9d..79a049be 100644 --- a/apps/frontend/src/components/layout/layout.settings.tsx +++ b/apps/frontend/src/components/layout/layout.settings.tsx @@ -87,7 +87,7 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => { >
Logo { ) : (
)} -
+
diff --git a/apps/frontend/src/components/layout/settings.component.tsx b/apps/frontend/src/components/layout/settings.component.tsx index f4d56eea..a6436911 100644 --- a/apps/frontend/src/components/layout/settings.component.tsx +++ b/apps/frontend/src/components/layout/settings.component.tsx @@ -20,7 +20,7 @@ import { useSearchParams } from 'next/navigation'; import { useVariables } from '@gitroom/react/helpers/variable.context'; export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { - const {isGeneral} = useVariables(); + const { isGeneral, enabledOpenaiSelf } = useVariables(); const { getRef } = props; const fetch = useFetch(); const toast = useToaster(); @@ -38,11 +38,12 @@ export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { }, []); const url = useSearchParams(); - const showLogout = !url.get('onboarding') || user?.tier?.current === "FREE"; + const showLogout = !url.get('onboarding') || user?.tier?.current === 'FREE'; const loadProfile = useCallback(async () => { const personal = await (await fetch('/user/personal')).json(); form.setValue('fullname', personal.name || ''); + form.setValue('openAIAPIKey', personal.openAIAPIKey || ''); form.setValue('bio', personal.bio || ''); form.setValue('picture', personal.picture); }, []); @@ -76,6 +77,8 @@ export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { loadProfile(); }, []); + console.log('zaaa', enabledOpenaiSelf); + return (
@@ -155,7 +158,10 @@ export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => { />
-
+
Upload image
@@ -188,10 +194,17 @@ export const SettingsPopup: FC<{ getRef?: Ref }> = (props) => {