Merge pull request #861 from gitroomhq/feat/handle-errors
Better error messages
This commit is contained in:
commit
c6024e61d7
|
|
@ -163,11 +163,11 @@ export class IntegrationService {
|
|||
await this.informAboutRefreshError(orgId, integration);
|
||||
}
|
||||
|
||||
async informAboutRefreshError(orgId: string, integration: Integration) {
|
||||
async informAboutRefreshError(orgId: string, integration: Integration, err = '') {
|
||||
await this._notificationService.inAppNotification(
|
||||
orgId,
|
||||
`Could not refresh your ${integration.providerIdentifier} channel`,
|
||||
`Could not refresh your ${integration.providerIdentifier} channel. Please go back to the system and connect it again ${process.env.FRONTEND_URL}/launches`,
|
||||
`Could not refresh your ${integration.providerIdentifier} channel ${err}`,
|
||||
`Could not refresh your ${integration.providerIdentifier} channel ${err}. Please go back to the system and connect it again ${process.env.FRONTEND_URL}/launches`,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,16 +299,10 @@ export class PostsService {
|
|||
}
|
||||
|
||||
try {
|
||||
const finalPost =
|
||||
firstPost.integration?.type === 'article'
|
||||
? await this.postArticle(firstPost.integration!, [
|
||||
firstPost,
|
||||
...morePosts,
|
||||
])
|
||||
: await this.postSocial(firstPost.integration!, [
|
||||
firstPost,
|
||||
...morePosts,
|
||||
]);
|
||||
const finalPost = await this.postSocial(firstPost.integration!, [
|
||||
firstPost,
|
||||
...morePosts,
|
||||
]);
|
||||
|
||||
if (firstPost?.intervalInDays) {
|
||||
this._workerServiceProducer.emit('post', {
|
||||
|
|
@ -333,31 +327,16 @@ export class PostsService {
|
|||
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstPost.submittedForOrderId) {
|
||||
this._workerServiceProducer.emit('submit', {
|
||||
payload: {
|
||||
id: firstPost.id,
|
||||
releaseURL: finalPost.releaseURL,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
await this._postRepository.changeState(firstPost.id, 'ERROR', err);
|
||||
await this._notificationService.inAppNotification(
|
||||
firstPost.organizationId,
|
||||
`Error posting on ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
|
||||
`An error occurred while posting on ${
|
||||
firstPost.integration?.providerIdentifier
|
||||
} ${
|
||||
!process.env.NODE_ENV || process.env.NODE_ENV === 'development'
|
||||
? err
|
||||
: ''
|
||||
}`,
|
||||
true
|
||||
);
|
||||
|
||||
if (err instanceof BadBody) {
|
||||
await this._notificationService.inAppNotification(
|
||||
firstPost.organizationId,
|
||||
`Error posting on ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
|
||||
`An error occurred while posting on ${firstPost.integration?.providerIdentifier}${err?.message ? `: ${err?.message}` : ``}`,
|
||||
true
|
||||
);
|
||||
|
||||
console.error(
|
||||
'[Error] posting on',
|
||||
firstPost.integration?.providerIdentifier,
|
||||
|
|
@ -366,15 +345,9 @@ export class PostsService {
|
|||
err.body,
|
||||
err
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'[Error] posting on',
|
||||
firstPost.integration?.providerIdentifier,
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -403,7 +376,8 @@ export class PostsService {
|
|||
private async postSocial(
|
||||
integration: Integration,
|
||||
posts: Post[],
|
||||
forceRefresh = false
|
||||
forceRefresh = false,
|
||||
err = ''
|
||||
): Promise<Partial<{ postId: string; releaseURL: string }>> {
|
||||
const getIntegration = this._integrationManager.getSocialIntegration(
|
||||
integration.providerIdentifier
|
||||
|
|
@ -533,7 +507,7 @@ export class PostsService {
|
|||
};
|
||||
} catch (err) {
|
||||
if (err instanceof RefreshToken) {
|
||||
return this.postSocial(integration, posts, true);
|
||||
return this.postSocial(integration, posts, true, err?.message || '');
|
||||
}
|
||||
|
||||
throw err;
|
||||
|
|
@ -627,41 +601,6 @@ export class PostsService {
|
|||
}
|
||||
}
|
||||
|
||||
private async postArticle(
|
||||
integration: Integration,
|
||||
posts: Post[]
|
||||
): Promise<any> {
|
||||
const getIntegration = this._integrationManager.getArticlesIntegration(
|
||||
integration.providerIdentifier
|
||||
);
|
||||
if (!getIntegration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPosts = await this.updateTags(integration.organizationId, posts);
|
||||
|
||||
const { postId, releaseURL } = await getIntegration.post(
|
||||
integration.token,
|
||||
newPosts.map((p) => p.content).join('\n\n'),
|
||||
JSON.parse(newPosts[0].settings || '{}')
|
||||
);
|
||||
|
||||
await this._notificationService.inAppNotification(
|
||||
integration.organizationId,
|
||||
`Your article has been published on ${capitalize(
|
||||
integration.providerIdentifier
|
||||
)}`,
|
||||
`Your article has been published at ${releaseURL}`,
|
||||
true
|
||||
);
|
||||
await this._postRepository.updatePost(newPosts[0].id, postId, releaseURL);
|
||||
|
||||
return {
|
||||
postId,
|
||||
releaseURL,
|
||||
};
|
||||
}
|
||||
|
||||
async deletePost(orgId: string, group: string) {
|
||||
const post = await this._postRepository.deletePost(orgId, group);
|
||||
if (post?.id) {
|
||||
|
|
@ -738,7 +677,7 @@ export class PostsService {
|
|||
);
|
||||
|
||||
if (!posts?.length) {
|
||||
return;
|
||||
return [] as any[];
|
||||
}
|
||||
|
||||
await this._workerServiceProducer.delete(
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export class IntegrationManager {
|
|||
plugs: (
|
||||
Reflect.getMetadata('custom:plug', p.constructor.prototype) || []
|
||||
)
|
||||
.filter((f) => !f.disabled)
|
||||
.filter((f: any) => !f.disabled)
|
||||
.map((p: any) => ({
|
||||
...p,
|
||||
fields: p.fields.map((c: any) => ({
|
||||
|
|
@ -111,7 +111,7 @@ export class IntegrationManager {
|
|||
'custom:internal_plug',
|
||||
p.constructor.prototype
|
||||
) || []
|
||||
).filter((f) => !f.disabled) || [],
|
||||
).filter((f: any) => !f.disabled) || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ export class RefreshToken {
|
|||
constructor(
|
||||
public identifier: string,
|
||||
public json: string,
|
||||
public body: BodyInit
|
||||
public body: BodyInit,
|
||||
public message = '',
|
||||
) {}
|
||||
}
|
||||
export class BadBody {
|
||||
constructor(
|
||||
public identifier: string,
|
||||
public json: string,
|
||||
public body: BodyInit
|
||||
public body: BodyInit,
|
||||
public message = ''
|
||||
) {}
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +24,7 @@ export class NotEnoughScopes {
|
|||
|
||||
const pThrottleInstance = pThrottle({
|
||||
limit: 1,
|
||||
interval: 2000
|
||||
interval: 5000
|
||||
});
|
||||
|
||||
export abstract class SocialAbstract {
|
||||
|
|
@ -30,6 +32,10 @@ export abstract class SocialAbstract {
|
|||
(url: RequestInfo, options?: RequestInit) => fetch(url, options)
|
||||
);
|
||||
|
||||
public handleErrors(body: string): {type: 'refresh-token' | 'bad-body', value: string}|undefined {
|
||||
return {type: 'bad-body', value: 'bad request'};
|
||||
}
|
||||
|
||||
async fetch(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
|
|
@ -55,28 +61,21 @@ export abstract class SocialAbstract {
|
|||
}
|
||||
|
||||
if (json.includes('rate_limit_exceeded') || json.includes('Rate limit')) {
|
||||
await timer(2000);
|
||||
await timer(5000);
|
||||
return this.fetch(url, options, identifier, totalRetries + 1);
|
||||
}
|
||||
|
||||
if (
|
||||
request.status === 401 ||
|
||||
(json.includes('OAuthException') &&
|
||||
!json.includes('The user is not an Instagram Business') &&
|
||||
!json.includes('Unsupported format') &&
|
||||
!json.includes('2207018') &&
|
||||
!json.includes('352') &&
|
||||
!json.includes('REVOKED_ACCESS_TOKEN'))
|
||||
) {
|
||||
throw new RefreshToken(identifier, json, options.body!);
|
||||
const handleError = this.handleErrors(json);
|
||||
|
||||
if (request.status === 401 || handleError?.type === 'refresh-token') {
|
||||
throw new RefreshToken(identifier, json, options.body!, handleError?.value);
|
||||
}
|
||||
|
||||
if (totalRetries < 2) {
|
||||
await timer(2000);
|
||||
return this.fetch(url, options, identifier, totalRetries + 1);
|
||||
}
|
||||
|
||||
throw new BadBody(identifier, json, options.body!);
|
||||
throw new BadBody(identifier, json, options.body!, handleError?.value);
|
||||
}
|
||||
|
||||
checkScopes(required: string[], got: string | string[]) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,110 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
|||
'pages_read_engagement',
|
||||
'read_insights',
|
||||
];
|
||||
|
||||
override handleErrors(body: string): {
|
||||
type: 'refresh-token' | 'bad-body';
|
||||
value: string;
|
||||
} | undefined {
|
||||
// Access token validation errors - require re-authentication
|
||||
if (body.indexOf('Error validating access token') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value: 'Please re-authenticate your Facebook account',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('490') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value: 'Access token expired, please re-authenticate',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('REVOKED_ACCESS_TOKEN') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value: 'Access token has been revoked, please re-authenticate',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('1390008') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'You are posting too fast, please slow down',
|
||||
};
|
||||
}
|
||||
|
||||
// Content policy violations
|
||||
if (body.indexOf('1346003') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Content flagged as abusive by Facebook',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('1404102') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Content violates Facebook Community Standards',
|
||||
};
|
||||
}
|
||||
|
||||
// Permission errors
|
||||
if (body.indexOf('1404078') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value: 'Page publishing authorization required, please re-authenticate',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('1609008') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Cannot post Facebook.com links',
|
||||
};
|
||||
}
|
||||
|
||||
// Parameter validation errors
|
||||
if (body.indexOf('2061006') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Invalid URL format in post content',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('1349125') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Invalid content format',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('Name parameter too long') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Post content is too long',
|
||||
};
|
||||
}
|
||||
|
||||
// Service errors - checking specific subcodes first
|
||||
if (body.indexOf('1363047') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Facebook service temporarily unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('1609010') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Facebook service temporarily unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
|
||||
return {
|
||||
refreshToken: '',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import dayjs from 'dayjs';
|
|||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto';
|
||||
import { Integration } from '@prisma/client';
|
||||
import { number } from 'yup';
|
||||
|
||||
export class InstagramProvider
|
||||
extends SocialAbstract
|
||||
|
|
@ -42,6 +43,240 @@ export class InstagramProvider
|
|||
};
|
||||
}
|
||||
|
||||
public override handleErrors(body: string):
|
||||
| {
|
||||
type: 'refresh-token' | 'bad-body';
|
||||
value: string;
|
||||
}
|
||||
| undefined {
|
||||
if (body.indexOf('2207018') > -1 || body.indexOf('REVOKED_ACCESS_TOKEN')) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value:
|
||||
'Something is wrong with your connected user, please re-authenticate',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('The user is not an Instagram Business') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value:
|
||||
'Your Instagram account is not a business account, please convert it to a business account',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('Error validating access token') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value: 'Please re-authenticate your Instagram account',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207050') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value: 'Instagram user is restricted',
|
||||
};
|
||||
}
|
||||
|
||||
// Media download/upload errors
|
||||
if (body.indexOf('2207003') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Timeout downloading media, please try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207020') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Media expired, please upload again',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207032') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Failed to create media, please try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207053') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Unknown upload error, please try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207052') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Media fetch failed, please try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207057') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Invalid thumbnail offset for video',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207026') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Unsupported video format',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207023') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Unknown media type',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207006') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Media not found, please upload again',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207008') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Media builder expired, please try again',
|
||||
};
|
||||
}
|
||||
|
||||
// Content validation errors
|
||||
if (body.indexOf('2207028') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Carousel validation failed',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207010') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Caption is too long',
|
||||
};
|
||||
}
|
||||
|
||||
// Product tagging errors
|
||||
if (body.indexOf('2207035') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Product tag positions not supported for videos',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207036') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Product tag positions required for photos',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207037') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Product tag validation failed',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207040') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Too many product tags',
|
||||
};
|
||||
}
|
||||
|
||||
// Image format/size errors
|
||||
if (body.indexOf('2207004') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Image is too large',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207005') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Unsupported image format',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207009') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Aspect ratio not supported, must be between 4:5 to 1.91:1',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('Page request limit reached') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Page posting for today is limited, please try again tomorrow',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207042') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value:
|
||||
'You have reached the maximum of 25 posts per day, allowed for your account',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('Not enough permissions to post') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Not enough permissions to post',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('36003') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Aspect ratio not supported, must be between 4:5 to 1.91:1',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('36001') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Invalid Instagram image resolution max: 1920x1080px',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207051') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Instagram blocked your request',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207001') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value:
|
||||
'Instagram detected that your post is spam, please try again with different content',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('2207027') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Unknown error, please try again later or contact support',
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async reConnect(
|
||||
id: string,
|
||||
requiredId: string,
|
||||
|
|
@ -226,10 +461,14 @@ export class InstagramProvider
|
|||
? firstPost?.media?.length === 1
|
||||
? isStory
|
||||
? `video_url=${m.path}&media_type=STORIES`
|
||||
: `video_url=${m.path}&media_type=REELS&thumb_offset=${m?.thumbnailTimestamp || 0}`
|
||||
: `video_url=${m.path}&media_type=REELS&thumb_offset=${
|
||||
m?.thumbnailTimestamp || 0
|
||||
}`
|
||||
: isStory
|
||||
? `video_url=${m.path}&media_type=STORIES`
|
||||
: `video_url=${m.path}&media_type=VIDEO&thumb_offset=${m?.thumbnailTimestamp || 0}`
|
||||
: `video_url=${m.path}&media_type=VIDEO&thumb_offset=${
|
||||
m?.thumbnailTimestamp || 0
|
||||
}`
|
||||
: isStory
|
||||
? `image_url=${m.path}&media_type=STORIES`
|
||||
: `image_url=${m.path}`;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ export class InstagramStandaloneProvider
|
|||
'instagram_business_manage_insights',
|
||||
];
|
||||
|
||||
public override handleErrors(body: string): { type: "refresh-token" | "bad-body"; value: string } | undefined {
|
||||
return instagramProvider.handleErrors(body);
|
||||
}
|
||||
|
||||
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
|
||||
const { access_token } = await (
|
||||
await this.fetch(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,125 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
|||
'user.info.profile',
|
||||
];
|
||||
|
||||
override handleErrors(body: string):
|
||||
| {
|
||||
type: 'refresh-token' | 'bad-body';
|
||||
value: string;
|
||||
}
|
||||
| undefined {
|
||||
// Authentication/Authorization errors - require re-authentication
|
||||
if (body.indexOf('access_token_invalid') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value:
|
||||
'Access token invalid, please re-authenticate your TikTok account',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('scope_not_authorized') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value:
|
||||
'Missing required permissions, please re-authenticate with all scopes',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('scope_permission_missed') > -1) {
|
||||
return {
|
||||
type: 'refresh-token' as const,
|
||||
value: 'Additional permissions required, please re-authenticate',
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limiting errors
|
||||
if (body.indexOf('rate_limit_exceeded') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'TikTok API rate limit exceeded, please try again later',
|
||||
};
|
||||
}
|
||||
|
||||
// Spam/Policy errors
|
||||
if (body.indexOf('spam_risk_too_many_posts') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Daily post limit reached, please try again tomorrow',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('spam_risk_user_banned_from_posting') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value:
|
||||
'Account banned from posting, please check TikTok account status',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('reached_active_user_cap') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Daily active user quota reached, please try again later',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
body.indexOf('unaudited_client_can_only_post_to_private_accounts') > -1
|
||||
) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'App not approved for public posting, contact support',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('url_ownership_unverified') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'URL ownership not verified, please verify domain ownership',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('privacy_level_option_mismatch') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Privacy level mismatch, please check privacy settings',
|
||||
};
|
||||
}
|
||||
|
||||
// Content/Format validation errors
|
||||
if (body.indexOf('invalid_file_upload') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Invalid file format or specifications not met',
|
||||
};
|
||||
}
|
||||
|
||||
if (body.indexOf('invalid_params') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'Invalid request parameters, please check content format',
|
||||
};
|
||||
}
|
||||
|
||||
// Server errors
|
||||
if (body.indexOf('internal_error') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'TikTok server error, please try again later',
|
||||
};
|
||||
}
|
||||
|
||||
// Generic TikTok API errors
|
||||
if (body.indexOf('TikTok API error') > -1) {
|
||||
return {
|
||||
type: 'bad-body' as const,
|
||||
value: 'TikTok API error, please try again',
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to parent class error handling
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
|
||||
const value = {
|
||||
client_key: process.env.TIKTOK_CLIENT_ID!,
|
||||
|
|
@ -240,108 +359,85 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
|
|||
integration: Integration
|
||||
): Promise<PostResponse[]> {
|
||||
const [firstPost, ...comments] = postDetails;
|
||||
const maxRetries = 3;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`TikTok post attempt ${attempt}/${maxRetries}`, firstPost);
|
||||
|
||||
const {
|
||||
data: { publish_id },
|
||||
} = await (
|
||||
await this.fetch(
|
||||
`https://open.tiktokapis.com/v2/post/publish${this.postingMethod(
|
||||
firstPost.settings.content_posting_method,
|
||||
(firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) === -1
|
||||
)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...((firstPost?.settings?.content_posting_method ||
|
||||
'DIRECT_POST') === 'DIRECT_POST'
|
||||
? {
|
||||
post_info: {
|
||||
title: firstPost.message,
|
||||
privacy_level:
|
||||
firstPost.settings.privacy_level || 'PUBLIC_TO_EVERYONE',
|
||||
disable_duet: !firstPost.settings.duet || false,
|
||||
disable_comment: !firstPost.settings.comment || false,
|
||||
disable_stitch: !firstPost.settings.stitch || false,
|
||||
brand_content_toggle:
|
||||
firstPost.settings.brand_content_toggle || false,
|
||||
brand_organic_toggle:
|
||||
firstPost.settings.brand_organic_toggle || false,
|
||||
...((firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) ===
|
||||
-1
|
||||
? {
|
||||
auto_add_music:
|
||||
firstPost.settings.autoAddMusic === 'yes',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...((firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) > -1
|
||||
? {
|
||||
source_info: {
|
||||
source: 'PULL_FROM_URL',
|
||||
video_url: firstPost?.media?.[0]?.path!,
|
||||
...(firstPost?.media?.[0]?.thumbnailTimestamp!
|
||||
? {
|
||||
video_cover_timestamp_ms:
|
||||
firstPost?.media?.[0]?.thumbnailTimestamp!,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
source_info: {
|
||||
source: 'PULL_FROM_URL',
|
||||
photo_cover_index: 0,
|
||||
photo_images: firstPost.media?.map((p) => p.path),
|
||||
},
|
||||
post_mode: 'DIRECT_POST',
|
||||
media_type: 'PHOTO',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
).json();
|
||||
|
||||
const { url, id: videoId } = await this.uploadedVideoSuccess(
|
||||
integration.profile!,
|
||||
publish_id,
|
||||
accessToken
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: firstPost.id,
|
||||
releaseURL: url,
|
||||
postId: String(videoId),
|
||||
status: 'success',
|
||||
const {
|
||||
data: { publish_id },
|
||||
} = await (
|
||||
await this.fetch(
|
||||
`https://open.tiktokapis.com/v2/post/publish${this.postingMethod(
|
||||
firstPost.settings.content_posting_method,
|
||||
(firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) === -1
|
||||
)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
console.log(`TikTok post attempt ${attempt} failed:`, error);
|
||||
|
||||
// If it's the last attempt, throw the error
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
body: JSON.stringify({
|
||||
...((firstPost?.settings?.content_posting_method ||
|
||||
'DIRECT_POST') === 'DIRECT_POST'
|
||||
? {
|
||||
post_info: {
|
||||
title: firstPost.message,
|
||||
privacy_level: firstPost.settings.privacy_level || 'PUBLIC_TO_EVERYONE',
|
||||
disable_duet: !firstPost.settings.duet || false,
|
||||
disable_comment: !firstPost.settings.comment || false,
|
||||
disable_stitch: !firstPost.settings.stitch || false,
|
||||
brand_content_toggle:
|
||||
firstPost.settings.brand_content_toggle || false,
|
||||
brand_organic_toggle:
|
||||
firstPost.settings.brand_organic_toggle || false,
|
||||
...((firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) ===
|
||||
-1
|
||||
? {
|
||||
auto_add_music:
|
||||
firstPost.settings.autoAddMusic === 'yes',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...((firstPost?.media?.[0]?.path?.indexOf('mp4') || -1) > -1
|
||||
? {
|
||||
source_info: {
|
||||
source: 'PULL_FROM_URL',
|
||||
video_url: firstPost?.media?.[0]?.path!,
|
||||
...(firstPost?.media?.[0]?.thumbnailTimestamp!
|
||||
? {
|
||||
video_cover_timestamp_ms:
|
||||
firstPost?.media?.[0]?.thumbnailTimestamp!,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
source_info: {
|
||||
source: 'PULL_FROM_URL',
|
||||
photo_cover_index: 0,
|
||||
photo_images: firstPost.media?.map((p) => p.path),
|
||||
},
|
||||
post_mode: 'DIRECT_POST',
|
||||
media_type: 'PHOTO',
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
// Wait before retrying (exponential backoff)
|
||||
await timer(attempt * 2000);
|
||||
}
|
||||
}
|
||||
)
|
||||
).json();
|
||||
|
||||
// This should never be reached, but just in case
|
||||
throw lastError || new Error('TikTok post failed after all retries');
|
||||
const { url, id: videoId } = await this.uploadedVideoSuccess(
|
||||
integration.profile!,
|
||||
publish_id,
|
||||
accessToken
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: firstPost.id,
|
||||
releaseURL: url,
|
||||
postId: String(videoId),
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,18 @@ import {
|
|||
SocialProvider,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
||||
import { google } from 'googleapis';
|
||||
import { google, youtube_v3 } from 'googleapis';
|
||||
import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client';
|
||||
import axios from 'axios';
|
||||
import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto';
|
||||
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import {
|
||||
BadBody,
|
||||
SocialAbstract,
|
||||
} from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import * as process from 'node:process';
|
||||
import dayjs from 'dayjs';
|
||||
import { GaxiosError } from 'gaxios/build/src/common';
|
||||
import { GaxiosResponse } from 'gaxios/build/src/common';
|
||||
import Schema$Video = youtube_v3.Schema$Video;
|
||||
|
||||
const clientAndYoutube = () => {
|
||||
const client = new google.auth.OAuth2({
|
||||
|
|
@ -147,8 +151,9 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
|
|||
responseType: 'stream',
|
||||
});
|
||||
|
||||
let all: GaxiosResponse<Schema$Video>;
|
||||
try {
|
||||
const all = await youtubeClient.videos.insert({
|
||||
all = await youtubeClient.videos.insert({
|
||||
part: ['id', 'snippet', 'status'],
|
||||
notifySubscribers: true,
|
||||
requestBody: {
|
||||
|
|
@ -158,15 +163,6 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
|
|||
...(settings?.tags?.length
|
||||
? { tags: settings.tags.map((p) => p.label) }
|
||||
: {}),
|
||||
// ...(settings?.thumbnail?.url
|
||||
// ? {
|
||||
// thumbnails: {
|
||||
// default: {
|
||||
// url: settings?.thumbnail?.url,
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// : {}),
|
||||
},
|
||||
status: {
|
||||
privacyStatus: settings.type,
|
||||
|
|
@ -176,58 +172,83 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
|
|||
body: response.data,
|
||||
},
|
||||
});
|
||||
|
||||
if (settings?.thumbnail?.path) {
|
||||
try {
|
||||
const allb = await youtubeClient.thumbnails.set({
|
||||
videoId: all?.data?.id!,
|
||||
media: {
|
||||
body: (
|
||||
await axios({
|
||||
url: settings?.thumbnail?.path,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
})
|
||||
).data,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err.response?.data?.error?.errors?.[0]?.domain ===
|
||||
'youtube.thumbnail'
|
||||
) {
|
||||
throw 'Your account is not verified, we have uploaded your video but we could not set the thumbnail. Please verify your account and try again.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: firstPost.id,
|
||||
releaseURL: `https://www.youtube.com/watch?v=${all?.data?.id}`,
|
||||
postId: all?.data?.id!,
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err.response?.data?.error?.errors?.[0]?.reason === 'failedPrecondition'
|
||||
) {
|
||||
throw 'We have uploaded your video but we could not set the thumbnail. Thumbnail size is too large';
|
||||
throw new BadBody(
|
||||
'youtube',
|
||||
JSON.stringify(err.response.data),
|
||||
JSON.stringify(err.response.data),
|
||||
'We have uploaded your video but we could not set the thumbnail. Thumbnail size is too large.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
err.response?.data?.error?.errors?.[0]?.reason === 'uploadLimitExceeded'
|
||||
) {
|
||||
throw 'You have reached your daily upload limit, please try again tomorrow.';
|
||||
throw new BadBody(
|
||||
'youtube',
|
||||
JSON.stringify(err.response.data),
|
||||
JSON.stringify(err.response.data),
|
||||
'You have reached your daily upload limit, please try again tomorrow.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
err.response?.data?.error?.errors?.[0]?.reason ===
|
||||
'youtubeSignupRequired'
|
||||
) {
|
||||
throw 'You have to link your youtube account to your google account first.';
|
||||
throw new BadBody(
|
||||
'youtube',
|
||||
JSON.stringify(err.response.data),
|
||||
JSON.stringify(err.response.data),
|
||||
'You have to link your youtube account to your google account first.'
|
||||
);
|
||||
}
|
||||
|
||||
throw new BadBody(
|
||||
'youtube',
|
||||
JSON.stringify(err.response.data),
|
||||
JSON.stringify(err.response.data),
|
||||
'An error occurred while uploading your video, please try again later.'
|
||||
);
|
||||
}
|
||||
|
||||
if (settings?.thumbnail?.path) {
|
||||
try {
|
||||
await youtubeClient.thumbnails.set({
|
||||
videoId: all?.data?.id!,
|
||||
media: {
|
||||
body: (
|
||||
await axios({
|
||||
url: settings?.thumbnail?.path,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
})
|
||||
).data,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err.response?.data?.error?.errors?.[0]?.domain === 'youtube.thumbnail'
|
||||
) {
|
||||
throw new BadBody(
|
||||
'',
|
||||
JSON.stringify(err.response.data),
|
||||
JSON.stringify(err.response.data),
|
||||
'Your account is not verified, we have uploaded your video but we could not set the thumbnail. Please verify your account and try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: firstPost.id,
|
||||
releaseURL: `https://www.youtube.com/watch?v=${all?.data?.id}`,
|
||||
postId: all?.data?.id!,
|
||||
status: 'success',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async analytics(
|
||||
|
|
|
|||
Loading…
Reference in New Issue