also looking at this
feat: crosspost to LinkedIn and Instagram
#1
31 files
+2335
-87
| @@ -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 |
| @@ -0,0 +1,43 @@ | ||
| 1 | export 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 | ||
| 14 | export 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 | ||
| 23 | export 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 | ||
| 31 | declare global { | |
| 32 | interface Window { | |
| 33 | postlineInstagram?: { | |
| 34 | fetchStatus: typeof fetchInstagramStatus | |
| 35 | formatConnectionLabel: typeof formatInstagramConnectionLabel | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | window.postlineInstagram = { | |
| 41 | fetchStatus: fetchInstagramStatus, | |
| 42 | formatConnectionLabel: formatInstagramConnectionLabel, | |
| 43 | } | |
| @@ -0,0 +1,43 @@ | ||
| 1 | export 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 | ||
| 13 | export 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 | ||
| 22 | export 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 | ||
| 31 | declare global { | |
| 32 | interface Window { | |
| 33 | postlineLinkedin?: { | |
| 34 | fetchStatus: typeof fetchLinkedInStatus | |
| 35 | formatConnectionLabel: typeof formatLinkedInConnectionLabel | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | window.postlineLinkedin = { | |
| 41 | fetchStatus: fetchLinkedInStatus, | |
| 42 | formatConnectionLabel: formatLinkedInConnectionLabel, | |
| 43 | } | |
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | <div> |
| 10 | 10 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Accounts</p> |
| 11 | 11 | <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> | |
| 13 | 13 | </div> |
| 14 | 14 | </header> |
| 15 | 15 | |
| @@ -40,15 +40,39 @@ | ||
| 40 | 40 | <p class="my-1.5 text-[13px] text-slate-700" data-bluesky-channel-handle>Not connected</p> |
| 41 | 41 | <span class="text-xs text-zinc-500" data-bluesky-channel-detail>Add an app password to publish and sync your timeline.</span> |
| 42 | 42 | </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> | |
| 44 | 68 | </article> |
| 45 | 69 | <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80"> |
| 46 | 70 | <div> |
| 47 | 71 | <h2 class="my-0 font-bold text-[15px] text-zinc-700">Twitter/X</h2> |
| 48 | 72 | <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> | |
| 50 | 74 | </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> | |
| 52 | 76 | </article> |
| 53 | 77 | </div> |
| 54 | 78 | </section> |
| @@ -136,6 +160,200 @@ | ||
| 136 | 160 | } |
| 137 | 161 | }) |
| 138 | 162 | |
| 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 | ||
| 139 | 357 | void refreshStatus() |
| 140 | 358 | })() |
| 141 | 359 | </script> |
| @@ -10,9 +10,9 @@ | ||
| 10 | 10 | <header class="flex flex-wrap gap-3 items-center justify-between p-3 border-b border-zinc-200"> |
| 11 | 11 | <div class="flex gap-2 items-center"> |
| 12 | 12 | <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> | |
| 16 | 16 | </a> |
| 17 | 17 | </div> |
| 18 | 18 | <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 @@ | ||
| 23 | 23 | </header> |
| 24 | 24 | |
| 25 | 25 | <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> | |
| 30 | 28 | </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> | |
| 31 | 33 | <div class="grid overflow-y-auto content-start gap-4 pr-1 max-h-72" data-composer-posts> |
| 32 | 34 | <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"> |
| 33 | 35 | <header class="flex gap-3 items-center justify-between"> |
| @@ -39,7 +41,7 @@ | ||
| 39 | 41 | <span class="h-3.5 w-3.5 i-hugeicons-cancel-01" aria-hidden="true"></span> |
| 40 | 42 | </button> |
| 41 | 43 | </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> | |
| 43 | 45 | <div class="hidden overflow-hidden bg-zinc-50 border border-zinc-200 rounded-2xl" data-link-preview></div> |
| 44 | 46 | </article> |
| 45 | 47 | </div> |
| @@ -79,7 +81,9 @@ | ||
| 79 | 81 | const publishLabel = root.querySelector('[data-bluesky-publish-label]') |
| 80 | 82 | const buttons = Array.from(root.querySelectorAll('[data-bluesky-publish], [data-bluesky-publish-top]')) |
| 81 | 83 | 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]') | |
| 83 | 87 | const postsRoot = root.querySelector('[data-composer-posts]') |
| 84 | 88 | const addPostButton = root.querySelector('[data-add-post]') |
| 85 | 89 | const previewButton = root.querySelector('[data-preview-post]') |
| @@ -97,6 +101,8 @@ | ||
| 97 | 101 | let draftSaveTimer |
| 98 | 102 | |
| 99 | 103 | let canPublish = false |
| 104 | let providers = [] | |
| 105 | const selected = new Set() | |
| 100 | 106 | |
| 101 | 107 | const setStatus = (message, tone = 'neutral', options = {}) => { |
| 102 | 108 | if (toast) { |
| @@ -112,34 +118,112 @@ | ||
| 112 | 118 | console.log(`[postline] ${message}`) |
| 113 | 119 | } |
| 114 | 120 | |
| 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) => { | |
| 130 | 189 | if (canPublish) button.removeAttribute('disabled') |
| 131 | 190 | else button.setAttribute('disabled', 'disabled') |
| 132 | 191 | }) |
| 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() | |
| 133 | 217 | } |
| 134 | 218 | |
| 135 | 219 | const refreshConnection = async () => { |
| 136 | 220 | try { |
| 137 | const status = await blueskyApi?.fetchStatus() | |
| 138 | applyConnection(status) | |
| 221 | const list = await window.postlineSocial?.fetchProviders() | |
| 222 | applyConnection(list || []) | |
| 139 | 223 | } |
| 140 | 224 | 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' }) | |
| 143 | 227 | } |
| 144 | 228 | } |
| 145 | 229 | |
| @@ -232,11 +316,11 @@ | ||
| 232 | 316 | const textarea = activeTextarea() |
| 233 | 317 | if (!textarea || !count) return |
| 234 | 318 | const length = textarea.value.length |
| 235 | const limit = 300 | |
| 319 | const limit = effectiveLimit() | |
| 236 | 320 | 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) | |
| 238 | 322 | 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) | |
| 240 | 324 | } |
| 241 | 325 | |
| 242 | 326 | const renderLinkPreview = (post, preview) => { |
| @@ -380,30 +464,29 @@ | ||
| 380 | 464 | if (!options.skipSave) saveDraft() |
| 381 | 465 | } |
| 382 | 466 | |
| 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.') | |
| 384 | 470 | const textarea = textareaFor(post) |
| 385 | 471 | if (!textarea) throw new Error('Missing post body.') |
| 386 | 472 | const text = textarea.value.trim() |
| 387 | 473 | 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 }) | |
| 402 | 484 | } |
| 403 | 485 | |
| 404 | 486 | 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' }) | |
| 407 | 490 | return |
| 408 | 491 | } |
| 409 | 492 | |
| @@ -417,18 +500,30 @@ | ||
| 417 | 500 | return |
| 418 | 501 | } |
| 419 | 502 | |
| 503 | const providerNames = targetProviders.map(providerLabel).join(', ') | |
| 420 | 504 | buttons.forEach(button => button.setAttribute('disabled', 'disabled')) |
| 421 | 505 | if (publishLabel) publishLabel.textContent = 'Publishing…' |
| 422 | 506 | 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}…`, | |
| 424 | 508 | 'info', |
| 425 | 509 | { title: 'Publishing', id: 'publish' }, |
| 426 | 510 | ) |
| 427 | 511 | |
| 428 | 512 | try { |
| 429 | 513 | let lastUrl |
| 514 | const failures = [] | |
| 515 | let successCount = 0 | |
| 430 | 516 | 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 | } | |
| 432 | 527 | if (targets.length > 1) { |
| 433 | 528 | textareaFor(post).value = '' |
| 434 | 529 | renderLinkPreview(post, null) |
| @@ -452,13 +547,21 @@ | ||
| 452 | 547 | updateCount() |
| 453 | 548 | saveDraft() |
| 454 | 549 | 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 | } | |
| 462 | 565 | } |
| 463 | 566 | catch (error) { |
| 464 | 567 | window.postlineToast?.dismiss?.('publish') |
| @@ -505,15 +608,26 @@ | ||
| 505 | 608 | try { |
| 506 | 609 | await refreshConnection() |
| 507 | 610 | 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.', | |
| 509 | 612 | canPublish ? 'success' : 'error', |
| 510 | { title: 'Bluesky' }, | |
| 613 | { title: 'Accounts' }, | |
| 511 | 614 | ) |
| 512 | 615 | } |
| 513 | 616 | finally { |
| 514 | 617 | refreshButton.removeAttribute('disabled') |
| 515 | 618 | } |
| 516 | 619 | }) |
| 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 | }) | |
| 517 | 631 | searchButton?.addEventListener('click', () => { |
| 518 | 632 | const text = activeTextarea()?.value.trim() |
| 519 | 633 | 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 @@ | ||
| 527 | 641 | selectPost(allPosts()[savedActiveIndex] || activePost, { skipSave: true }) |
| 528 | 642 | updateCount() |
| 529 | 643 | 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 | } | |
| 531 | 652 | })() |
| 532 | 653 | </script> |
| 533 | 654 | @endsection |
| @@ -113,6 +113,9 @@ | ||
| 113 | 113 | <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> |
| 114 | 114 | <script type="module" src="/assets/scripts/postline-toast.ts"></script> |
| 115 | 115 | <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> | |
| 116 | 119 | <script type="module" src="/assets/scripts/postline-analytics.ts"></script> |
| 117 | 120 | <script> |
| 118 | 121 | ;(() => { |
| @@ -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 | }) | |