diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 242c7c88..4738b630 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: 8 + version: 10 run_install: false - name: Get pnpm store directory diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index 5bd2f52e..024a8904 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -101,6 +101,7 @@ export class IntegrationsController { internalId: p.internalId, disabled: p.disabled, editor: findIntegration.editor, + stripLinks: !!findIntegration?.stripLinks?.(), picture: p.picture || '/no-picture.jpg', identifier: p.providerIdentifier, inBetweenSteps: p.inBetweenSteps, diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 7a0be2d0..f3f19ce8 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -36,7 +36,7 @@ import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; throttlers: [ { ttl: 3600000, - limit: process.env.API_LIMIT ? Number(process.env.API_LIMIT) : 30, + limit: process.env.API_LIMIT ? Number(process.env.API_LIMIT) : 90, }, ], storage: new ThrottlerStorageRedisService(ioRedis), diff --git a/apps/frontend/src/components/launches/calendar.context.tsx b/apps/frontend/src/components/launches/calendar.context.tsx index 5515b2c4..8c81c99e 100644 --- a/apps/frontend/src/components/launches/calendar.context.tsx +++ b/apps/frontend/src/components/launches/calendar.context.tsx @@ -86,6 +86,7 @@ export interface Integrations { disabled?: boolean; inBetweenSteps: boolean; editor: 'none' | 'normal' | 'markdown' | 'html'; + stripLinks?: boolean; display: string; identifier: string; type: string; diff --git a/apps/frontend/src/components/launches/information.component.tsx b/apps/frontend/src/components/launches/information.component.tsx index 5a5190e4..c734429f 100644 --- a/apps/frontend/src/components/launches/information.component.tsx +++ b/apps/frontend/src/components/launches/information.component.tsx @@ -7,6 +7,7 @@ import clsx from 'clsx'; import SafeImage from '@gitroom/react/helpers/safe.image'; import { capitalize } from 'lodash'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { hasLinks } from '@gitroom/helpers/utils/strip.links'; const Valid: FC = () => { return ( @@ -59,15 +60,36 @@ export const InformationComponent: FC<{ totalChars: number; totalAllowedChars: number; isPicture: boolean; -}> = ({ totalChars, totalAllowedChars, chars, isPicture }) => { + text?: string; +}> = ({ totalChars, totalAllowedChars, chars, isPicture, text }) => { const t = useT(); - const { isGlobal, selectedIntegrations, internal } = useLaunchStore( - useShallow((state) => ({ - isGlobal: state.current === 'global', - selectedIntegrations: state.selectedIntegrations, - internal: state.internal, - })) - ); + const { isGlobal, selectedIntegrations, internal, currentIntegration } = + useLaunchStore( + useShallow((state) => ({ + isGlobal: state.current === 'global', + selectedIntegrations: state.selectedIntegrations, + internal: state.internal, + currentIntegration: state.integrations.find( + (p) => p.id === state.current + ), + })) + ); + + const stripLinkNames = useMemo(() => { + if (!hasLinks(text)) { + return [] as string[]; + } + + if (!isGlobal) { + return currentIntegration?.stripLinks ? [currentIntegration.name] : []; + } + + return selectedIntegrations + .filter((p) => p.integration.stripLinks) + .map((p) => p.integration.name); + }, [text, isGlobal, currentIntegration, selectedIntegrations]); + + const showStripLinkWarning = stripLinkNames.length > 0; const isInternal = useMemo(() => { if (!isGlobal) { @@ -83,6 +105,10 @@ export const InformationComponent: FC<{ }, [isGlobal, internal, selectedIntegrations]); const isValid = useMemo(() => { + if (showStripLinkWarning) { + return false; + } + if (!isPicture && !totalChars) { return false; } @@ -108,7 +134,14 @@ export const InformationComponent: FC<{ } return true; - }, [totalAllowedChars, totalChars, isInternal, isPicture, chars]); + }, [ + totalAllowedChars, + totalChars, + isInternal, + isPicture, + chars, + showStripLinkWarning, + ]); const globalDisplayLimit = useMemo(() => { if (!isGlobal || !selectedIntegrations.length) { @@ -230,6 +263,19 @@ export const InformationComponent: FC<{ ))} )} + {showStripLinkWarning && ( +
+ {t('links_will_be_removed_from', 'Links will be removed from')}:{' '} + {stripLinkNames.join(', ')} +
+ )} )} diff --git a/apps/frontend/src/components/new-launch/editor.tsx b/apps/frontend/src/components/new-launch/editor.tsx index 69027bee..5c357b7c 100644 --- a/apps/frontend/src/components/new-launch/editor.tsx +++ b/apps/frontend/src/components/new-launch/editor.tsx @@ -772,6 +772,7 @@ export const Editor: FC<{ chars={chars} totalChars={valueWithoutHtml.length} totalAllowedChars={props.totalChars} + text={valueWithoutHtml} /> } toolBar={ diff --git a/apps/frontend/src/components/new-launch/manage.modal.tsx b/apps/frontend/src/components/new-launch/manage.modal.tsx index 0af6af15..f8edb218 100644 --- a/apps/frontend/src/components/new-launch/manage.modal.tsx +++ b/apps/frontend/src/components/new-launch/manage.modal.tsx @@ -341,9 +341,12 @@ export const ManageModal: FC = (props) => { await fetch('/posts/should-shortlink', { method: 'POST', body: JSON.stringify({ - messages: checkAllValid.flatMap((p: any) => - p.values.flatMap((a: any) => a.content) - ), + messages: checkAllValid + // platforms that remove links won't keep shortlinks either + .filter((p: any) => !p?.integration?.stripLinks) + .flatMap((p: any) => + p.values.flatMap((a: any) => a.content) + ), }), }) ).json(); diff --git a/apps/orchestrator/src/activities/post.activity.ts b/apps/orchestrator/src/activities/post.activity.ts index b2e9a62b..ffd74981 100644 --- a/apps/orchestrator/src/activities/post.activity.ts +++ b/apps/orchestrator/src/activities/post.activity.ts @@ -76,7 +76,7 @@ export class PostActivity { for (const post of list) { await this._temporalService.client .getRawClient() - .workflow.signalWithStart('postWorkflowV104', { + .workflow.signalWithStart('postWorkflowV105', { workflowId: `post_${post.id}`, taskQueue: 'main', signal: 'poke', @@ -110,10 +110,25 @@ export class PostActivity { await this._postService.updatePost(id, postId, releaseURL); } + @ActivityMethod() + async getPost(orgId: string, postId: string) { + if (process.env.STRIPE_SECRET_KEY) { + const subscription = await this._subscriptionService.getSubscription( + orgId + ); + if (!subscription) { + return false; + } + } + return this._postService.getPostById(postId, orgId); + } + @ActivityMethod() async getPostsList(orgId: string, postId: string) { if (process.env.STRIPE_SECRET_KEY) { - const subscription = await this._subscriptionService.getSubscription(orgId); + const subscription = await this._subscriptionService.getSubscription( + orgId + ); if (!subscription) { return []; } @@ -186,6 +201,16 @@ export class PostActivity { @ActivityMethod() async postSocial(integration: Integration, posts: Post[]) { + if (process.env.STRIPE_SECRET_KEY) { + const subscription = await this._subscriptionService.getSubscription( + integration.organizationId + ); + + if (!subscription) { + throw new Error('No active subscription found for this organization.'); + } + } + const getIntegration = this._integrationManager.getSocialIntegration( integration.providerIdentifier ); @@ -384,10 +409,7 @@ export class PostActivity { return refresh; } catch (err) { - await this._refreshIntegrationService.setBetweenSteps( - integration, - cause - ); + await this._refreshIntegrationService.setBetweenSteps(integration, cause); return false; } } diff --git a/apps/orchestrator/src/workflows/index.ts b/apps/orchestrator/src/workflows/index.ts index b4da83e8..ad6e8c45 100644 --- a/apps/orchestrator/src/workflows/index.ts +++ b/apps/orchestrator/src/workflows/index.ts @@ -2,6 +2,7 @@ export * from './post-workflows/post.workflow.v1.0.1'; export * from './post-workflows/post.workflow.v1.0.2'; export * from './post-workflows/post.workflow.v1.0.3'; export * from './post-workflows/post.workflow.v1.0.4'; +export * from './post-workflows/post.workflow.v1.0.5'; export * from './autopost.workflow'; export * from './digest.email.workflow'; export * from './missing.post.workflow'; diff --git a/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.5.ts b/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.5.ts new file mode 100644 index 00000000..21b7c05b --- /dev/null +++ b/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.5.ts @@ -0,0 +1,438 @@ +import { PostActivity } from '@gitroom/orchestrator/activities/post.activity'; +import { + ActivityFailure, + ApplicationFailure, + startChild, + proxyActivities, + sleep, + defineSignal, + setHandler, +} from '@temporalio/workflow'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { capitalize, sortBy } from 'lodash'; +import { PostResponse } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { TypedSearchAttributes } from '@temporalio/common'; +import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; + +const proxyTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '10 minute', + taskQueue, + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, + }); +}; + +const { + getPostsList, + getPost, + inAppNotification, + changeState, + updatePost, + sendWebhooks, + isCommentable, +} = proxyActivities({ + startToCloseTimeout: '10 minute', + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, +}); + +const poke = defineSignal('poke'); + +const iterate = Array.from({ length: 5 }); + +export async function postWorkflowV105({ + taskQueue, + postId, + organizationId, + postNow = false, +}: { + taskQueue: string; + postId: string; + organizationId: string; + postNow?: boolean; +}) { + // Dynamic task queue, for concurrency + const { + postSocial, + postComment, + getIntegrationById, + refreshTokenWithCause, + internalPlugs, + globalPlugs, + processInternalPlug, + processPlug, + } = proxyTaskQueue(taskQueue); + + let poked = false; + setHandler(poke, () => { + poked = true; + }); + + const startTime = new Date(); + // get all the posts and comments to post + const firstPost = await getPost(organizationId, postId); + + // in case doesn't exists for some reason, fail it + if (!firstPost) { + await changeState(postId, 'ERROR', 'No Post'); + return; + } + + if (!postNow && firstPost.state !== 'QUEUE') { + await changeState(firstPost.id, 'ERROR', 'Already posted', [firstPost]); + return; + } + + // if it's a repeatable post, we should ignore this. + if (!postNow) { + await sleep( + dayjs(firstPost.publishDate).isBefore(dayjs()) + ? 0 + : dayjs(firstPost.publishDate).diff(dayjs(), 'millisecond') + ); + } + + const postsListBefore = await getPostsList(organizationId, postId); + const [post] = postsListBefore; + + if (!post) { + await changeState(postId, 'ERROR', 'No Post'); + return; + } + + // if refresh is needed from last time, let's inform the user + if (post.integration?.refreshNeeded) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because you need to reconnect it. Please enable it and try again.`, + true, + false, + 'info' + ); + + await changeState( + postsListBefore[0].id, + 'ERROR', + 'Refresh channel needed', + postsListBefore + ); + return; + } + + // if it's disabled, inform the user + if (post.integration?.disabled) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because it's disabled. Please enable it and try again.`, + true, + false, + 'info' + ); + + await changeState( + postsListBefore[0].id, + 'ERROR', + 'Channel disabled', + postsListBefore + ); + return; + } + + // Do we need to post comment for this social? + const toComment: boolean = + postsListBefore.length === 1 + ? false + : await isCommentable(post.integration); + + const postsList = toComment ? postsListBefore : [postsListBefore[0]]; + + // list of all the saved results + const postsResults: PostResponse[] = []; + + // iterate over the posts + for (let i = 0; i < postsList.length; i++) { + const before = postsResults.length; + // this is a small trick to repeat an action in case of token refresh + for (const _ of iterate) { + try { + // first post the main post + if (i === 0) { + postsResults.push( + ...(await postSocial(post.integration as Integration, [ + postsList[i], + ])) + ); + + // then post the comments if any + } else { + if (postsList[i].delay) { + await sleep(60000 * Math.max(0, Number(postsList[i].delay ?? 0))); + } + + postsResults.push( + ...(await postComment( + postsResults[0].postId, + postsResults.length === 1 + ? undefined + : postsResults[i - 1].postId, + post.integration, + [postsList[i]] + )) + ); + } + + // mark post as successful + await updatePost( + postsList[i].id, + postsResults[i].postId, + postsResults[i].releaseURL + ); + + if (i === 0) { + // send notification on a sucessful post + await inAppNotification( + post.integration.organizationId, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )}`, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )} at ${postsResults[0].releaseURL}`, + true, + true + ); + } + + // break the current while to move to the next post + break; + } catch (err) { + // if token refresh is needed, do it and repeat + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'refresh_token' + ) { + const refresh = await refreshTokenWithCause( + post.integration, + err?.cause?.message || '' + ); + if (!refresh || !refresh.accessToken) { + await changeState(postsList[0].id, 'ERROR', err, postsList); + return false; + } + + post.integration.token = refresh.accessToken; + continue; + } + + // for other errors, change state and inform the user if needed + await changeState(postsList[0].id, 'ERROR', err, postsList); + + // specific case for bad body errors + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'bad_body' + ) { + await inAppNotification( + post.organizationId, + `Error posting${i === 0 ? ' ' : ' comments '}on ${ + post.integration?.providerIdentifier + } for ${post?.integration?.name}`, + `An error occurred while posting${i === 0 ? ' ' : ' comments '}on ${ + post.integration?.providerIdentifier + }${err?.cause?.message ? `: ${err?.cause?.message}` : ``}`, + true, + false, + 'fail' + ); + return false; + } + } + } + + if (postsResults.length === before) { + // all retries exhausted without success + return false; + } + } + + // send webhooks for the post + await sendWebhooks( + postsResults[0].postId, + post.organizationId, + post.integration.id + ); + + // load internal plugs like repost by other users + const internalPlugsList = await internalPlugs( + post.integration, + JSON.parse(post.settings) + ); + + // load global plugs, like repost a post if it gets to a certain number of likes + const globalPlugsList = (await globalPlugs(post.integration)).reduce( + (all, current) => { + for (let i = 1; i <= current.totalRuns; i++) { + all.push({ + ...current, + delay: current.delay * i, + }); + } + + return all; + }, + [] + ); + + // Check if the post is repeatable + const repeatPost = !post.intervalInDays + ? [] + : [ + { + type: 'repeat-post', + delay: + post.intervalInDays * 24 * 60 * 60 * 1000 - + (new Date().getTime() - startTime.getTime()), + }, + ]; + + // Sort all the actions by delay, so we can process them in order + const list = sortBy( + [...internalPlugsList, ...globalPlugsList, ...repeatPost], + 'delay' + ); + + // process all the plugs in order, we are using while because in some cases we need to remove items from the list + while (list.length > 0) { + // get the next to process + const todo = list.shift(); + + // wait for the delay + await sleep(Math.max(0, Number(todo.delay ?? 0))); + + // process internal plug + if (todo.type === 'internal-plug') { + for (const _ of iterate) { + try { + await processInternalPlug({ ...todo, post: postsResults[0].postId }); + } catch (err) { + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'refresh_token' + ) { + const refresh = await refreshTokenWithCause( + await getIntegrationById(organizationId, todo.integration), + err?.cause?.message || '' + ); + if (!refresh || !refresh.accessToken) { + break; + } + + continue; + } + + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'bad_body' + ) { + break; + } + + continue; + } + break; + } + } + + // process global plug + if (todo.type === 'global') { + for (const _ of iterate) { + try { + const process = await processPlug({ + ...todo, + postId: postsResults[0].postId, + }); + if (process) { + const toDelete = list + .reduce((all, current, index) => { + if (current.plugId === todo.plugId) { + all.push(index); + } + + return all; + }, []) + .reverse(); + + for (const index of toDelete) { + list.splice(index, 1); + } + } + } catch (err) { + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'refresh_token' + ) { + const refresh = await refreshTokenWithCause( + post.integration, + err?.cause?.message || '' + ); + if (!refresh || !refresh.accessToken) { + break; + } + + continue; + } + + if ( + err instanceof ActivityFailure && + err.cause instanceof ApplicationFailure && + err.cause.type === 'bad_body' + ) { + break; + } + + continue; + } + + break; + } + } + + // process repeat post in a new workflow, this is important so the other plugs can keep running + if (todo.type === 'repeat-post') { + await startChild(postWorkflowV105, { + parentClosePolicy: 'ABANDON', + args: [ + { + taskQueue, + postId, + organizationId, + postNow: true, + }, + ], + workflowId: `post_${post.id}_${makeId(10)}`, + typedSearchAttributes: new TypedSearchAttributes([ + { + key: postIdSearchParam, + value: postId, + }, + ]), + }); + } + } +} diff --git a/libraries/helpers/src/utils/strip.links.ts b/libraries/helpers/src/utils/strip.links.ts new file mode 100644 index 00000000..337a52a1 --- /dev/null +++ b/libraries/helpers/src/utils/strip.links.ts @@ -0,0 +1,17 @@ +// Keep this in sync with the URL detection used by the short linking service +const urlRegex = () => + /(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*))/gm; + +export function hasLinks(text?: string | null): boolean { + return !!(text || '').match(urlRegex()); +} + +export function stripLinks(text?: string | null): string { + return (text || '') + .replace(urlRegex(), '') + // collapse the whitespace / empty anchor leftovers the removed link left behind + .replace(/]*>\s*<\/a>/gi, '') + .replace(/[ \t]{2,}/g, ' ') + .replace(/ +\n/g, '\n') + .trim(); +} diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index ffc75339..5f4694ba 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -18,7 +18,10 @@ import utc from 'dayjs/plugin/utc'; import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; import { CreateTagDto } from '@gitroom/nestjs-libraries/dtos/posts/create.tag.dto'; -import { minifyPostsList, minifyPosts } from '@gitroom/helpers/utils/posts.list.minify'; +import { + minifyPostsList, + minifyPosts, +} from '@gitroom/helpers/utils/posts.list.minify'; import axios from 'axios'; import sharp from 'sharp'; import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; @@ -38,6 +41,7 @@ import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract'; import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service'; import { hasExtension } from '@gitroom/helpers/utils/has.extension'; +import { stripLinks } from '@gitroom/helpers/utils/strip.links'; type PostWithConditionals = Post & { integration?: Integration; @@ -126,6 +130,10 @@ export class PostsService { return []; } + async getPostById(postId: string, orgId: string) { + return this._postRepository.getPostById(postId, orgId); + } + async updateReleaseId(orgId: string, postId: string, releaseId: string) { return this._postRepository.updateReleaseId(postId, orgId, releaseId); } @@ -707,7 +715,7 @@ export class PostsService { try { await this._temporalService.client .getRawClient() - ?.workflow.start('postWorkflowV104', { + ?.workflow.start('postWorkflowV105', { workflowId: `post_${postId}`, taskQueue: 'main', workflowIdConflictPolicy: 'TERMINATE_EXISTING', @@ -735,14 +743,24 @@ export class PostsService { async createPost(orgId: string, body: CreatePostDto): Promise { const postList = []; for (const post of body.posts) { + const provider = this._integrationManager.getSocialIntegration( + (post.settings as any)?.__type + ); + const removeLinks = !!provider?.stripLinks?.(); + const messages = (post.value || []).map((p) => p.content); - const updateContent = !body.shortLink - ? messages - : await this._shortLinkService.convertTextToShortLinks(orgId, messages); + // No point shortlinking links on platforms that strip them out anyway + const updateContent = + !body.shortLink || removeLinks + ? messages + : await this._shortLinkService.convertTextToShortLinks( + orgId, + messages + ); post.value = (post.value || []).map((p, i) => ({ ...p, - content: updateContent[i], + content: removeLinks ? stripLinks(updateContent[i]) : updateContent[i], })); const { posts } = await this._postRepository.createOrUpdatePost( @@ -831,7 +849,9 @@ export class PostsService { if (action === 'schedule') { try { await this.startWorkflow( - getPostById.integration.providerIdentifier.split('-')[0].toLowerCase(), + getPostById.integration.providerIdentifier + .split('-')[0] + .toLowerCase(), getPostById.id, orgId, getPostById.state === 'DRAFT' ? 'DRAFT' : 'QUEUE' diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index 29d8356b..a48598a8 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -141,6 +141,7 @@ export interface SocialProvider identifier: string; refreshWait?: boolean; convertToJPEG?: boolean; + stripLinks?: () => boolean; refreshCron?: boolean; dto?: any; maxLength: (additionalSettings?: any) => number; diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts index fcedcd15..4a511630 100644 --- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -18,6 +18,7 @@ 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'; +import { stripLinks as removeLinks } from '@gitroom/helpers/utils/strip.links'; import { XDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/x.dto'; import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; import { hasExtension } from '@gitroom/helpers/utils/has.extension'; @@ -30,6 +31,7 @@ export class XProvider extends SocialAbstract implements SocialProvider { name = 'X'; isBetweenSteps = false; scopes = [] as string[]; + stripLinks = () => !!process.env.STRIP_LINKS_FROM_X_POSTS; 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'; @@ -228,8 +230,9 @@ export class XProvider extends SocialAbstract implements SocialProvider { ) { await timer(2000); + const plugText = stripHtmlValidation('normal', fields.post, true); await client.v2.tweet({ - text: stripHtmlValidation('normal', fields.post, true), + text: this.stripLinks() ? removeLinks(plugText) : plugText, reply: { in_reply_to_tweet_id: id }, }); return true; @@ -470,7 +473,9 @@ export class XProvider extends SocialAbstract implements SocialProvider { firstPost?.settings?.community?.split('/').pop() || '', } : {}), - text: firstPost.message, + text: this.stripLinks() + ? removeLinks(firstPost.message) + : firstPost.message, ...(media_ids.length ? { media: { media_ids } } : {}), made_with_ai: !!firstPost?.settings?.made_with_ai, paid_partnership: !!firstPost?.settings?.paid_partnership, @@ -537,7 +542,9 @@ export class XProvider extends SocialAbstract implements SocialProvider { const tweetUrl = 'https://api.x.com/2/tweets'; const tweetBody = { - text: commentPost.message, + text: this.stripLinks() + ? removeLinks(commentPost.message) + : commentPost.message, ...(media_ids.length ? { media: { media_ids } } : {}), reply: { in_reply_to_tweet_id: replyToId }, made_with_ai: !!commentPost?.settings?.made_with_ai, diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index f21bd66c..e36d74be 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -71,10 +71,7 @@ export class StripeService { currency: 'usd', payment_method: latestMethod.id, customer: event.data.object.customer as string, - automatic_payment_methods: { - allow_redirects: 'never', - enabled: true, - }, + off_session: true, capture_method: 'manual', // Authorize without capturing confirm: true, // Confirm the PaymentIntent });