also looking at this
feat: crosspost to LinkedIn and Instagram
#1
31 files
+2335
-87
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -103,3 +103,30 @@ FRONTEND_STRIPE_PUBLIC_KEY= | ||
| 103 | 103 | # Create an app password at https://bsky.app/settings/app-passwords |
| 104 | 104 | BLUESKY_IDENTIFIER= |
| 105 | 105 | BLUESKY_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" | |
| 111 | LINKEDIN_CLIENT_ID= | |
| 112 | LINKEDIN_CLIENT_SECRET= | |
| 113 | LINKEDIN_REDIRECT_URL=https://postline.localhost/api/postline/linkedin/callback | |
| 114 | # Bump if LinkedIn deprecates this monthly REST API version (YYYYMM) | |
| 115 | LINKEDIN_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. | |
| 118 | LINKEDIN_ACCESS_TOKEN= | |
| 119 | LINKEDIN_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. | |
| 124 | INSTAGRAM_CLIENT_ID= | |
| 125 | INSTAGRAM_CLIENT_SECRET= | |
| 126 | INSTAGRAM_REDIRECT_URL=https://postline.localhost/api/postline/instagram/callback | |
| 127 | # Bump if Meta deprecates this Graph API version | |
| 128 | INSTAGRAM_GRAPH_VERSION=v21.0 | |
| 129 | # Env-only shortcut: a Page access token + the IG Business account id skip OAuth. | |
| 130 | INSTAGRAM_ACCESS_TOKEN= | |
| 131 | INSTAGRAM_USER_ID= | |
| 132 | INSTAGRAM_USERNAME= | |
| @@ -0,0 +1,52 @@ | ||
| 1 | import type { SocialProvider } from '../../Support/Social/types' | |
| 2 | import type { RequestInstance } from '@stacksjs/types' | |
| 3 | import { Action } from '@stacksjs/actions' | |
| 4 | import { response } from '@stacksjs/router' | |
| 5 | import { crosspost, crosspostProviders } from '../../Services/Social/CrosspostService' | |
| 6 | ||
| 7 | export 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 | }) | |
| @@ -0,0 +1,19 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { instagram } from '../../Services/Social/InstagramService' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -0,0 +1,30 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { instagram } from '../../Services/Social/InstagramService' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { instagram } from '../../Services/Social/InstagramService' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -0,0 +1,21 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { instagram } from '../../Services/Social/InstagramService' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -0,0 +1,19 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { linkedin } from '../../Services/Social/LinkedInService' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -0,0 +1,30 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { linkedin } from '../../Services/Social/LinkedInService' | |
| 5 | ||
| 6 | export 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 | }) | |
| @@ -0,0 +1,22 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { linkedin } from '../../Services/Social/LinkedInService' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -0,0 +1,21 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { linkedin } from '../../Services/Social/LinkedInService' | |
| 4 | ||
| 5 | export 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 | }) | |
| @@ -0,0 +1,29 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { bluesky } from '../../Services/Social/BlueskyService' | |
| 4 | import { instagram } from '../../Services/Social/InstagramService' | |
| 5 | import { linkedin } from '../../Services/Social/LinkedInService' | |
| 6 | ||
| 7 | export 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 | }) | |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { BlueskySession, PublishedPost, TimelineResult } from '../../Support/Social/types' | |
| 1 | import type { BlueskySession, CrosspostTargetResult, PublishContent, PublishedPost, TimelineResult } from '../../Support/Social/types' | |
| 2 | 2 | import { db } from '@stacksjs/database' |
| 3 | 3 | import { env } from '@stacksjs/env' |
| 4 | 4 | import { BlueskyApiError, BlueskyDriver } from './Drivers/BlueskyDriver' |
| @@ -108,11 +108,8 @@ export class BlueskyService { | ||
| 108 | 108 | throw new Error(`Bluesky posts must be ${this.driver.characterLimit} characters or fewer.`) |
| 109 | 109 | } |
| 110 | 110 | |
| 111 | const identity = await this.requireIdentity() | |
| 112 | const driver = await this.ensureDriver() | |
| 113 | 111 | const accountId = await this.ensureAccount() |
| 114 | 112 | const postUuid = uuid() |
| 115 | const targetUuid = uuid() | |
| 116 | 113 | const createdAt = now() |
| 117 | 114 | |
| 118 | 115 | await database.insertInto('posts').values({ |
| @@ -132,10 +129,65 @@ export class BlueskyService { | ||
| 132 | 129 | .selectAll() |
| 133 | 130 | .where('uuid', '=', postUuid) |
| 134 | 131 | .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] & { | |
| 136 | 188 | external?: { uri: string, title: string, description?: string } |
| 137 | 189 | } |
| 138 | if (external) publishInput.external = external | |
| 190 | if (content?.external) publishInput.external = content.external | |
| 139 | 191 | |
| 140 | 192 | await database.insertInto('post_targets').values({ |
| 141 | 193 | uuid: targetUuid, |
| @@ -158,19 +210,13 @@ export class BlueskyService { | ||
| 158 | 210 | const published = await this.withFreshSession(identity, freshIdentity => |
| 159 | 211 | this.driver.publish({ |
| 160 | 212 | 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, | |
| 164 | 216 | }, publishInput), |
| 165 | 217 | ) |
| 166 | 218 | |
| 167 | 219 | 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 | ||
| 174 | 220 | await database.updateTable('post_targets').set({ |
| 175 | 221 | status: 'published', |
| 176 | 222 | remote_uri: published.uri, |
| @@ -180,26 +226,23 @@ export class BlueskyService { | ||
| 180 | 226 | }).where('id', '=', target.id).execute() |
| 181 | 227 | |
| 182 | 228 | return { |
| 183 | post: published, | |
| 184 | postId: Number(post.id), | |
| 229 | provider: 'bluesky', | |
| 230 | ok: true, | |
| 231 | url: published.url, | |
| 232 | uri: published.uri, | |
| 185 | 233 | targetId: Number(target.id), |
| 186 | 234 | } |
| 187 | 235 | } |
| 188 | 236 | catch (error) { |
| 189 | 237 | const message = error instanceof Error ? error.message : String(error) |
| 190 | 238 | const failedAt = now() |
| 191 | await database.updateTable('posts').set({ | |
| 192 | status: 'failed', | |
| 193 | updated_at: failedAt, | |
| 194 | }).where('id', '=', post.id).execute() | |
| 195 | ||
| 196 | 239 | await database.updateTable('post_targets').set({ |
| 197 | 240 | status: 'failed', |
| 198 | 241 | failure_reason: message, |
| 199 | 242 | updated_at: failedAt, |
| 200 | 243 | }).where('id', '=', target.id).execute() |
| 201 | 244 | |
| 202 | throw error | |
| 245 | return { provider: 'bluesky', ok: false, error: message, targetId: Number(target.id) } | |
| 203 | 246 | } |
| 204 | 247 | } |
| 205 | 248 | |
| @@ -0,0 +1,86 @@ | ||
| 1 | import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { env } from '@stacksjs/env' | |
| 4 | import { bluesky } from './BlueskyService' | |
| 5 | import { instagram } from './InstagramService' | |
| 6 | import { linkedin } from './LinkedInService' | |
| 7 | import { ensureAccount, now, uuid } from './support' | |
| 8 | ||
| 9 | const database = db as any | |
| 10 | ||
| 11 | interface 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`. | |
| 16 | const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = { | |
| 17 | bluesky, | |
| 18 | linkedin, | |
| 19 | instagram, | |
| 20 | } | |
| 21 | ||
| 22 | export function crosspostProviders(): SocialProvider[] { | |
| 23 | return Object.keys(publishers) as SocialProvider[] | |
| 24 | } | |
| 25 | ||
| 26 | export 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 | ||
| 86 | export const crosspost = new CrosspostService() | |
| @@ -1,8 +1,13 @@ | ||
| 1 | 1 | import type { SocialDriver, SocialProvider } from '../../Support/Social/types' |
| 2 | import { env } from '@stacksjs/env' | |
| 2 | 3 | import { BlueskyDriver } from './Drivers/BlueskyDriver' |
| 4 | import { InstagramDriver } from './Drivers/InstagramDriver' | |
| 5 | import { LinkedInDriver } from './Drivers/LinkedInDriver' | |
| 3 | 6 | |
| 4 | 7 | const drivers: Partial<Record<SocialProvider, SocialDriver>> = { |
| 5 | 8 | 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') }), | |
| 6 | 11 | } |
| 7 | 12 | |
| 8 | 13 | export function getSocialDriver(provider: SocialProvider): SocialDriver { |
| @@ -0,0 +1,1 @@ | ||
| 1 | export { InstagramApiError, InstagramPublishingDriver as InstagramDriver } from '@stacksjs/socials' | |
| @@ -0,0 +1,1 @@ | ||
| 1 | export { LinkedInApiError, LinkedInPublishingDriver as LinkedInDriver } from '@stacksjs/socials' | |
| @@ -0,0 +1,374 @@ | ||
| 1 | import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { env } from '@stacksjs/env' | |
| 4 | import { InstagramApiError, InstagramDriver } from './Drivers/InstagramDriver' | |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 6 | ||
| 7 | const database = db as any | |
| 8 | ||
| 9 | type 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 | ||
| 22 | type SocialDriverRow = { | |
| 23 | id: number | |
| 24 | provider: 'instagram' | |
| 25 | display_name: string | |
| 26 | status: 'active' | 'planned' | 'disabled' | |
| 27 | character_limit: number | |
| 28 | } | |
| 29 | ||
| 30 | interface 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 | ||
| 41 | function 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 | ||
| 47 | export 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 | ||
| 370 | function messageOf(error: unknown): string { | |
| 371 | return error instanceof Error ? error.message : String(error) | |
| 372 | } | |
| 373 | ||
| 374 | export const instagram = new InstagramService() | |
| @@ -0,0 +1,373 @@ | ||
| 1 | import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { env } from '@stacksjs/env' | |
| 4 | import { LinkedInApiError, LinkedInDriver } from './Drivers/LinkedInDriver' | |
| 5 | import { ensureAccount, now, uuid } from './support' | |
| 6 | ||
| 7 | const database = db as any | |
| 8 | ||
| 9 | type 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 | ||
| 22 | type SocialDriverRow = { | |
| 23 | id: number | |
| 24 | provider: 'linkedin' | |
| 25 | display_name: string | |
| 26 | status: 'active' | 'planned' | 'disabled' | |
| 27 | character_limit: number | |
| 28 | } | |
| 29 | ||
| 30 | interface LinkedInConfig { | |
| 31 | clientId: string | |
| 32 | clientSecret: string | |
| 33 | redirectUrl: string | |
| 34 | apiVersion: string | |
| 35 | accessToken: string | |
| 36 | authorUrn: string | |
| 37 | scopes: string[] | |
| 38 | } | |
| 39 | ||
| 40 | function 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 | ||
| 46 | export 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 | ||
| 369 | function messageOf(error: unknown): string { | |
| 370 | return error instanceof Error ? error.message : String(error) | |
| 371 | } | |
| 372 | ||
| 373 | export const linkedin = new LinkedInService() | |
| @@ -0,0 +1,46 @@ | ||
| 1 | import { db } from '@stacksjs/database' | |
| 2 | ||
| 3 | const database = db as any | |
| 4 | ||
| 5 | /** SQLite-friendly `YYYY-MM-DD HH:MM:SS` timestamp. */ | |
| 6 | export function now(): string { | |
| 7 | return new Date().toISOString().slice(0, 19).replace('T', ' ') | |
| 8 | } | |
| 9 | ||
| 10 | export 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 | */ | |
| 19 | export 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 | } | |
| @@ -48,6 +48,29 @@ export interface PublishedPost { | ||
| 48 | 48 | url?: string |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | /** Optional content attached to a post: a link card and/or media. */ | |
| 52 | export 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. */ | |
| 65 | export interface CrosspostTargetResult { | |
| 66 | provider: SocialProvider | |
| 67 | ok: boolean | |
| 68 | url?: string | |
| 69 | uri?: string | |
| 70 | targetId?: number | |
| 71 | error?: string | |
| 72 | } | |
| 73 | ||
| 51 | 74 | export interface TimelineQuery { |
| 52 | 75 | cursor?: string |
| 53 | 76 | limit?: number |
| @@ -23,3 +23,22 @@ route.group({ prefix: '/postline/bluesky' }, () => { | ||
| 23 | 23 | route.get('/link-preview', 'Actions/Postline/BlueskyLinkPreviewAction').skipCsrf() |
| 24 | 24 | route.get('/timeline', 'Actions/Postline/BlueskyTimelineAction').skipCsrf() |
| 25 | 25 | }) |
| 26 | ||
| 27 | route.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 | ||
| 34 | route.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 | ||
| 41 | route.group({ prefix: '/postline' }, () => { | |
| 42 | route.get('/providers', 'Actions/Postline/ProvidersStatusAction').skipCsrf() | |
| 43 | route.post('/publish', 'Actions/Postline/CrosspostPublishAction').skipCsrf() | |
| 44 | }) | |