From 0d98fc02fb6edccb696c3bc933d4612451fae838 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 30 Apr 2026 18:34:18 +0700 Subject: [PATCH] feat: X errors and force upload --- .../v1/public.integrations.controller.ts | 27 ++- .../new-launch/providers/x/x.provider.tsx | 13 +- .../src/integrations/social/x.provider.ts | 167 ++++++++++++++---- 3 files changed, 159 insertions(+), 48 deletions(-) diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index d2a74efe..38ce060b 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -49,7 +49,10 @@ const PUBLIC_API_ALLOWED_MIME = new Set([ 'video/mp4', ]); import * as Sentry from '@sentry/nestjs'; -import { socialIntegrationList, IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { + socialIntegrationList, + IntegrationManager, +} from '@gitroom/nestjs-libraries/integrations/integration.manager'; import { getValidationSchemas } from '@gitroom/nestjs-libraries/chat/validation.schemas.helper'; import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service'; import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract'; @@ -167,6 +170,24 @@ export class PublicIntegrationsController { ); body.type = rawBody.type; + if ( + process.env.RESTRICT_UPLOAD_DOMAINS && + body.posts.some((p) => + p.value.some((a) => + a.image.some( + (i) => i.path.indexOf(process.env.RESTRICT_UPLOAD_DOMAINS) === -1 + ) + ) + ) + ) { + throw new HttpException( + { + msg: `All media must be uploaded through our upload API route and contain the domain: ${process.env.RESTRICT_UPLOAD_DOMAINS}`, + }, + 400 + ); + } + console.log(JSON.stringify(body, null, 2)); return this._postsService.createPost(org.id, body); } @@ -238,7 +259,9 @@ export class PublicIntegrationsController { if (integrationProvider.externalUrl) { throw new HttpException( - { msg: 'This integration requires an external URL and is not supported via the public API' }, + { + msg: 'This integration requires an external URL and is not supported via the public API', + }, 400 ); } diff --git a/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx b/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx index cf4fcd20..7b5efca0 100644 --- a/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx +++ b/apps/frontend/src/components/new-launch/providers/x/x.provider.tsx @@ -92,16 +92,9 @@ export default withProvider({ const premium = additionalSettings?.find((p: any) => p?.title === 'Verified')?.value || false; - if (posts?.some((p) => (p?.length ?? 0) > 4)) { - return 'There can be maximum 4 pictures in a post.'; - } - if ( - posts?.some( - (p) => p?.some((m) => (m?.path?.indexOf?.('mp4') ?? -1) > -1) && (p?.length ?? 0) > 1 - ) - ) { - return 'There can be maximum 1 video in a post.'; - } + // if (posts?.some((p) => (p?.length ?? 0) > 4)) { + // return 'There can be maximum 4 pictures in a post.'; + // } for (const load of posts?.flatMap((p) => p?.flatMap((a) => a?.path)) ?? []) { if ((load?.indexOf?.('mp4') ?? -1) > -1) { const isValid = await checkVideoDuration(load, premium); diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index 2cf7f282..68284508 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -1,4 +1,5 @@ import { TweetV2, TwitterApi } from 'twitter-api-v2'; +import { createHmac, randomBytes } from 'crypto'; import { AnalyticsData, AuthTokenDetails, @@ -45,6 +46,24 @@ export class XProvider extends SocialAbstract implements SocialProvider { value: string; } | undefined { + if (body.includes('You are not permitted to perform this action')) { + return { + type: 'bad-body', + value: 'There is a problem posting, please edit your post and check character count and media attachments', + } + } + if (body.includes('maximum of one cashtag')) { + return { + type: 'bad-body', + value: 'There can be maximum of one cashtag ($SYMBOL) per post', + }; + } + if (body.includes('maximum of 4 items')) { + return { + type: 'bad-body', + value: 'There must be a maximum of 4 items per post', + }; + } if (body.includes('Unsupported Authentication')) { return { type: 'refresh-token', @@ -308,6 +327,54 @@ export class XProvider extends SocialAbstract implements SocialProvider { }); } + private signOAuth1( + method: string, + url: string, + accessToken: string, + accessSecret: string + ): string { + const pct = (s: string) => + encodeURIComponent(s) + .replace(/!/g, '%21') + .replace(/\*/g, '%2A') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29'); + + const params: Record = { + oauth_consumer_key: process.env.X_API_KEY!, + oauth_nonce: randomBytes(16).toString('hex'), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: String(Math.floor(Date.now() / 1000)), + oauth_token: accessToken, + oauth_version: '1.0', + }; + + const paramString = Object.keys(params) + .sort() + .map((k) => `${pct(k)}=${pct(params[k])}`) + .join('&'); + + const baseString = [ + method.toUpperCase(), + pct(url.split('?')[0]), + pct(paramString), + ].join('&'); + + const signingKey = `${pct(process.env.X_API_SECRET!)}&${pct(accessSecret)}`; + params.oauth_signature = createHmac('sha1', signingKey) + .update(baseString) + .digest('base64'); + + return ( + 'OAuth ' + + Object.keys(params) + .sort() + .map((k) => `${pct(k)}="${pct(params[k])}"`) + .join(', ') + ); + } + private async uploadMedia( client: TwitterApi, postDetails: PostDetails[] @@ -370,6 +437,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { paid_partnership?: boolean; }>[] ): Promise { + const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); const client = await this.getClient(accessToken); const { data: { username }, @@ -386,30 +454,43 @@ export class XProvider extends SocialAbstract implements SocialProvider { const media_ids = (uploadAll[firstPost.id] || []).filter((f) => f); - // @ts-ignore - const { data }: { data: { id: string } } = await this.runInConcurrent( - async () => - // @ts-ignore - client.v2.tweet({ - ...(!firstPost?.settings?.who_can_reply_post || - firstPost?.settings?.who_can_reply_post === 'everyone' - ? {} - : { - reply_settings: firstPost?.settings?.who_can_reply_post, - }), - ...(firstPost?.settings?.community - ? { - share_with_followers: true, - community_id: - firstPost?.settings?.community?.split('/').pop() || '', - } - : {}), - text: firstPost.message, - ...(media_ids.length ? { media: { media_ids } } : {}), - made_with_ai: !!firstPost?.settings?.made_with_ai, - paid_partnership: !!firstPost?.settings?.paid_partnership, - }) - ); + const tweetUrl = 'https://api.x.com/2/tweets'; + const tweetBody = { + ...(!firstPost?.settings?.who_can_reply_post || + firstPost?.settings?.who_can_reply_post === 'everyone' + ? {} + : { + reply_settings: firstPost?.settings?.who_can_reply_post, + }), + ...(firstPost?.settings?.community + ? { + share_with_followers: true, + community_id: + firstPost?.settings?.community?.split('/').pop() || '', + } + : {}), + text: firstPost.message, + ...(media_ids.length ? { media: { media_ids } } : {}), + made_with_ai: !!firstPost?.settings?.made_with_ai, + paid_partnership: !!firstPost?.settings?.paid_partnership, + }; + + const tweetResponse = await this.fetch(tweetUrl, { + method: 'POST', + headers: { + Authorization: this.signOAuth1( + 'POST', + tweetUrl, + accessTokenSplit, + accessSecretSplit + ), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(tweetBody), + }); + const { data } = (await tweetResponse.json()) as { + data: { id: string }; + }; return [ { @@ -434,6 +515,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { }>[], integration: Integration ): Promise { + const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); const client = await this.getClient(accessToken); const { data: { username }, @@ -452,18 +534,31 @@ export class XProvider extends SocialAbstract implements SocialProvider { const replyToId = lastCommentId || postId; - // @ts-ignore - const { data }: { data: { id: string } } = await this.runInConcurrent( - async () => - // @ts-ignore - client.v2.tweet({ - text: commentPost.message, - ...(media_ids.length ? { media: { media_ids } } : {}), - reply: { in_reply_to_tweet_id: replyToId }, - made_with_ai: !!commentPost?.settings?.made_with_ai, - paid_partnership: !!commentPost?.settings?.paid_partnership, - }) - ); + const tweetUrl = 'https://api.x.com/2/tweets'; + const tweetBody = { + text: commentPost.message, + ...(media_ids.length ? { media: { media_ids } } : {}), + reply: { in_reply_to_tweet_id: replyToId }, + made_with_ai: !!commentPost?.settings?.made_with_ai, + paid_partnership: !!commentPost?.settings?.paid_partnership, + }; + + const tweetResponse = await this.fetch(tweetUrl, { + method: 'POST', + headers: { + Authorization: this.signOAuth1( + 'POST', + tweetUrl, + accessTokenSplit, + accessSecretSplit + ), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(tweetBody), + }); + const { data } = (await tweetResponse.json()) as { + data: { id: string }; + }; return [ {