feat: X errors and force upload
Some checks failed
Build / build (22.12.0) (push) Has been cancelled
Code Quality Analysis / Analyze (javascript-typescript) (push) Has been cancelled

This commit is contained in:
Nevo David 2026-04-30 18:34:18 +07:00
parent 7264c00298
commit 0d98fc02fb
3 changed files with 159 additions and 48 deletions

View file

@ -49,7 +49,10 @@ const PUBLIC_API_ALLOWED_MIME = new Set<string>([
'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
);
}

View file

@ -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);

View file

@ -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<string, string> = {
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<any>[]
@ -370,6 +437,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
paid_partnership?: boolean;
}>[]
): Promise<PostResponse[]> {
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<PostResponse[]> {
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 [
{