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',
+ }));
+ }
+}