diff --git a/.eslintrc b/.eslintrc index 250c6bb..0cb9c77 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,6 +15,7 @@ ], "rules": { "prettier/prettier": 2, // Means error - "jsdoc/require-jsdoc": 0 + "jsdoc/require-jsdoc": 0, + "jsdoc/require-param-description" : 0 } } \ No newline at end of file diff --git a/src/models/Post.ts b/src/models/Post.ts index cf3234b..6f951d8 100644 --- a/src/models/Post.ts +++ b/src/models/Post.ts @@ -31,6 +31,7 @@ export default class Post { other: string[]; }; link?: string; + remoteId?: string; constructor(folder: Folder, platform: Platform, data?: object) { this.folder = folder; @@ -115,6 +116,44 @@ export default class Post { } } } + + /** + * Process a post result. Push the result to results[], + * and if not dryrun, fix dates and statusses and + * note remote id and link + * @param remoteId - the remote id of the post + * @param link - the remote link of the post + * @param result - the postresult + * @returns boolean if success + */ + + processResult(remoteId: string, link: string, result: PostResult): boolean { + this.results.push(result); + + if (result.error) { + Logger.warn( + "Post.processResult", + this.id, + "failed", + result.error, + result.response, + ); + } + + if (!result.dryrun) { + if (!result.error) { + this.remoteId = remoteId; + this.link = link; + this.status = PostStatus.PUBLISHED; + this.published = new Date(); + } else { + this.status = PostStatus.FAILED; + } + } + + this.save(); + return result.success; + } } export interface PostResult { diff --git a/src/platforms/Ayrshare/Ayrshare.ts b/src/platforms/Ayrshare/Ayrshare.ts index 458fb99..a92d409 100644 --- a/src/platforms/Ayrshare/Ayrshare.ts +++ b/src/platforms/Ayrshare/Ayrshare.ts @@ -12,7 +12,6 @@ import Logger from "../../services/Logger"; import Platform from "../../models/Platform"; import { PlatformId } from ".."; import Post from "../../models/Post"; -import { PostStatus } from "../../models/Post"; import Storage from "../../services/Storage"; import { randomUUID } from "crypto"; @@ -61,19 +60,25 @@ export default abstract class Ayrshare extends Platform { return super.preparePost(folder); } + /** + * Publish a post for one platform on Ayrshare + * @param post - the post to publish + * @param platformOptions - ayrshare options dependant on platform + * @param dryrun - wether to actually post it + * @returns boolean for success + */ async publishAyrshare( post: Post, platformOptions: object, dryrun: boolean = false, ): Promise { let error = undefined; - let response = dryrun - ? { postIds: [] } - : ({} as { - postIds?: { - postUrl: string; - }[]; - }); + let response = { id: "-99", postIds: [] } as { + id: string; + postIds?: { + postUrl: string; + }[]; + }; const media = [...post.files.image, ...post.files.video].map( (f) => post.folder.path + "/" + f, @@ -88,32 +93,24 @@ export default abstract class Ayrshare extends Platform { error = e; } - post.results.push({ - date: new Date(), - dryrun: dryrun, - success: !error, - error: error, - response: response, - }); - - if (error) { - Logger.warn("Ayrshare.publishPost", this.id, "failed", response); - } - - if (!dryrun) { - if (!error) { - post.link = response.postIds?.find((e) => !!e)?.postUrl ?? ""; - post.status = PostStatus.PUBLISHED; - post.published = new Date(); - } else { - post.status = PostStatus.FAILED; - } - } - - post.save(); - return !error; + return post.processResult( + response.id, + response.postIds?.find((e) => !!e)?.postUrl ?? "#unknown", + { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }, + ); } + /** + * Upload media to ayrshare for publishing later. Uses a leash. + * @param media - array of path to files + * @returns array of links to uploaded media + */ async uploadMedia(media: string[]): Promise { const APIKEY = Storage.get("settings", "AYRSHARE_API_KEY"); const urls = [] as string[]; @@ -122,7 +119,7 @@ export default abstract class Ayrshare extends Platform { const ext = path.extname(file); const basename = path.basename(file, ext); const uname = basename + "-" + randomUUID() + ext; - Logger.trace("fetching uploadid...", file); + Logger.trace("Ayrshare.uploadMedia: fetching uploadid...", file); const data = (await fetch( "https://app.ayrshare.com/api/media/uploadUrl?fileName=" + uname + @@ -143,7 +140,7 @@ export default abstract class Ayrshare extends Platform { accessUrl: string; }; - Logger.trace("uploading..", uname, data); + Logger.trace("Ayrshare.uploadMedia: uploading..", uname, data); await fetch(data.uploadUrl, { method: "PUT", @@ -161,19 +158,31 @@ export default abstract class Ayrshare extends Platform { return urls; } + /** + * Publish a post for one platform on Ayrshare + * @param post - the post to publish + * @param platformOptions - ayrshare options dependant on platform + * @param uploads - array of urls to uploaded files + * @returns object conatining ayrshare id and post url + */ async postAyrshare( post: Post, platformOptions: object, uploads: string[], - ): Promise { + ): Promise<{ + id: string; + postIds?: { + postUrl: string; + }[]; + }> { const APIKEY = Storage.get("settings", "AYRSHARE_API_KEY"); const scheduleDate = post.scheduled; - //scheduleDate.setDate(scheduleDate.getDate()+100); const postPlatform = this.platforms[this.id]; if (!postPlatform) { throw Logger.error( - "No ayrshare platform associated with platform " + this.id, + "Ayrshare.postAyrshare: No ayrshare platform associated with platform " + + this.id, ); } const body = JSON.stringify( @@ -193,7 +202,7 @@ export default abstract class Ayrshare extends Platform { requiresApproval: this.requiresApproval, }, ); - Logger.trace("publishing...", postPlatform); + Logger.trace("Ayrshare.postAyrshare: publishing...", postPlatform); const response = (await fetch("https://app.ayrshare.com/api/post", { method: "POST", headers: { @@ -213,7 +222,8 @@ export default abstract class Ayrshare extends Platform { response["status"] !== "success" && response["status"] !== "scheduled" ) { - const error = "Bad result status: " + response["status"]; + const error = + "Ayrshare.postAyrshare: Bad result status: " + response["status"]; throw Logger.error(error); } return response; diff --git a/src/platforms/Facebook/Facebook.ts b/src/platforms/Facebook/Facebook.ts index c613daf..e47835d 100644 --- a/src/platforms/Facebook/Facebook.ts +++ b/src/platforms/Facebook/Facebook.ts @@ -9,7 +9,6 @@ import Logger from "../../services/Logger"; import Platform from "../../models/Platform"; import { PlatformId } from ".."; import Post from "../../models/Post"; -import { PostStatus } from "../../models/Post"; import Storage from "../../services/Storage"; /** @@ -73,99 +72,88 @@ export default class Facebook extends Platform { async publishPost(post: Post, dryrun: boolean = false): Promise { Logger.trace("Facebook.publishPost", post.id, dryrun); - let response = dryrun - ? { id: "-99" } - : ({} as { id?: string; error?: string }); + let response = { id: "-99" } as { id: string }; let error = undefined; if (post.files.video.length) { - if (!dryrun) { - try { - response = await this.publishVideo( - post.folder.path + "/" + post.files.video[0], - post.title, - post.body, - ); - } catch (e) { - error = e; - } + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e; + } + } else if (post.files.image.length) { + try { + response = await this.publishImagesPost(post, dryrun); + } catch (e) { + error = e; } } else { try { - const attachments = []; - if (post.files.image.length) { - for (const image of post.files.image) { - attachments.push({ - media_fbid: ( - await this.uploadPhoto(post.folder.path + "/" + image) - )["id"], - }); - } - } - if (!dryrun) { - response = (await this.api.postJson("%PAGE%/feed", { - message: post.body, - published: Storage.get("settings", "FACEBOOK_PUBLISH_POSTS"), - attached_media: attachments, - })) as { id: string }; - } + response = await this.publishTextPost(post, dryrun); } catch (e) { error = e; } } - post.results.push({ - date: new Date(), - dryrun: dryrun, - success: !error, - error: error, - response: response, - }); - - if (error) { - Logger.warn("Facebook.publishPost", this.id, "failed", response); - } + return post.processResult( + response.id, + "https://facebook.com/" + response.id, + { + date: new Date(), + dryrun: dryrun, + success: !error, + response: response, + error: error, + }, + ); + } + /** + * POST body to the page/feed endpoint using json + * @param post - the post + * @param dryrun - wether to really execure + * @returns object, incl. id of the created post + */ + private async publishTextPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ id: string }> { if (!dryrun) { - if (!error) { - post.link = "https://facebook.com/" + response.id; - post.status = PostStatus.PUBLISHED; - post.published = new Date(); - } else { - post.status = PostStatus.FAILED; - } + return (await this.api.postJson("%PAGE%/feed", { + message: post.body, + published: Storage.get("settings", "FACEBOOK_PUBLISH_POSTS"), + })) as { id: string }; } - - post.save(); - return !error; + return { id: "-99" }; } /** - * POST an image to the page/photos endpoint using multipart/form-data - * @param file - path to the file to post - * @param published - wether the photo should be published as a single facebook post - * @returns id of the uploaded photo to use in post attachments + * POST images to the page/feed endpoint using json + * @param post - the post + * @param dryrun - wether to really execure + * @returns object, incl. id of the created post */ - private async uploadPhoto( - file: string = "", - published = false, + private async publishImagesPost( + post: Post, + dryrun: boolean = false, ): Promise<{ id: string }> { - Logger.trace("Reading file", file); - const rawData = fs.readFileSync(file); - const blob = new Blob([rawData]); - - const body = new FormData(); - body.set("published", published ? "true" : "false"); - body.set("source", blob, path.basename(file)); - - const result = (await this.api.postForm("%PAGE%/photos", body)) as { - id: "string"; - }; + const attachments = []; + for (const image of post.files.image) { + attachments.push({ + media_fbid: (await this.uploadImage(post.folder.path + "/" + image))[ + "id" + ], + }); + } - if (!result["id"]) { - throw Logger.error("No id returned when uploading photo"); + if (!dryrun) { + return (await this.api.postJson("%PAGE%/feed", { + message: post.body, + published: Storage.get("settings", "FACEBOOK_PUBLISH_POSTS"), + attached_media: attachments, + })) as { id: string }; } - return result; + return { id: "-99" }; } /** @@ -174,16 +162,18 @@ export default class Facebook extends Platform { * Videos will always become a single facebook post * when using the api. * Uses sync posting. may take a while or timeout. - * @param file - path to the video to post - * @param title - title of the post - * @param description - body text of the post - * @returns id of the uploaded video + * @param post - the post + * @param dryrun - wether to really execure + * @returns object, incl. id of the uploaded video */ - private async publishVideo( - file: string, - title: string, - description: string, + private async publishVideoPost( + post: Post, + dryrun: boolean = false, ): Promise<{ id: string }> { + const file = post.folder.path + "/" + post.files.video[0]; + const title = post.title; + const description = post.body; + Logger.trace("Reading file", file); const rawData = fs.readFileSync(file); const blob = new Blob([rawData]); @@ -194,12 +184,42 @@ export default class Facebook extends Platform { body.set("published", Storage.get("settings", "FACEBOOK_PUBLISH_POSTS")); body.set("source", blob, path.basename(file)); - const result = (await this.api.postForm("%PAGE%/videos", body)) as { - id: string; + if (!dryrun) { + const result = (await this.api.postForm("%PAGE%/videos", body)) as { + id: string; + }; + if (!result["id"]) { + throw Logger.error("No id returned when uploading video"); + } + return result; + } + return { id: "-99" }; + } + + /** + * POST an image to the page/photos endpoint using multipart/form-data + * @param file - path to the file to post + * @param published - wether the photo should be published as a single facebook post + * @returns id of the uploaded photo to use in post attachments + */ + private async uploadImage( + file: string = "", + published = false, + ): Promise<{ id: string }> { + Logger.trace("Reading file", file); + const rawData = fs.readFileSync(file); + const blob = new Blob([rawData]); + + const body = new FormData(); + body.set("published", published ? "true" : "false"); + body.set("source", blob, path.basename(file)); + + const result = (await this.api.postForm("%PAGE%/photos", body)) as { + id: "string"; }; if (!result["id"]) { - throw Logger.error("No id returned when uploading video"); + throw Logger.error("No id returned when uploading photo"); } return result; } diff --git a/src/platforms/Instagram/Instagram.ts b/src/platforms/Instagram/Instagram.ts index 3175f46..aa2fbf0 100644 --- a/src/platforms/Instagram/Instagram.ts +++ b/src/platforms/Instagram/Instagram.ts @@ -9,7 +9,6 @@ import Logger from "../../services/Logger"; import Platform from "../../models/Platform"; import { PlatformId } from ".."; import Post from "../../models/Post"; -import { PostStatus } from "../../models/Post"; /** * Instagram: support for instagram platform. @@ -87,47 +86,36 @@ export default class Instagram extends Platform { async publishPost(post: Post, dryrun: boolean = false): Promise { Logger.trace("Instagram.publishPost", post.id, dryrun); - let response = dryrun ? { id: "-99" } : ({} as { id: string }); + let response = { id: "-99" } as { id: string }; let error = undefined; - try { - if (post.files.video.length === 1 && !post.files.image.length) { - response = await this.publishVideo( - post.files.video[0], - post.body, - dryrun, - ); - } else if (post.files.image.length === 1 && !post.files.video.length) { - response = await this.publishPhoto( - post.files.image[0], - post.body, - dryrun, - ); - } else { - response = await this.publishCaroussel(post, dryrun); + if (post.files.video.length === 1 && !post.files.image.length) { + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e; + } + } else if (post.files.image.length === 1 && !post.files.video.length) { + try { + response = await this.publishImagePost(post, dryrun); + } catch (e) { + error = e; + } + } else { + try { + response = await this.publishMixedPost(post, dryrun); + } catch (e) { + error = e; } - } catch (e) { - error = e; } - post.results.push({ + return post.processResult(response.id, "#unknown", { date: new Date(), dryrun: dryrun, success: !error, error: error, response: response, }); - - if (error) { - Logger.warn("Instagram.publishPost", this.id, "failed", response); - } else if (!dryrun) { - // post.link = ""; // todo : get instagram shortcode - post.status = PostStatus.PUBLISHED; - post.published = new Date(); - } - - post.save(); - return !error; } /** @@ -135,18 +123,18 @@ export default class Instagram extends Platform { * * Upload a photo to facebook, use the largest derivate * to put in a single container and publish that - * @param file - path to the photo to post - * @param caption - text body of the post + * @param post - the post * @param dryrun - wether to actually post it * @returns id of the published container */ - private async publishPhoto( - file, - caption: string = "", + private async publishImagePost( + post: Post, dryrun: boolean = false, ): Promise<{ id: string }> { - const photoId = (await this.fbUploadPhoto(file))["id"]; - const photoLink = await this.fbGetPhotoLink(photoId); + const file = post.files.image[0]; + const caption = post.body; + const photoId = (await this.uploadImage(file))["id"]; + const photoLink = await this.getImageLink(photoId); const container = (await this.api.postJson("%USER%/media", { image_url: photoLink, caption: caption, @@ -175,18 +163,18 @@ export default class Instagram extends Platform { * * Upload a video to facebook, use the derivate * to put in a single container and publish that - * @param file - path to the photo to post - * @param caption - text body of the post + * @param post * @param dryrun - wether to actually post it * @returns id of the published container */ - private async publishVideo( - file, - caption: string = "", + private async publishVideoPost( + post: Post, dryrun: boolean = false, ): Promise<{ id: string }> { - const videoId = (await this.fbUploadVideo(file))["id"]; - const videoLink = await this.fbGetVideoLink(videoId); + const file = post.files.video[0]; + const caption = post.body; + const videoId = (await this.uploadVideo(file))["id"]; + const videoLink = await this.getVideoLink(videoId); const container = (await this.api.postJson("%USER%/media", { video_url: videoLink, caption: caption, @@ -219,17 +207,17 @@ export default class Instagram extends Platform { * @param dryrun - wether to actually post it * @returns id of the published container */ - private async publishCaroussel( + private async publishMixedPost( post: Post, dryrun: boolean = false, ): Promise<{ id: string }> { const uploadIds = [] as string[]; for (const video of post.files.video) { - const videoId = ( - await this.fbUploadVideo(post.folder.path + "/" + video) - )["id"]; - const videoLink = await this.fbGetVideoLink(videoId); + const videoId = (await this.uploadVideo(post.folder.path + "/" + video))[ + "id" + ]; + const videoLink = await this.getVideoLink(videoId); uploadIds.push( ( await this.api.postJson("%USER%/media", { @@ -241,10 +229,10 @@ export default class Instagram extends Platform { } for (const image of post.files.image) { - const photoId = ( - await this.fbUploadPhoto(post.folder.path + "/" + image) - )["id"]; - const photoLink = await this.fbGetPhotoLink(photoId); + const photoId = (await this.uploadImage(post.folder.path + "/" + image))[ + "id" + ]; + const photoLink = await this.getImageLink(photoId); uploadIds.push( ( await this.api.postJson("%USER%/media", { @@ -292,7 +280,7 @@ export default class Instagram extends Platform { * @param file - path to the file to post * @returns id of the uploaded photo to use in post attachments */ - private async fbUploadPhoto(file: string = ""): Promise<{ id: string }> { + private async uploadImage(file: string = ""): Promise<{ id: string }> { Logger.trace("Reading file", file); const rawData = fs.readFileSync(file); const blob = new Blob([rawData]); @@ -301,7 +289,7 @@ export default class Instagram extends Platform { body.set("published", "false"); body.set("source", blob, path.basename(file)); - const result = (await this.api.postFormData("%PAGE%/photos", body)) as { + const result = (await this.api.postForm("%PAGE%/photos", body)) as { id: "string"; }; @@ -316,7 +304,7 @@ export default class Instagram extends Platform { * @param id - id of the uploaded photo * @returns link to the largest derivate of that photo to use in post attachments */ - private async fbGetPhotoLink(id: string): Promise { + private async getImageLink(id: string): Promise { // get photo derivatives const photoData = (await this.api.get(id, { fields: "link,images,picture", @@ -349,7 +337,7 @@ export default class Instagram extends Platform { * @returns id of the uploaded video to use in post attachments */ - private async fbUploadVideo(file: string): Promise<{ id: string }> { + private async uploadVideo(file: string): Promise<{ id: string }> { Logger.trace("Reading file", file); const rawData = fs.readFileSync(file); const blob = new Blob([rawData]); @@ -359,7 +347,7 @@ export default class Instagram extends Platform { body.set("published", "false"); body.set("source", blob, path.basename(file)); - const result = (await this.api.postFormData("%PAGE%/videos", body)) as { + const result = (await this.api.postForm("%PAGE%/videos", body)) as { id: string; }; @@ -375,7 +363,7 @@ export default class Instagram extends Platform { * @returns link to the video to use in post attachments */ - private async fbGetVideoLink(id: string): Promise { + private async getVideoLink(id: string): Promise { const videoData = (await this.api.get(id, { fields: "permalink_url,source", })) as { diff --git a/src/platforms/Instagram/InstagramApi.ts b/src/platforms/Instagram/InstagramApi.ts index 9ff2ebc..4e1c22f 100644 --- a/src/platforms/Instagram/InstagramApi.ts +++ b/src/platforms/Instagram/InstagramApi.ts @@ -100,7 +100,7 @@ export default class InstagramApi { * @returns the parsed response as object */ - public async postFormData(endpoint: string, body: FormData): Promise { + public async postForm(endpoint: string, body: FormData): Promise { endpoint = endpoint.replace( "%USER%", Storage.get("settings", "INSTAGRAM_USER_ID"), diff --git a/src/platforms/LinkedIn/LinkedIn.ts b/src/platforms/LinkedIn/LinkedIn.ts index 1c22b08..bfde400 100644 --- a/src/platforms/LinkedIn/LinkedIn.ts +++ b/src/platforms/LinkedIn/LinkedIn.ts @@ -11,7 +11,6 @@ import Logger from "../../services/Logger"; import Platform from "../../models/Platform"; import { PlatformId } from ".."; import Post from "../../models/Post"; -import { PostStatus } from "../../models/Post"; import Storage from "../../services/Storage"; export default class LinkedIn extends Platform { @@ -53,6 +52,7 @@ export default class LinkedIn extends Platform { return true; } + /** @inheritdoc */ async preparePost(folder: Folder): Promise { Logger.trace("LinkedIn.preparePost", folder.id); const post = await super.preparePost(folder); @@ -82,93 +82,61 @@ export default class LinkedIn extends Platform { return post; } + /** @inheritdoc */ async publishPost(post: Post, dryrun: boolean = false): Promise { Logger.trace("LinkedIn.publishPost", post.id, dryrun); - let response = dryrun - ? { id: "-99" } - : ({} as { id?: string; headers?: { [key: string]: string } }); + let response = { id: "-99" } as { + id?: string; + headers?: { [key: string]: string }; + }; let error = undefined; if (post.files.video.length) { - if (!dryrun) { - try { - response = await this.publishVideo( - post.title, - post.body, - post.folder.path + "/" + post.files.video[0], - ); - } catch (e) { - error = e; - } + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e; } } else if (post.files.image.length > 1) { - if (!dryrun) { - try { - response = await this.publishImages( - post.title + "\n\n" + post.body, - post.files.image.map((image) => post.folder.path + "/" + image), - ); - } catch (e) { - error = e; - } + try { + response = await this.publishImagesPost(post, dryrun); + } catch (e) { + error = e; } } else if (post.files.image.length === 1) { - if (!dryrun) { - try { - response = await this.publishImage( - post.title, - post.body, - post.folder.path + "/" + post.files.image[0], - ); - } catch (e) { - error = e; - } + try { + response = await this.publishImagePost(post, dryrun); + } catch (e) { + error = e; } } else { try { - if (!dryrun) { - response = await this.publishText(post.title + "\n\n" + post.body); - } + response = await this.publishTextPost(post, dryrun); } catch (e) { error = e; } } - if (response.headers?.["x-restli-id"]) { - response.id = response.headers["x-restli-id"]; - } else if (response.headers?.["x-linkedin-id"]) { - response.id = response.headers["x-linkedin-id"]; - } - - post.results.push({ - date: new Date(), - dryrun: dryrun, - success: !error, - error: error, - response: response, - }); - - if (error) { - Logger.warn("Facebook.publishPost", this.id, "failed", response); - } - - if (!dryrun) { - if (!error) { - post.link = "https://www.linkedin.com/feed/update/" + response.id; - post.status = PostStatus.PUBLISHED; - post.published = new Date(); - } else { - post.status = PostStatus.FAILED; - } - } - - post.save(); - return !error; + return post.processResult( + response.id, + "https://www.linkedin.com/feed/update/" + response.id, + { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }, + ); } // Platform API Specific + /** + * GET part of the profile + * @returns object, incl. some ids and names + */ private async getProfile() { const me = await this.api.get("me"); if (!me) return false; @@ -180,8 +148,15 @@ export default class LinkedIn extends Platform { }; } - private async publishText(content: string) { - Logger.trace("LinkedIn.publishText"); + /** + * POST title & body to the posts endpoint using json + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + private async publishTextPost(post: Post, dryrun: boolean = false) { + Logger.trace("LinkedIn.publishTextPost"); + const content = post.title + "\n\n" + post.body; const body = { author: this.POST_AUTHOR, commentary: content, @@ -190,10 +165,25 @@ export default class LinkedIn extends Platform { lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: this.POST_NORESHARE, }; - return await this.api.postJson("posts", body, true); + if (!dryrun) { + return await this.api.postJson("posts", body, true); + } + return { id: "-99" }; } - private async publishImage(title: string, content: string, image: string) { - Logger.trace("LinkedIn.publishImage"); + + /** + * POST title & body & image to the posts endpoint using json + * + * uploads image using a leash + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + private async publishImagePost(post: Post, dryrun: boolean = false) { + Logger.trace("LinkedIn.publishImagePost"); + const title = post.title; + const content = post.body; + const image = post.folder.path + "/" + post.files.image[0]; const leash = await this.getImageLeash(); await this.uploadImage(leash.value.uploadUrl, image); // TODO: save headers[etag] .. @@ -212,11 +202,27 @@ export default class LinkedIn extends Platform { lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: this.POST_NORESHARE, }; - return await this.api.postJson("posts", body, true); + if (!dryrun) { + return await this.api.postJson("posts", body, true); + } + return { id: "-99" }; } - private async publishImages(content: string, images: string[]) { - Logger.trace("LinkedIn.publishImages"); + /** + * POST title & body to the posts endpoint using json + * + * uploads images using a leash + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + + private async publishImagesPost(post: Post, dryrun: boolean = false) { + Logger.trace("LinkedIn.publishImagesPost"); + const content = post.title + "\n\n" + post.body; + const images = post.files.image.map( + (image) => post.folder.path + "/" + image, + ); const imageIds = []; for (const image of images) { const leash = await this.getImageLeash(); @@ -244,12 +250,27 @@ export default class LinkedIn extends Platform { }, }, }; - return await this.api.postJson("posts", body, true); + if (!dryrun) { + return await this.api.postJson("posts", body, true); + } + return { id: "-99" }; } - // untested - private async publishVideo(title: string, content: string, video: string) { - Logger.trace("LinkedIn.publishVideo"); + /** + * POST title & body & video to the posts endpoint using json + * + * untested. + * @param post + * @param dryrun + * @returns object, incl. id of the created post + */ + private async publishVideoPost(post: Post, dryrun: boolean = false) { + Logger.trace("LinkedIn.publishVideoPost"); + + const title = post.title; + const content = post.body; + const video = post.folder.path + "/" + post.files.video[0]; + const leash = await this.getVideoLeash(video); await this.uploadVideo(leash.value.uploadInstructions[0].uploadUrl, video); // TODO: save headers[etag] .. @@ -268,9 +289,17 @@ export default class LinkedIn extends Platform { lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: this.POST_NORESHARE, }; - return await this.api.postJson("posts", body, true); + if (!dryrun) { + return await this.api.postJson("posts", body, true); + } + return { id: "-99" }; } + /** + * Get a leash to upload an image + * @returns object, incl. uploadUrl + */ + private async getImageLeash(): Promise<{ value: { uploadUrlExpiresAt: number; @@ -299,6 +328,12 @@ export default class LinkedIn extends Platform { return response; } + /** + * Upload an image file to an url + * @param leashUrl + * @param file + * @returns empty + */ private async uploadImage(leashUrl: string, file: string) { Logger.trace("LinkedIn.uploadImage"); const rawData = fs.readFileSync(file); @@ -316,7 +351,13 @@ export default class LinkedIn extends Platform { .catch((err) => handleApiError(err)); } - // untested + /** + * Get a leash to upload an video + * + * untested + * @param file + * @returns object, incl. uploadUrl + */ private async getVideoLeash(file: string): Promise<{ value: { uploadUrlsExpireAt: number; @@ -359,7 +400,14 @@ export default class LinkedIn extends Platform { return response; } - // untested + /** + * Upload a video file to an url + * + * untested + * @param leashUrl + * @param file + * @returns empty + */ private async uploadVideo(leashUrl: string, file: string) { Logger.trace("LinkedIn.uploadVideo"); const rawData = fs.readFileSync(file); diff --git a/src/platforms/LinkedIn/LinkedInApi.ts b/src/platforms/LinkedIn/LinkedInApi.ts index 9b33b4e..d43ff07 100644 --- a/src/platforms/LinkedIn/LinkedInApi.ts +++ b/src/platforms/LinkedIn/LinkedInApi.ts @@ -84,6 +84,16 @@ export default class LinkedInApi { ? handleEmptyResponse(res, true) : handleJsonResponse(res, true), ) + .then((res) => { + if (!res["id"] && "headers" in res) { + if (res.headers?.["x-restli-id"]) { + res["id"] = res.headers["x-restli-id"]; + } else if (res.headers?.["x-linkedin-id"]) { + res["id"] = res.headers["x-linkedin-id"]; + } + } + return res; + }) .catch((err) => this.handleLinkedInError(err)) .catch((err) => handleApiError(err)); } diff --git a/src/platforms/Reddit/Reddit.ts b/src/platforms/Reddit/Reddit.ts index 14b0281..b370f60 100644 --- a/src/platforms/Reddit/Reddit.ts +++ b/src/platforms/Reddit/Reddit.ts @@ -2,12 +2,11 @@ import * as fs from "fs"; import * as path from "path"; import * as sharp from "sharp"; -import Post, { PostStatus } from "../../models/Post"; - import Folder from "../../models/Folder"; import Logger from "../../services/Logger"; import Platform from "../../models/Platform"; import { PlatformId } from ".."; +import Post from "../../models/Post"; import RedditApi from "./RedditApi"; import RedditAuth from "./RedditAuth"; import Storage from "../../services/Storage"; @@ -51,6 +50,7 @@ export default class Reddit extends Platform { return true; } + /** @inheritdoc */ async preparePost(folder: Folder): Promise { Logger.trace("Reddit.preparePost", folder.id); const post = await super.preparePost(folder); @@ -82,58 +82,56 @@ export default class Reddit extends Platform { return post; } + /** @inheritdoc */ async publishPost(post: Post, dryrun: boolean = false): Promise { Logger.trace("Reddit.publishPost", post.id, dryrun); - let response = dryrun ? { dryrun: true } : {}; + let response = {}; let error = undefined; - try { - if (post.files.video.length) { - response = await this.publishVideo( - post.title, - post.folder.path + "/" + post.files.video[0], - dryrun, - ); - } else if (post.files.image.length) { - response = await this.publishImage( - post.title, - post.folder.path + "/" + post.files.image[0], - dryrun, - ); - } else { - response = await this.publishText(post.title, post.body, dryrun); + if (post.files.video.length) { + try { + response = await this.publishVideoPost(post, dryrun); + } catch (e) { + error = e; + } + } else if (post.files.image.length) { + try { + response = await this.publishImagePost(post, dryrun); + } catch (e) { + error = e; + } + } else { + try { + response = await this.publishTextPost(post, dryrun); + } catch (e) { + error = e; } - } catch (e) { - error = e; - } - - post.results.push({ - date: new Date(), - dryrun: dryrun, - success: !error, - error: error, - response: response, - }); - - if (error) { - Logger.warn("Reddit.publishPost", this.id, "failed", response); - } else if (!dryrun) { - // post.link = ""; // todo : await reddit websockets - post.status = PostStatus.PUBLISHED; - post.published = new Date(); } - post.save(); - return !error; + return post.processResult( + "#unknown", // todo: listen to websocket for id + "#unknown", // todo: listen to websocket for link + { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }, + ); } - private async publishText( - title: string, - body: string, - dryrun = false, - ): Promise { - Logger.trace("Reddit.publishText"); + /** + * POST self-post to the submit endpoint using json + * @param post + * @param dryrun + * @returns result + */ + private async publishTextPost(post: Post, dryrun = false): Promise { + Logger.trace("Reddit.publishTextPost"); + const title = post.title; + const body = post.body; if (!dryrun) { return (await this.api.post("submit", { sr: this.SUBREDDIT, @@ -157,14 +155,18 @@ export default class Reddit extends Platform { }; } - private async publishImage( - title: string, - file: string, - dryrun = false, - ): Promise { - Logger.trace("Reddit.publishImage"); - const lease = await this.getUploadLease(file); - const imageUrl = await this.uploadFile(lease, file); + /** + * POST image post to the submit endpoint using json + * @param post + * @param dryrun + * @returns result + */ + private async publishImagePost(post: Post, dryrun = false): Promise { + Logger.trace("Reddit.publishImagePost"); + const title = post.title; + const file = post.folder.path + "/" + post.files.image[0]; + const leash = await this.getUploadLeash(file); + const imageUrl = await this.uploadFile(leash, file); if (!dryrun) { return (await this.api.post("submit", { sr: this.SUBREDDIT, @@ -188,14 +190,18 @@ export default class Reddit extends Platform { }; } - private async publishVideo( - title: string, - file: string, - dryrun = false, - ): Promise { - Logger.trace("Reddit.publishVideo"); - const lease = await this.getUploadLease(file); - const videoUrl = await this.uploadFile(lease, file); + /** + * POST video post to the submit endpoint using json + * @param post + * @param dryrun + * @returns result + */ + private async publishVideoPost(post: Post, dryrun = false): Promise { + Logger.trace("Reddit.publishVideoPost"); + const title = post.title; + const file = post.folder.path + "/" + post.files.video[0]; + const leash = await this.getUploadLeash(file); + const videoUrl = await this.uploadFile(leash, file); if (!dryrun) { return (await this.api.post("submit", { sr: this.SUBREDDIT, @@ -220,7 +226,14 @@ export default class Reddit extends Platform { }; } - private async getUploadLease(file: string): Promise<{ + /** + * POST to media/asset.json to get a leash with a lot of fields, + * + * All these fields should be reposted on the upload + * @param file - path to the file to upload + * @returns leash - args with action and fields + */ + private async getUploadLeash(file: string): Promise<{ action: string; fields: { [name: string]: string; @@ -233,7 +246,7 @@ export default class Reddit extends Platform { form.append("filepath", filename); form.append("mimetype", mimetype); - const lease = (await this.api.postForm("media/asset.json", form)) as { + const leash = (await this.api.postForm("media/asset.json", form)) as { args: { action: string; fields: { @@ -242,22 +255,30 @@ export default class Reddit extends Platform { }[]; }; }; - if (!lease.args?.action || !lease.args?.fields) { - const msg = "Reddit.getUploadLease: bad answer"; - throw Logger.error(msg, lease); + if (!leash.args?.action || !leash.args?.fields) { + const msg = "Reddit.getUploadLeash: bad answer"; + throw Logger.error(msg, leash); } return { - action: "https:" + lease.args.action, + action: "https:" + leash.args.action, fields: Object.assign( {}, - ...lease.args.fields.map((f) => ({ [f.name]: f.value })), + ...leash.args.fields.map((f) => ({ [f.name]: f.value })), ), }; } + /** + * POST file as formdata using a leash + * @param leash + * @param leash.action - url to post to + * @param leash.fields - fields to post + * @param file - path to the file to upload + * @returns url to uploaded file + */ private async uploadFile( - lease: { + leash: { action: string; fields: { [name: string]: string; @@ -269,13 +290,13 @@ export default class Reddit extends Platform { const filename = path.basename(file); const form = new FormData(); - for (const fieldname in lease.fields) { - form.append(fieldname, lease.fields[fieldname]); + for (const fieldname in leash.fields) { + form.append(fieldname, leash.fields[fieldname]); } form.append("file", new Blob([buffer]), filename); - Logger.trace("POST", lease.action); + Logger.trace("POST", leash.action); - const responseRaw = await fetch(lease.action, { + const responseRaw = await fetch(leash.action, { method: "POST", headers: { Accept: "application/json", diff --git a/src/platforms/Twitter/Twitter.ts b/src/platforms/Twitter/Twitter.ts index abe5afc..47d5cf5 100644 --- a/src/platforms/Twitter/Twitter.ts +++ b/src/platforms/Twitter/Twitter.ts @@ -1,12 +1,11 @@ import * as fs from "fs"; import * as sharp from "sharp"; -import Post, { PostStatus } from "../../models/Post"; - import Folder from "../../models/Folder"; import Logger from "../../services/Logger"; import Platform from "../../models/Platform"; import { PlatformId } from ".."; +import Post from "../../models/Post"; import Storage from "../../services/Storage"; import { TwitterApi } from "twitter-api-v2"; import TwitterAuth from "./TwitterAuth"; @@ -51,6 +50,7 @@ export default class Twitter extends Platform { return true; } + /** @inheritdoc */ async preparePost(folder: Folder): Promise { Logger.trace("Twitter.preparePost", folder.id); const post = await super.preparePost(folder); @@ -80,17 +80,101 @@ export default class Twitter extends Platform { return post; } + /** @inheritdoc */ async publishPost(post: Post, dryrun: boolean = false): Promise { Logger.trace("Twitter.publishPost", post.id, dryrun); + + let response = { data: { id: "-99" } } as { + data: { + id: string; + }; + }; + let error = undefined; + + if (post.files.image.length) { + try { + response = await this.publishImagesPost(post, dryrun); + } catch (e) { + error = e; + } + } else { + try { + response = await this.publishTextPost(post, dryrun); + } catch (e) { + error = e; + } + } + + return post.processResult( + response.data.id, + "https://twitter.com/user/status/" + response.data.id, + { + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }, + ); + } + + /** + * tweet body using oauth2 client + * @param post - the post + * @param dryrun - wether to really execure + * @returns object, incl. id of the created post + */ + private async publishTextPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ + data: { + id: string; + }; + }> { + Logger.trace("Twitter.publishTextPost", post.id, dryrun); + if (!dryrun) { + const client2 = new TwitterApi( + Storage.get("auth", "TWITTER_ACCESS_TOKEN"), + ); + const result = await client2.v2.tweet({ + text: post.body, + }); + if (result.errors) { + throw Logger.error(result.errors.join()); + } + return result; + } + return { + data: { + id: "-99", + }, + }; + } + + /** + * Upload a images to twitter using oauth1 client + * and create a post with body & media using oauth2 client + * @param post - the post to publish + * @param dryrun - wether to actually post it + * @returns object incl id of the created post + */ + private async publishImagesPost( + post: Post, + dryrun: boolean = false, + ): Promise<{ + data: { + id: string; + }; + }> { + Logger.trace("Twitter.publishImagesPost", post.id, dryrun); + const client1 = new TwitterApi({ appKey: Storage.get("settings", "TWITTER_OA1_API_KEY"), appSecret: Storage.get("settings", "TWITTER_OA1_API_KEY_SECRET"), accessToken: Storage.get("settings", "TWITTER_OA1_ACCESS_TOKEN"), accessSecret: Storage.get("settings", "TWITTER_OA1_ACCESS_SECRET"), }); - - let error = undefined; - let result = undefined; const mediaIds = []; if (post.files.image.length) { for (const image of post.files.image) { @@ -99,58 +183,28 @@ export default class Twitter extends Platform { try { mediaIds.push(await client1.v1.uploadMedia(path)); } catch (e) { - Logger.warn("Twitter.publishPost uploadMedia failed", e); - error = e; + throw Logger.error("Twitter.publishPost uploadMedia failed", e); } } } - const client2 = new TwitterApi(Storage.get("auth", "TWITTER_ACCESS_TOKEN")); if (!dryrun) { - if (!error) { - Logger.trace("Tweeting " + post.id + "..."); - try { - result = await client2.v2.tweet({ - text: post.body, - media: { media_ids: mediaIds }, - }); - if (result.errors) { - error = new Error(result.errors.join()); - } - } catch (e) { - error = e; - } - } - } else { - result = { - id: "99", - }; - } - - post.results.push({ - date: new Date(), - dryrun: dryrun, - success: !error, - error: error, - response: result, - }); - - if (error) { - Logger.warn("Twitter.publishPost", this.id, "failed", error, result); - } - - if (!dryrun) { - if (!error) { - post.link = "https://twitter.com/user/status/" + result.data.id; - post.status = PostStatus.PUBLISHED; - post.published = new Date(); - } else { - post.status = PostStatus.FAILED; + Logger.trace("Tweeting " + post.id + "..."); + const result = await client2.v2.tweet({ + text: post.body, + media: { media_ids: mediaIds }, + }); + if (result.errors) { + throw Logger.error(result.errors.join()); } + return result; } - post.save(); - return !error; + return { + data: { + id: "-99", + }, + }; } }