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
.env.examplemodified+27-0
Changes to .env.example
@@ -103,3 +103,30 @@ FRONTEND_STRIPE_PUBLIC_KEY=
103103# Create an app password at https://bsky.app/settings/app-passwords
104104BLUESKY_IDENTIFIER=
105105BLUESKY_APP_PASSWORD=
106
107# LinkedIn (optional — connect via Accounts, or set a pre-obtained token below)
108# 1. Create an app at https://www.linkedin.com/developers/apps
109# 2. Add the "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn" products
110# 3. Add the redirect URL below to the app's "Authorized redirect URLs"
111LINKEDIN_CLIENT_ID=
112LINKEDIN_CLIENT_SECRET=
113LINKEDIN_REDIRECT_URL=https://postline.localhost/api/postline/linkedin/callback
114# Bump if LinkedIn deprecates this monthly REST API version (YYYYMM)
115LINKEDIN_API_VERSION=202405
116# Env-only shortcut: paste a member access token (w_member_social) to skip the
117# OAuth flow. Optionally set the author URN to avoid an extra profile lookup.
118LINKEDIN_ACCESS_TOKEN=
119LINKEDIN_AUTHOR_URN=
120
121# Instagram (optional — posts REQUIRE an image URL; text-only is rejected)
122# Uses the Facebook Graph API: create a Meta app, add Instagram + Facebook Login,
123# link an Instagram Business/Creator account to a Facebook Page.
124INSTAGRAM_CLIENT_ID=
125INSTAGRAM_CLIENT_SECRET=
126INSTAGRAM_REDIRECT_URL=https://postline.localhost/api/postline/instagram/callback
127# Bump if Meta deprecates this Graph API version
128INSTAGRAM_GRAPH_VERSION=v21.0
129# Env-only shortcut: a Page access token + the IG Business account id skip OAuth.
130INSTAGRAM_ACCESS_TOKEN=
131INSTAGRAM_USER_ID=
132INSTAGRAM_USERNAME=
app/Actions/Postline/CrosspostPublishAction.tsadded+52-0
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -0,0 +1,52 @@
1import type { SocialProvider } from '../../Support/Social/types'
2import type { RequestInstance } from '@stacksjs/types'
3import { Action } from '@stacksjs/actions'
4import { response } from '@stacksjs/router'
5import { crosspost, crosspostProviders } from '../../Services/Social/CrosspostService'
6
7export default new Action({
8 name: 'Postline Crosspost Publish',
9 description: 'Publish a post to one or more connected providers at once.',
10 method: 'POST',
11
12 async handle(request: RequestInstance) {
13 const text = String(request.get('text') || request.get('body') || '').trim()
14
15 const available = new Set<string>(crosspostProviders())
16 const providers = String(request.get('providers') || request.get('provider') || '')
17 .split(',')
18 .map(value => value.trim().toLowerCase())
19 .filter(value => available.has(value)) as SocialProvider[]
20
21 const externalUri = String(request.get('external_uri') || '').trim()
22 const externalTitle = String(request.get('external_title') || '').trim()
23 const externalDescription = String(request.get('external_description') || '').trim()
24 const external = externalUri && externalTitle
25 ? {
26 uri: externalUri,
27 title: externalTitle,
28 description: externalDescription || undefined,
29 }
30 : undefined
31
32 const imageUrl = String(request.get('image_url') || '').trim()
33 const imageAlt = String(request.get('image_alt') || '').trim()
34 const media = imageUrl
35 ? [{ url: imageUrl, altText: imageAlt || undefined }]
36 : undefined
37
38 try {
39 const data = await crosspost.publish(text, providers, { external, media })
40 // Surface a partial-failure (some targets failed) as a 207-style payload
41 // while still returning 200 so the client can report per-provider state.
42 const allFailed = data.results.length > 0 && data.results.every(result => !result.ok)
43 return response.json({ ok: !allFailed, data }, { status: allFailed ? 422 : 200 })
44 }
45 catch (error) {
46 return response.json({
47 ok: false,
48 error: error instanceof Error ? error.message : String(error),
49 }, { status: 422 })
50 }
51 },
52})
app/Actions/Postline/InstagramAuthAction.tsadded+19-0
Changes to app/Actions/Postline/InstagramAuthAction.ts
@@ -0,0 +1,19 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { instagram } from '../../Services/Social/InstagramService'
4
5export default new Action({
6 name: 'Postline Instagram Auth',
7 description: 'Redirect to Facebook for Instagram publishing consent.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.redirect(instagram.getAuthUrl())
13 }
14 catch (error) {
15 const message = error instanceof Error ? error.message : String(error)
16 return response.redirect(`/accounts?instagram=error&message=${encodeURIComponent(message)}`)
17 }
18 },
19})
app/Actions/Postline/InstagramCallbackAction.tsadded+30-0
Changes to app/Actions/Postline/InstagramCallbackAction.ts
@@ -0,0 +1,30 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { instagram } from '../../Services/Social/InstagramService'
5
6export default new Action({
7 name: 'Postline Instagram Callback',
8 description: 'Handle the Facebook OAuth redirect and store the Instagram token.',
9 method: 'GET',
10
11 async handle(request: RequestInstance) {
12 const code = String(request.get('code') || '')
13 const state = String(request.get('state') || '')
14 const oauthError = String(request.get('error') || '')
15
16 if (oauthError) {
17 const description = String(request.get('error_description') || oauthError)
18 return response.redirect(`/accounts?instagram=error&message=${encodeURIComponent(description)}`)
19 }
20
21 try {
22 await instagram.handleCallback(code, state)
23 return response.redirect('/accounts?instagram=connected')
24 }
25 catch (error) {
26 const message = error instanceof Error ? error.message : String(error)
27 return response.redirect(`/accounts?instagram=error&message=${encodeURIComponent(message)}`)
28 }
29 },
30})
app/Actions/Postline/InstagramConnectAction.tsadded+22-0
Changes to app/Actions/Postline/InstagramConnectAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { instagram } from '../../Services/Social/InstagramService'
4
5export default new Action({
6 name: 'Postline Instagram Connect',
7 description: 'Connect Instagram from a pre-obtained access token in the environment.',
8 method: 'POST',
9
10 async handle() {
11 try {
12 const data = await instagram.connectFromEnv()
13 return response.json({ ok: true, data })
14 }
15 catch (error) {
16 return response.json({
17 ok: false,
18 error: error instanceof Error ? error.message : String(error),
19 }, { status: 422 })
20 }
21 },
22})
app/Actions/Postline/InstagramStatusAction.tsadded+21-0
Changes to app/Actions/Postline/InstagramStatusAction.ts
@@ -0,0 +1,21 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { instagram } from '../../Services/Social/InstagramService'
4
5export default new Action({
6 name: 'Postline Instagram Status',
7 description: 'Return the current Instagram connection state.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.json({ ok: true, data: await instagram.status() })
13 }
14 catch (error) {
15 return response.json({
16 ok: false,
17 error: error instanceof Error ? error.message : String(error),
18 }, { status: 500 })
19 }
20 },
21})
app/Actions/Postline/LinkedInAuthAction.tsadded+19-0
Changes to app/Actions/Postline/LinkedInAuthAction.ts
@@ -0,0 +1,19 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { linkedin } from '../../Services/Social/LinkedInService'
4
5export default new Action({
6 name: 'Postline LinkedIn Auth',
7 description: 'Redirect the member to LinkedIn for OAuth consent.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.redirect(linkedin.getAuthUrl())
13 }
14 catch (error) {
15 const message = error instanceof Error ? error.message : String(error)
16 return response.redirect(`/accounts?linkedin=error&message=${encodeURIComponent(message)}`)
17 }
18 },
19})
app/Actions/Postline/LinkedInCallbackAction.tsadded+30-0
Changes to app/Actions/Postline/LinkedInCallbackAction.ts
@@ -0,0 +1,30 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { linkedin } from '../../Services/Social/LinkedInService'
5
6export default new Action({
7 name: 'Postline LinkedIn Callback',
8 description: 'Handle the LinkedIn OAuth redirect and store the access token.',
9 method: 'GET',
10
11 async handle(request: RequestInstance) {
12 const code = String(request.get('code') || '')
13 const state = String(request.get('state') || '')
14 const oauthError = String(request.get('error') || '')
15
16 if (oauthError) {
17 const description = String(request.get('error_description') || oauthError)
18 return response.redirect(`/accounts?linkedin=error&message=${encodeURIComponent(description)}`)
19 }
20
21 try {
22 await linkedin.handleCallback(code, state)
23 return response.redirect('/accounts?linkedin=connected')
24 }
25 catch (error) {
26 const message = error instanceof Error ? error.message : String(error)
27 return response.redirect(`/accounts?linkedin=error&message=${encodeURIComponent(message)}`)
28 }
29 },
30})
app/Actions/Postline/LinkedInConnectAction.tsadded+22-0
Changes to app/Actions/Postline/LinkedInConnectAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { linkedin } from '../../Services/Social/LinkedInService'
4
5export default new Action({
6 name: 'Postline LinkedIn Connect',
7 description: 'Connect LinkedIn from a pre-obtained access token in the environment.',
8 method: 'POST',
9
10 async handle() {
11 try {
12 const data = await linkedin.connectFromEnv()
13 return response.json({ ok: true, data })
14 }
15 catch (error) {
16 return response.json({
17 ok: false,
18 error: error instanceof Error ? error.message : String(error),
19 }, { status: 422 })
20 }
21 },
22})
app/Actions/Postline/LinkedInStatusAction.tsadded+21-0
Changes to app/Actions/Postline/LinkedInStatusAction.ts
@@ -0,0 +1,21 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { linkedin } from '../../Services/Social/LinkedInService'
4
5export default new Action({
6 name: 'Postline LinkedIn Status',
7 description: 'Return the current LinkedIn connection state.',
8 method: 'GET',
9
10 async handle() {
11 try {
12 return response.json({ ok: true, data: await linkedin.status() })
13 }
14 catch (error) {
15 return response.json({
16 ok: false,
17 error: error instanceof Error ? error.message : String(error),
18 }, { status: 500 })
19 }
20 },
21})
app/Actions/Postline/ProvidersStatusAction.tsadded+29-0
Changes to app/Actions/Postline/ProvidersStatusAction.ts
@@ -0,0 +1,29 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { bluesky } from '../../Services/Social/BlueskyService'
4import { instagram } from '../../Services/Social/InstagramService'
5import { linkedin } from '../../Services/Social/LinkedInService'
6
7export default new Action({
8 name: 'Postline Providers Status',
9 description: 'Return the connection state for every crosspost provider.',
10 method: 'GET',
11
12 async handle() {
13 try {
14 const providers = await Promise.all([
15 bluesky.status(),
16 linkedin.status(),
17 instagram.status(),
18 ])
19
20 return response.json({ ok: true, data: { providers } })
21 }
22 catch (error) {
23 return response.json({
24 ok: false,
25 error: error instanceof Error ? error.message : String(error),
26 }, { status: 500 })
27 }
28 },
29})
app/Services/Social/BlueskyService.tsmodified+66-23
Changes to app/Services/Social/BlueskyService.ts
@@ -1,4 +1,4 @@
1import type { BlueskySession, PublishedPost, TimelineResult } from '../../Support/Social/types'
1import type { BlueskySession, CrosspostTargetResult, PublishContent, PublishedPost, TimelineResult } from '../../Support/Social/types'
22import { db } from '@stacksjs/database'
33import { env } from '@stacksjs/env'
44import { BlueskyApiError, BlueskyDriver } from './Drivers/BlueskyDriver'
@@ -108,11 +108,8 @@ export class BlueskyService {
108108 throw new Error(`Bluesky posts must be ${this.driver.characterLimit} characters or fewer.`)
109109 }
110110
111 const identity = await this.requireIdentity()
112 const driver = await this.ensureDriver()
113111 const accountId = await this.ensureAccount()
114112 const postUuid = uuid()
115 const targetUuid = uuid()
116113 const createdAt = now()
117114
118115 await database.insertInto('posts').values({
@@ -132,10 +129,65 @@ export class BlueskyService {
132129 .selectAll()
133130 .where('uuid', '=', postUuid)
134131 .executeTakeFirstOrThrow()
135 const publishInput = { text: body } as Parameters<BlueskyDriver['publish']>[1] & {
132
133 const result = await this.publishToPost({ id: Number(post.id), body }, external ? { external } : undefined)
134 const finishedAt = now()
135
136 if (!result.ok) {
137 await database.updateTable('posts').set({
138 status: 'failed',
139 updated_at: finishedAt,
140 }).where('id', '=', post.id).execute()
141
142 throw new Error(result.error || 'Bluesky publish failed.')
143 }
144
145 await database.updateTable('posts').set({
146 status: 'published',
147 published_at: finishedAt,
148 updated_at: finishedAt,
149 }).where('id', '=', post.id).execute()
150
151 return {
152 post: { provider: 'bluesky', uri: result.uri || '', url: result.url },
153 postId: Number(post.id),
154 targetId: Number(result.targetId),
155 }
156 }
157
158 /**
159 * Publish an already-created post row to Bluesky as a new target. Never
160 * throws — failures are recorded on the target and returned so a crosspost
161 * to other providers can still proceed.
162 */
163 async publishToPost(
164 post: { id: number, body: string },
165 content?: PublishContent,
166 ): Promise<CrosspostTargetResult> {
167 const driver = await this.ensureDriver()
168
169 let identity: SocialIdentityRow
170 try {
171 identity = await this.requireIdentity()
172 }
173 catch (error) {
174 return { provider: 'bluesky', ok: false, error: error instanceof Error ? error.message : String(error) }
175 }
176
177 if (post.body.length > this.driver.characterLimit) {
178 return {
179 provider: 'bluesky',
180 ok: false,
181 error: `Bluesky posts must be ${this.driver.characterLimit} characters or fewer.`,
182 }
183 }
184
185 const targetUuid = uuid()
186 const createdAt = now()
187 const publishInput = { text: post.body } as Parameters<BlueskyDriver['publish']>[1] & {
136188 external?: { uri: string, title: string, description?: string }
137189 }
138 if (external) publishInput.external = external
190 if (content?.external) publishInput.external = content.external
139191
140192 await database.insertInto('post_targets').values({
141193 uuid: targetUuid,
@@ -158,19 +210,13 @@ export class BlueskyService {
158210 const published = await this.withFreshSession(identity, freshIdentity =>
159211 this.driver.publish({
160212 handle: freshIdentity.handle,
161 did: freshIdentity.external_id || undefined,
162 accessToken: freshIdentity.access_token || undefined,
163 refreshToken: freshIdentity.refresh_token || undefined,
213 did: freshIdentity.external_id || undefined,
214 accessToken: freshIdentity.access_token || undefined,
215 refreshToken: freshIdentity.refresh_token || undefined,
164216 }, publishInput),
165217 )
166218
167219 const publishedAt = now()
168 await database.updateTable('posts').set({
169 status: 'published',
170 published_at: publishedAt,
171 updated_at: publishedAt,
172 }).where('id', '=', post.id).execute()
173
174220 await database.updateTable('post_targets').set({
175221 status: 'published',
176222 remote_uri: published.uri,
@@ -180,26 +226,23 @@ export class BlueskyService {
180226 }).where('id', '=', target.id).execute()
181227
182228 return {
183 post: published,
184 postId: Number(post.id),
229 provider: 'bluesky',
230 ok: true,
231 url: published.url,
232 uri: published.uri,
185233 targetId: Number(target.id),
186234 }
187235 }
188236 catch (error) {
189237 const message = error instanceof Error ? error.message : String(error)
190238 const failedAt = now()
191 await database.updateTable('posts').set({
192 status: 'failed',
193 updated_at: failedAt,
194 }).where('id', '=', post.id).execute()
195
196239 await database.updateTable('post_targets').set({
197240 status: 'failed',
198241 failure_reason: message,
199242 updated_at: failedAt,
200243 }).where('id', '=', target.id).execute()
201244
202 throw error
245 return { provider: 'bluesky', ok: false, error: message, targetId: Number(target.id) }
203246 }
204247 }
205248
app/Services/Social/CrosspostService.tsadded+86-0
Changes to app/Services/Social/CrosspostService.ts
@@ -0,0 +1,86 @@
1import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { bluesky } from './BlueskyService'
5import { instagram } from './InstagramService'
6import { linkedin } from './LinkedInService'
7import { ensureAccount, now, uuid } from './support'
8
9const database = db as any
10
11interface ProviderPublisher {
12 publishToPost: (post: { id: number, body: string }, content?: PublishContent) => Promise<CrosspostTargetResult>
13}
14
15// Each provider owns its own connection/token handling behind `publishToPost`.
16const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = {
17 bluesky,
18 linkedin,
19 instagram,
20}
21
22export function crosspostProviders(): SocialProvider[] {
23 return Object.keys(publishers) as SocialProvider[]
24}
25
26export class CrosspostService {
27 /**
28 * Publish one piece of content to several providers at once. A single
29 * `posts` row is created and each provider gets its own `post_targets` row.
30 * Per-provider failures are isolated — one platform erroring never aborts
31 * the others.
32 */
33 async publish(
34 text: string,
35 providers: SocialProvider[],
36 content?: PublishContent,
37 ): Promise<{ postId: number, results: CrosspostTargetResult[] }> {
38 const body = text.trim()
39 if (!body) throw new Error('Post text is required.')
40
41 const selected = providers.filter(provider => publishers[provider])
42 if (selected.length === 0) {
43 throw new Error('Select at least one connected provider to publish.')
44 }
45
46 const accountId = await ensureAccount()
47 const postUuid = uuid()
48 const createdAt = now()
49
50 await database.insertInto('posts').values({
51 uuid: postUuid,
52 title: body.slice(0, 80),
53 body,
54 status: 'publishing',
55 timezone: env.TZ || 'America/Los_Angeles',
56 source: 'composer',
57 account_id: accountId,
58 created_at: createdAt,
59 updated_at: createdAt,
60 }).execute()
61
62 const post = await database
63 .selectFrom('posts')
64 .selectAll()
65 .where('uuid', '=', postUuid)
66 .executeTakeFirstOrThrow()
67
68 const results: CrosspostTargetResult[] = []
69 for (const provider of selected) {
70 const publisher = publishers[provider]!
71 results.push(await publisher.publishToPost({ id: Number(post.id), body }, content))
72 }
73
74 const anyOk = results.some(result => result.ok)
75 const finishedAt = now()
76 await database.updateTable('posts').set({
77 status: anyOk ? 'published' : 'failed',
78 ...(anyOk ? { published_at: finishedAt } : {}),
79 updated_at: finishedAt,
80 }).where('id', '=', post.id).execute()
81
82 return { postId: Number(post.id), results }
83 }
84}
85
86export const crosspost = new CrosspostService()
app/Services/Social/DriverRegistry.tsmodified+5-0
Changes to app/Services/Social/DriverRegistry.ts
@@ -1,8 +1,13 @@
11import type { SocialDriver, SocialProvider } from '../../Support/Social/types'
2import { env } from '@stacksjs/env'
23import { BlueskyDriver } from './Drivers/BlueskyDriver'
4import { InstagramDriver } from './Drivers/InstagramDriver'
5import { LinkedInDriver } from './Drivers/LinkedInDriver'
36
47const drivers: Partial<Record<SocialProvider, SocialDriver>> = {
58 bluesky: new BlueskyDriver(),
9 linkedin: new LinkedInDriver({ apiVersion: String(env.LINKEDIN_API_VERSION || '202405') }),
10 instagram: new InstagramDriver({ graphVersion: String(env.INSTAGRAM_GRAPH_VERSION || 'v21.0') }),
611}
712
813export function getSocialDriver(provider: SocialProvider): SocialDriver {
app/Services/Social/Drivers/InstagramDriver.tsadded+1-0
Changes to app/Services/Social/Drivers/InstagramDriver.ts
@@ -0,0 +1,1 @@
1export { InstagramApiError, InstagramPublishingDriver as InstagramDriver } from '@stacksjs/socials'
app/Services/Social/Drivers/LinkedInDriver.tsadded+1-0
Changes to app/Services/Social/Drivers/LinkedInDriver.ts
@@ -0,0 +1,1 @@
1export { LinkedInApiError, LinkedInPublishingDriver as LinkedInDriver } from '@stacksjs/socials'
app/Services/Social/InstagramService.tsadded+374-0
Changes to app/Services/Social/InstagramService.ts
@@ -0,0 +1,374 @@
1import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { InstagramApiError, InstagramDriver } from './Drivers/InstagramDriver'
5import { ensureAccount, now, uuid } from './support'
6
7const database = db as any
8
9type SocialIdentityRow = {
10 id: number
11 handle: string
12 display_name?: string | null
13 provider: 'instagram'
14 external_id?: string | null
15 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
16 access_token?: string | null
17 refresh_token?: string | null
18 account_id?: number | null
19 social_driver_id?: number | null
20}
21
22type SocialDriverRow = {
23 id: number
24 provider: 'instagram'
25 display_name: string
26 status: 'active' | 'planned' | 'disabled'
27 character_limit: number
28}
29
30interface InstagramConfig {
31 clientId: string
32 clientSecret: string
33 redirectUrl: string
34 graphVersion: string
35 accessToken: string
36 userId: string
37 username: string
38 scopes: string[]
39}
40
41function randomState(): string {
42 const bytes = new Uint8Array(24)
43 crypto.getRandomValues(bytes)
44 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
45}
46
47export class InstagramService {
48 private driver: InstagramDriver
49 private pendingState: string | null = null
50
51 constructor() {
52 this.driver = new InstagramDriver({ graphVersion: this.config().graphVersion })
53 }
54
55 private config(): InstagramConfig {
56 return {
57 clientId: String(env.INSTAGRAM_CLIENT_ID || '').trim(),
58 clientSecret: String(env.INSTAGRAM_CLIENT_SECRET || '').trim(),
59 redirectUrl: String(env.INSTAGRAM_REDIRECT_URL || '').trim(),
60 graphVersion: String(env.INSTAGRAM_GRAPH_VERSION || 'v21.0').trim(),
61 accessToken: String(env.INSTAGRAM_ACCESS_TOKEN || '').trim(),
62 userId: String(env.INSTAGRAM_USER_ID || '').trim(),
63 username: String(env.INSTAGRAM_USERNAME || '').trim(),
64 scopes: ['instagram_basic', 'instagram_content_publish', 'pages_show_list', 'pages_read_engagement'],
65 }
66 }
67
68 async status() {
69 const identity = await this.findIdentity()
70 const cfg = this.config()
71 const connected = identity?.auth_status === 'connected' && Boolean(identity.access_token)
72
73 return {
74 connected,
75 provider: 'instagram',
76 handle: identity?.handle || null,
77 displayName: identity?.display_name || null,
78 did: identity?.external_id || null,
79 authStatus: connected ? 'connected' : (identity?.handle ? identity.auth_status : 'missing'),
80 characterLimit: this.driver.characterLimit,
81 canPublish: connected,
82 requiresMedia: true,
83 configuredFromEnv: Boolean(cfg.accessToken && cfg.userId),
84 oauthConfigured: Boolean(cfg.clientId && cfg.clientSecret && cfg.redirectUrl),
85 }
86 }
87
88 getAuthUrl(): string {
89 const cfg = this.config()
90 if (!cfg.clientId || !cfg.clientSecret) {
91 throw new Error('Set INSTAGRAM_CLIENT_ID and INSTAGRAM_CLIENT_SECRET to connect Instagram.')
92 }
93 if (!cfg.redirectUrl) {
94 throw new Error('Set INSTAGRAM_REDIRECT_URL to connect Instagram.')
95 }
96
97 this.pendingState = randomState()
98 return this.driver.getAuthUrl({
99 clientId: cfg.clientId,
100 redirectUrl: cfg.redirectUrl,
101 scopes: cfg.scopes,
102 state: this.pendingState,
103 })
104 }
105
106 async handleCallback(code: string, state: string) {
107 const cfg = this.config()
108 if (!code) throw new Error('Facebook did not return an authorization code.')
109 if (this.pendingState && state !== this.pendingState) {
110 throw new Error('Instagram OAuth state mismatch. Please start the connection again.')
111 }
112 this.pendingState = null
113
114 const token = await this.driver.exchangeCode({
115 clientId: cfg.clientId,
116 clientSecret: cfg.clientSecret,
117 redirectUrl: cfg.redirectUrl,
118 code,
119 })
120
121 const account = await this.driver.resolveAccount(token.accessToken)
122 const identity = await this.saveSession({
123 accessToken: account.pageAccessToken,
124 igUserId: account.igUserId,
125 username: account.username,
126 })
127 return this.publicIdentity(identity)
128 }
129
130 async connectFromEnv() {
131 const cfg = this.config()
132 if (!cfg.accessToken) {
133 throw new Error('Set INSTAGRAM_ACCESS_TOKEN (or connect via OAuth) before using Instagram.')
134 }
135
136 let igUserId = cfg.userId
137 let username = cfg.username || undefined
138 let pageAccessToken = cfg.accessToken
139 if (!igUserId) {
140 const account = await this.driver.resolveAccount(cfg.accessToken)
141 igUserId = account.igUserId
142 username = account.username
143 pageAccessToken = account.pageAccessToken
144 }
145
146 const identity = await this.saveSession({ accessToken: pageAccessToken, igUserId, username })
147 return this.publicIdentity(identity)
148 }
149
150 /**
151 * Publish an already-created post row to Instagram. Never throws — failures
152 * (including the "image required" case) are recorded on the target and
153 * returned so other crosspost providers still succeed.
154 */
155 async publishToPost(
156 post: { id: number, body: string },
157 content?: PublishContent,
158 ): Promise<CrosspostTargetResult> {
159 const media = content?.media?.[0]
160 if (!media?.url) {
161 return { provider: 'instagram', ok: false, error: 'Instagram requires an image — add an image URL.' }
162 }
163
164 const driver = await this.ensureDriver()
165
166 let identity: SocialIdentityRow
167 try {
168 identity = await this.requireIdentity()
169 }
170 catch (error) {
171 return { provider: 'instagram', ok: false, error: messageOf(error) }
172 }
173
174 if (post.body.length > this.driver.characterLimit) {
175 return {
176 provider: 'instagram',
177 ok: false,
178 error: `Instagram captions must be ${this.driver.characterLimit} characters or fewer.`,
179 }
180 }
181
182 const targetUuid = uuid()
183 const createdAt = now()
184 await database.insertInto('post_targets').values({
185 uuid: targetUuid,
186 provider: 'instagram',
187 status: 'publishing',
188 post_id: post.id,
189 social_driver_id: driver.id,
190 social_identity_id: identity.id,
191 created_at: createdAt,
192 updated_at: createdAt,
193 }).execute()
194
195 const target = await database
196 .selectFrom('post_targets')
197 .selectAll()
198 .where('uuid', '=', targetUuid)
199 .executeTakeFirstOrThrow()
200
201 try {
202 const published = await this.driver.publish({
203 handle: identity.handle,
204 did: identity.external_id || undefined,
205 accessToken: identity.access_token || undefined,
206 }, {
207 text: post.body,
208 media: content?.media,
209 })
210
211 await database.updateTable('post_targets').set({
212 status: 'published',
213 remote_uri: published.uri || null,
214 failure_reason: null,
215 updated_at: now(),
216 }).where('id', '=', target.id).execute()
217
218 return {
219 provider: 'instagram',
220 ok: true,
221 url: published.url,
222 uri: published.uri,
223 targetId: Number(target.id),
224 }
225 }
226 catch (error) {
227 const message = messageOf(error)
228 if (error instanceof InstagramApiError && error.isAuthError) {
229 await this.markExpired(identity.id)
230 }
231 await database.updateTable('post_targets').set({
232 status: 'failed',
233 failure_reason: message,
234 updated_at: now(),
235 }).where('id', '=', target.id).execute()
236
237 return { provider: 'instagram', ok: false, error: message, targetId: Number(target.id) }
238 }
239 }
240
241 private async requireIdentity(): Promise<SocialIdentityRow> {
242 const existing = await this.findIdentity()
243 if (existing?.access_token && existing.auth_status === 'connected') return existing
244
245 if (this.config().accessToken) {
246 await this.connectFromEnv()
247 const connected = await this.findIdentity()
248 if (connected?.access_token) return connected
249 }
250
251 throw new Error('Connect Instagram before publishing.')
252 }
253
254 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
255 return await database
256 .selectFrom('social_identities')
257 .selectAll()
258 .where('provider', '=', 'instagram')
259 .orderBy('updated_at', 'desc')
260 .executeTakeFirst()
261 }
262
263 private async saveSession(input: { accessToken: string, igUserId: string, username?: string }): Promise<SocialIdentityRow> {
264 const accountId = await ensureAccount()
265 const driver = await this.ensureDriver()
266 const existing = await this.findIdentity()
267 const savedAt = now()
268 const handle = (input.username || input.igUserId).trim()
269
270 const values = {
271 handle,
272 display_name: input.username || null,
273 provider: 'instagram',
274 external_id: input.igUserId,
275 auth_status: 'connected',
276 access_token: input.accessToken,
277 refresh_token: null,
278 account_id: accountId,
279 social_driver_id: driver.id,
280 updated_at: savedAt,
281 }
282
283 if (existing) {
284 await database.updateTable('social_identities').set(values).where('id', '=', existing.id).execute()
285 }
286 else {
287 await database.insertInto('social_identities').values({
288 uuid: uuid(),
289 ...values,
290 created_at: savedAt,
291 }).execute()
292 }
293
294 return await this.findIdentity() as SocialIdentityRow
295 }
296
297 private async markExpired(id: number): Promise<void> {
298 await database.updateTable('social_identities').set({
299 auth_status: 'expired',
300 updated_at: now(),
301 }).where('id', '=', id).execute()
302 }
303
304 private async ensureDriver(): Promise<SocialDriverRow> {
305 const existing = await database
306 .selectFrom('social_drivers')
307 .selectAll()
308 .where('provider', '=', 'instagram')
309 .executeTakeFirst()
310
311 if (existing) {
312 if (existing.status !== 'active' || existing.character_limit !== this.driver.characterLimit) {
313 await database.updateTable('social_drivers').set({
314 status: 'active',
315 character_limit: this.driver.characterLimit,
316 updated_at: now(),
317 }).where('id', '=', existing.id).execute()
318 }
319
320 return await database
321 .selectFrom('social_drivers')
322 .selectAll()
323 .where('id', '=', existing.id)
324 .executeTakeFirstOrThrow()
325 }
326
327 const driverUuid = uuid()
328 await database.insertInto('social_drivers').values({
329 uuid: driverUuid,
330 provider: 'instagram',
331 display_name: 'Instagram',
332 status: 'active',
333 character_limit: this.driver.characterLimit,
334 capabilities: JSON.stringify({ posts: true, timelines: false, oauth: true, requiresMedia: true }),
335 created_at: now(),
336 updated_at: now(),
337 }).execute()
338
339 return await database
340 .selectFrom('social_drivers')
341 .selectAll()
342 .where('uuid', '=', driverUuid)
343 .executeTakeFirstOrThrow()
344 }
345
346 private publicIdentity(row: SocialIdentityRow | undefined) {
347 if (!row) {
348 return {
349 connected: false,
350 provider: 'instagram',
351 handle: null,
352 displayName: null,
353 did: null,
354 authStatus: 'missing',
355 }
356 }
357
358 const connected = row.auth_status === 'connected' && Boolean(row.access_token)
359 return {
360 connected,
361 provider: 'instagram',
362 handle: row.handle,
363 displayName: row.display_name || null,
364 did: row.external_id || null,
365 authStatus: connected ? 'connected' : 'missing',
366 }
367 }
368}
369
370function messageOf(error: unknown): string {
371 return error instanceof Error ? error.message : String(error)
372}
373
374export const instagram = new InstagramService()
app/Services/Social/LinkedInService.tsadded+373-0
Changes to app/Services/Social/LinkedInService.ts
@@ -0,0 +1,373 @@
1import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver'
5import { ensureAccount, now, uuid } from './support'
6
7const database = db as any
8
9type SocialIdentityRow = {
10 id: number
11 handle: string
12 display_name?: string | null
13 provider: 'linkedin'
14 external_id?: string | null
15 auth_status: 'connected' | 'expired' | 'revoked' | 'missing'
16 access_token?: string | null
17 refresh_token?: string | null
18 account_id?: number | null
19 social_driver_id?: number | null
20}
21
22type SocialDriverRow = {
23 id: number
24 provider: 'linkedin'
25 display_name: string
26 status: 'active' | 'planned' | 'disabled'
27 character_limit: number
28}
29
30interface LinkedInConfig {
31 clientId: string
32 clientSecret: string
33 redirectUrl: string
34 apiVersion: string
35 accessToken: string
36 authorUrn: string
37 scopes: string[]
38}
39
40function randomState(): string {
41 const bytes = new Uint8Array(24)
42 crypto.getRandomValues(bytes)
43 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
44}
45
46export class LinkedInService {
47 private driver: LinkedInDriver
48 // OAuth CSRF token stashed between the auth redirect and the callback.
49 // Postline runs single-user, so module memory is an adequate store.
50 private pendingState: string | null = null
51
52 constructor() {
53 this.driver = new LinkedInDriver({ apiVersion: this.config().apiVersion })
54 }
55
56 private config(): LinkedInConfig {
57 return {
58 clientId: String(env.LINKEDIN_CLIENT_ID || '').trim(),
59 clientSecret: String(env.LINKEDIN_CLIENT_SECRET || '').trim(),
60 redirectUrl: String(env.LINKEDIN_REDIRECT_URL || '').trim(),
61 apiVersion: String(env.LINKEDIN_API_VERSION || '202405').trim(),
62 accessToken: String(env.LINKEDIN_ACCESS_TOKEN || '').trim(),
63 authorUrn: String(env.LINKEDIN_AUTHOR_URN || '').trim(),
64 scopes: ['openid', 'profile', 'w_member_social'],
65 }
66 }
67
68 async status() {
69 const identity = await this.findIdentity()
70 const cfg = this.config()
71 const connected = identity?.auth_status === 'connected' && Boolean(identity.access_token)
72
73 return {
74 connected,
75 provider: 'linkedin',
76 handle: identity?.handle || null,
77 displayName: identity?.display_name || null,
78 did: identity?.external_id || null,
79 authStatus: connected ? 'connected' : (identity?.handle ? identity.auth_status : 'missing'),
80 characterLimit: this.driver.characterLimit,
81 canPublish: connected,
82 configuredFromEnv: Boolean(cfg.accessToken),
83 oauthConfigured: Boolean(cfg.clientId && cfg.clientSecret && cfg.redirectUrl),
84 }
85 }
86
87 /** Build the LinkedIn consent URL and remember the CSRF state. */
88 getAuthUrl(): string {
89 const cfg = this.config()
90 if (!cfg.clientId || !cfg.clientSecret) {
91 throw new Error('Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET to connect with LinkedIn.')
92 }
93 if (!cfg.redirectUrl) {
94 throw new Error('Set LINKEDIN_REDIRECT_URL to connect with LinkedIn.')
95 }
96
97 this.pendingState = randomState()
98 return this.driver.getAuthUrl({
99 clientId: cfg.clientId,
100 redirectUrl: cfg.redirectUrl,
101 scopes: cfg.scopes,
102 state: this.pendingState,
103 })
104 }
105
106 /** Handle the OAuth redirect: exchange the code and store the token. */
107 async handleCallback(code: string, state: string) {
108 const cfg = this.config()
109 if (!code) throw new Error('LinkedIn did not return an authorization code.')
110 if (this.pendingState && state !== this.pendingState) {
111 throw new Error('LinkedIn OAuth state mismatch. Please start the connection again.')
112 }
113 this.pendingState = null
114
115 const token = await this.driver.exchangeCode({
116 clientId: cfg.clientId,
117 clientSecret: cfg.clientSecret,
118 redirectUrl: cfg.redirectUrl,
119 code,
120 })
121
122 const profile = await this.driver.getProfile(token.accessToken).catch(() => undefined)
123 const authorUrn = profile?.sub ? `urn:li:person:${profile.sub}` : cfg.authorUrn
124 if (!authorUrn) throw new Error('Could not resolve your LinkedIn member URN.')
125
126 const identity = await this.saveSession({
127 accessToken: token.accessToken,
128 authorUrn,
129 name: profile?.name,
130 })
131 return this.publicIdentity(identity)
132 }
133
134 /** Connect using a pre-obtained token from the environment. */
135 async connectFromEnv() {
136 const cfg = this.config()
137 if (!cfg.accessToken) {
138 throw new Error('Set LINKEDIN_ACCESS_TOKEN (or connect via OAuth) before using LinkedIn.')
139 }
140
141 let authorUrn = cfg.authorUrn
142 let name: string | undefined
143 if (!authorUrn) {
144 const profile = await this.driver.getProfile(cfg.accessToken)
145 authorUrn = `urn:li:person:${profile.sub}`
146 name = profile.name
147 }
148
149 const identity = await this.saveSession({ accessToken: cfg.accessToken, authorUrn, name })
150 return this.publicIdentity(identity)
151 }
152
153 /**
154 * Publish an already-created post row to LinkedIn as a new target. Never
155 * throws — failures are recorded on the target and returned so a crosspost
156 * to other providers can still succeed.
157 */
158 async publishToPost(
159 post: { id: number, body: string },
160 content?: PublishContent,
161 ): Promise<CrosspostTargetResult> {
162 const driver = await this.ensureDriver()
163
164 let identity: SocialIdentityRow
165 try {
166 identity = await this.requireIdentity()
167 }
168 catch (error) {
169 return { provider: 'linkedin', ok: false, error: messageOf(error) }
170 }
171
172 if (post.body.length > this.driver.characterLimit) {
173 return {
174 provider: 'linkedin',
175 ok: false,
176 error: `LinkedIn posts must be ${this.driver.characterLimit} characters or fewer.`,
177 }
178 }
179
180 const targetUuid = uuid()
181 const createdAt = now()
182 await database.insertInto('post_targets').values({
183 uuid: targetUuid,
184 provider: 'linkedin',
185 status: 'publishing',
186 post_id: post.id,
187 social_driver_id: driver.id,
188 social_identity_id: identity.id,
189 created_at: createdAt,
190 updated_at: createdAt,
191 }).execute()
192
193 const target = await database
194 .selectFrom('post_targets')
195 .selectAll()
196 .where('uuid', '=', targetUuid)
197 .executeTakeFirstOrThrow()
198
199 try {
200 const published = await this.driver.publish({
201 handle: identity.handle,
202 did: identity.external_id || undefined,
203 accessToken: identity.access_token || undefined,
204 }, {
205 text: post.body,
206 ...(content?.external ? { external: content.external } : {}),
207 })
208
209 await database.updateTable('post_targets').set({
210 status: 'published',
211 remote_uri: published.uri || null,
212 failure_reason: null,
213 updated_at: now(),
214 }).where('id', '=', target.id).execute()
215
216 return {
217 provider: 'linkedin',
218 ok: true,
219 url: published.url,
220 uri: published.uri,
221 targetId: Number(target.id),
222 }
223 }
224 catch (error) {
225 const message = messageOf(error)
226 if (error instanceof LinkedInApiError && error.isAuthError) {
227 await this.markExpired(identity.id)
228 }
229 await database.updateTable('post_targets').set({
230 status: 'failed',
231 failure_reason: message,
232 updated_at: now(),
233 }).where('id', '=', target.id).execute()
234
235 return { provider: 'linkedin', ok: false, error: message, targetId: Number(target.id) }
236 }
237 }
238
239 private async requireIdentity(): Promise<SocialIdentityRow> {
240 const existing = await this.findIdentity()
241 if (existing?.access_token && existing.auth_status === 'connected') return existing
242
243 // Fall back to an env-configured token if one is available.
244 if (this.config().accessToken) {
245 await this.connectFromEnv()
246 const connected = await this.findIdentity()
247 if (connected?.access_token) return connected
248 }
249
250 throw new Error('Connect LinkedIn before publishing.')
251 }
252
253 private async findIdentity(): Promise<SocialIdentityRow | undefined> {
254 return await database
255 .selectFrom('social_identities')
256 .selectAll()
257 .where('provider', '=', 'linkedin')
258 .orderBy('updated_at', 'desc')
259 .executeTakeFirst()
260 }
261
262 private async saveSession(input: { accessToken: string, authorUrn: string, name?: string }): Promise<SocialIdentityRow> {
263 const accountId = await ensureAccount()
264 const driver = await this.ensureDriver()
265 const existing = await this.findIdentity()
266 const savedAt = now()
267 const handle = (input.name || input.authorUrn.replace('urn:li:person:', '')).trim()
268
269 const values = {
270 handle,
271 display_name: input.name || null,
272 provider: 'linkedin',
273 external_id: input.authorUrn,
274 auth_status: 'connected',
275 access_token: input.accessToken,
276 refresh_token: null,
277 account_id: accountId,
278 social_driver_id: driver.id,
279 updated_at: savedAt,
280 }
281
282 if (existing) {
283 await database.updateTable('social_identities').set(values).where('id', '=', existing.id).execute()
284 }
285 else {
286 await database.insertInto('social_identities').values({
287 uuid: uuid(),
288 ...values,
289 created_at: savedAt,
290 }).execute()
291 }
292
293 return await this.findIdentity() as SocialIdentityRow
294 }
295
296 private async markExpired(id: number): Promise<void> {
297 await database.updateTable('social_identities').set({
298 auth_status: 'expired',
299 updated_at: now(),
300 }).where('id', '=', id).execute()
301 }
302
303 private async ensureDriver(): Promise<SocialDriverRow> {
304 const existing = await database
305 .selectFrom('social_drivers')
306 .selectAll()
307 .where('provider', '=', 'linkedin')
308 .executeTakeFirst()
309
310 if (existing) {
311 if (existing.status !== 'active' || existing.character_limit !== this.driver.characterLimit) {
312 await database.updateTable('social_drivers').set({
313 status: 'active',
314 character_limit: this.driver.characterLimit,
315 updated_at: now(),
316 }).where('id', '=', existing.id).execute()
317 }
318
319 return await database
320 .selectFrom('social_drivers')
321 .selectAll()
322 .where('id', '=', existing.id)
323 .executeTakeFirstOrThrow()
324 }
325
326 const driverUuid = uuid()
327 await database.insertInto('social_drivers').values({
328 uuid: driverUuid,
329 provider: 'linkedin',
330 display_name: 'LinkedIn',
331 status: 'active',
332 character_limit: this.driver.characterLimit,
333 capabilities: JSON.stringify({ posts: true, timelines: false, oauth: true }),
334 created_at: now(),
335 updated_at: now(),
336 }).execute()
337
338 return await database
339 .selectFrom('social_drivers')
340 .selectAll()
341 .where('uuid', '=', driverUuid)
342 .executeTakeFirstOrThrow()
343 }
344
345 private publicIdentity(row: SocialIdentityRow | undefined) {
346 if (!row) {
347 return {
348 connected: false,
349 provider: 'linkedin',
350 handle: null,
351 displayName: null,
352 did: null,
353 authStatus: 'missing',
354 }
355 }
356
357 const connected = row.auth_status === 'connected' && Boolean(row.access_token)
358 return {
359 connected,
360 provider: 'linkedin',
361 handle: row.handle,
362 displayName: row.display_name || null,
363 did: row.external_id || null,
364 authStatus: connected ? 'connected' : 'missing',
365 }
366 }
367}
368
369function messageOf(error: unknown): string {
370 return error instanceof Error ? error.message : String(error)
371}
372
373export const linkedin = new LinkedInService()
app/Services/Social/support.tsadded+46-0
Changes to app/Services/Social/support.ts
@@ -0,0 +1,46 @@
1import { db } from '@stacksjs/database'
2
3const database = db as any
4
5/** SQLite-friendly `YYYY-MM-DD HH:MM:SS` timestamp. */
6export function now(): string {
7 return new Date().toISOString().slice(0, 19).replace('T', ' ')
8}
9
10export function uuid(): string {
11 return crypto.randomUUID()
12}
13
14/**
15 * Postline is currently single-account. Return the existing account id, or
16 * create the default workspace on first use. Shared by every social service
17 * so a crosspost groups all of its targets under one account/post.
18 */
19export async function ensureAccount(): Promise<number> {
20 const existing = await database
21 .selectFrom('accounts')
22 .select(['id'])
23 .orderBy('id', 'asc')
24 .executeTakeFirst()
25
26 if (existing?.id) return Number(existing.id)
27
28 const accountUuid = uuid()
29 await database.insertInto('accounts').values({
30 uuid: accountUuid,
31 name: 'Chris Breuer',
32 workspace_name: 'Postline',
33 timezone: 'America/Los_Angeles',
34 default_audience: 'public',
35 created_at: now(),
36 updated_at: now(),
37 }).execute()
38
39 const account = await database
40 .selectFrom('accounts')
41 .select(['id'])
42 .where('uuid', '=', accountUuid)
43 .executeTakeFirstOrThrow()
44
45 return Number(account.id)
46}
app/Support/Social/types.tsmodified+23-0
Changes to app/Support/Social/types.ts
@@ -48,6 +48,29 @@ export interface PublishedPost {
4848 url?: string
4949}
5050
51/** Optional content attached to a post: a link card and/or media. */
52export interface PublishContent {
53 external?: {
54 uri: string
55 title: string
56 description?: string
57 }
58 media?: Array<{
59 url: string
60 altText?: string
61 }>
62}
63
64/** Outcome of publishing one post to a single provider during a crosspost. */
65export interface CrosspostTargetResult {
66 provider: SocialProvider
67 ok: boolean
68 url?: string
69 uri?: string
70 targetId?: number
71 error?: string
72}
73
5174export interface TimelineQuery {
5275 cursor?: string
5376 limit?: number
resources/assets/scripts/postline-instagram.tsadded+43-0
Changes to resources/assets/scripts/postline-instagram.ts
@@ -0,0 +1,43 @@
1export interface InstagramStatusPayload {
2 connected: boolean
3 provider: string
4 handle: string | null
5 displayName: string | null
6 canPublish: boolean
7 characterLimit: number
8 requiresMedia: boolean
9 configuredFromEnv: boolean
10 oauthConfigured: boolean
11 authStatus: string
12}
13
14export async function fetchInstagramStatus(): Promise<InstagramStatusPayload> {
15 const response = await fetch('/api/postline/instagram/status')
16 const payload = await response.json()
17 if (!response.ok || !payload.ok) {
18 throw new Error(payload.error || 'Could not load Instagram status.')
19 }
20 return payload.data as InstagramStatusPayload
21}
22
23export function formatInstagramConnectionLabel(status: InstagramStatusPayload): string {
24 if (status.canPublish && status.handle) return `@${status.handle}`
25 if (status.handle) return `@${status.handle} — reconnect`
26 if (status.configuredFromEnv) return 'Configured in .env'
27 if (status.oauthConfigured) return 'Connect with Instagram'
28 return 'Not configured'
29}
30
31declare global {
32 interface Window {
33 postlineInstagram?: {
34 fetchStatus: typeof fetchInstagramStatus
35 formatConnectionLabel: typeof formatInstagramConnectionLabel
36 }
37 }
38}
39
40window.postlineInstagram = {
41 fetchStatus: fetchInstagramStatus,
42 formatConnectionLabel: formatInstagramConnectionLabel,
43}
resources/assets/scripts/postline-linkedin.tsadded+43-0
Changes to resources/assets/scripts/postline-linkedin.ts
@@ -0,0 +1,43 @@
1export interface LinkedInStatusPayload {
2 connected: boolean
3 provider: string
4 handle: string | null
5 displayName: string | null
6 canPublish: boolean
7 characterLimit: number
8 configuredFromEnv: boolean
9 oauthConfigured: boolean
10 authStatus: string
11}
12
13export async function fetchLinkedInStatus(): Promise<LinkedInStatusPayload> {
14 const response = await fetch('/api/postline/linkedin/status')
15 const payload = await response.json()
16 if (!response.ok || !payload.ok) {
17 throw new Error(payload.error || 'Could not load LinkedIn status.')
18 }
19 return payload.data as LinkedInStatusPayload
20}
21
22export function formatLinkedInConnectionLabel(status: LinkedInStatusPayload): string {
23 if (status.canPublish && status.displayName) return status.displayName
24 if (status.canPublish && status.handle) return status.handle
25 if (status.handle) return `${status.handle} — reconnect`
26 if (status.configuredFromEnv) return 'Configured in .env'
27 if (status.oauthConfigured) return 'Connect with LinkedIn'
28 return 'Not configured'
29}
30
31declare global {
32 interface Window {
33 postlineLinkedin?: {
34 fetchStatus: typeof fetchLinkedInStatus
35 formatConnectionLabel: typeof formatLinkedInConnectionLabel
36 }
37 }
38}
39
40window.postlineLinkedin = {
41 fetchStatus: fetchLinkedInStatus,
42 formatConnectionLabel: formatLinkedInConnectionLabel,
43}
resources/assets/scripts/postline-social.tsadded+88-0
Changes to resources/assets/scripts/postline-social.ts
@@ -0,0 +1,88 @@
1export interface ProviderStatus {
2 provider: string
3 connected: boolean
4 canPublish: boolean
5 handle: string | null
6 displayName?: string | null
7 characterLimit: number
8 authStatus: string
9 configuredFromEnv?: boolean
10 oauthConfigured?: boolean
11}
12
13export interface CrosspostResultItem {
14 provider: string
15 ok: boolean
16 url?: string
17 uri?: string
18 error?: string
19 targetId?: number
20}
21
22export interface CrosspostInput {
23 text: string
24 providers: string[]
25 external?: { uri: string, title: string, description?: string }
26 image?: { url: string, altText?: string }
27}
28
29const PROVIDER_LABELS: Record<string, string> = {
30 bluesky: 'Bluesky',
31 linkedin: 'LinkedIn',
32 twitter: 'Twitter/X',
33 mastodon: 'Mastodon',
34 facebook: 'Facebook',
35 instagram: 'Instagram',
36 tiktok: 'TikTok',
37}
38
39export function providerLabel(provider: string): string {
40 return PROVIDER_LABELS[provider] || provider.charAt(0).toUpperCase() + provider.slice(1)
41}
42
43export async function fetchProviders(): Promise<ProviderStatus[]> {
44 const response = await fetch('/api/postline/providers')
45 const payload = await response.json()
46 if (!response.ok || !payload.ok) {
47 throw new Error(payload.error || 'Could not load connected accounts.')
48 }
49 return (payload.data?.providers || []) as ProviderStatus[]
50}
51
52export async function publishCrosspost(input: CrosspostInput): Promise<{ postId: number, results: CrosspostResultItem[] }> {
53 const body = new FormData()
54 body.set('text', input.text)
55 body.set('providers', input.providers.join(','))
56 if (input.external?.uri && input.external?.title) {
57 body.set('external_uri', input.external.uri)
58 body.set('external_title', input.external.title)
59 body.set('external_description', input.external.description || '')
60 }
61 if (input.image?.url) {
62 body.set('image_url', input.image.url)
63 if (input.image.altText) body.set('image_alt', input.image.altText)
64 }
65
66 const response = await fetch('/api/postline/publish', { method: 'POST', body })
67 const payload = await response.json()
68 if (!payload?.data) {
69 throw new Error(payload?.error || 'Publish failed.')
70 }
71 return payload.data as { postId: number, results: CrosspostResultItem[] }
72}
73
74declare global {
75 interface Window {
76 postlineSocial?: {
77 fetchProviders: typeof fetchProviders
78 publishCrosspost: typeof publishCrosspost
79 providerLabel: typeof providerLabel
80 }
81 }
82}
83
84window.postlineSocial = {
85 fetchProviders,
86 publishCrosspost,
87 providerLabel,
88}
resources/views/accounts.stxmodified+222-4
Changes to resources/views/accounts.stx
@@ -9,7 +9,7 @@
99 <div>
1010 <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Accounts</p>
1111 <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing drivers</h1>
12 <p class="mt-1.5 mb-0 text-[13px] text-zinc-600">Connect Bluesky with an app password to publish from Postline.</p>
12 <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Connect Bluesky and LinkedIn to publish and crosspost from Postline.</p>
1313 </div>
1414 </header>
1515
@@ -40,15 +40,39 @@
4040 <p class="my-1.5 text-[13px] text-slate-700" data-bluesky-channel-handle>Not connected</p>
4141 <span class="text-xs text-zinc-500" data-bluesky-channel-detail>Add an app password to publish and sync your timeline.</span>
4242 </div>
43 <span class="whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold border-zinc-200 text-zinc-500" data-bluesky-channel-status>Setup</span>
43 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-bluesky-channel-status>Setup</span>
44 </article>
45 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="linkedin">
46 <div>
47 <h2 class="my-0 font-bold text-[15px]">LinkedIn</h2>
48 <p class="my-1.5 text-[13px] text-slate-700" data-linkedin-channel-handle>Not connected</p>
49 <span class="text-xs text-zinc-500" data-linkedin-channel-detail>Connect with LinkedIn to crosspost member shares.</span>
50 </div>
51 <div class="flex flex-col gap-2 items-end">
52 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-linkedin-channel-status>Setup</span>
53 <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-[#0a66c2] rounded-xl disabled:opacity-50" type="button" data-linkedin-connect>Connect with LinkedIn</button>
54 <button class="hidden text-xs text-zinc-500 underline" type="button" data-linkedin-connect-env>Use token from .env</button>
55 </div>
56 </article>
57 <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="instagram">
58 <div>
59 <h2 class="my-0 font-bold text-[15px]">Instagram</h2>
60 <p class="my-1.5 text-[13px] text-slate-700" data-instagram-channel-handle>Not connected</p>
61 <span class="text-xs text-zinc-500" data-instagram-channel-detail>Connect an Instagram Business account to crosspost. Posts require an image.</span>
62 </div>
63 <div class="flex flex-col gap-2 items-end">
64 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-instagram-channel-status>Setup</span>
65 <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-gradient-to-tr from-amber-500 to-purple-600 via-pink-600 rounded-xl disabled:opacity-50" type="button" data-instagram-connect>Connect with Instagram</button>
66 <button class="hidden text-xs text-zinc-500 underline" type="button" data-instagram-connect-env>Use token from .env</button>
67 </div>
4468 </article>
4569 <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80">
4670 <div>
4771 <h2 class="my-0 font-bold text-[15px] text-zinc-700">Twitter/X</h2>
4872 <p class="my-1.5 text-[13px] text-zinc-600">Planned</p>
49 <span class="text-xs text-zinc-500">Driver adapter coming after Bluesky is stable.</span>
73 <span class="text-xs text-zinc-500">Driver adapter coming after Bluesky, LinkedIn, and Instagram are stable.</span>
5074 </div>
51 <span class="whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold border-zinc-200 text-zinc-500">Planned</span>
75 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full">Planned</span>
5276 </article>
5377 </div>
5478 </section>
@@ -136,6 +160,200 @@
136160 }
137161 })
138162
163 // --- LinkedIn ---
164 const linkedinHandle = root.querySelector('[data-linkedin-channel-handle]')
165 const linkedinDetail = root.querySelector('[data-linkedin-channel-detail]')
166 const linkedinStatusPill = root.querySelector('[data-linkedin-channel-status]')
167 const linkedinConnect = root.querySelector('[data-linkedin-connect]')
168 const linkedinConnectEnv = root.querySelector('[data-linkedin-connect-env]')
169
170 const renderLinkedIn = (data) => {
171 const connected = Boolean(data?.canPublish)
172 const label = data?.displayName || data?.handle || 'Not connected'
173 if (linkedinHandle) {
174 linkedinHandle.textContent = connected ? label : (data?.handle ? `${label} — reconnect` : 'Not connected')
175 }
176 if (linkedinDetail) {
177 linkedinDetail.textContent = connected
178 ? 'Ready to crosspost member shares from Postline.'
179 : data?.oauthConfigured
180 ? 'Connect with LinkedIn to crosspost member shares.'
181 : data?.configuredFromEnv
182 ? 'Token found in .env — connect to start crossposting.'
183 : 'Add LinkedIn API keys to .env, then connect.'
184 }
185 if (linkedinStatusPill) {
186 linkedinStatusPill.textContent = connected ? 'Connected' : (data?.handle ? 'Reconnect' : 'Setup')
187 linkedinStatusPill.className = `whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold ${
188 connected
189 ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
190 : data?.handle
191 ? 'border-amber-200 bg-amber-50 text-amber-800'
192 : 'border-zinc-200 text-zinc-500'
193 }`
194 }
195 if (linkedinConnect) {
196 linkedinConnect.textContent = connected ? 'Reconnect LinkedIn' : 'Connect with LinkedIn'
197 if (data && !data.oauthConfigured) linkedinConnect.setAttribute('disabled', 'disabled')
198 else linkedinConnect.removeAttribute('disabled')
199 }
200 if (linkedinConnectEnv) linkedinConnectEnv.classList.toggle('hidden', !data?.configuredFromEnv)
201 }
202
203 const refreshLinkedIn = async () => {
204 try {
205 const data = await window.postlineLinkedin?.fetchStatus()
206 renderLinkedIn(data)
207 }
208 catch (error) {
209 renderLinkedIn(null)
210 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'LinkedIn' })
211 }
212 }
213
214 linkedinConnect?.addEventListener('click', () => {
215 notify('Redirecting to LinkedIn…', 'info', { title: 'LinkedIn' })
216 window.location.href = '/api/postline/linkedin/auth'
217 })
218
219 linkedinConnectEnv?.addEventListener('click', async () => {
220 linkedinConnectEnv.setAttribute('disabled', 'disabled')
221 notify('Connecting LinkedIn from .env…', 'info', { title: 'LinkedIn' })
222 try {
223 const response = await fetch('/api/postline/linkedin/connect', { method: 'POST' })
224 const payload = await response.json()
225 if (!response.ok || !payload.ok) throw new Error(payload.error || 'LinkedIn connect failed.')
226 await refreshLinkedIn()
227 notify('LinkedIn connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
228 }
229 catch (error) {
230 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Connect failed' })
231 }
232 finally {
233 linkedinConnectEnv.removeAttribute('disabled')
234 }
235 })
236
237 // Surface the OAuth callback result, then strip it from the URL.
238 const params = new URLSearchParams(window.location.search)
239 const linkedinResult = params.get('linkedin')
240 if (linkedinResult === 'connected') {
241 notify('LinkedIn connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
242 }
243 else if (linkedinResult === 'error') {
244 notify(params.get('message') || 'LinkedIn connection failed.', 'error', { title: 'LinkedIn' })
245 }
246 if (linkedinResult) {
247 params.delete('linkedin')
248 params.delete('message')
249 const query = params.toString()
250 window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`)
251 }
252
253 const startLinkedIn = () => void refreshLinkedIn()
254 if (document.readyState === 'loading') {
255 document.addEventListener('DOMContentLoaded', startLinkedIn, { once: true })
256 }
257 else {
258 startLinkedIn()
259 }
260
261 // --- Instagram ---
262 const instagramHandle = root.querySelector('[data-instagram-channel-handle]')
263 const instagramDetail = root.querySelector('[data-instagram-channel-detail]')
264 const instagramStatusPill = root.querySelector('[data-instagram-channel-status]')
265 const instagramConnect = root.querySelector('[data-instagram-connect]')
266 const instagramConnectEnv = root.querySelector('[data-instagram-connect-env]')
267
268 const renderInstagram = (data) => {
269 const connected = Boolean(data?.canPublish)
270 const label = data?.handle ? `@${data.handle}` : 'Not connected'
271 if (instagramHandle) {
272 instagramHandle.textContent = connected ? label : (data?.handle ? `${label} — reconnect` : 'Not connected')
273 }
274 if (instagramDetail) {
275 instagramDetail.textContent = connected
276 ? 'Ready to crosspost. Remember Instagram posts need an image.'
277 : data?.oauthConfigured
278 ? 'Connect an Instagram Business account to crosspost. Posts require an image.'
279 : data?.configuredFromEnv
280 ? 'Token found in .env — connect to start crossposting.'
281 : 'Add Instagram Graph API keys to .env, then connect.'
282 }
283 if (instagramStatusPill) {
284 instagramStatusPill.textContent = connected ? 'Connected' : (data?.handle ? 'Reconnect' : 'Setup')
285 instagramStatusPill.className = `whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold ${
286 connected
287 ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
288 : data?.handle
289 ? 'border-amber-200 bg-amber-50 text-amber-800'
290 : 'border-zinc-200 text-zinc-500'
291 }`
292 }
293 if (instagramConnect) {
294 instagramConnect.textContent = connected ? 'Reconnect Instagram' : 'Connect with Instagram'
295 if (data && !data.oauthConfigured) instagramConnect.setAttribute('disabled', 'disabled')
296 else instagramConnect.removeAttribute('disabled')
297 }
298 if (instagramConnectEnv) instagramConnectEnv.classList.toggle('hidden', !data?.configuredFromEnv)
299 }
300
301 const refreshInstagram = async () => {
302 try {
303 const data = await window.postlineInstagram?.fetchStatus()
304 renderInstagram(data)
305 }
306 catch (error) {
307 renderInstagram(null)
308 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Instagram' })
309 }
310 }
311
312 instagramConnect?.addEventListener('click', () => {
313 notify('Redirecting to Instagram…', 'info', { title: 'Instagram' })
314 window.location.href = '/api/postline/instagram/auth'
315 })
316
317 instagramConnectEnv?.addEventListener('click', async () => {
318 instagramConnectEnv.setAttribute('disabled', 'disabled')
319 notify('Connecting Instagram from .env…', 'info', { title: 'Instagram' })
320 try {
321 const response = await fetch('/api/postline/instagram/connect', { method: 'POST' })
322 const payload = await response.json()
323 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Instagram connect failed.')
324 await refreshInstagram()
325 notify('Instagram connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
326 }
327 catch (error) {
328 notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Connect failed' })
329 }
330 finally {
331 instagramConnectEnv.removeAttribute('disabled')
332 }
333 })
334
335 const instagramResult = params.get('instagram')
336 if (instagramResult === 'connected') {
337 notify('Instagram connected. You can crosspost from the composer.', 'success', { title: 'Connected' })
338 }
339 else if (instagramResult === 'error') {
340 notify(params.get('message') || 'Instagram connection failed.', 'error', { title: 'Instagram' })
341 }
342 if (instagramResult) {
343 params.delete('instagram')
344 params.delete('message')
345 const query = params.toString()
346 window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`)
347 }
348
349 const startInstagram = () => void refreshInstagram()
350 if (document.readyState === 'loading') {
351 document.addEventListener('DOMContentLoaded', startInstagram, { once: true })
352 }
353 else {
354 startInstagram()
355 }
356
139357 void refreshStatus()
140358 })()
141359 </script>
resources/views/index.stxmodified+181-60
Changes to resources/views/index.stx
@@ -10,9 +10,9 @@
1010 <header class="flex flex-wrap gap-3 items-center justify-between p-3 border-b border-zinc-200">
1111 <div class="flex gap-2 items-center">
1212 <strong class="text-[13px]">New post</strong>
13 <a class="inline-flex gap-1.5 items-center px-2.5 min-h-7 text-xs font-bold border rounded-lg transition-colors border-zinc-200 bg-zinc-50 text-zinc-600 hover:bg-white" href="/accounts" data-stx-link data-bluesky-connection data-no-window-drag>
14 <span class="h-2 w-2 rounded-full bg-zinc-300" data-bluesky-connection-dot aria-hidden="true"></span>
15 <span data-bluesky-connection-label>Checking Bluesky…</span>
13 <a class="inline-flex gap-1.5 items-center px-2.5 min-h-7 font-bold text-xs text-zinc-600 bg-zinc-50 hover:bg-white border border-zinc-200 rounded-lg transition-colors" href="/accounts" data-stx-link data-bluesky-connection data-no-window-drag>
14 <span class="h-2 w-2 bg-zinc-300 rounded-full" data-bluesky-connection-dot aria-hidden="true"></span>
15 <span data-bluesky-connection-label>Checking accounts…</span>
1616 </a>
1717 </div>
1818 <div class="grid grid-cols-3 p-0.5 bg-zinc-100 border border-zinc-200 rounded-xl" role="group" aria-label="Post mode">
@@ -23,11 +23,13 @@
2323 </header>
2424
2525 <div class="grid p-3">
26 <div class="flex flex-wrap gap-2 mb-2.5">
27 <div class="flex items-center px-2.5 min-h-8 text-xs font-bold text-zinc-700 bg-zinc-50 border border-zinc-200 rounded-xl">Bluesky</div>
28 <div class="flex items-center px-2.5 min-h-8 text-xs text-zinc-500 bg-zinc-50 border border-zinc-200 rounded-xl">Publish now</div>
29 <div class="flex items-center px-2.5 min-h-8 text-xs text-zinc-500 bg-zinc-50 border border-zinc-200 rounded-xl">Public</div>
26 <div class="flex flex-wrap gap-2 mb-2.5" data-provider-toggles role="group" aria-label="Crosspost targets">
27 <span class="flex items-center px-2.5 min-h-8 text-xs text-zinc-400 bg-zinc-50 border border-zinc-200 rounded-xl" data-provider-empty>Loading accounts…</span>
3028 </div>
29 <label class="hidden gap-2 items-center mb-2.5" data-compose-image-row>
30 <span class="h-4 w-4 text-zinc-500 i-hugeicons-image-01" aria-hidden="true"></span>
31 <input class="px-3 h-9 w-full text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" type="url" inputmode="url" placeholder="Image URL (required for Instagram)" aria-label="Image URL for Instagram" data-compose-image>
32 </label>
3133 <div class="grid overflow-y-auto content-start gap-4 pr-1 max-h-72" data-composer-posts>
3234 <article class="grid gap-2 pb-3 last:pb-0 border-b border-zinc-100 last:border-b-0" data-composer-post data-active="true">
3335 <header class="flex gap-3 items-center justify-between">
@@ -39,7 +41,7 @@
3941 <span class="h-3.5 w-3.5 i-hugeicons-cancel-01" aria-hidden="true"></span>
4042 </button>
4143 </header>
42 <textarea class="min-h-24 w-full leading-relaxed text-[18px] text-neutral-950 bg-transparent border-0 outline-none resize-none placeholder:text-zinc-400" spellcheck="true" aria-label="Post body 1" maxlength="300" placeholder="What do you want to share on Bluesky?" data-bluesky-text></textarea>
44 <textarea class="min-h-24 w-full leading-relaxed text-[18px] text-neutral-950 placeholder:text-zinc-400 bg-transparent border-0 outline-none resize-none" spellcheck="true" aria-label="Post body 1" maxlength="300" placeholder="What do you want to share?" data-bluesky-text></textarea>
4345 <div class="hidden overflow-hidden bg-zinc-50 border border-zinc-200 rounded-2xl" data-link-preview></div>
4446 </article>
4547 </div>
@@ -79,7 +81,9 @@
7981 const publishLabel = root.querySelector('[data-bluesky-publish-label]')
8082 const buttons = Array.from(root.querySelectorAll('[data-bluesky-publish], [data-bluesky-publish-top]'))
8183 const toast = window.postlineToast?.show
82 const blueskyApi = window.postlineBluesky
84 const providerToggles = root.querySelector('[data-provider-toggles]')
85 const composeImageRow = root.querySelector('[data-compose-image-row]')
86 const composeImage = root.querySelector('[data-compose-image]')
8387 const postsRoot = root.querySelector('[data-composer-posts]')
8488 const addPostButton = root.querySelector('[data-add-post]')
8589 const previewButton = root.querySelector('[data-preview-post]')
@@ -97,6 +101,8 @@
97101 let draftSaveTimer
98102
99103 let canPublish = false
104 let providers = []
105 const selected = new Set()
100106
101107 const setStatus = (message, tone = 'neutral', options = {}) => {
102108 if (toast) {
@@ -112,34 +118,112 @@
112118 console.log(`[postline] ${message}`)
113119 }
114120
115 const applyConnection = (status) => {
116 if (!status) return
117 canPublish = Boolean(status.canPublish)
118 const label = blueskyApi?.formatConnectionLabel(status) || 'Bluesky'
119 if (connectionLabel) connectionLabel.textContent = label
120 connection?.classList.toggle('border-emerald-200', canPublish)
121 connection?.classList.toggle('bg-emerald-50', canPublish)
122 connection?.classList.toggle('text-emerald-800', canPublish)
123 connection?.classList.toggle('border-amber-200', !canPublish && Boolean(status.handle))
124 connection?.classList.toggle('bg-amber-50', !canPublish && Boolean(status.handle))
125 connection?.classList.toggle('text-amber-900', !canPublish && Boolean(status.handle))
126 connectionDot?.classList.toggle('bg-emerald-500', canPublish)
127 connectionDot?.classList.toggle('bg-amber-500', !canPublish && Boolean(status.handle))
128 connectionDot?.classList.toggle('bg-zinc-300', !canPublish && !status.handle)
129 buttons.forEach(button => {
121 const providerLabel = provider => window.postlineSocial?.providerLabel(provider) || provider
122 const connectedProviders = () => providers.filter(provider => provider.canPublish)
123 const selectedConnected = () => connectedProviders().filter(provider => selected.has(provider.provider))
124
125 const effectiveLimit = () => {
126 const limits = selectedConnected().map(provider => provider.characterLimit).filter(Boolean)
127 return limits.length ? Math.min(...limits) : 300
128 }
129
130 const applyMaxLength = () => {
131 const limit = effectiveLimit()
132 allPosts().forEach((post) => {
133 textareaFor(post)?.setAttribute('maxlength', String(limit))
134 })
135 }
136
137 const toggleClass = (isSelected, connected) => {
138 const base = 'inline-flex gap-1.5 items-center px-2.5 min-h-8 text-xs font-bold border rounded-xl transition-colors'
139 if (!connected) return `${base} border-zinc-200 bg-zinc-50 text-zinc-400 cursor-not-allowed`
140 return isSelected
141 ? `${base} border-neutral-950 bg-neutral-950 text-white`
142 : `${base} border-zinc-200 bg-zinc-50 text-zinc-600 hover:bg-white`
143 }
144
145 const createProviderToggle = (status) => {
146 const label = providerLabel(status.provider)
147 const isSelected = selected.has(status.provider) && status.canPublish
148 const button = document.createElement('button')
149 button.type = 'button'
150 button.dataset.providerToggle = status.provider
151 button.setAttribute('aria-pressed', isSelected ? 'true' : 'false')
152 button.className = toggleClass(isSelected, status.canPublish)
153 if (!status.canPublish) {
154 button.setAttribute('disabled', 'disabled')
155 button.title = `Connect ${label} on the Accounts page to crosspost here.`
156 }
157 const dot = isSelected ? 'bg-emerald-400' : 'bg-zinc-300'
158 button.innerHTML = `<span class="h-2 w-2 rounded-full ${dot}" aria-hidden="true"></span><span>${label}</span>`
159 return button
160 }
161
162 const renderProviderToggles = () => {
163 if (!providerToggles) return
164 if (providers.length === 0) {
165 providerToggles.innerHTML = '<span class="flex items-center px-2.5 min-h-8 text-xs text-zinc-400 bg-zinc-50 border border-zinc-200 rounded-xl">No accounts yet — connect one on Accounts.</span>'
166 return
167 }
168 providerToggles.replaceChildren(...providers.map(createProviderToggle))
169 }
170
171 const renderConnectionSummary = () => {
172 const connected = connectedProviders()
173 const ready = connected.length > 0
174 if (connectionLabel) {
175 connectionLabel.textContent = ready
176 ? `${connected.map(provider => providerLabel(provider.provider)).join(', ')} ready`
177 : 'Connect an account'
178 }
179 connection?.classList.toggle('border-emerald-200', ready)
180 connection?.classList.toggle('bg-emerald-50', ready)
181 connection?.classList.toggle('text-emerald-800', ready)
182 connectionDot?.classList.toggle('bg-emerald-500', ready)
183 connectionDot?.classList.toggle('bg-zinc-300', !ready)
184 }
185
186 const updatePublishability = () => {
187 canPublish = selectedConnected().length > 0
188 buttons.forEach((button) => {
130189 if (canPublish) button.removeAttribute('disabled')
131190 else button.setAttribute('disabled', 'disabled')
132191 })
192 // The image field is only meaningful for Instagram, which rejects
193 // text-only posts.
194 const needsImage = selected.has('instagram') && connectedProviders().some(provider => provider.provider === 'instagram')
195 composeImageRow?.classList.toggle('hidden', !needsImage)
196 composeImageRow?.classList.toggle('flex', needsImage)
197 applyMaxLength()
198 updateCount()
199 }
200
201 const applyConnection = (list) => {
202 providers = Array.isArray(list) ? list : []
203 const connectedKeys = connectedProviders().map(provider => provider.provider)
204 // Default to every connected provider; otherwise drop any selected
205 // provider that is no longer connected.
206 if (selected.size === 0) {
207 connectedKeys.forEach(key => selected.add(key))
208 }
209 else {
210 ;[...selected].forEach((key) => {
211 if (!connectedKeys.includes(key)) selected.delete(key)
212 })
213 }
214 renderProviderToggles()
215 renderConnectionSummary()
216 updatePublishability()
133217 }
134218
135219 const refreshConnection = async () => {
136220 try {
137 const status = await blueskyApi?.fetchStatus()
138 applyConnection(status)
221 const list = await window.postlineSocial?.fetchProviders()
222 applyConnection(list || [])
139223 }
140224 catch (error) {
141 applyConnection({ canPublish: false, handle: null, configuredFromEnv: false })
142 setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Bluesky' })
225 applyConnection([])
226 setStatus(error instanceof Error ? error.message : String(error), 'error', { title: 'Accounts' })
143227 }
144228 }
145229
@@ -232,11 +316,11 @@
232316 const textarea = activeTextarea()
233317 if (!textarea || !count) return
234318 const length = textarea.value.length
235 const limit = 300
319 const limit = effectiveLimit()
236320 count.textContent = `${length} / ${limit} characters - ${allPosts().length} post${allPosts().length === 1 ? '' : 's'}`
237 count.classList.toggle('text-amber-700', length > 260 && length <= limit)
321 count.classList.toggle('text-amber-700', length > limit - 40 && length <= limit)
238322 count.classList.toggle('text-red-700', length >= limit)
239 count.classList.toggle('text-zinc-500', length <= 260)
323 count.classList.toggle('text-zinc-500', length <= limit - 40)
240324 }
241325
242326 const renderLinkPreview = (post, preview) => {
@@ -380,30 +464,29 @@
380464 if (!options.skipSave) saveDraft()
381465 }
382466
383 const publishOne = async (post) => {
467 const publishOne = async (post, targetProviders) => {
468 const social = window.postlineSocial
469 if (!social) throw new Error('Publishing is still loading. Try again in a moment.')
384470 const textarea = textareaFor(post)
385471 if (!textarea) throw new Error('Missing post body.')
386472 const text = textarea.value.trim()
387473 if (!text) throw new Error('Write something before publishing.')
388 const body = new FormData()
389 body.set('text', text)
390 if (post?.dataset.externalUri && post.dataset.externalTitle) {
391 body.set('external_uri', post.dataset.externalUri)
392 body.set('external_title', post.dataset.externalTitle)
393 body.set('external_description', post.dataset.externalDescription || '')
394 }
395 const response = await fetch('/api/postline/bluesky/publish', {
396 method: 'POST',
397 body,
398 })
399 const payload = await response.json()
400 if (!response.ok || !payload.ok) throw new Error(payload.error || 'Bluesky publish failed.')
401 return payload.data?.post?.url
474 const external = (post?.dataset.externalUri && post.dataset.externalTitle)
475 ? {
476 uri: post.dataset.externalUri,
477 title: post.dataset.externalTitle,
478 description: post.dataset.externalDescription || '',
479 }
480 : undefined
481 const imageUrl = composeImage?.value.trim()
482 const image = imageUrl ? { url: imageUrl } : undefined
483 return await social.publishCrosspost({ text, providers: targetProviders, external, image })
402484 }
403485
404486 const publish = async () => {
405 if (!canPublish) {
406 setStatus('Connect Bluesky on the Accounts page before publishing.', 'error', { title: 'Not connected' })
487 const targetProviders = selectedConnected().map(provider => provider.provider)
488 if (targetProviders.length === 0) {
489 setStatus('Select a connected account on the Accounts page before publishing.', 'error', { title: 'Not connected' })
407490 return
408491 }
409492
@@ -417,18 +500,30 @@
417500 return
418501 }
419502
503 const providerNames = targetProviders.map(providerLabel).join(', ')
420504 buttons.forEach(button => button.setAttribute('disabled', 'disabled'))
421505 if (publishLabel) publishLabel.textContent = 'Publishing…'
422506 setStatus(
423 targets.length > 1 ? `Publishing ${targets.length} posts to Bluesky…` : 'Publishing to Bluesky…',
507 targets.length > 1 ? `Publishing ${targets.length} posts to ${providerNames}…` : `Publishing to ${providerNames}…`,
424508 'info',
425509 { title: 'Publishing', id: 'publish' },
426510 )
427511
428512 try {
429513 let lastUrl
514 const failures = []
515 let successCount = 0
430516 for (const [index, post] of targets.entries()) {
431 lastUrl = await publishOne(post)
517 const data = await publishOne(post, targetProviders)
518 for (const result of data.results || []) {
519 if (result.ok) {
520 successCount += 1
521 if (result.url) lastUrl = result.url
522 }
523 else {
524 failures.push(`${providerLabel(result.provider)}: ${result.error || 'failed'}`)
525 }
526 }
432527 if (targets.length > 1) {
433528 textareaFor(post).value = ''
434529 renderLinkPreview(post, null)
@@ -452,13 +547,21 @@
452547 updateCount()
453548 saveDraft()
454549 window.postlineToast?.dismiss?.('publish')
455 setStatus(
456 targets.length > 1
457 ? `Published ${targets.length} posts to Bluesky.`
458 : 'Your post is live on Bluesky.',
459 'success',
460 { title: 'Published', url: lastUrl },
461 )
550 if (failures.length && successCount === 0) {
551 setStatus(`Publish failed — ${failures.join('; ')}`, 'error', { title: 'Publish failed' })
552 }
553 else if (failures.length) {
554 setStatus(`Published, but some targets failed — ${failures.join('; ')}`, 'info', { title: 'Partially published', url: lastUrl })
555 }
556 else {
557 setStatus(
558 targets.length > 1
559 ? `Published ${targets.length} posts to ${providerNames}.`
560 : `Your post is live on ${providerNames}.`,
561 'success',
562 { title: 'Published', url: lastUrl },
563 )
564 }
462565 }
463566 catch (error) {
464567 window.postlineToast?.dismiss?.('publish')
@@ -505,15 +608,26 @@
505608 try {
506609 await refreshConnection()
507610 setStatus(
508 canPublish ? 'Bluesky is ready to publish.' : 'Add your Bluesky app password on Accounts.',
611 canPublish ? 'Accounts are ready to publish.' : 'Connect an account on the Accounts page.',
509612 canPublish ? 'success' : 'error',
510 { title: 'Bluesky' },
613 { title: 'Accounts' },
511614 )
512615 }
513616 finally {
514617 refreshButton.removeAttribute('disabled')
515618 }
516619 })
620 providerToggles?.addEventListener('click', (event) => {
621 const target = event.target
622 if (!(target instanceof HTMLElement)) return
623 const button = target.closest('[data-provider-toggle]')
624 if (!button || button.hasAttribute('disabled')) return
625 const provider = button.dataset.providerToggle
626 if (selected.has(provider)) selected.delete(provider)
627 else selected.add(provider)
628 renderProviderToggles()
629 updatePublishability()
630 })
517631 searchButton?.addEventListener('click', () => {
518632 const text = activeTextarea()?.value.trim()
519633 setStatus(text ? `Searching local drafts for: ${text.slice(0, 80)}` : 'Write a phrase first, then search local drafts.', text ? 'success' : 'error')
@@ -527,7 +641,14 @@
527641 selectPost(allPosts()[savedActiveIndex] || activePost, { skipSave: true })
528642 updateCount()
529643 void updateLinkPreview(activePost)
530 void refreshConnection()
644 // Wait for the social module (a deferred `type=module` script) before the
645 // first provider fetch so window.postlineSocial is guaranteed to exist.
646 if (document.readyState === 'loading') {
647 document.addEventListener('DOMContentLoaded', () => void refreshConnection(), { once: true })
648 }
649 else {
650 void refreshConnection()
651 }
531652 })()
532653 </script>
533654@endsection
resources/views/layouts/postline.stxmodified+3-0
Changes to resources/views/layouts/postline.stx
@@ -113,6 +113,9 @@
113113 <div id="postline-toast-host" class="pointer-events-none fixed bottom-6 right-6 z-[100] flex w-[calc(100%-2rem)] max-w-[22rem] flex-col-reverse gap-2.5 sm:right-6" aria-live="polite" aria-relevant="additions"></div>
114114 <script type="module" src="/assets/scripts/postline-toast.ts"></script>
115115 <script type="module" src="/assets/scripts/postline-bluesky.ts"></script>
116 <script type="module" src="/assets/scripts/postline-linkedin.ts"></script>
117 <script type="module" src="/assets/scripts/postline-instagram.ts"></script>
118 <script type="module" src="/assets/scripts/postline-social.ts"></script>
116119 <script type="module" src="/assets/scripts/postline-analytics.ts"></script>
117120 <script>
118121 ;(() => {
routes/api.tsmodified+19-0
Changes to routes/api.ts
@@ -23,3 +23,22 @@ route.group({ prefix: '/postline/bluesky' }, () => {
2323 route.get('/link-preview', 'Actions/Postline/BlueskyLinkPreviewAction').skipCsrf()
2424 route.get('/timeline', 'Actions/Postline/BlueskyTimelineAction').skipCsrf()
2525})
26
27route.group({ prefix: '/postline/linkedin' }, () => {
28 route.get('/status', 'Actions/Postline/LinkedInStatusAction').skipCsrf()
29 route.post('/connect', 'Actions/Postline/LinkedInConnectAction').skipCsrf()
30 route.get('/auth', 'Actions/Postline/LinkedInAuthAction').skipCsrf()
31 route.get('/callback', 'Actions/Postline/LinkedInCallbackAction').skipCsrf()
32})
33
34route.group({ prefix: '/postline/instagram' }, () => {
35 route.get('/status', 'Actions/Postline/InstagramStatusAction').skipCsrf()
36 route.post('/connect', 'Actions/Postline/InstagramConnectAction').skipCsrf()
37 route.get('/auth', 'Actions/Postline/InstagramAuthAction').skipCsrf()
38 route.get('/callback', 'Actions/Postline/InstagramCallbackAction').skipCsrf()
39})
40
41route.group({ prefix: '/postline' }, () => {
42 route.get('/providers', 'Actions/Postline/ProvidersStatusAction').skipCsrf()
43 route.post('/publish', 'Actions/Postline/CrosspostPublishAction').skipCsrf()
44})
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 {