ReviewOS

also looking at this

stacks/postline

feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter

#30
Merged glennmichael123 wants to merge feat/crosspost-media-and-token-refresh into main
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.

app/Services/Social/Drivers/LinkedInDriver.tsmodified+176-1
Changes to app/Services/Social/Drivers/LinkedInDriver.ts
@@ -1,1 +1,176 @@
1export { LinkedInApiError, LinkedInPublishingDriver as LinkedInDriver } from '@stacksjs/socials'
1import type { PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials'
2import { escapeLinkedInText, LinkedInApiError, LinkedInPublishingDriver } from '@stacksjs/socials'
3
4export { LinkedInApiError }
5
6interface 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 */
21export 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}
app/Services/Social/LinkedInService.tsmodified+1-0
Changes to app/Services/Social/LinkedInService.ts
@@ -204,6 +204,7 @@ export class LinkedInService {
204204 }, {
205205 text: post.body,
206206 ...(content?.external ? { external: content.external } : {}),
207 ...(content?.media?.length ? { media: content.media } : {}),
207208 })
208209
209210 await database.updateTable('post_targets').set({
tests/unit/linkedin-driver.test.tsadded+167-0
Changes to tests/unit/linkedin-driver.test.ts
@@ -0,0 +1,167 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { LinkedInApiError, LinkedInDriver } from '../../app/Services/Social/Drivers/LinkedInDriver'
3
4const realFetch = globalThis.fetch
5afterEach(() => { globalThis.fetch = realFetch })
6
7const identity = { handle: 'glenn', did: 'urn:li:person:ABC', accessToken: 'tok' }
8
9interface 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 */
16function 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
46function postRequest(captured: Captured[]): Captured | undefined {
47 return captured.find(c => c.url.endsWith('/rest/posts'))
48}
49
50describe('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})