ReviewOS

also looking at this

stacks/postline

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.

storage/framework/core/socials/src/drivers/index.tsmodified+2-0
Changes to storage/framework/core/socials/src/drivers/index.ts
@@ -2,4 +2,6 @@ export * from './bluesky'
22export * from './facebook'
33export * from './github'
44export * from './google'
5export * from './instagram'
6export * from './linkedin'
57export * from './twitter'
storage/framework/core/socials/src/drivers/instagram.tsadded+225-0
Changes to storage/framework/core/socials/src/drivers/instagram.ts
@@ -0,0 +1,225 @@
1import type {
2 PublishedPost,
3 PublishPostInput,
4 SocialIdentityCredentials,
5 SocialPublishingDriver,
6 TimelineQuery,
7 TimelineResult,
8} from '../types'
9
10export 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
26export interface InstagramDriverOptions {
27 /** Facebook Graph API version, e.g. `v21.0`. */
28 graphVersion?: string
29 authBase?: string
30 graphBase?: string
31}
32
33export interface InstagramAuthUrlInput {
34 clientId: string
35 redirectUrl: string
36 scopes: string[]
37 state: string
38}
39
40export interface InstagramTokenExchangeInput {
41 clientId: string
42 clientSecret: string
43 redirectUrl: string
44 code: string
45}
46
47export 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 */
63export 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}
storage/framework/core/socials/src/drivers/linkedin.tsadded+234-0
Changes to storage/framework/core/socials/src/drivers/linkedin.ts
@@ -0,0 +1,234 @@
1import type {
2 PublishedPost,
3 PublishPostInput,
4 SocialIdentityCredentials,
5 SocialPublishingDriver,
6 TimelineQuery,
7 TimelineResult,
8} from '../types'
9
10export 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
25export interface LinkedInDriverOptions {
26 /** Monthly REST API version header, e.g. `202405`. */
27 apiVersion?: string
28 authBase?: string
29 apiBase?: string
30}
31
32export interface LinkedInAuthUrlInput {
33 clientId: string
34 redirectUrl: string
35 scopes: string[]
36 state: string
37}
38
39export interface LinkedInTokenExchangeInput {
40 clientId: string
41 clientSecret: string
42 redirectUrl: string
43 code: string
44}
45
46export interface LinkedInTokenResponse {
47 accessToken: string
48 expiresIn?: number
49 scope?: string
50}
51
52export 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 */
67export 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 */
232export function escapeLinkedInText(text: string): string {
233 return text.replace(/[\\|{}@[\]()<>#*_~]/g, '\\$&')
234}
storage/framework/core/socials/src/types.tsmodified+8-0
Changes to storage/framework/core/socials/src/types.ts
@@ -102,6 +102,14 @@ export interface PublishPostInput {
102102 title: string
103103 description?: string
104104 }
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 }>
105113}
106114
107115export interface PublishedPost {