diff --git a/.env.example b/.env.example
index b6f600ddac..864e5111c3 100644
--- a/.env.example
+++ b/.env.example
@@ -43,6 +43,8 @@ STORAGE_PROVIDER="local"
X_URL=""
X_API_KEY=""
X_API_SECRET=""
+X_CLIENT_ID=""
+X_CLIENT_SECRET=""
LINKEDIN_CLIENT_ID=""
LINKEDIN_CLIENT_SECRET=""
REDDIT_CLIENT_ID=""
diff --git a/apps/backend/src/api/routes/enterprise.controller.ts b/apps/backend/src/api/routes/enterprise.controller.ts
index 5e9bbdc806..e4ae6c0995 100644
--- a/apps/backend/src/api/routes/enterprise.controller.ts
+++ b/apps/backend/src/api/routes/enterprise.controller.ts
@@ -51,6 +51,7 @@ export class EnterpriseController {
refreshId?: string;
provider: string;
webhookUrl: string;
+ authMode?: string;
};
if (!load || !load.redirectUrl || !load.apiKey || !load.provider) {
@@ -75,8 +76,25 @@ export class EnterpriseController {
load.provider
);
+ const selectableAuthProvider =
+ this._integrationManager.getSelectableAuthProvider(load.provider);
+ const previousIntegration =
+ selectableAuthProvider?.getAuthMode && load.refreshId
+ ? (await this._integrationService.getIntegrationsList(org.id)).find(
+ (item) => item.internalId === load.refreshId
+ )
+ : undefined;
+ const resolvedAuthMode =
+ load.authMode ||
+ (previousIntegration
+ ? selectableAuthProvider?.getAuthMode?.(previousIntegration)
+ : undefined);
const { codeVerifier, state, url } =
- await integrationProvider.generateAuthUrl();
+ selectableAuthProvider && resolvedAuthMode
+ ? await selectableAuthProvider.generateAuthUrlForMode(
+ resolvedAuthMode
+ )
+ : await integrationProvider.generateAuthUrl();
if (load.refreshId) {
await ioRedis.set(`refresh:${state}`, load.refreshId, 'EX', 3600);
diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts
index 024a890432..62ea69f48b 100644
--- a/apps/backend/src/api/routes/integrations.controller.ts
+++ b/apps/backend/src/api/routes/integrations.controller.ts
@@ -198,6 +198,7 @@ export class IntegrationsController {
@Query('externalUrl') externalUrl: string,
@Query('redirectUrl') redirectUrl: string,
@Query('onboarding') onboarding: string,
+ @Query('authMode') authMode: string,
@GetOrgFromRequest() org: Organization
) {
if (
@@ -223,8 +224,26 @@ export class IntegrationsController {
}
: undefined;
+ const selectableAuthProvider =
+ this._integrationManager.getSelectableAuthProvider(integration);
+ const previousIntegration =
+ selectableAuthProvider?.getAuthMode && refresh
+ ? (await this._integrationService.getIntegrationsList(org.id)).find(
+ (item) => item.internalId === refresh
+ )
+ : undefined;
+ const resolvedAuthMode =
+ authMode ||
+ (previousIntegration
+ ? selectableAuthProvider?.getAuthMode?.(previousIntegration)
+ : undefined);
const { codeVerifier, state, url } =
- await integrationProvider.generateAuthUrl(getExternalUrl);
+ selectableAuthProvider && resolvedAuthMode
+ ? await selectableAuthProvider.generateAuthUrlForMode(
+ resolvedAuthMode,
+ getExternalUrl
+ )
+ : await integrationProvider.generateAuthUrl(getExternalUrl);
if (refresh) {
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 3600);
diff --git a/apps/backend/src/api/routes/no.auth.integrations.controller.ts b/apps/backend/src/api/routes/no.auth.integrations.controller.ts
index 3f048937ce..a2628f6ebc 100644
--- a/apps/backend/src/api/routes/no.auth.integrations.controller.ts
+++ b/apps/backend/src/api/routes/no.auth.integrations.controller.ts
@@ -239,11 +239,13 @@ export class NoAuthIntegrationsController {
: undefined
);
- this._refreshIntegrationService
- .startRefreshWorkflow(org.id, createUpdate.id, integrationProvider)
- .catch((err) => {
- console.log(err);
- });
+ if (refreshToken) {
+ this._refreshIntegrationService
+ .startRefreshWorkflow(org.id, createUpdate.id, integrationProvider)
+ .catch((err) => {
+ console.log(err);
+ });
+ }
// Fetch pages if this is a two-step provider and not a refresh
let pages: any[] = [];
diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts
index 5cb53b90d3..c07fe8733d 100644
--- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts
+++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts
@@ -328,6 +328,7 @@ export class PublicIntegrationsController {
async getIntegrationUrl(
@Param('integration') integration: string,
@Query('refresh') refresh: string,
+ @Query('authMode') authMode: string,
@GetOrgFromRequest() org: Organization
) {
Sentry.metrics.count('public_api-request', 1);
@@ -352,8 +353,25 @@ export class PublicIntegrationsController {
}
try {
+ const selectableAuthProvider =
+ this._integrationManager.getSelectableAuthProvider(integration);
+ const previousIntegration =
+ selectableAuthProvider?.getAuthMode && refresh
+ ? (await this._integrationService.getIntegrationsList(org.id)).find(
+ (item) => item.internalId === refresh
+ )
+ : undefined;
+ const resolvedAuthMode =
+ authMode ||
+ (previousIntegration
+ ? selectableAuthProvider?.getAuthMode?.(previousIntegration)
+ : undefined);
const { codeVerifier, state, url } =
- await integrationProvider.generateAuthUrl();
+ selectableAuthProvider && resolvedAuthMode
+ ? await selectableAuthProvider.generateAuthUrlForMode(
+ resolvedAuthMode
+ )
+ : await integrationProvider.generateAuthUrl();
if (refresh) {
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 3600);
diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx
index 9ff651acb7..b076fa62cc 100644
--- a/apps/frontend/src/components/launches/add.provider.component.tsx
+++ b/apps/frontend/src/components/launches/add.provider.component.tsx
@@ -1,7 +1,7 @@
'use client';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
-import React, { FC, useCallback, useMemo } from 'react';
+import React, { FC, useCallback, useMemo, useState } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Input } from '@gitroom/react/form/input';
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
@@ -21,6 +21,35 @@ import copy from 'copy-to-clipboard';
import { capitalize } from 'lodash';
const resolver = classValidatorResolver(ApiKeyDto);
+type ProviderAuthOption = {
+ id: string;
+ title: string;
+ description: string;
+ capabilities: string[];
+ recommended?: boolean;
+};
+
+type SocialProviderItem = {
+ identifier: string;
+ name: string;
+ toolTip?: string;
+ isExternal: boolean;
+ isWeb3: boolean;
+ isChromeExtension?: boolean;
+ extensionCookies?: Array<{
+ name: string;
+ domain: string;
+ }>;
+ authOptions?: ProviderAuthOption[];
+ customFields?: Array<{
+ key: string;
+ label: string;
+ validation: string;
+ type: 'text' | 'password';
+ hint?: string;
+ }>;
+};
+
export const useAddProvider = (update?: () => void, invite?: boolean) => {
const modal = useModals();
const fetch = useFetch();
@@ -377,25 +406,7 @@ const ChromeExtensionWarning: FC<{
};
export const AddProviderComponent: FC<{
- social: Array<{
- identifier: string;
- name: string;
- toolTip?: string;
- isExternal: boolean;
- isWeb3: boolean;
- isChromeExtension?: boolean;
- extensionCookies?: Array<{
- name: string;
- domain: string;
- }>;
- customFields?: Array<{
- key: string;
- label: string;
- validation: string;
- type: 'text' | 'password';
- hint?: string;
- }>;
- }>;
+ social: SocialProviderItem[];
article: Array<{
identifier: string;
name: string;
@@ -411,6 +422,12 @@ export const AddProviderComponent: FC<{
const router = useRouter();
const fetch = useFetch();
const modal = useModals();
+ const t = useT();
+ const [authSelection, setAuthSelection] = useState<{
+ invite: boolean;
+ provider: SocialProviderItem;
+ }>();
+ const [selectedAuthMode, setSelectedAuthMode] = useState('');
const getSocialLink = useCallback(
(
invite: boolean,
@@ -425,9 +442,25 @@ export const AddProviderComponent: FC<{
defaultValue?: string;
type: 'text' | 'password';
hint?: string;
- }>
+ }>,
+ authOptions?: ProviderAuthOption[],
+ authMode?: string
) =>
async () => {
+ if (authOptions && authOptions.length > 1 && !authMode) {
+ const provider = social.find(
+ (item) => item.identifier === identifier
+ )!;
+ setSelectedAuthMode(
+ authOptions.find((option) => option.recommended)?.id ||
+ authOptions[0].id
+ );
+ setAuthSelection({ invite, provider });
+ return;
+ }
+
+ const resolvedAuthMode =
+ authMode || (authOptions?.length === 1 ? authOptions[0].id : '');
const onboardingParam = onboarding ? 'onboarding=true' : '';
const openWeb3 = async () => {
const { component: Web3Providers } = web3List.find(
@@ -475,6 +508,9 @@ export const AddProviderComponent: FC<{
isMobile
? `redirectUrl=${encodeURIComponent('postiz://integrations')}`
: '',
+ resolvedAuthMode
+ ? `authMode=${encodeURIComponent(resolvedAuthMode)}`
+ : '',
]
.filter(Boolean)
.join('&');
@@ -664,10 +700,100 @@ export const AddProviderComponent: FC<{
}
await gotoIntegration();
},
- [onboarding]
+ [onboarding, social]
);
- const t = useT();
+ if (authSelection) {
+ const { provider, invite } = authSelection;
+ return (
+
+
+

+
+
+ {t('connect_provider', 'Connect {{provider}}', {
+ provider: provider.name,
+ })}
+
+
+ {t(
+ 'choose_authentication_method',
+ 'Choose how this channel should connect.'
+ )}
+
+
+
+
+
+ {provider.authOptions!.map((option) => {
+ const selected = selectedAuthMode === option.id;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+ }
return (
@@ -702,7 +828,8 @@ export const AddProviderComponent: FC<{
item.isExternal,
item.isWeb3,
item.isChromeExtension,
- item.customFields
+ item.customFields,
+ item.authOptions
)}
{...(!!item.toolTip
? {
diff --git a/apps/frontend/src/components/launches/continue.integration.tsx b/apps/frontend/src/components/launches/continue.integration.tsx
index 0240dc2d41..05210b5844 100644
--- a/apps/frontend/src/components/launches/continue.integration.tsx
+++ b/apps/frontend/src/components/launches/continue.integration.tsx
@@ -64,7 +64,7 @@ export const ContinueIntegration: FC<{
refresh: searchParams.refresh || '',
};
}
- if (provider === 'x') {
+ if (provider === 'x' && searchParams.oauth_token) {
return {
state: searchParams.oauth_token || '',
code: searchParams.oauth_verifier || '',
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 6d7d57e6f9..6f6016022c 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -34,6 +34,8 @@ services:
X_URL: ''
X_API_KEY: ''
X_API_SECRET: ''
+ X_CLIENT_ID: ''
+ X_CLIENT_SECRET: ''
LINKEDIN_CLIENT_ID: ''
LINKEDIN_CLIENT_SECRET: ''
REDDIT_CLIENT_ID: ''
diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts
index 9229e019a1..02c3b91ec0 100644
--- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts
+++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts
@@ -37,6 +37,10 @@ import { SkoolProvider } from '@gitroom/nestjs-libraries/integrations/social/sko
import { WhopProvider } from '@gitroom/nestjs-libraries/integrations/social/whop.provider';
import { MeweProvider } from '@gitroom/nestjs-libraries/integrations/social/mewe.provider';
import { TumblrProvider } from '@gitroom/nestjs-libraries/integrations/social/tumblr.provider';
+import {
+ CapableSocialProvider,
+ supportsSelectableAuth,
+} from '@gitroom/nestjs-libraries/integrations/provider.capabilities';
export const socialIntegrationList: Array
= [
new XProvider(),
@@ -81,19 +85,25 @@ export class IntegrationManager {
async getAllIntegrations() {
return {
social: await Promise.all(
- socialIntegrationList.map(async (p) => ({
- name: p.name,
- identifier: p.identifier,
- toolTip: p.toolTip,
- editor: p.editor,
- isExternal: !!p.externalUrl,
- isWeb3: !!p.isWeb3,
- isChromeExtension: !!p.isChromeExtension,
- ...(p.extensionCookies
- ? { extensionCookies: p.extensionCookies }
- : {}),
- ...(p.customFields ? { customFields: await p.customFields() } : {}),
- }))
+ socialIntegrationList.map(async (p) => {
+ const provider = p as CapableSocialProvider;
+ return {
+ name: p.name,
+ identifier: p.identifier,
+ toolTip: p.toolTip,
+ editor: p.editor,
+ isExternal: !!p.externalUrl,
+ isWeb3: !!p.isWeb3,
+ isChromeExtension: !!p.isChromeExtension,
+ ...(p.extensionCookies
+ ? { extensionCookies: p.extensionCookies }
+ : {}),
+ ...(p.customFields ? { customFields: await p.customFields() } : {}),
+ ...(supportsSelectableAuth(provider)
+ ? { authOptions: provider.authOptions }
+ : {}),
+ };
+ })
),
article: [] as any[],
};
@@ -174,4 +184,11 @@ export class IntegrationManager {
getSocialIntegration(integration: string): SocialProvider {
return socialIntegrationList.find((i) => i.identifier === integration)!;
}
+
+ getSelectableAuthProvider(integration: string) {
+ const provider = socialIntegrationList.find(
+ (item) => item.identifier === integration
+ ) as CapableSocialProvider | undefined;
+ return provider && supportsSelectableAuth(provider) ? provider : undefined;
+ }
}
diff --git a/libraries/nestjs-libraries/src/integrations/provider.capabilities.ts b/libraries/nestjs-libraries/src/integrations/provider.capabilities.ts
new file mode 100644
index 0000000000..85b2c81372
--- /dev/null
+++ b/libraries/nestjs-libraries/src/integrations/provider.capabilities.ts
@@ -0,0 +1,32 @@
+import { Integration } from '@prisma/client';
+import {
+ ClientInformation,
+ GenerateAuthUrlResponse,
+ SocialProvider,
+} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
+
+export type ProviderAuthOption = {
+ id: string;
+ title: string;
+ description: string;
+ capabilities: string[];
+ recommended?: boolean;
+};
+
+export interface SelectableAuthProvider {
+ authOptions: ProviderAuthOption[];
+ getAuthMode?(integration: Integration): string;
+ generateAuthUrlForMode(
+ authMode: string,
+ clientInformation?: ClientInformation
+ ): Promise;
+}
+
+export type CapableSocialProvider = SocialProvider &
+ Partial;
+
+export const supportsSelectableAuth = (
+ provider: CapableSocialProvider
+): provider is CapableSocialProvider & SelectableAuthProvider =>
+ Array.isArray(provider.authOptions) &&
+ typeof provider.generateAuthUrlForMode === 'function';
diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
index 22a85f73d4..36d4ffc2fb 100644
--- a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
+++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts
@@ -22,6 +22,16 @@ 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';
+import { ProviderAuthOption } from '@gitroom/nestjs-libraries/integrations/provider.capabilities';
+
+const X_OAUTH2_PREFIX = 'oauth2:';
+const X_OAUTH2_SCOPES = [
+ 'tweet.read',
+ 'tweet.write',
+ 'users.read',
+ 'media.write',
+ 'offline.access',
+] as const;
@Rules(
`X can have maximum 4 pictures, or maximum one video, it can also be without attachments ${
@@ -34,6 +44,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
identifier = 'x';
name = 'X';
isBetweenSteps = false;
+ refreshCron = true;
scopes = [] as string[];
stripLinks = () => !!process.env.STRIP_LINKS_FROM_X_POSTS;
override maxConcurrentJob = 1; // X has strict rate limits (300 posts per 3 hours)
@@ -43,6 +54,36 @@ export class XProvider extends SocialAbstract implements SocialProvider {
editor = 'normal' as const;
dto = XDto;
+ get authOptions(): ProviderAuthOption[] {
+ const oauth1: ProviderAuthOption = {
+ id: 'oauth1',
+ title: 'Legacy publishing',
+ description:
+ 'Connect with OAuth 1.0a for publishing and existing X features.',
+ capabilities: ['publishing', 'analytics'],
+ };
+
+ const oauth2: ProviderAuthOption = {
+ id: 'oauth2',
+ title: 'OAuth 2.0',
+ description:
+ 'Connect with OAuth 2.0 for publishing and refreshable access.',
+ capabilities: ['publishing', 'analytics'],
+ recommended: true,
+ };
+
+ return [
+ ...(process.env.X_CLIENT_ID && process.env.X_CLIENT_SECRET
+ ? [oauth2]
+ : []),
+ ...(process.env.X_API_KEY && process.env.X_API_SECRET ? [oauth1] : []),
+ ];
+ }
+
+ getAuthMode(integration: Integration) {
+ return integration.token.startsWith(X_OAUTH2_PREFIX) ? 'oauth2' : 'oauth1';
+ }
+
maxLength(additionalSettings?: any) {
// Accepts either the parsed additionalSettings array (from validation) or a
// plain boolean (legacy callers). "Verified" => premium => higher limit.
@@ -94,7 +135,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
return {
type: 'bad-body',
value: 'You are not allowed to create a post with duplicate content',
- }
+ };
}
if (body.includes('usage-capped')) {
@@ -121,8 +162,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
if (body.includes('Your account is not permitted to access this feature')) {
return {
type: 'bad-body',
- value:
- 'X blocked your request',
+ value: 'X blocked your request',
};
}
if (body.includes('The Tweet contains an invalid URL.')) {
@@ -168,15 +208,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
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,
- });
+ const client = await this.getClient(integration.token);
if (
(await client.v2.tweetLikedBy(id)).meta.result_count >=
@@ -203,13 +235,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
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 client = await this.getClient(integration.token);
const {
data: { id },
@@ -252,15 +278,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
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,
- });
+ const client = await this.getClient(integration.token);
if (
(await client.v2.tweetLikedBy(id)).meta.result_count >=
@@ -279,33 +297,62 @@ export class XProvider extends SocialAbstract implements SocialProvider {
return false;
}
- async refreshToken(): Promise {
- return {
- id: '',
- name: '',
- accessToken: '',
- refreshToken: '',
- expiresIn: 0,
- picture: '',
- username: '',
- };
+ async generateAuthUrl() {
+ const options = this.authOptions;
+ const defaultAuthMode =
+ options.find((option) => option.id === 'oauth1')?.id || options[0]?.id;
+
+ if (!defaultAuthMode) {
+ throw new Error('X authentication is not configured');
+ }
+
+ return this.generateAuthUrlForMode(defaultAuthMode);
}
- async generateAuthUrl() {
+ async generateAuthUrlForMode(authMode: string) {
+ const callbackUrl = this.getCallbackUrl();
+
+ if (authMode === 'oauth2') {
+ if (!process.env.X_CLIENT_ID || !process.env.X_CLIENT_SECRET) {
+ throw new Error('X OAuth 2.0 is not configured');
+ }
+
+ const client = new TwitterApi({
+ clientId: process.env.X_CLIENT_ID,
+ clientSecret: process.env.X_CLIENT_SECRET,
+ });
+ const { url, codeVerifier, state } = client.generateOAuth2AuthLink(
+ callbackUrl,
+ {
+ scope: [...X_OAUTH2_SCOPES],
+ }
+ );
+
+ return {
+ url,
+ state,
+ codeVerifier: X_OAUTH2_PREFIX + codeVerifier,
+ };
+ }
+
+ if (authMode !== 'oauth1') {
+ throw new Error('Unsupported X authentication mode');
+ }
+
+ if (!process.env.X_API_KEY || !process.env.X_API_SECRET) {
+ throw new Error('X OAuth 1.0a is not configured');
+ }
+
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,
- }
- );
+ await client.generateAuthLink(callbackUrl, {
+ authAccessType: 'write',
+ linkMode: 'authenticate',
+ forceLogin: false,
+ });
return {
url,
codeVerifier: oauth_token + ':' + oauth_token_secret,
@@ -315,6 +362,55 @@ export class XProvider extends SocialAbstract implements SocialProvider {
async authenticate(params: { code: string; codeVerifier: string }) {
const { code, codeVerifier } = params;
+
+ if (codeVerifier.startsWith(X_OAUTH2_PREFIX)) {
+ const startingClient = new TwitterApi({
+ clientId: process.env.X_CLIENT_ID!,
+ clientSecret: process.env.X_CLIENT_SECRET!,
+ });
+ const { accessToken, refreshToken, expiresIn, client, scope } =
+ await startingClient.loginWithOAuth2({
+ code,
+ codeVerifier: codeVerifier.slice(X_OAUTH2_PREFIX.length),
+ redirectUri: this.getCallbackUrl(),
+ });
+
+ this.checkScopes([...X_OAUTH2_SCOPES], scope);
+
+ const {
+ data: { username, verified, profile_image_url, name, id },
+ } = await client.v2.me({
+ 'user.fields': [
+ 'username',
+ 'verified',
+ 'verified_type',
+ 'profile_image_url',
+ 'name',
+ ],
+ });
+ if (!refreshToken) {
+ throw new Error('X did not return an OAuth 2.0 refresh token');
+ }
+
+ return {
+ id: String(id),
+ accessToken: X_OAUTH2_PREFIX + accessToken,
+ name,
+ refreshToken: X_OAUTH2_PREFIX + refreshToken,
+ expiresIn,
+ picture: profile_image_url || '',
+ username,
+ additionalSettings: [
+ {
+ title: 'Verified',
+ description: 'Is this a verified user? (Premium)',
+ type: 'checkbox' as const,
+ value: verified,
+ },
+ ],
+ };
+ }
+
const [oauth_token, oauth_token_secret] = codeVerifier.split(':');
const startingClient = new TwitterApi({
@@ -359,7 +455,57 @@ export class XProvider extends SocialAbstract implements SocialProvider {
};
}
+ async refreshToken(refreshToken: string): Promise {
+ if (!refreshToken?.startsWith(X_OAUTH2_PREFIX)) {
+ return {
+ id: '',
+ name: '',
+ accessToken: '',
+ refreshToken: '',
+ expiresIn: 0,
+ picture: '',
+ username: '',
+ };
+ }
+
+ const startingClient = new TwitterApi({
+ clientId: process.env.X_CLIENT_ID!,
+ clientSecret: process.env.X_CLIENT_SECRET!,
+ });
+ const {
+ accessToken,
+ refreshToken: nextRefreshToken,
+ expiresIn,
+ client,
+ scope,
+ } = await startingClient.refreshOAuth2Token(
+ refreshToken.slice(X_OAUTH2_PREFIX.length)
+ );
+ this.checkScopes([...X_OAUTH2_SCOPES], scope);
+ const {
+ data: { username, profile_image_url, name, id },
+ } = await client.v2.me({
+ 'user.fields': ['username', 'profile_image_url', 'name'],
+ });
+
+ return {
+ id: String(id),
+ name,
+ accessToken: X_OAUTH2_PREFIX + accessToken,
+ refreshToken:
+ X_OAUTH2_PREFIX +
+ (nextRefreshToken || refreshToken.slice(X_OAUTH2_PREFIX.length)),
+ expiresIn,
+ picture: profile_image_url || '',
+ username,
+ };
+ }
+
private async getClient(accessToken: string) {
+ if (accessToken.startsWith(X_OAUTH2_PREFIX)) {
+ return new TwitterApi(accessToken.slice(X_OAUTH2_PREFIX.length));
+ }
+
const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
return new TwitterApi({
appKey: process.env.X_API_KEY!,
@@ -369,6 +515,28 @@ export class XProvider extends SocialAbstract implements SocialProvider {
});
}
+ private getCallbackUrl() {
+ const baseUrl = process.env.X_URL || process.env.FRONTEND_URL;
+ if (!baseUrl) {
+ throw new Error('X callback URL is not configured');
+ }
+
+ return `${baseUrl.replace(/\/$/, '')}/integrations/social/x`;
+ }
+
+ private getAuthorizationHeader(
+ method: string,
+ url: string,
+ accessToken: string
+ ) {
+ if (accessToken.startsWith(X_OAUTH2_PREFIX)) {
+ return `Bearer ${accessToken.slice(X_OAUTH2_PREFIX.length)}`;
+ }
+
+ const [token, secret] = accessToken.split(':');
+ return this.signOAuth1(method, url, token, secret);
+ }
+
private signOAuth1(
method: string,
url: string,
@@ -480,7 +648,6 @@ export class XProvider extends SocialAbstract implements SocialProvider {
}>[],
integration: Integration
): Promise {
- const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
const client = await this.getClient(accessToken);
const [firstPost] = postDetails;
@@ -510,17 +677,18 @@ export class XProvider extends SocialAbstract implements SocialProvider {
: firstPost.message,
...(media_ids.length ? { media: { media_ids } } : {}),
made_with_ai: this.assetBoolean(firstPost?.settings?.made_with_ai),
- paid_partnership: this.assetBoolean(firstPost?.settings?.paid_partnership),
+ paid_partnership: this.assetBoolean(
+ firstPost?.settings?.paid_partnership
+ ),
};
const tweetResponse = await this.fetch(tweetUrl, {
method: 'POST',
headers: {
- Authorization: this.signOAuth1(
+ Authorization: this.getAuthorizationHeader(
'POST',
tweetUrl,
- accessTokenSplit,
- accessSecretSplit
+ accessToken
),
'Content-Type': 'application/json',
},
@@ -553,7 +721,6 @@ export class XProvider extends SocialAbstract implements SocialProvider {
}>[],
integration: Integration
): Promise {
- const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
const client = await this.getClient(accessToken);
const [commentPost] = postDetails;
@@ -580,11 +747,10 @@ export class XProvider extends SocialAbstract implements SocialProvider {
const tweetResponse = await this.fetch(tweetUrl, {
method: 'POST',
headers: {
- Authorization: this.signOAuth1(
+ Authorization: this.getAuthorizationHeader(
'POST',
tweetUrl,
- accessTokenSplit,
- accessSecretSplit
+ accessToken
),
'Content-Type': 'application/json',
},
@@ -650,13 +816,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
const until = dayjs().endOf('day');
const since = dayjs().subtract(date > 100 ? 100 : 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,
- });
+ const client = await this.getClient(accessToken);
try {
const tweets = uniqBy(
@@ -740,13 +900,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
const today = dayjs().format('YYYY-MM-DD');
- 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 client = await this.getClient(accessToken);
try {
// Fetch the specific tweet with public metrics
@@ -819,13 +973,7 @@ export class XProvider extends SocialAbstract implements SocialProvider {
}
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,
- });
+ const client = await this.getClient(token);
try {
const data = await client.v2.userByUsername(d.query, {