| 
 | 1 | +import process from "node:process";  | 
 | 2 | +import makeDebug from "debug";  | 
 | 3 | +import { IndiekitError } from "@indiekit/error";  | 
 | 4 | +import { createPost, userInfo } from "./lib/linkedin.js";  | 
 | 5 | + | 
 | 6 | +const debug = makeDebug(`indiekit-syndicator:linkedin`);  | 
 | 7 | + | 
 | 8 | +const UID = "https://www.linkedin.com/";  | 
 | 9 | + | 
 | 10 | +const defaults = {  | 
 | 11 | +  accessToken: process.env.LINKEDIN_ACCESS_TOKEN,  | 
 | 12 | +  // The character limit for a LinkedIn post is 3000 characters.  | 
 | 13 | +  // https://www.linkedin.com/help/linkedin/answer/a528176  | 
 | 14 | +  characterLimit: 3000,  | 
 | 15 | +  checked: false,  | 
 | 16 | +  // Client ID and Client Secret of the LinkedIn OAuth app you are using for  | 
 | 17 | +  // publishing on your LinkedIn account.  | 
 | 18 | +  // This LinkedIn OAuth app could be the official Indiekit app or a custom one.  | 
 | 19 | +  // TODO:  | 
 | 20 | +  // 1. create a LinkedIn page for Indiekit  | 
 | 21 | +  // 2. create a Linkedin OAuth app  | 
 | 22 | +  // 3. associate the Linkedin OAuth app to the LinkedIn page  | 
 | 23 | +  // 4. submit the Linkedin OAuth app for verification  | 
 | 24 | +  clientId: process.env.LINKEDIN_CLIENT_ID,  | 
 | 25 | +  clientSecret: process.env.LINKEDIN_CLIENT_SECRET,  | 
 | 26 | + | 
 | 27 | +  // https://learn.microsoft.com/en-us/linkedin/marketing/versioning  | 
 | 28 | +  // https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api  | 
 | 29 | +  postsAPIVersion: "202401",  | 
 | 30 | +};  | 
 | 31 | + | 
 | 32 | +export default class LinkedInSyndicator {  | 
 | 33 | +  /**  | 
 | 34 | +   * @param {object} [options] - Plug-in options  | 
 | 35 | +   * @param {string} [options.accessToken] - Linkedin OAuth app access token  | 
 | 36 | +   * @param {string} [options.authorId] - LinkedIn ID of the author. See https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns  | 
 | 37 | +   * @param {string} [options.authorName] - Full name of the author  | 
 | 38 | +   * @param {string} [options.authorProfileUrl] - LinkedIn profile URL of the author  | 
 | 39 | +   * @param {number} [options.characterLimit] - LinkedIn post character limit  | 
 | 40 | +   * @param {boolean} [options.checked] - Check syndicator in UI  | 
 | 41 | +   * @param {string} [options.clientId] - Linkedin OAuth app Client ID  | 
 | 42 | +   * @param {string} [options.clientSecret] - Linkedin OAuth app Client Secret  | 
 | 43 | +   * @param {string} [options.postsAPIVersion] - Version of the Linkedin /posts API to use  | 
 | 44 | +   */  | 
 | 45 | +  constructor(options = {}) {  | 
 | 46 | +    this.name = "LinkedIn syndicator";  | 
 | 47 | +    this.options = { ...defaults, ...options };  | 
 | 48 | +  }  | 
 | 49 | + | 
 | 50 | +  get environment() {  | 
 | 51 | +    return [  | 
 | 52 | +      "LINKEDIN_ACCESS_TOKEN",  | 
 | 53 | +      "LINKEDIN_AUTHOR_ID",  | 
 | 54 | +      "LINKEDIN_AUTHOR_NAME",  | 
 | 55 | +      "LINKEDIN_AUTHOR_PROFILE_URL",  | 
 | 56 | +      "LINKEDIN_CLIENT_ID",  | 
 | 57 | +      "LINKEDIN_CLIENT_SECRET",  | 
 | 58 | +    ];  | 
 | 59 | +  }  | 
 | 60 | + | 
 | 61 | +  get info() {  | 
 | 62 | +    const service = {  | 
 | 63 | +      name: "LinkedIn",  | 
 | 64 | +      photo: "/assets/@indiekit-syndicator-linkedin/icon.svg",  | 
 | 65 | +      url: "https://www.linkedin.com/",  | 
 | 66 | +    };  | 
 | 67 | + | 
 | 68 | +    const name = this.options.authorName || "unknown LinkedIn author name";  | 
 | 69 | +    const uid = this.options.authorProfileUrl || UID;  | 
 | 70 | +    const url =  | 
 | 71 | +      this.options.authorProfileUrl || "unknown LinkedIn author profile URL";  | 
 | 72 | + | 
 | 73 | +    return {  | 
 | 74 | +      checked: this.options.checked,  | 
 | 75 | +      name,  | 
 | 76 | +      service,  | 
 | 77 | +      uid,  | 
 | 78 | +      user: { name, url },  | 
 | 79 | +    };  | 
 | 80 | +  }  | 
 | 81 | + | 
 | 82 | +  // TODO: think about which fields to ask for in the prompt  | 
 | 83 | +  get prompts() {  | 
 | 84 | +    return [  | 
 | 85 | +      {  | 
 | 86 | +        type: "text",  | 
 | 87 | +        name: "clientId",  | 
 | 88 | +        message:  | 
 | 89 | +          "What is the Client ID of your LinkedIn OAuth 2.0 application?",  | 
 | 90 | +        description: "i.e. 12abcde3fghi4j",  | 
 | 91 | +      },  | 
 | 92 | +      {  | 
 | 93 | +        type: "text",  | 
 | 94 | +        name: "clientSecret",  | 
 | 95 | +        message:  | 
 | 96 | +          "What is the Client Secret of your LinkedIn OAuth 2.0 application?",  | 
 | 97 | +        description: "i.e. WPL_AP0.foo.bar",  | 
 | 98 | +      },  | 
 | 99 | +    ];  | 
 | 100 | +  }  | 
 | 101 | + | 
 | 102 | +  async syndicate(properties, publication) {  | 
 | 103 | +    debug(`syndicate properties %O`, properties);  | 
 | 104 | +    debug(`syndicate publication %O: `, {  | 
 | 105 | +      categories: publication.categories,  | 
 | 106 | +      me: publication.me,  | 
 | 107 | +    });  | 
 | 108 | + | 
 | 109 | +    const accessToken = this.options.accessToken;  | 
 | 110 | +    // const clientId = this.options.clientId;  | 
 | 111 | +    // const clientSecret = this.options.clientSecret;  | 
 | 112 | + | 
 | 113 | +    let authorName;  | 
 | 114 | +    let authorUrn;  | 
 | 115 | +    try {  | 
 | 116 | +      const userinfo = await userInfo({ accessToken });  | 
 | 117 | +      authorName = userinfo.name;  | 
 | 118 | +      authorUrn = userinfo.urn;  | 
 | 119 | +    } catch (error) {  | 
 | 120 | +      throw new IndiekitError(error.message, {  | 
 | 121 | +        cause: error,  | 
 | 122 | +        plugin: this.name,  | 
 | 123 | +        status: error.statusCode,  | 
 | 124 | +      });  | 
 | 125 | +    }  | 
 | 126 | + | 
 | 127 | +    // TODO: switch on properties['post-type'] // e.g. article, note  | 
 | 128 | +    const text = properties.content.text;  | 
 | 129 | + | 
 | 130 | +    try {  | 
 | 131 | +      const { url } = await createPost({  | 
 | 132 | +        accessToken,  | 
 | 133 | +        authorName,  | 
 | 134 | +        authorUrn,  | 
 | 135 | +        text,  | 
 | 136 | +        versionString: this.options.postsAPIVersion,  | 
 | 137 | +      });  | 
 | 138 | +      debug(`post created, online at ${url}`);  | 
 | 139 | +      return url;  | 
 | 140 | +    } catch (error) {  | 
 | 141 | +      // Axios Error  | 
 | 142 | +      // https://axios-http.com/docs/handling_errors  | 
 | 143 | +      const status = error.response.status;  | 
 | 144 | +      const message = `could not create LinkedIn post: ${error.response.statusText}`;  | 
 | 145 | +      throw new IndiekitError(message, {  | 
 | 146 | +        cause: error,  | 
 | 147 | +        plugin: this.name,  | 
 | 148 | +        status,  | 
 | 149 | +      });  | 
 | 150 | +    }  | 
 | 151 | +  }  | 
 | 152 | + | 
 | 153 | +  init(Indiekit) {  | 
 | 154 | +    const required_configs = ["clientId", "clientSecret"];  | 
 | 155 | +    for (const required of required_configs) {  | 
 | 156 | +      if (!this.options[required]) {  | 
 | 157 | +        const message = `could not initialize ${this.name}: ${required} not set.`;  | 
 | 158 | +        debug(message);  | 
 | 159 | +        console.error(message);  | 
 | 160 | +        throw new Error(message);  | 
 | 161 | +      }  | 
 | 162 | +    }  | 
 | 163 | +    Indiekit.addSyndicator(this);  | 
 | 164 | +  }  | 
 | 165 | +}  | 
0 commit comments