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 { 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 { 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 { 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 { // 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 { 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 { // 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 { 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 { 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}`; // } }