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