also looking at this
feat: crosspost to LinkedIn and Instagram
#1
Merged
chris
wants to merge
feat/crosspost-linkedin-instagram
into
main
31 files
+2335
-87
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,225 @@ | ||
| 1 | +import type { | |
| 2 | + PublishedPost, | |
| 3 | + PublishPostInput, | |
| 4 | + SocialIdentityCredentials, | |
| 5 | + SocialPublishingDriver, | |
| 6 | + TimelineQuery, | |
| 7 | + TimelineResult, | |
| 8 | +} from '../types' | |
| 9 | + | |
| 10 | +export class InstagramApiError extends Error { | |
| 11 | + constructor( | |
| 12 | + message: string, | |
| 13 | + public status: number, | |
| 14 | + public body: string, | |
| 15 | + ) { | |
| 16 | + super(message) | |
| 17 | + this.name = 'InstagramApiError' | |
| 18 | + } | |
| 19 | + | |
| 20 | + get isAuthError(): boolean { | |
| 21 | + // 190 is Graph's "access token expired/invalid" subcode surfaced as status. | |
| 22 | + return this.status === 401 || this.status === 403 || this.status === 190 | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface InstagramDriverOptions { | |
| 27 | + /** Facebook Graph API version, e.g. `v21.0`. */ | |
| 28 | + graphVersion?: string | |
| 29 | + authBase?: string | |
| 30 | + graphBase?: string | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface InstagramAuthUrlInput { | |
| 34 | + clientId: string | |
| 35 | + redirectUrl: string | |
| 36 | + scopes: string[] | |
| 37 | + state: string | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface InstagramTokenExchangeInput { | |
| 41 | + clientId: string | |
| 42 | + clientSecret: string | |
| 43 | + redirectUrl: string | |
| 44 | + code: string | |
| 45 | +} | |
| 46 | + | |
| 47 | +export interface InstagramAccount { | |
| 48 | + /** Instagram Business/Creator account id (the publishing target). */ | |
| 49 | + igUserId: string | |
| 50 | + username?: string | |
| 51 | + /** Page access token used for content publishing. */ | |
| 52 | + pageAccessToken: string | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** | |
| 56 | + * Publishing driver for Instagram Business/Creator accounts via the Facebook | |
| 57 | + * Graph API. Auth is Facebook Login (OAuth 2.0). Publishing is the documented | |
| 58 | + * two-step flow: create a media container, then publish it. | |
| 59 | + * | |
| 60 | + * Instagram does not allow text-only posts — every post requires an image (or | |
| 61 | + * video) reachable at a public URL, supplied via `post.media`. | |
| 62 | + */ | |
| 63 | +export class InstagramPublishingDriver implements SocialPublishingDriver { | |
| 64 | + readonly provider = 'instagram' as const | |
| 65 | + characterLimit = 2200 | |
| 66 | + | |
| 67 | + protected graphVersion: string | |
| 68 | + protected authBase: string | |
| 69 | + protected graphBase: string | |
| 70 | + | |
| 71 | + constructor(options: InstagramDriverOptions = {}) { | |
| 72 | + this.graphVersion = options.graphVersion || 'v21.0' | |
| 73 | + this.authBase = options.authBase || 'https://www.facebook.com' | |
| 74 | + this.graphBase = options.graphBase || 'https://graph.facebook.com' | |
| 75 | + } | |
| 76 | + | |
| 77 | + /** Build the Facebook Login consent URL. */ | |
| 78 | + getAuthUrl(input: InstagramAuthUrlInput): string { | |
| 79 | + const query = new URLSearchParams({ | |
| 80 | + client_id: input.clientId, | |
| 81 | + redirect_uri: input.redirectUrl, | |
| 82 | + scope: input.scopes.join(','), | |
| 83 | + state: input.state, | |
| 84 | + response_type: 'code', | |
| 85 | + }) | |
| 86 | + | |
| 87 | + return `${this.authBase}/${this.graphVersion}/dialog/oauth?${query.toString()}` | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** Exchange an authorization code for a user access token. */ | |
| 91 | + async exchangeCode(input: InstagramTokenExchangeInput): Promise<{ accessToken: string, expiresIn?: number }> { | |
| 92 | + const query = new URLSearchParams({ | |
| 93 | + client_id: input.clientId, | |
| 94 | + client_secret: input.clientSecret, | |
| 95 | + redirect_uri: input.redirectUrl, | |
| 96 | + code: input.code, | |
| 97 | + }) | |
| 98 | + | |
| 99 | + const payload = await this.graph<{ access_token: string, expires_in?: number }>( | |
| 100 | + `/oauth/access_token?${query.toString()}`, | |
| 101 | + { method: 'GET' }, | |
| 102 | + ) | |
| 103 | + | |
| 104 | + if (!payload.access_token) { | |
| 105 | + throw new InstagramApiError('Facebook did not return an access token.', 400, JSON.stringify(payload)) | |
| 106 | + } | |
| 107 | + | |
| 108 | + return { accessToken: payload.access_token, expiresIn: payload.expires_in } | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** | |
| 112 | + * Find the Instagram Business account linked to one of the user's Pages. | |
| 113 | + * Returns the IG account id plus the Page access token used to publish. | |
| 114 | + */ | |
| 115 | + async resolveAccount(accessToken: string): Promise<InstagramAccount> { | |
| 116 | + const query = new URLSearchParams({ | |
| 117 | + fields: 'name,access_token,instagram_business_account{id,username}', | |
| 118 | + access_token: accessToken, | |
| 119 | + }) | |
| 120 | + | |
| 121 | + const payload = await this.graph<{ | |
| 122 | + data?: Array<{ | |
| 123 | + access_token?: string | |
| 124 | + instagram_business_account?: { id: string, username?: string } | |
| 125 | + }> | |
| 126 | + }>(`/me/accounts?${query.toString()}`, { method: 'GET' }) | |
| 127 | + | |
| 128 | + const page = (payload.data || []).find(entry => entry.instagram_business_account?.id) | |
| 129 | + const account = page?.instagram_business_account | |
| 130 | + if (!account?.id || !page?.access_token) { | |
| 131 | + throw new InstagramApiError( | |
| 132 | + 'No Instagram Business account is linked to your Facebook Pages.', | |
| 133 | + 400, | |
| 134 | + JSON.stringify(payload), | |
| 135 | + ) | |
| 136 | + } | |
| 137 | + | |
| 138 | + return { | |
| 139 | + igUserId: account.id, | |
| 140 | + username: account.username, | |
| 141 | + pageAccessToken: page.access_token, | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + async publish(identity: SocialIdentityCredentials, post: PublishPostInput): Promise<PublishedPost> { | |
| 146 | + if (!identity.accessToken) throw new Error('Instagram access token is missing for this identity.') | |
| 147 | + | |
| 148 | + // `did` carries the Instagram Business account id for Instagram identities. | |
| 149 | + const igUserId = identity.did | |
| 150 | + if (!igUserId) throw new Error('Instagram account id is required to publish.') | |
| 151 | + | |
| 152 | + const media = post.media?.[0] | |
| 153 | + if (!media?.url) throw new Error('Instagram requires an image to post.') | |
| 154 | + | |
| 155 | + if (post.text.length > this.characterLimit) { | |
| 156 | + throw new Error(`Instagram captions must be ${this.characterLimit} characters or fewer.`) | |
| 157 | + } | |
| 158 | + | |
| 159 | + // 1) Create the media container. | |
| 160 | + const container = await this.graph<{ id: string }>( | |
| 161 | + `/${igUserId}/media`, | |
| 162 | + { | |
| 163 | + method: 'POST', | |
| 164 | + headers: { 'content-type': 'application/x-www-form-urlencoded' }, | |
| 165 | + body: new URLSearchParams({ | |
| 166 | + image_url: media.url, | |
| 167 | + caption: post.text, | |
| 168 | + access_token: identity.accessToken, | |
| 169 | + }).toString(), | |
| 170 | + }, | |
| 171 | + ) | |
| 172 | + | |
| 173 | + if (!container.id) { | |
| 174 | + throw new InstagramApiError('Instagram did not return a media container id.', 400, JSON.stringify(container)) | |
| 175 | + } | |
| 176 | + | |
| 177 | + // 2) Publish the container. | |
| 178 | + const published = await this.graph<{ id: string }>( | |
| 179 | + `/${igUserId}/media_publish`, | |
| 180 | + { | |
| 181 | + method: 'POST', | |
| 182 | + headers: { 'content-type': 'application/x-www-form-urlencoded' }, | |
| 183 | + body: new URLSearchParams({ | |
| 184 | + creation_id: container.id, | |
| 185 | + access_token: identity.accessToken, | |
| 186 | + }).toString(), | |
| 187 | + }, | |
| 188 | + ) | |
| 189 | + | |
| 190 | + const permalink = await this.graph<{ permalink?: string }>( | |
| 191 | + `/${published.id}?fields=permalink&access_token=${encodeURIComponent(identity.accessToken)}`, | |
| 192 | + { method: 'GET' }, | |
| 193 | + ).catch(() => undefined) | |
| 194 | + | |
| 195 | + return { | |
| 196 | + provider: this.provider, | |
| 197 | + uri: published.id, | |
| 198 | + url: permalink?.permalink, | |
| 199 | + } | |
| 200 | + } | |
| 201 | + | |
| 202 | + // The Content Publishing scope does not grant a readable home timeline. | |
| 203 | + async timeline(_identity: SocialIdentityCredentials, _query: TimelineQuery = {}): Promise<TimelineResult> { | |
| 204 | + return { items: [] } | |
| 205 | + } | |
| 206 | + | |
| 207 | + protected async graph<T>(path: string, init: RequestInit): Promise<T> { | |
| 208 | + const response = await fetch(`${this.graphBase}/${this.graphVersion}${path}`, init) | |
| 209 | + const text = await response.text() | |
| 210 | + let json: any = {} | |
| 211 | + try { | |
| 212 | + json = text ? JSON.parse(text) : {} | |
| 213 | + } | |
| 214 | + catch { | |
| 215 | + json = {} | |
| 216 | + } | |
| 217 | + | |
| 218 | + if (!response.ok || json?.error) { | |
| 219 | + const message = json?.error?.message || text || response.statusText | |
| 220 | + throw new InstagramApiError(`Instagram API failed (${response.status}): ${message}`, response.status, text) | |
| 221 | + } | |
| 222 | + | |
| 223 | + return json as T | |
| 224 | + } | |
| 225 | +} | |
| @@ -0,0 +1,234 @@ | ||
| 1 | +import type { | |
| 2 | + PublishedPost, | |
| 3 | + PublishPostInput, | |
| 4 | + SocialIdentityCredentials, | |
| 5 | + SocialPublishingDriver, | |
| 6 | + TimelineQuery, | |
| 7 | + TimelineResult, | |
| 8 | +} from '../types' | |
| 9 | + | |
| 10 | +export class LinkedInApiError extends Error { | |
| 11 | + constructor( | |
| 12 | + message: string, | |
| 13 | + public status: number, | |
| 14 | + public body: string, | |
| 15 | + ) { | |
| 16 | + super(message) | |
| 17 | + this.name = 'LinkedInApiError' | |
| 18 | + } | |
| 19 | + | |
| 20 | + get isAuthError(): boolean { | |
| 21 | + return this.status === 401 || this.status === 403 | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface LinkedInDriverOptions { | |
| 26 | + /** Monthly REST API version header, e.g. `202405`. */ | |
| 27 | + apiVersion?: string | |
| 28 | + authBase?: string | |
| 29 | + apiBase?: string | |
| 30 | +} | |
| 31 | + | |
| 32 | +export interface LinkedInAuthUrlInput { | |
| 33 | + clientId: string | |
| 34 | + redirectUrl: string | |
| 35 | + scopes: string[] | |
| 36 | + state: string | |
| 37 | +} | |
| 38 | + | |
| 39 | +export interface LinkedInTokenExchangeInput { | |
| 40 | + clientId: string | |
| 41 | + clientSecret: string | |
| 42 | + redirectUrl: string | |
| 43 | + code: string | |
| 44 | +} | |
| 45 | + | |
| 46 | +export interface LinkedInTokenResponse { | |
| 47 | + accessToken: string | |
| 48 | + expiresIn?: number | |
| 49 | + scope?: string | |
| 50 | +} | |
| 51 | + | |
| 52 | +export interface LinkedInProfile { | |
| 53 | + /** OpenID subject — the member id used to build `urn:li:person:{sub}`. */ | |
| 54 | + sub: string | |
| 55 | + name?: string | |
| 56 | + picture?: string | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * Publishing driver for LinkedIn member shares. | |
| 61 | + * | |
| 62 | + * Auth is OAuth 2.0 (authorization code). Posting uses the versioned REST | |
| 63 | + * `/rest/posts` endpoint with the `w_member_social` scope. Unlike Bluesky, | |
| 64 | + * LinkedIn has no app-password, so a token is always obtained via OAuth (or | |
| 65 | + * pasted in from a prior OAuth grant). | |
| 66 | + */ | |
| 67 | +export class LinkedInPublishingDriver implements SocialPublishingDriver { | |
| 68 | + readonly provider = 'linkedin' as const | |
| 69 | + characterLimit = 3000 | |
| 70 | + | |
| 71 | + protected apiVersion: string | |
| 72 | + protected authBase: string | |
| 73 | + protected apiBase: string | |
| 74 | + | |
| 75 | + constructor(options: LinkedInDriverOptions = {}) { | |
| 76 | + this.apiVersion = options.apiVersion || '202405' | |
| 77 | + this.authBase = options.authBase || 'https://www.linkedin.com' | |
| 78 | + this.apiBase = options.apiBase || 'https://api.linkedin.com' | |
| 79 | + } | |
| 80 | + | |
| 81 | + /** Build the consent URL the member is redirected to. */ | |
| 82 | + getAuthUrl(input: LinkedInAuthUrlInput): string { | |
| 83 | + const query = new URLSearchParams({ | |
| 84 | + response_type: 'code', | |
| 85 | + client_id: input.clientId, | |
| 86 | + redirect_uri: input.redirectUrl, | |
| 87 | + scope: input.scopes.join(' '), | |
| 88 | + state: input.state, | |
| 89 | + }) | |
| 90 | + | |
| 91 | + return `${this.authBase}/oauth/v2/authorization?${query.toString()}` | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** Exchange an authorization code for a member access token. */ | |
| 95 | + async exchangeCode(input: LinkedInTokenExchangeInput): Promise<LinkedInTokenResponse> { | |
| 96 | + const body = new URLSearchParams({ | |
| 97 | + grant_type: 'authorization_code', | |
| 98 | + code: input.code, | |
| 99 | + redirect_uri: input.redirectUrl, | |
| 100 | + client_id: input.clientId, | |
| 101 | + client_secret: input.clientSecret, | |
| 102 | + }) | |
| 103 | + | |
| 104 | + const payload = await this.request<{ access_token: string, expires_in?: number, scope?: string }>( | |
| 105 | + `${this.authBase}/oauth/v2/accessToken`, | |
| 106 | + { | |
| 107 | + method: 'POST', | |
| 108 | + headers: { 'content-type': 'application/x-www-form-urlencoded' }, | |
| 109 | + body: body.toString(), | |
| 110 | + }, | |
| 111 | + ) | |
| 112 | + | |
| 113 | + if (!payload.access_token) { | |
| 114 | + throw new LinkedInApiError('LinkedIn did not return an access token.', 400, JSON.stringify(payload)) | |
| 115 | + } | |
| 116 | + | |
| 117 | + return { | |
| 118 | + accessToken: payload.access_token, | |
| 119 | + expiresIn: payload.expires_in, | |
| 120 | + scope: payload.scope, | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 124 | + /** Resolve the member profile (OpenID Connect `userinfo`). */ | |
| 125 | + async getProfile(accessToken: string): Promise<LinkedInProfile> { | |
| 126 | + if (!accessToken) throw new Error('LinkedIn access token is required.') | |
| 127 | + | |
| 128 | + const payload = await this.request<{ sub: string, name?: string, picture?: string }>( | |
| 129 | + `${this.apiBase}/v2/userinfo`, | |
| 130 | + { headers: { authorization: `Bearer ${accessToken}` } }, | |
| 131 | + ) | |
| 132 | + | |
| 133 | + if (!payload.sub) { | |
| 134 | + throw new LinkedInApiError('LinkedIn profile is missing a subject id.', 400, JSON.stringify(payload)) | |
| 135 | + } | |
| 136 | + | |
| 137 | + return { sub: payload.sub, name: payload.name, picture: payload.picture } | |
| 138 | + } | |
| 139 | + | |
| 140 | + async publish(identity: SocialIdentityCredentials, post: PublishPostInput): Promise<PublishedPost> { | |
| 141 | + if (!identity.accessToken) throw new Error('LinkedIn access token is missing for this identity.') | |
| 142 | + | |
| 143 | + // `did` carries the member URN (urn:li:person:{sub}) for LinkedIn identities. | |
| 144 | + const author = identity.did | |
| 145 | + if (!author) throw new Error('LinkedIn member URN is required to publish.') | |
| 146 | + | |
| 147 | + if (post.text.length > this.characterLimit) { | |
| 148 | + throw new Error(`LinkedIn posts must be ${this.characterLimit} characters or fewer.`) | |
| 149 | + } | |
| 150 | + | |
| 151 | + const record: Record<string, unknown> = { | |
| 152 | + author, | |
| 153 | + commentary: escapeLinkedInText(post.text), | |
| 154 | + visibility: 'PUBLIC', | |
| 155 | + distribution: { | |
| 156 | + feedDistribution: 'MAIN_FEED', | |
| 157 | + targetEntities: [], | |
| 158 | + thirdPartyDistributionChannels: [], | |
| 159 | + }, | |
| 160 | + lifecycleState: 'PUBLISHED', | |
| 161 | + isReshareDisabledByAuthor: false, | |
| 162 | + } | |
| 163 | + | |
| 164 | + if (post.external) { | |
| 165 | + record.content = { | |
| 166 | + article: { | |
| 167 | + source: post.external.uri, | |
| 168 | + title: post.external.title, | |
| 169 | + description: post.external.description || '', | |
| 170 | + }, | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + const response = await fetch(`${this.apiBase}/rest/posts`, { | |
| 175 | + method: 'POST', | |
| 176 | + headers: { | |
| 177 | + 'authorization': `Bearer ${identity.accessToken}`, | |
| 178 | + 'content-type': 'application/json', | |
| 179 | + 'linkedin-version': this.apiVersion, | |
| 180 | + 'x-restli-protocol-version': '2.0.0', | |
| 181 | + }, | |
| 182 | + body: JSON.stringify(record), | |
| 183 | + }) | |
| 184 | + | |
| 185 | + const text = await response.text() | |
| 186 | + if (!response.ok) { | |
| 187 | + throw new LinkedInApiError( | |
| 188 | + `LinkedIn API failed (${response.status}): ${text || response.statusText}`, | |
| 189 | + response.status, | |
| 190 | + text, | |
| 191 | + ) | |
| 192 | + } | |
| 193 | + | |
| 194 | + // The created post URN comes back in a response header, not the body. | |
| 195 | + const uri = response.headers.get('x-restli-id') | |
| 196 | + || response.headers.get('x-linkedin-id') | |
| 197 | + || '' | |
| 198 | + | |
| 199 | + return { | |
| 200 | + provider: this.provider, | |
| 201 | + uri, | |
| 202 | + url: uri ? `https://www.linkedin.com/feed/update/${uri}` : undefined, | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + // LinkedIn does not expose a personal home timeline under `w_member_social`. | |
| 207 | + async timeline(_identity: SocialIdentityCredentials, _query: TimelineQuery = {}): Promise<TimelineResult> { | |
| 208 | + return { items: [] } | |
| 209 | + } | |
| 210 | + | |
| 211 | + protected async request<T>(url: string, init: RequestInit): Promise<T> { | |
| 212 | + const response = await fetch(url, init) | |
| 213 | + const text = await response.text() | |
| 214 | + | |
| 215 | + if (!response.ok) { | |
| 216 | + throw new LinkedInApiError( | |
| 217 | + `LinkedIn API failed (${response.status}): ${text || response.statusText}`, | |
| 218 | + response.status, | |
| 219 | + text, | |
| 220 | + ) | |
| 221 | + } | |
| 222 | + | |
| 223 | + return text ? JSON.parse(text) as T : {} as T | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +/** | |
| 228 | + * Escape the reserved characters of LinkedIn's "little text" commentary format | |
| 229 | + * so the literal text renders as typed. Without this, characters like `(` `)` | |
| 230 | + * `@` `#` cause the share to be rejected with a 400. | |
| 231 | + */ | |
| 232 | +export function escapeLinkedInText(text: string): string { | |
| 233 | + return text.replace(/[\\|{}@[\]()<>#*_~]/g, '\\$&') | |
| 234 | +} | |
| @@ -102,6 +102,14 @@ export interface PublishPostInput { | ||
| 102 | 102 | title: string |
| 103 | 103 | description?: string |
| 104 | 104 | } |
| 105 | + /** | |
| 106 | + * Attached media. Required by media-only providers such as Instagram, and | |
| 107 | + * ignored by text-first providers (Bluesky, LinkedIn) for now. | |
| 108 | + */ | |
| 109 | + media?: Array<{ | |
| 110 | + url: string | |
| 111 | + altText?: string | |
| 112 | + }> | |
| 105 | 113 | } |
| 106 | 114 | |
| 107 | 115 | export interface PublishedPost { |