diff --git a/.env.example b/.env.example index 7ca10d13b..7870234fe 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 000000000..59799be46 Binary files /dev/null and b/apps/frontend/public/icons/platforms/xself.png differ diff --git a/apps/frontend/src/components/launches/providers/show.all.providers.tsx b/apps/frontend/src/components/launches/providers/show.all.providers.tsx index 3b26f62bc..cf0665f2d 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -1,11 +1,11 @@ -import {FC} from "react"; -import {Integrations} from "@gitroom/frontend/components/launches/calendar.context"; -import DevtoProvider from "@gitroom/frontend/components/launches/providers/devto/devto.provider"; -import XProvider from "@gitroom/frontend/components/launches/providers/x/x.provider"; -import LinkedinProvider from "@gitroom/frontend/components/launches/providers/linkedin/linkedin.provider"; -import RedditProvider from "@gitroom/frontend/components/launches/providers/reddit/reddit.provider"; -import MediumProvider from "@gitroom/frontend/components/launches/providers/medium/medium.provider"; -import HashnodeProvider from "@gitroom/frontend/components/launches/providers/hashnode/hashnode.provider"; +import { FC } from 'react'; +import { Integrations } from '@gitroom/frontend/components/launches/calendar.context'; +import DevtoProvider from '@gitroom/frontend/components/launches/providers/devto/devto.provider'; +import XProvider from '@gitroom/frontend/components/launches/providers/x/x.provider'; +import LinkedinProvider from '@gitroom/frontend/components/launches/providers/linkedin/linkedin.provider'; +import RedditProvider from '@gitroom/frontend/components/launches/providers/reddit/reddit.provider'; +import MediumProvider from '@gitroom/frontend/components/launches/providers/medium/medium.provider'; +import HashnodeProvider from '@gitroom/frontend/components/launches/providers/hashnode/hashnode.provider'; import FacebookProvider from '@gitroom/frontend/components/launches/providers/facebook/facebook.provider'; import InstagramProvider from '@gitroom/frontend/components/launches/providers/instagram/instagram.collaborators'; import YoutubeProvider from '@gitroom/frontend/components/launches/providers/youtube/youtube.provider'; @@ -17,40 +17,58 @@ import DiscordProvider from '@gitroom/frontend/components/launches/providers/dis import SlackProvider from '@gitroom/frontend/components/launches/providers/slack/slack.provider'; import MastodonProvider from '@gitroom/frontend/components/launches/providers/mastodon/mastodon.provider'; import BlueskyProvider from '@gitroom/frontend/components/launches/providers/bluesky/bluesky.provider'; +import XSelfProvider from '@gitroom/frontend/components/launches/providers/xself/xself.provider'; export const Providers = [ - {identifier: 'devto', component: DevtoProvider}, - {identifier: 'x', component: XProvider}, - {identifier: 'linkedin', component: LinkedinProvider}, - {identifier: 'linkedin-page', component: LinkedinProvider}, - {identifier: 'reddit', component: RedditProvider}, - {identifier: 'medium', component: MediumProvider}, - {identifier: 'hashnode', component: HashnodeProvider}, - {identifier: 'facebook', component: FacebookProvider}, - {identifier: 'instagram', component: InstagramProvider}, - {identifier: 'youtube', component: YoutubeProvider}, - {identifier: 'tiktok', component: TiktokProvider}, - {identifier: 'pinterest', component: PinterestProvider}, - {identifier: 'dribbble', component: DribbbleProvider}, - {identifier: 'threads', component: ThreadsProvider}, - {identifier: 'discord', component: DiscordProvider}, - {identifier: 'slack', component: SlackProvider}, - {identifier: 'mastodon', component: MastodonProvider}, - {identifier: 'bluesky', component: BlueskyProvider}, + { identifier: 'devto', component: DevtoProvider }, + { identifier: 'x', component: XProvider }, + { identifier: 'linkedin', component: LinkedinProvider }, + { identifier: 'linkedin-page', component: LinkedinProvider }, + { identifier: 'reddit', component: RedditProvider }, + { identifier: 'medium', component: MediumProvider }, + { identifier: 'hashnode', component: HashnodeProvider }, + { identifier: 'facebook', component: FacebookProvider }, + { identifier: 'instagram', component: InstagramProvider }, + { identifier: 'youtube', component: YoutubeProvider }, + { identifier: 'tiktok', component: TiktokProvider }, + { identifier: 'pinterest', component: PinterestProvider }, + { identifier: 'dribbble', component: DribbbleProvider }, + { identifier: 'threads', component: ThreadsProvider }, + { identifier: 'discord', component: DiscordProvider }, + { identifier: 'slack', component: SlackProvider }, + { identifier: 'mastodon', component: MastodonProvider }, + { identifier: 'bluesky', component: BlueskyProvider }, + { identifier: 'xself', component: XSelfProvider }, ]; +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 ( + + ); + })} + + ); +}; -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 000000000..a9ffc5fea --- /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 097565766..91499b59a 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 000000000..7298c1ab5 --- /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', + })); + } +}