560 lines
15 KiB
TypeScript
560 lines
15 KiB
TypeScript
import { TweetV2, TwitterApi } from 'twitter-api-v2';
|
|
import {
|
|
AnalyticsData,
|
|
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 { 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 { PostPlug } from '@gitroom/helpers/decorators/post.plug';
|
|
import dayjs from 'dayjs';
|
|
import { uniqBy } from 'lodash';
|
|
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
|
|
|
|
export class XProvider extends SocialAbstract implements SocialProvider {
|
|
identifier = 'x';
|
|
name = 'X';
|
|
isBetweenSteps = false;
|
|
scopes = [] as string[];
|
|
override maxConcurrentJob = 1; // X has strict rate limits (300 posts per 3 hours)
|
|
toolTip =
|
|
'You will be logged in into your current account, if you would like a different account, change it first on X';
|
|
|
|
editor = 'normal' as const;
|
|
|
|
override handleErrors(body: string):
|
|
| {
|
|
type: 'refresh-token' | 'bad-body';
|
|
value: string;
|
|
}
|
|
| undefined {
|
|
if (body.includes('usage-capped')) {
|
|
return {
|
|
type: 'refresh-token',
|
|
value:
|
|
'Posting failed - capped reached. Please try again later',
|
|
};
|
|
}
|
|
if (body.includes('duplicate-rules')) {
|
|
return {
|
|
type: 'refresh-token',
|
|
value:
|
|
'You have already posted this post, please wait before posting again',
|
|
};
|
|
}
|
|
if (body.includes('The Tweet contains an invalid URL.')) {
|
|
return {
|
|
type: 'bad-body',
|
|
value: 'The Tweet contains a URL that is not allowed on X',
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
@Plug({
|
|
identifier: 'x-autoRepostPost',
|
|
title: 'Auto Repost Posts',
|
|
disabled: !!process.env.DISABLE_X_ANALYTICS,
|
|
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 }
|
|
) {
|
|
// @ts-ignore
|
|
// eslint-disable-next-line prefer-rest-params
|
|
const [accessTokenSplit, accessSecretSplit] = integration.token.split(':');
|
|
const client = new TwitterApi({
|
|
appKey: process.env.X_API_KEY!,
|
|
appSecret: process.env.X_API_SECRET!,
|
|
accessToken: accessTokenSplit,
|
|
accessSecret: accessSecretSplit,
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
@PostPlug({
|
|
identifier: 'x-repost-post-users',
|
|
title: 'Add Re-posters',
|
|
description: 'Add accounts to repost your post',
|
|
pickIntegration: ['x'],
|
|
fields: [],
|
|
})
|
|
async repostPostUsers(
|
|
integration: Integration,
|
|
originalIntegration: Integration,
|
|
postId: string,
|
|
information: any
|
|
) {
|
|
const [accessTokenSplit, accessSecretSplit] = integration.token.split(':');
|
|
const client = new TwitterApi({
|
|
appKey: process.env.X_API_KEY!,
|
|
appSecret: process.env.X_API_SECRET!,
|
|
accessToken: accessTokenSplit,
|
|
accessSecret: accessSecretSplit,
|
|
});
|
|
|
|
const {
|
|
data: { id },
|
|
} = await client.v2.me();
|
|
|
|
try {
|
|
await client.v2.retweet(id, postId);
|
|
} catch (err) {
|
|
/** nothing **/
|
|
}
|
|
}
|
|
|
|
@Plug({
|
|
identifier: 'x-autoPlugPost',
|
|
title: 'Auto plug post',
|
|
disabled: !!process.env.DISABLE_X_ANALYTICS,
|
|
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 }
|
|
) {
|
|
// @ts-ignore
|
|
// eslint-disable-next-line prefer-rest-params
|
|
const [accessTokenSplit, accessSecretSplit] = integration.token.split(':');
|
|
const client = new TwitterApi({
|
|
appKey: process.env.X_API_KEY!,
|
|
appSecret: process.env.X_API_SECRET!,
|
|
accessToken: accessTokenSplit,
|
|
accessSecret: accessSecretSplit,
|
|
});
|
|
|
|
if (
|
|
(await client.v2.tweetLikedBy(id)).meta.result_count >=
|
|
+fields.likesAmount
|
|
) {
|
|
await timer(2000);
|
|
|
|
await client.v2.tweet({
|
|
text: stripHtmlValidation('normal', fields.post, true),
|
|
reply: { in_reply_to_tweet_id: id },
|
|
});
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async refreshToken(): Promise<AuthTokenDetails> {
|
|
return {
|
|
id: '',
|
|
name: '',
|
|
accessToken: '',
|
|
refreshToken: '',
|
|
expiresIn: 0,
|
|
picture: '',
|
|
username: '',
|
|
};
|
|
}
|
|
|
|
async generateAuthUrl() {
|
|
const client = new TwitterApi({
|
|
appKey: process.env.X_API_KEY!,
|
|
appSecret: process.env.X_API_SECRET!,
|
|
});
|
|
const { url, oauth_token, oauth_token_secret } =
|
|
await client.generateAuthLink(
|
|
(process.env.X_URL || process.env.FRONTEND_URL) +
|
|
`/integrations/social/x`,
|
|
{
|
|
authAccessType: 'write',
|
|
linkMode: 'authenticate',
|
|
forceLogin: false,
|
|
}
|
|
);
|
|
return {
|
|
url,
|
|
codeVerifier: oauth_token + ':' + oauth_token_secret,
|
|
state: oauth_token,
|
|
};
|
|
}
|
|
|
|
async authenticate(params: { code: string; codeVerifier: string }) {
|
|
const { code, codeVerifier } = params;
|
|
const [oauth_token, oauth_token_secret] = codeVerifier.split(':');
|
|
|
|
const startingClient = new TwitterApi({
|
|
appKey: process.env.X_API_KEY!,
|
|
appSecret: process.env.X_API_SECRET!,
|
|
accessToken: oauth_token,
|
|
accessSecret: oauth_token_secret,
|
|
});
|
|
|
|
const { accessToken, client, accessSecret } = await startingClient.login(
|
|
code
|
|
);
|
|
|
|
const {
|
|
data: { username, verified, profile_image_url, name, id },
|
|
} = await client.v2.me({
|
|
'user.fields': [
|
|
'username',
|
|
'verified',
|
|
'verified_type',
|
|
'profile_image_url',
|
|
'name',
|
|
],
|
|
});
|
|
|
|
return {
|
|
id: String(id),
|
|
accessToken: accessToken + ':' + accessSecret,
|
|
name,
|
|
refreshToken: '',
|
|
expiresIn: 999999999,
|
|
picture: profile_image_url || '',
|
|
username,
|
|
additionalSettings: [
|
|
{
|
|
title: 'Verified',
|
|
description: 'Is this a verified user? (Premium)',
|
|
type: 'checkbox' as const,
|
|
value: verified,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
async post(
|
|
id: string,
|
|
accessToken: string,
|
|
postDetails: PostDetails<{
|
|
active_thread_finisher: boolean;
|
|
thread_finisher: string;
|
|
community?: string;
|
|
who_can_reply_post:
|
|
| 'everyone'
|
|
| 'following'
|
|
| 'mentionedUsers'
|
|
| 'subscribers'
|
|
| 'verified';
|
|
}>[]
|
|
): Promise<PostResponse[]> {
|
|
const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
|
|
const client = new TwitterApi({
|
|
appKey: process.env.X_API_KEY!,
|
|
appSecret: process.env.X_API_SECRET!,
|
|
accessToken: accessTokenSplit,
|
|
accessSecret: accessSecretSplit,
|
|
});
|
|
const {
|
|
data: { username },
|
|
} = await this.runInConcurrent(async () =>
|
|
client.v2.me({
|
|
'user.fields': 'username',
|
|
})
|
|
);
|
|
|
|
// 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 this.runInConcurrent(async () =>
|
|
client.v1.uploadMedia(
|
|
m.path.indexOf('mp4') > -1
|
|
? Buffer.from(await readOrFetch(m.path))
|
|
: await sharp(await readOrFetch(m.path), {
|
|
animated: lookup(m.path) === 'image/gif',
|
|
})
|
|
.resize({
|
|
width: 1000,
|
|
})
|
|
.gif()
|
|
.toBuffer(),
|
|
{
|
|
mimeType: lookup(m.path) || '',
|
|
}
|
|
)
|
|
),
|
|
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<string, string[]>);
|
|
|
|
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 this.runInConcurrent(
|
|
async () =>
|
|
// @ts-ignore
|
|
client.v2.tweet({
|
|
...(!postDetails?.[0]?.settings?.who_can_reply_post ||
|
|
postDetails?.[0]?.settings?.who_can_reply_post === 'everyone'
|
|
? {}
|
|
: {
|
|
reply_settings:
|
|
postDetails?.[0]?.settings?.who_can_reply_post,
|
|
}),
|
|
...(postDetails?.[0]?.settings?.community
|
|
? {
|
|
community_id:
|
|
postDetails?.[0]?.settings?.community?.split('/').pop() ||
|
|
'',
|
|
}
|
|
: {}),
|
|
text: post.message,
|
|
...(media_ids.length ? { media: { media_ids } } : {}),
|
|
...(ids.length
|
|
? { reply: { in_reply_to_tweet_id: ids[ids.length - 1].postId } }
|
|
: {}),
|
|
})
|
|
);
|
|
|
|
ids.push({
|
|
postId: data.id,
|
|
id: post.id,
|
|
releaseURL: `https://twitter.com/${username}/status/${data.id}`,
|
|
});
|
|
}
|
|
|
|
if (postDetails?.[0]?.settings?.active_thread_finisher) {
|
|
try {
|
|
await this.runInConcurrent(async () =>
|
|
client.v2.tweet({
|
|
text:
|
|
postDetails?.[0]?.settings?.thread_finisher! +
|
|
'\n' +
|
|
ids[0].releaseURL,
|
|
reply: { in_reply_to_tweet_id: ids[ids.length - 1].postId },
|
|
})
|
|
);
|
|
} catch (err) {}
|
|
}
|
|
|
|
return ids.map((p) => ({
|
|
...p,
|
|
status: 'posted',
|
|
}));
|
|
}
|
|
|
|
private loadAllTweets = async (
|
|
client: TwitterApi,
|
|
id: string,
|
|
until: string,
|
|
since: string,
|
|
token = ''
|
|
): Promise<TweetV2[]> => {
|
|
const tweets = await client.v2.userTimeline(id, {
|
|
'tweet.fields': ['id'],
|
|
'user.fields': [],
|
|
'poll.fields': [],
|
|
'place.fields': [],
|
|
'media.fields': [],
|
|
exclude: ['replies', 'retweets'],
|
|
start_time: since,
|
|
end_time: until,
|
|
max_results: 100,
|
|
...(token ? { pagination_token: token } : {}),
|
|
});
|
|
|
|
return [
|
|
...tweets.data.data,
|
|
...(tweets.data.data.length === 100
|
|
? await this.loadAllTweets(
|
|
client,
|
|
id,
|
|
until,
|
|
since,
|
|
tweets.meta.next_token
|
|
)
|
|
: []),
|
|
];
|
|
};
|
|
|
|
async analytics(
|
|
id: string,
|
|
accessToken: string,
|
|
date: number
|
|
): Promise<AnalyticsData[]> {
|
|
if (process.env.DISABLE_X_ANALYTICS) {
|
|
return [];
|
|
}
|
|
|
|
const until = dayjs().endOf('day');
|
|
const since = dayjs().subtract(date, 'day');
|
|
|
|
const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
|
|
const client = new TwitterApi({
|
|
appKey: process.env.X_API_KEY!,
|
|
appSecret: process.env.X_API_SECRET!,
|
|
accessToken: accessTokenSplit,
|
|
accessSecret: accessSecretSplit,
|
|
});
|
|
|
|
try {
|
|
const tweets = uniqBy(
|
|
await this.loadAllTweets(
|
|
client,
|
|
id,
|
|
until.format('YYYY-MM-DDTHH:mm:ssZ'),
|
|
since.format('YYYY-MM-DDTHH:mm:ssZ')
|
|
),
|
|
(p) => p.id
|
|
);
|
|
|
|
if (tweets.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const data = await client.v2.tweets(
|
|
tweets.map((p) => p.id),
|
|
{
|
|
'tweet.fields': ['public_metrics'],
|
|
}
|
|
);
|
|
|
|
const metrics = data.data.reduce(
|
|
(all, current) => {
|
|
all.impression_count =
|
|
(all.impression_count || 0) +
|
|
+current.public_metrics.impression_count;
|
|
all.bookmark_count =
|
|
(all.bookmark_count || 0) + +current.public_metrics.bookmark_count;
|
|
all.like_count =
|
|
(all.like_count || 0) + +current.public_metrics.like_count;
|
|
all.quote_count =
|
|
(all.quote_count || 0) + +current.public_metrics.quote_count;
|
|
all.reply_count =
|
|
(all.reply_count || 0) + +current.public_metrics.reply_count;
|
|
all.retweet_count =
|
|
(all.retweet_count || 0) + +current.public_metrics.retweet_count;
|
|
|
|
return all;
|
|
},
|
|
{
|
|
impression_count: 0,
|
|
bookmark_count: 0,
|
|
like_count: 0,
|
|
quote_count: 0,
|
|
reply_count: 0,
|
|
retweet_count: 0,
|
|
}
|
|
);
|
|
|
|
return Object.entries(metrics).map(([key, value]) => ({
|
|
label: key.replace('_count', '').replace('_', ' ').toUpperCase(),
|
|
percentageChange: 5,
|
|
data: [
|
|
{
|
|
total: String(0),
|
|
date: since.format('YYYY-MM-DD'),
|
|
},
|
|
{
|
|
total: String(value),
|
|
date: until.format('YYYY-MM-DD'),
|
|
},
|
|
],
|
|
}));
|
|
} catch (err) {
|
|
console.log(err);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
override async mention(token: string, d: { query: string }) {
|
|
const [accessTokenSplit, accessSecretSplit] = token.split(':');
|
|
const client = new TwitterApi({
|
|
appKey: process.env.X_API_KEY!,
|
|
appSecret: process.env.X_API_SECRET!,
|
|
accessToken: accessTokenSplit,
|
|
accessSecret: accessSecretSplit,
|
|
});
|
|
|
|
try {
|
|
const data = await client.v2.userByUsername(d.query, {
|
|
'user.fields': ['username', 'name', 'profile_image_url'],
|
|
});
|
|
|
|
if (!data?.data?.username) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: data.data.username,
|
|
image: data.data.profile_image_url,
|
|
label: data.data.name,
|
|
},
|
|
];
|
|
} catch (err) {
|
|
console.log(err);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
mentionFormat(idOrHandle: string, name: string) {
|
|
return `@${idOrHandle}`;
|
|
}
|
|
}
|