also looking at this
feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter
#30
28 files
+1810
-109
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.
| @@ -1,1 +1,176 @@ | ||
| 1 | export { LinkedInApiError, LinkedInPublishingDriver as LinkedInDriver } from '@stacksjs/socials' | |
| 1 | import type { PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials' | |
| 2 | import { escapeLinkedInText, LinkedInApiError, LinkedInPublishingDriver } from '@stacksjs/socials' | |
| 3 | ||
| 4 | export { LinkedInApiError } | |
| 5 | ||
| 6 | interface InitializeUploadResponse { | |
| 7 | value?: { uploadUrl?: string, image?: string } | |
| 8 | } | |
| 9 | ||
| 10 | /** | |
| 11 | * App-owned LinkedIn driver. Extends the vendored `@stacksjs/socials` driver to | |
| 12 | * add image posting — the base driver only supports text and article link | |
| 13 | * cards. Kept here in `app/` (not patched into `storage/framework/`) so a | |
| 14 | * framework upgrade can't clobber it, the same "re-home into app code" rule the | |
| 15 | * app-owned Mastodon driver and the auth bridge follow. | |
| 16 | * | |
| 17 | * Everything else (OAuth `getAuthUrl`/`exchangeCode`/`getProfile`, the versioned | |
| 18 | * REST plumbing) is inherited unchanged. Only `publish` is overridden, and a | |
| 19 | * single `uploadImage` step is added. | |
| 20 | */ | |
| 21 | export class LinkedInDriver extends LinkedInPublishingDriver { | |
| 22 | /** | |
| 23 | * Register and upload one image via LinkedIn's versioned Images API, then | |
| 24 | * return its `urn:li:image:...` id for attachment. | |
| 25 | * | |
| 26 | * 1. POST /rest/images?action=initializeUpload → { uploadUrl, image URN } | |
| 27 | * 2. PUT the raw bytes to `uploadUrl` | |
| 28 | * | |
| 29 | * `owner` is the author URN (`urn:li:person:{sub}`) that owns the asset. | |
| 30 | */ | |
| 31 | async uploadImage(accessToken: string, owner: string, bytes: Uint8Array, mimeType?: string): Promise<string> { | |
| 32 | const initialized = await this.request<InitializeUploadResponse>( | |
| 33 | `${this.apiBase}/rest/images?action=initializeUpload`, | |
| 34 | { | |
| 35 | method: 'POST', | |
| 36 | headers: { | |
| 37 | 'authorization': `Bearer ${accessToken}`, | |
| 38 | 'content-type': 'application/json', | |
| 39 | 'linkedin-version': this.apiVersion, | |
| 40 | 'x-restli-protocol-version': '2.0.0', | |
| 41 | }, | |
| 42 | body: JSON.stringify({ initializeUploadRequest: { owner } }), | |
| 43 | }, | |
| 44 | ) | |
| 45 | ||
| 46 | const uploadUrl = initialized.value?.uploadUrl | |
| 47 | const image = initialized.value?.image | |
| 48 | if (!uploadUrl || !image) { | |
| 49 | throw new LinkedInApiError('LinkedIn did not return an image upload URL.', 400, JSON.stringify(initialized)) | |
| 50 | } | |
| 51 | ||
| 52 | const upload = await fetch(uploadUrl, { | |
| 53 | method: 'PUT', | |
| 54 | headers: { | |
| 55 | 'authorization': `Bearer ${accessToken}`, | |
| 56 | 'content-type': mimeType || 'image/jpeg', | |
| 57 | }, | |
| 58 | body: bytes as unknown as BodyInit, | |
| 59 | }) | |
| 60 | ||
| 61 | if (!upload.ok) { | |
| 62 | const text = await upload.text().catch(() => '') | |
| 63 | throw new LinkedInApiError( | |
| 64 | `LinkedIn image upload failed (${upload.status}): ${text || upload.statusText}`, | |
| 65 | upload.status, | |
| 66 | text, | |
| 67 | ) | |
| 68 | } | |
| 69 | ||
| 70 | return image | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Publish a member share. Extends the base text/article flow with a single | |
| 75 | * attached image (`post.media[0]`): the image is uploaded first, then | |
| 76 | * referenced under `content.media`. LinkedIn's `content` is a one-of, so an | |
| 77 | * image supersedes an article link card when both are present (matching how | |
| 78 | * Bluesky lets an image override the link card). | |
| 79 | * | |
| 80 | * The record shape is duplicated from the base driver by necessity — the base | |
| 81 | * `publish` builds it inline with no seam to inject media — and is pinned by | |
| 82 | * tests/unit/linkedin-driver.test.ts so upgrade drift surfaces immediately. | |
| 83 | */ | |
| 84 | async publish(identity: SocialIdentityCredentials, post: PublishPostInput): Promise<PublishedPost> { | |
| 85 | if (!identity.accessToken) throw new Error('LinkedIn access token is missing for this identity.') | |
| 86 | ||
| 87 | // `did` carries the member URN (urn:li:person:{sub}) for LinkedIn identities. | |
| 88 | const author = identity.did | |
| 89 | if (!author) throw new Error('LinkedIn member URN is required to publish.') | |
| 90 | ||
| 91 | if (post.text.length > this.characterLimit) { | |
| 92 | throw new Error(`LinkedIn posts must be ${this.characterLimit} characters or fewer.`) | |
| 93 | } | |
| 94 | ||
| 95 | // Resolve an attached image to bytes — uploaded files arrive as `bytes`; | |
| 96 | // a bare `url` is fetched first (same handling as the Mastodon driver). | |
| 97 | const media = post.media?.[0] | |
| 98 | let imageUrn: string | undefined | |
| 99 | if (media) { | |
| 100 | let bytes = media.bytes | |
| 101 | let mimeType = media.mimeType | |
| 102 | if (!bytes?.length && media.url) { | |
| 103 | const response = await fetch(media.url) | |
| 104 | if (response.ok) { | |
| 105 | bytes = new Uint8Array(await response.arrayBuffer()) | |
| 106 | mimeType = mimeType || response.headers.get('content-type') || 'image/jpeg' | |
| 107 | } | |
| 108 | } | |
| 109 | if (bytes?.length) { | |
| 110 | imageUrn = await this.uploadImage(identity.accessToken, author, bytes, mimeType) | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | const record: Record<string, unknown> = { | |
| 115 | author, | |
| 116 | commentary: escapeLinkedInText(post.text), | |
| 117 | visibility: 'PUBLIC', | |
| 118 | distribution: { | |
| 119 | feedDistribution: 'MAIN_FEED', | |
| 120 | targetEntities: [], | |
| 121 | thirdPartyDistributionChannels: [], | |
| 122 | }, | |
| 123 | lifecycleState: 'PUBLISHED', | |
| 124 | isReshareDisabledByAuthor: false, | |
| 125 | } | |
| 126 | ||
| 127 | if (imageUrn) { | |
| 128 | record.content = { | |
| 129 | media: { | |
| 130 | id: imageUrn, | |
| 131 | ...(media?.altText ? { altText: media.altText } : {}), | |
| 132 | }, | |
| 133 | } | |
| 134 | } | |
| 135 | else if (post.external) { | |
| 136 | record.content = { | |
| 137 | article: { | |
| 138 | source: post.external.uri, | |
| 139 | title: post.external.title, | |
| 140 | description: post.external.description || '', | |
| 141 | }, | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | const response = await fetch(`${this.apiBase}/rest/posts`, { | |
| 146 | method: 'POST', | |
| 147 | headers: { | |
| 148 | 'authorization': `Bearer ${identity.accessToken}`, | |
| 149 | 'content-type': 'application/json', | |
| 150 | 'linkedin-version': this.apiVersion, | |
| 151 | 'x-restli-protocol-version': '2.0.0', | |
| 152 | }, | |
| 153 | body: JSON.stringify(record), | |
| 154 | }) | |
| 155 | ||
| 156 | const text = await response.text() | |
| 157 | if (!response.ok) { | |
| 158 | throw new LinkedInApiError( | |
| 159 | `LinkedIn API failed (${response.status}): ${text || response.statusText}`, | |
| 160 | response.status, | |
| 161 | text, | |
| 162 | ) | |
| 163 | } | |
| 164 | ||
| 165 | // The created post URN comes back in a response header, not the body. | |
| 166 | const uri = response.headers.get('x-restli-id') | |
| 167 | || response.headers.get('x-linkedin-id') | |
| 168 | || '' | |
| 169 | ||
| 170 | return { | |
| 171 | provider: this.provider, | |
| 172 | uri, | |
| 173 | url: uri ? `https://www.linkedin.com/feed/update/${uri}` : undefined, | |
| 174 | } | |
| 175 | } | |
| 176 | } | |
| @@ -204,6 +204,7 @@ export class LinkedInService { | ||
| 204 | 204 | }, { |
| 205 | 205 | text: post.body, |
| 206 | 206 | ...(content?.external ? { external: content.external } : {}), |
| 207 | ...(content?.media?.length ? { media: content.media } : {}), | |
| 207 | 208 | }) |
| 208 | 209 | |
| 209 | 210 | await database.updateTable('post_targets').set({ |
| @@ -0,0 +1,167 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { LinkedInApiError, LinkedInDriver } from '../../app/Services/Social/Drivers/LinkedInDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | const identity = { handle: 'glenn', did: 'urn:li:person:ABC', accessToken: 'tok' } | |
| 8 | ||
| 9 | interface Captured { url: string, method: string, body: any, headers: any } | |
| 10 | ||
| 11 | /** | |
| 12 | * Mock LinkedIn's versioned REST API. Recognizes the three calls an image post | |
| 13 | * makes — initializeUpload, the binary PUT, and the /rest/posts create — plus a | |
| 14 | * bytes fallback for any other GET (used when the driver fetches a media URL). | |
| 15 | */ | |
| 16 | function mockLinkedInApi(captured: Captured[], overrides: (url: string, method: string) => Response | null = () => null): void { | |
| 17 | globalThis.fetch = (async (input: any, init: any = {}) => { | |
| 18 | const url = String(input) | |
| 19 | const method = init.method || 'GET' | |
| 20 | ||
| 21 | const over = overrides(url, method) | |
| 22 | if (over) { captured.push({ url, method, body: init.body, headers: init.headers }); return over } | |
| 23 | ||
| 24 | if (url.includes('action=initializeUpload')) { | |
| 25 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 26 | return new Response( | |
| 27 | JSON.stringify({ value: { uploadUrl: 'https://upload.linkedin.example/img', image: 'urn:li:image:IMG123' } }), | |
| 28 | { status: 200 }, | |
| 29 | ) | |
| 30 | } | |
| 31 | if (method === 'PUT') { | |
| 32 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 33 | return new Response(null, { status: 201 }) | |
| 34 | } | |
| 35 | if (url.endsWith('/rest/posts')) { | |
| 36 | const body = init.body ? JSON.parse(String(init.body)) : undefined | |
| 37 | captured.push({ url, method, body, headers: init.headers }) | |
| 38 | return new Response('', { status: 201, headers: { 'x-restli-id': 'urn:li:share:987' } }) | |
| 39 | } | |
| 40 | // Fallback: fetching a remote media URL — hand back a few bytes. | |
| 41 | captured.push({ url, method, body: undefined, headers: init.headers }) | |
| 42 | return new Response(new Uint8Array([9, 9, 9]) as any, { status: 200, headers: { 'content-type': 'image/png' } }) | |
| 43 | }) as typeof fetch | |
| 44 | } | |
| 45 | ||
| 46 | function postRequest(captured: Captured[]): Captured | undefined { | |
| 47 | return captured.find(c => c.url.endsWith('/rest/posts')) | |
| 48 | } | |
| 49 | ||
| 50 | describe('LinkedInDriver.publish', () => { | |
| 51 | test('publishes a plain text share with no content block', async () => { | |
| 52 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 53 | const driver = new LinkedInDriver() | |
| 54 | const result = await driver.publish(identity, { text: 'hello linkedin' }) | |
| 55 | ||
| 56 | const post = postRequest(cap) | |
| 57 | expect(post?.body.commentary).toBe('hello linkedin') | |
| 58 | expect(post?.body.content).toBeUndefined() | |
| 59 | expect(cap.find(c => c.url.includes('initializeUpload'))).toBeUndefined() | |
| 60 | expect(result.uri).toBe('urn:li:share:987') | |
| 61 | expect(result.url).toContain('linkedin.com/feed/update/urn:li:share:987') | |
| 62 | }) | |
| 63 | ||
| 64 | test('attaches an article link card when external is provided and no image', async () => { | |
| 65 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 66 | const driver = new LinkedInDriver() | |
| 67 | await driver.publish(identity, { | |
| 68 | text: 'read this', | |
| 69 | external: { uri: 'https://example.com', title: 'Example', description: 'Desc' }, | |
| 70 | }) | |
| 71 | ||
| 72 | const post = postRequest(cap) | |
| 73 | expect(post?.body.content.article.source).toBe('https://example.com') | |
| 74 | expect(post?.body.content.media).toBeUndefined() | |
| 75 | expect(cap.find(c => c.url.includes('initializeUpload'))).toBeUndefined() | |
| 76 | }) | |
| 77 | ||
| 78 | test('uploads image bytes and references the URN under content.media', async () => { | |
| 79 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 80 | const driver = new LinkedInDriver() | |
| 81 | await driver.publish(identity, { | |
| 82 | text: 'photo', | |
| 83 | media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png', altText: 'a chart' }], | |
| 84 | }) | |
| 85 | ||
| 86 | const init = cap.find(c => c.url.includes('initializeUpload')) | |
| 87 | expect(init).toBeDefined() | |
| 88 | expect(JSON.parse(String(init!.body)).initializeUploadRequest.owner).toBe('urn:li:person:ABC') | |
| 89 | ||
| 90 | const put = cap.find(c => c.method === 'PUT') | |
| 91 | expect(put?.url).toBe('https://upload.linkedin.example/img') | |
| 92 | ||
| 93 | const post = postRequest(cap) | |
| 94 | expect(post?.body.content.media.id).toBe('urn:li:image:IMG123') | |
| 95 | expect(post?.body.content.media.altText).toBe('a chart') | |
| 96 | expect(post?.body.content.article).toBeUndefined() | |
| 97 | }) | |
| 98 | ||
| 99 | test('fetches a media URL to bytes when only a url is given', async () => { | |
| 100 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 101 | const driver = new LinkedInDriver() | |
| 102 | await driver.publish(identity, { text: 'via url', media: [{ url: 'https://cdn.example/pic.png' }] }) | |
| 103 | ||
| 104 | expect(cap.find(c => c.url === 'https://cdn.example/pic.png')).toBeDefined() | |
| 105 | expect(cap.find(c => c.url.includes('initializeUpload'))).toBeDefined() | |
| 106 | expect(postRequest(cap)?.body.content.media.id).toBe('urn:li:image:IMG123') | |
| 107 | }) | |
| 108 | ||
| 109 | test('an attached image supersedes an article link card', async () => { | |
| 110 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 111 | const driver = new LinkedInDriver() | |
| 112 | await driver.publish(identity, { | |
| 113 | text: 'both', | |
| 114 | external: { uri: 'https://example.com', title: 'E' }, | |
| 115 | media: [{ bytes: new Uint8Array([1]), mimeType: 'image/jpeg' }], | |
| 116 | }) | |
| 117 | ||
| 118 | const post = postRequest(cap) | |
| 119 | expect(post?.body.content.media.id).toBe('urn:li:image:IMG123') | |
| 120 | expect(post?.body.content.article).toBeUndefined() | |
| 121 | }) | |
| 122 | ||
| 123 | test('rejects text over the character limit before calling the API', async () => { | |
| 124 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 125 | const driver = new LinkedInDriver() | |
| 126 | await expect(driver.publish(identity, { text: 'x'.repeat(3001) })).rejects.toThrow(/3000 characters/) | |
| 127 | expect(cap).toHaveLength(0) | |
| 128 | }) | |
| 129 | ||
| 130 | test('rejects when the identity has no member URN', async () => { | |
| 131 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 132 | const driver = new LinkedInDriver() | |
| 133 | await expect(driver.publish({ handle: 'glenn', accessToken: 'tok' }, { text: 'hi' })).rejects.toThrow(/member URN/) | |
| 134 | expect(cap).toHaveLength(0) | |
| 135 | }) | |
| 136 | ||
| 137 | test('rejects when the identity has no access token', async () => { | |
| 138 | const cap: Captured[] = []; mockLinkedInApi(cap) | |
| 139 | const driver = new LinkedInDriver() | |
| 140 | await expect(driver.publish({ handle: 'glenn', did: 'urn:li:person:ABC' }, { text: 'hi' })).rejects.toThrow(/access token/) | |
| 141 | expect(cap).toHaveLength(0) | |
| 142 | }) | |
| 143 | ||
| 144 | test('surfaces a posts API error as LinkedInApiError with status', async () => { | |
| 145 | const cap: Captured[] = [] | |
| 146 | mockLinkedInApi(cap, url => url.endsWith('/rest/posts') ? new Response('{"message":"nope"}', { status: 401 }) : null) | |
| 147 | const driver = new LinkedInDriver() | |
| 148 | let caught: unknown | |
| 149 | await driver.publish(identity, { text: 'hi' }).catch((e) => { caught = e }) | |
| 150 | expect(caught).toBeInstanceOf(LinkedInApiError) | |
| 151 | expect((caught as LinkedInApiError).status).toBe(401) | |
| 152 | expect((caught as LinkedInApiError).isAuthError).toBe(true) | |
| 153 | }) | |
| 154 | ||
| 155 | test('surfaces an image upload failure as LinkedInApiError', async () => { | |
| 156 | const cap: Captured[] = [] | |
| 157 | mockLinkedInApi(cap, (_url, method) => method === 'PUT' ? new Response('fail', { status: 500 }) : null) | |
| 158 | const driver = new LinkedInDriver() | |
| 159 | let caught: unknown | |
| 160 | await driver.publish(identity, { text: 'photo', media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png' }] }) | |
| 161 | .catch((e) => { caught = e }) | |
| 162 | expect(caught).toBeInstanceOf(LinkedInApiError) | |
| 163 | expect((caught as LinkedInApiError).status).toBe(500) | |
| 164 | // The post itself must never be created when its image failed to upload. | |
| 165 | expect(postRequest(cap)).toBeUndefined() | |
| 166 | }) | |
| 167 | }) | |