postiz/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts

549 lines
14 KiB
TypeScript

import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { timer } from '@gitroom/helpers/utils/timer';
import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { capitalize, chunk } from 'lodash';
import { Plug } from '@gitroom/helpers/decorators/plug.decorator';
import { Integration } from '@prisma/client';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
export class ThreadsProvider extends SocialAbstract implements SocialProvider {
identifier = 'threads';
name = 'Threads';
isBetweenSteps = false;
scopes = [
'threads_basic',
'threads_content_publish',
'threads_manage_replies',
'threads_manage_insights',
// 'threads_profile_discovery',
];
override maxConcurrentJob = 2; // Threads has moderate rate limits
editor = 'normal' as const;
maxLength() {
return 500;
}
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const { access_token } = await (
await this.fetch(
`https://graph.threads.net/refresh_access_token?grant_type=th_refresh_token&access_token=${refresh_token}`
)
).json();
const { id, name, username, picture } = await this.fetchPageInformation(
access_token
);
return {
id,
name,
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: picture?.data?.url || '',
username: '',
};
}
async generateAuthUrl() {
const state = makeId(6);
return {
url:
'https://www.threads.net/oauth/authorize' +
`?client_id=${process.env.THREADS_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${
process?.env.FRONTEND_URL?.indexOf('https') == -1
? `https://redirectmeto.com/${process?.env.FRONTEND_URL}`
: `${process?.env.FRONTEND_URL}`
}/integrations/social/threads`
)}` +
`&state=${state}` +
`&scope=${encodeURIComponent(this.scopes.join(','))}`,
codeVerifier: makeId(10),
state,
};
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
const getAccessToken = await (
await this.fetch(
'https://graph.threads.net/oauth/access_token' +
`?client_id=${process.env.THREADS_APP_ID}` +
`&redirect_uri=${encodeURIComponent(
`${
process?.env.FRONTEND_URL?.indexOf('https') == -1
? `https://redirectmeto.com/${process?.env.FRONTEND_URL}`
: `${process?.env.FRONTEND_URL}`
}/integrations/social/threads`
)}` +
`&grant_type=authorization_code` +
`&client_secret=${process.env.THREADS_APP_SECRET}` +
`&code=${params.code}`
)
).json();
const { access_token } = await (
await this.fetch(
'https://graph.threads.net/access_token' +
'?grant_type=th_exchange_token' +
`&client_secret=${process.env.THREADS_APP_SECRET}` +
`&access_token=${getAccessToken.access_token}&fields=access_token,expires_in`
)
).json();
const { id, name, username, picture } = await this.fetchPageInformation(
access_token
);
return {
id,
name,
accessToken: access_token,
refreshToken: access_token,
expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(),
picture: picture?.data?.url || '',
username: username,
};
}
private async checkLoaded(
mediaContainerId: string,
accessToken: string
): Promise<boolean> {
const { status, id, error_message } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${mediaContainerId}?fields=status,error_message&access_token=${accessToken}`
)
).json();
if (status === 'ERROR') {
throw new Error(id);
}
if (status === 'FINISHED') {
await timer(2000);
return true;
}
await timer(2200);
return this.checkLoaded(mediaContainerId, accessToken);
}
async fetchPageInformation(accessToken: string) {
const { id, username, threads_profile_picture_url, access_token } = await (
await this.fetch(
`https://graph.threads.net/v1.0/me?fields=id,username,threads_profile_picture_url&access_token=${accessToken}`
)
).json();
return {
id,
name: username,
access_token,
picture: { data: { url: threads_profile_picture_url } },
username,
};
}
private async createSingleMediaContent(
userId: string,
accessToken: string,
media: { path: string },
message: string,
isCarouselItem = false,
replyToId?: string
): Promise<string> {
const mediaType =
media.path.indexOf('.mp4') > -1 ? 'video_url' : 'image_url';
const mediaParams = new URLSearchParams({
...(mediaType === 'video_url' ? { video_url: media.path } : {}),
...(mediaType === 'image_url' ? { image_url: media.path } : {}),
...(isCarouselItem ? { is_carousel_item: 'true' } : {}),
...(replyToId ? { reply_to_id: replyToId } : {}),
media_type: mediaType === 'video_url' ? 'VIDEO' : 'IMAGE',
text: message,
access_token: accessToken,
});
const { id: mediaId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${userId}/threads?${mediaParams.toString()}`,
{
method: 'POST',
}
)
).json();
return mediaId;
}
private async createCarouselContent(
userId: string,
accessToken: string,
media: { path: string }[],
message: string,
replyToId?: string
): Promise<string> {
// Create each media item
const mediaIds = [];
for (const mediaItem of media) {
const mediaId = await this.createSingleMediaContent(
userId,
accessToken,
mediaItem,
message,
true
);
mediaIds.push(mediaId);
}
// Wait for all media to be loaded
await Promise.all(
mediaIds.map((id: string) => this.checkLoaded(id, accessToken))
);
// Create carousel container
const params = new URLSearchParams({
text: message,
media_type: 'CAROUSEL',
children: mediaIds.join(','),
...(replyToId ? { reply_to_id: replyToId } : {}),
access_token: accessToken,
});
const { id: containerId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${userId}/threads?${params.toString()}`,
{
method: 'POST',
}
)
).json();
return containerId;
}
private async createTextContent(
userId: string,
accessToken: string,
message: string,
replyToId?: string,
quoteId?: string
): Promise<string> {
const form = new FormData();
form.append('media_type', 'TEXT');
form.append('text', message);
form.append('access_token', accessToken);
if (replyToId) {
form.append('reply_to_id', replyToId);
}
if (quoteId) {
form.append('quote_post_id', quoteId);
}
const { id: contentId } = await (
await this.fetch(`https://graph.threads.net/v1.0/${userId}/threads`, {
method: 'POST',
body: form,
})
).json();
return contentId;
}
private async publishThread(
userId: string,
accessToken: string,
creationId: string
): Promise<{ threadId: string; permalink: string }> {
await this.checkLoaded(creationId, accessToken);
const { id: threadId } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${userId}/threads_publish?creation_id=${creationId}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
const { permalink } = await (
await this.fetch(
`https://graph.threads.net/v1.0/${threadId}?fields=id,permalink&access_token=${accessToken}`
)
).json();
return { threadId, permalink };
}
private async createThreadContent(
userId: string,
accessToken: string,
postDetails: PostDetails,
replyToId?: string,
quoteId?: string
): Promise<string> {
// Handle content creation based on media type
if (!postDetails.media || postDetails.media.length === 0) {
// Text-only content
return await this.createTextContent(
userId,
accessToken,
postDetails.message,
replyToId,
quoteId
);
} else if (postDetails.media.length === 1) {
// Single media content
return await this.createSingleMediaContent(
userId,
accessToken,
postDetails.media[0],
postDetails.message,
false,
replyToId
);
} else {
// Carousel content
return await this.createCarouselContent(
userId,
accessToken,
postDetails.media,
postDetails.message,
replyToId
);
}
}
async post(
userId: string,
accessToken: string,
postDetails: PostDetails<{
active_thread_finisher: boolean;
thread_finisher: string;
}>[]
): Promise<PostResponse[]> {
if (!postDetails.length) {
return [];
}
const [firstPost, ...replies] = postDetails;
// Create the initial thread
const initialContentId = await this.createThreadContent(
userId,
accessToken,
firstPost
);
// Publish the thread
const { threadId, permalink } = await this.publishThread(
userId,
accessToken,
initialContentId
);
// Track the responses
const responses: PostResponse[] = [
{
id: firstPost.id,
postId: threadId,
status: 'success',
releaseURL: permalink,
},
];
// Handle replies if any
let lastReplyId = threadId;
for (const reply of replies) {
// Create reply content
const replyContentId = await this.createThreadContent(
userId,
accessToken,
reply,
lastReplyId
);
// Publish the reply
const { threadId: replyThreadId } = await this.publishThread(
userId,
accessToken,
replyContentId
);
// Update the last reply ID for chaining
lastReplyId = replyThreadId;
// Add to responses
responses.push({
id: reply.id,
postId: threadId, // Main thread ID
status: 'success',
releaseURL: permalink, // Main thread URL
});
}
if (postDetails?.[0]?.settings?.active_thread_finisher) {
try {
const replyContentId = await this.createThreadContent(
userId,
accessToken,
{
id: makeId(10),
media: [],
message: postDetails?.[0]?.settings?.thread_finisher,
settings: {},
},
lastReplyId,
threadId
);
await this.publishThread(userId, accessToken, replyContentId);
} catch (err) {
console.log(err);
}
}
return responses;
}
async analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const until = dayjs().endOf('day').unix();
const since = dayjs().subtract(date, 'day').unix();
const { data, ...all } = await (
await fetch(
`https://graph.threads.net/v1.0/${id}/threads_insights?metric=views,likes,replies,reposts,quotes&access_token=${accessToken}&period=day&since=${since}&until=${until}`
)
).json();
return (
data?.map((d: any) => ({
label: capitalize(d.name),
percentageChange: 5,
data: d.total_value
? [{ total: d.total_value.value, date: dayjs().format('YYYY-MM-DD') }]
: d.values.map((v: any) => ({
total: v.value,
date: dayjs(v.end_time).format('YYYY-MM-DD'),
})),
})) || []
);
}
@Plug({
identifier: 'threads-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 { data } = await (
await fetch(
`https://graph.threads.net/v1.0/${id}/insights?metric=likes&access_token=${integration.token}`
)
).json();
const {
values: [value],
} = data.find((p: any) => p.name === 'likes');
if (value.value >= fields.likesAmount) {
await timer(2000);
const form = new FormData();
form.append('media_type', 'TEXT');
form.append('text', stripHtmlValidation('normal', fields.post, true));
form.append('reply_to_id', id);
form.append('access_token', integration.token);
const { id: replyId } = await (
await this.fetch('https://graph.threads.net/v1.0/me/threads', {
method: 'POST',
body: form,
})
).json();
await (
await this.fetch(
`https://graph.threads.net/v1.0/${integration.internalId}/threads_publish?creation_id=${replyId}&access_token=${integration.token}`,
{
method: 'POST',
}
)
).json();
return true;
}
return false;
}
// override async mention(
// token: string,
// data: { query: string },
// id: string,
// integration: Integration
// ) {
// const p = await (
// await fetch(
// `https://graph.threads.net/v1.0/profile_lookup?username=${data.query}&access_token=${integration.token}`
// )
// ).json();
//
// return [
// {
// id: String(p.id),
// label: p.name,
// image: p.profile_picture_url,
// },
// ];
// }
//
// mentionFormat(idOrHandle: string, name: string) {
// return `@${idOrHandle}`;
// }
}