also looking at this
feat: crossposting — media (LinkedIn/IG/Threads), OAuth token refresh, and X/Twitter
#30
28 files
+1810
-109
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -6,6 +6,7 @@ import { instagram } from '../../Services/Social/InstagramService' | ||
| 6 | 6 | import { linkedin } from '../../Services/Social/LinkedInService' |
| 7 | 7 | import { mastodon } from '../../Services/Social/MastodonService' |
| 8 | 8 | import { threads } from '../../Services/Social/ThreadsService' |
| 9 | import { twitter } from '../../Services/Social/TwitterService' | |
| 9 | 10 | |
| 10 | 11 | export default new Action({ |
| 11 | 12 | name: 'Postline Providers Status', |
| @@ -16,6 +17,7 @@ export default new Action({ | ||
| 16 | 17 | try { |
| 17 | 18 | const providers = await Promise.all([ |
| 18 | 19 | bluesky.status(), |
| 20 | twitter.status(), | |
| 19 | 21 | linkedin.status(), |
| 20 | 22 | instagram.status(), |
| 21 | 23 | threads.status(), |
| @@ -0,0 +1,19 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { twitter } from '../../Services/Social/TwitterService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Twitter Auth', | |
| 7 | description: 'Redirect the user to X/Twitter for OAuth consent.', | |
| 8 | method: 'GET', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | return response.redirect(await twitter.getAuthUrl()) | |
| 13 | } | |
| 14 | catch (error) { | |
| 15 | const message = error instanceof Error ? error.message : String(error) | |
| 16 | return response.redirect(`/accounts?twitter=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 { twitter } from '../../Services/Social/TwitterService' | |
| 5 | ||
| 6 | export default new Action({ | |
| 7 | name: 'Postline Twitter Callback', | |
| 8 | description: 'Handle the X/Twitter 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?twitter=error&message=${encodeURIComponent(description)}`) | |
| 19 | } | |
| 20 | ||
| 21 | try { | |
| 22 | await twitter.handleCallback(code, state) | |
| 23 | return response.redirect('/accounts?twitter=connected') | |
| 24 | } | |
| 25 | catch (error) { | |
| 26 | const message = error instanceof Error ? error.message : String(error) | |
| 27 | return response.redirect(`/accounts?twitter=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 { twitter } from '../../Services/Social/TwitterService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Twitter Connect', | |
| 7 | description: 'Connect X/Twitter from a pre-obtained access token in the environment.', | |
| 8 | method: 'POST', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | const data = await twitter.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 { twitter } from '../../Services/Social/TwitterService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Twitter Status', | |
| 7 | description: 'Return the current X/Twitter connection state.', | |
| 8 | method: 'GET', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | return response.json({ ok: true, data: await twitter.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 | }) | |
| @@ -7,6 +7,7 @@ import { instagram } from './InstagramService' | ||
| 7 | 7 | import { linkedin } from './LinkedInService' |
| 8 | 8 | import { mastodon } from './MastodonService' |
| 9 | 9 | import { threads } from './ThreadsService' |
| 10 | import { twitter } from './TwitterService' | |
| 10 | 11 | import { ensureAccount, now, uuid } from './support' |
| 11 | 12 | |
| 12 | 13 | const database = db as any |
| @@ -18,6 +19,7 @@ interface ProviderPublisher { | ||
| 18 | 19 | // Each provider owns its own connection/token handling behind `publishToPost`. |
| 19 | 20 | const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = { |
| 20 | 21 | bluesky, |
| 22 | twitter, | |
| 21 | 23 | linkedin, |
| 22 | 24 | instagram, |
| 23 | 25 | threads, |
| @@ -4,9 +4,11 @@ import { BlueskyDriver } from './Drivers/BlueskyDriver' | ||
| 4 | 4 | import { InstagramDriver } from './Drivers/InstagramDriver' |
| 5 | 5 | import { LinkedInDriver } from './Drivers/LinkedInDriver' |
| 6 | 6 | import { ThreadsDriver } from './Drivers/ThreadsDriver' |
| 7 | import { TwitterDriver } from './Drivers/TwitterDriver' | |
| 7 | 8 | |
| 8 | 9 | const drivers: Partial<Record<SocialProvider, SocialDriver>> = { |
| 9 | 10 | bluesky: new BlueskyDriver(), |
| 11 | twitter: new TwitterDriver(), | |
| 10 | 12 | linkedin: new LinkedInDriver({ apiVersion: String(env.LINKEDIN_API_VERSION || '202405') }), |
| 11 | 13 | instagram: new InstagramDriver({ graphVersion: String(env.INSTAGRAM_GRAPH_VERSION || 'v21.0') }), |
| 12 | 14 | threads: new ThreadsDriver({ graphVersion: String(env.THREADS_GRAPH_VERSION || 'v1.0') }), |
| @@ -0,0 +1,261 @@ | ||
| 1 | import type { PublishedPost, PublishPostInput, SocialIdentityCredentials } from '@stacksjs/socials' | |
| 2 | ||
| 3 | /** | |
| 4 | * Full app-owned X/Twitter publishing driver. The published `@stacksjs/socials` | |
| 5 | * ships only a *sign-in* provider for Twitter (getAuthUrl/getAccessToken/ | |
| 6 | * getUserByToken, no `publish`), so — like the Mastodon driver — this is a | |
| 7 | * complete local implementation rather than a subclass. | |
| 8 | * | |
| 9 | * Auth is OAuth 2.0 with PKCE (X's user-context flow). Posting is `POST | |
| 10 | * /2/tweets`; images go through `POST /2/media/upload` first and attach by | |
| 11 | * media id. Threads chain via `reply.in_reply_to_tweet_id`. | |
| 12 | * | |
| 13 | * NOTE: posting on X requires a paid API tier — this is exercised only against | |
| 14 | * mocked endpoints in tests; the request shapes follow X's documented v2 API. | |
| 15 | */ | |
| 16 | ||
| 17 | export class TwitterApiError extends Error { | |
| 18 | constructor( | |
| 19 | message: string, | |
| 20 | public status: number, | |
| 21 | public body: string, | |
| 22 | ) { | |
| 23 | super(message) | |
| 24 | this.name = 'TwitterApiError' | |
| 25 | } | |
| 26 | ||
| 27 | get isAuthError(): boolean { | |
| 28 | return this.status === 401 || this.status === 403 | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | export interface TwitterDriverOptions { | |
| 33 | apiBase?: string | |
| 34 | authorizeBase?: string | |
| 35 | } | |
| 36 | ||
| 37 | export interface TwitterAuthUrlInput { | |
| 38 | clientId: string | |
| 39 | redirectUrl: string | |
| 40 | scopes: string[] | |
| 41 | state: string | |
| 42 | } | |
| 43 | ||
| 44 | export interface TwitterTokenExchangeInput { | |
| 45 | clientId: string | |
| 46 | clientSecret?: string | |
| 47 | redirectUrl: string | |
| 48 | code: string | |
| 49 | codeVerifier: string | |
| 50 | } | |
| 51 | ||
| 52 | export interface TwitterRefreshInput { | |
| 53 | clientId: string | |
| 54 | clientSecret?: string | |
| 55 | refreshToken: string | |
| 56 | } | |
| 57 | ||
| 58 | export interface TwitterToken { | |
| 59 | accessToken: string | |
| 60 | refreshToken?: string | |
| 61 | expiresIn?: number | |
| 62 | scope?: string | |
| 63 | } | |
| 64 | ||
| 65 | export interface TwitterProfile { | |
| 66 | id: string | |
| 67 | username: string | |
| 68 | name?: string | |
| 69 | } | |
| 70 | ||
| 71 | /** base64url with padding stripped — the encoding OAuth2/PKCE expects. */ | |
| 72 | function base64Url(bytes: Uint8Array): string { | |
| 73 | let binary = '' | |
| 74 | for (const byte of bytes) binary += String.fromCharCode(byte) | |
| 75 | return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') | |
| 76 | } | |
| 77 | ||
| 78 | export class TwitterPublishingDriver { | |
| 79 | readonly provider: 'twitter' = 'twitter' | |
| 80 | characterLimit = 280 | |
| 81 | ||
| 82 | protected apiBase: string | |
| 83 | protected authorizeBase: string | |
| 84 | ||
| 85 | constructor(options: TwitterDriverOptions = {}) { | |
| 86 | this.apiBase = options.apiBase || 'https://api.twitter.com' | |
| 87 | this.authorizeBase = options.authorizeBase || 'https://twitter.com' | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Build the OAuth 2.0 consent URL with a PKCE challenge, returning both the | |
| 92 | * URL and the `code_verifier` the caller must stash for the token exchange. | |
| 93 | */ | |
| 94 | async createAuthorization(input: TwitterAuthUrlInput): Promise<{ url: string, codeVerifier: string }> { | |
| 95 | const codeVerifier = base64Url(crypto.getRandomValues(new Uint8Array(32))) | |
| 96 | const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)) | |
| 97 | const codeChallenge = base64Url(new Uint8Array(digest)) | |
| 98 | ||
| 99 | const query = new URLSearchParams({ | |
| 100 | response_type: 'code', | |
| 101 | client_id: input.clientId, | |
| 102 | redirect_uri: input.redirectUrl, | |
| 103 | scope: input.scopes.join(' '), | |
| 104 | state: input.state, | |
| 105 | code_challenge: codeChallenge, | |
| 106 | code_challenge_method: 'S256', | |
| 107 | }) | |
| 108 | ||
| 109 | return { url: `${this.authorizeBase}/i/oauth2/authorize?${query.toString()}`, codeVerifier } | |
| 110 | } | |
| 111 | ||
| 112 | /** Exchange an authorization code (+ PKCE verifier) for user tokens. */ | |
| 113 | async exchangeCode(input: TwitterTokenExchangeInput): Promise<TwitterToken> { | |
| 114 | return this.tokenRequest(new URLSearchParams({ | |
| 115 | grant_type: 'authorization_code', | |
| 116 | code: input.code, | |
| 117 | redirect_uri: input.redirectUrl, | |
| 118 | code_verifier: input.codeVerifier, | |
| 119 | client_id: input.clientId, | |
| 120 | }), input.clientId, input.clientSecret) | |
| 121 | } | |
| 122 | ||
| 123 | /** Mint a fresh access token from a stored refresh token. */ | |
| 124 | async refreshAccessToken(input: TwitterRefreshInput): Promise<TwitterToken> { | |
| 125 | return this.tokenRequest(new URLSearchParams({ | |
| 126 | grant_type: 'refresh_token', | |
| 127 | refresh_token: input.refreshToken, | |
| 128 | client_id: input.clientId, | |
| 129 | }), input.clientId, input.clientSecret) | |
| 130 | } | |
| 131 | ||
| 132 | /** Resolve the authenticated account (id + handle). */ | |
| 133 | async getProfile(accessToken: string): Promise<TwitterProfile> { | |
| 134 | const payload = await this.request<{ data?: { id: string, username: string, name?: string } }>( | |
| 135 | `${this.apiBase}/2/users/me?user.fields=username,name`, | |
| 136 | { headers: { authorization: `Bearer ${accessToken}` } }, | |
| 137 | ) | |
| 138 | if (!payload.data?.id || !payload.data.username) { | |
| 139 | throw new TwitterApiError('Twitter did not return the authenticated user.', 400, JSON.stringify(payload)) | |
| 140 | } | |
| 141 | return { id: payload.data.id, username: payload.data.username, name: payload.data.name } | |
| 142 | } | |
| 143 | ||
| 144 | /** Upload one image and return its media id for attachment. */ | |
| 145 | async uploadMedia(accessToken: string, bytes: Uint8Array, mimeType: string): Promise<string> { | |
| 146 | const form = new FormData() | |
| 147 | form.set('media', new Blob([bytes as unknown as BlobPart], { type: mimeType || 'image/jpeg' })) | |
| 148 | form.set('media_category', 'tweet_image') | |
| 149 | ||
| 150 | const payload = await this.request<{ data?: { id?: string }, media_id_string?: string, id?: string }>( | |
| 151 | `${this.apiBase}/2/media/upload`, | |
| 152 | { method: 'POST', headers: { authorization: `Bearer ${accessToken}` }, body: form }, | |
| 153 | ) | |
| 154 | ||
| 155 | const mediaId = payload.data?.id || payload.media_id_string || payload.id | |
| 156 | if (!mediaId) { | |
| 157 | throw new TwitterApiError('Twitter did not return a media id.', 400, JSON.stringify(payload)) | |
| 158 | } | |
| 159 | return mediaId | |
| 160 | } | |
| 161 | ||
| 162 | async publish(identity: SocialIdentityCredentials, post: PublishPostInput): Promise<PublishedPost> { | |
| 163 | if (!identity.accessToken) throw new Error('Twitter access token is missing for this identity.') | |
| 164 | if (post.text.length > this.characterLimit) { | |
| 165 | throw new Error(`Twitter posts must be ${this.characterLimit} characters or fewer.`) | |
| 166 | } | |
| 167 | ||
| 168 | // Resolve an attached image to bytes (uploaded files arrive as bytes; a bare | |
| 169 | // URL is fetched first) and upload it for its media id. | |
| 170 | const mediaIds: string[] = [] | |
| 171 | const media = post.media?.[0] | |
| 172 | if (media) { | |
| 173 | let bytes = media.bytes | |
| 174 | let mimeType = media.mimeType | |
| 175 | if (!bytes?.length && media.url) { | |
| 176 | const response = await fetch(media.url) | |
| 177 | if (response.ok) { | |
| 178 | bytes = new Uint8Array(await response.arrayBuffer()) | |
| 179 | mimeType = mimeType || response.headers.get('content-type') || 'image/jpeg' | |
| 180 | } | |
| 181 | } | |
| 182 | if (bytes?.length) mediaIds.push(await this.uploadMedia(identity.accessToken, bytes, mimeType || 'image/jpeg')) | |
| 183 | } | |
| 184 | ||
| 185 | const body: Record<string, unknown> = { text: post.text } | |
| 186 | if (mediaIds.length) body.media = { media_ids: mediaIds } | |
| 187 | // Threads chain through the parent tweet id, carried in reply.parent.uri. | |
| 188 | if (post.reply?.parent?.uri) body.reply = { in_reply_to_tweet_id: post.reply.parent.uri } | |
| 189 | ||
| 190 | const payload = await this.request<{ data?: { id?: string } }>( | |
| 191 | `${this.apiBase}/2/tweets`, | |
| 192 | { | |
| 193 | method: 'POST', | |
| 194 | headers: { 'authorization': `Bearer ${identity.accessToken}`, 'content-type': 'application/json' }, | |
| 195 | body: JSON.stringify(body), | |
| 196 | }, | |
| 197 | ) | |
| 198 | ||
| 199 | const id = payload.data?.id | |
| 200 | if (!id) throw new TwitterApiError('Twitter did not return a tweet id.', 400, JSON.stringify(payload)) | |
| 201 | ||
| 202 | return { | |
| 203 | provider: this.provider, | |
| 204 | // Expose the tweet id as both uri and cid so the crosspost thread-chaining | |
| 205 | // logic (which needs a cid) can reply-chain subsequent segments. | |
| 206 | uri: id, | |
| 207 | cid: id, | |
| 208 | url: identity.handle | |
| 209 | ? `https://x.com/${identity.handle}/status/${id}` | |
| 210 | : `https://x.com/i/web/status/${id}`, | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | // X's home timeline needs elevated access we don't assume here. | |
| 215 | async timeline(): Promise<{ items: [] }> { | |
| 216 | return { items: [] } | |
| 217 | } | |
| 218 | ||
| 219 | protected async tokenRequest(body: URLSearchParams, clientId: string, clientSecret?: string): Promise<TwitterToken> { | |
| 220 | const headers: Record<string, string> = { 'content-type': 'application/x-www-form-urlencoded' } | |
| 221 | // Confidential clients authenticate the token endpoint with HTTP Basic; | |
| 222 | // public (PKCE-only) clients send just the client_id in the body. | |
| 223 | if (clientSecret) headers.authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}` | |
| 224 | ||
| 225 | const payload = await this.request<{ | |
| 226 | access_token?: string | |
| 227 | refresh_token?: string | |
| 228 | expires_in?: number | |
| 229 | scope?: string | |
| 230 | }>(`${this.apiBase}/2/oauth2/token`, { method: 'POST', headers, body: body.toString() }) | |
| 231 | ||
| 232 | if (!payload.access_token) { | |
| 233 | throw new TwitterApiError('Twitter did not return an access token.', 400, JSON.stringify(payload)) | |
| 234 | } | |
| 235 | ||
| 236 | return { | |
| 237 | accessToken: payload.access_token, | |
| 238 | refreshToken: payload.refresh_token, | |
| 239 | expiresIn: payload.expires_in, | |
| 240 | scope: payload.scope, | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | protected async request<T>(url: string, init: RequestInit): Promise<T> { | |
| 245 | const response = await fetch(url, init) | |
| 246 | const text = await response.text() | |
| 247 | ||
| 248 | if (!response.ok) { | |
| 249 | throw new TwitterApiError( | |
| 250 | `Twitter API failed (${response.status}): ${text || response.statusText}`, | |
| 251 | response.status, | |
| 252 | text, | |
| 253 | ) | |
| 254 | } | |
| 255 | ||
| 256 | return text ? JSON.parse(text) as T : {} as T | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | // Consumers import this as `TwitterDriver`, matching the sibling drivers. | |
| 261 | export { TwitterPublishingDriver as TwitterDriver } | |
| @@ -0,0 +1,406 @@ | ||
| 1 | import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { env } from '@stacksjs/env' | |
| 4 | import { TwitterApiError, TwitterDriver } from './Drivers/TwitterDriver' | |
| 5 | import { ensureAccount, expiresAt, isExpiringSoon, 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: 'twitter' | |
| 14 | external_id?: string | null | |
| 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' | |
| 16 | access_token?: string | null | |
| 17 | refresh_token?: string | null | |
| 18 | token_expires_at?: string | null | |
| 19 | account_id?: number | null | |
| 20 | social_driver_id?: number | null | |
| 21 | } | |
| 22 | ||
| 23 | type SocialDriverRow = { | |
| 24 | id: number | |
| 25 | provider: 'twitter' | |
| 26 | display_name: string | |
| 27 | status: 'active' | 'planned' | 'disabled' | |
| 28 | character_limit: number | |
| 29 | } | |
| 30 | ||
| 31 | interface TwitterConfig { | |
| 32 | clientId: string | |
| 33 | clientSecret: string | |
| 34 | redirectUrl: string | |
| 35 | accessToken: string | |
| 36 | scopes: string[] | |
| 37 | } | |
| 38 | ||
| 39 | function randomState(): string { | |
| 40 | const bytes = new Uint8Array(24) | |
| 41 | crypto.getRandomValues(bytes) | |
| 42 | return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') | |
| 43 | } | |
| 44 | ||
| 45 | export class TwitterService { | |
| 46 | private driver = new TwitterDriver() | |
| 47 | // OAuth CSRF token + PKCE verifier stashed between the auth redirect and the | |
| 48 | // callback. Postline runs single-user, so module memory is an adequate store. | |
| 49 | private pendingState: string | null = null | |
| 50 | private pendingVerifier: string | null = null | |
| 51 | ||
| 52 | private config(): TwitterConfig { | |
| 53 | return { | |
| 54 | clientId: String(env.TWITTER_CLIENT_ID || '').trim(), | |
| 55 | clientSecret: String(env.TWITTER_CLIENT_SECRET || '').trim(), | |
| 56 | redirectUrl: String(env.TWITTER_REDIRECT_URL || '').trim(), | |
| 57 | accessToken: String(env.TWITTER_ACCESS_TOKEN || '').trim(), | |
| 58 | scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'], | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | async status() { | |
| 63 | const identity = await this.findIdentity() | |
| 64 | const cfg = this.config() | |
| 65 | const connected = identity?.auth_status === 'connected' && Boolean(identity.access_token) | |
| 66 | ||
| 67 | return { | |
| 68 | connected, | |
| 69 | provider: 'twitter', | |
| 70 | handle: identity?.handle || null, | |
| 71 | displayName: identity?.display_name || null, | |
| 72 | did: identity?.external_id || null, | |
| 73 | authStatus: connected ? 'connected' : (identity?.handle ? identity.auth_status : 'missing'), | |
| 74 | characterLimit: this.driver.characterLimit, | |
| 75 | canPublish: connected, | |
| 76 | configuredFromEnv: Boolean(cfg.accessToken), | |
| 77 | oauthConfigured: Boolean(cfg.clientId && cfg.redirectUrl), | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /** Build the X consent URL (with PKCE) and remember the CSRF state + verifier. */ | |
| 82 | async getAuthUrl(): Promise<string> { | |
| 83 | const cfg = this.config() | |
| 84 | if (!cfg.clientId) throw new Error('Set TWITTER_CLIENT_ID to connect X/Twitter.') | |
| 85 | if (!cfg.redirectUrl) throw new Error('Set TWITTER_REDIRECT_URL to connect X/Twitter.') | |
| 86 | ||
| 87 | this.pendingState = randomState() | |
| 88 | const { url, codeVerifier } = await this.driver.createAuthorization({ | |
| 89 | clientId: cfg.clientId, | |
| 90 | redirectUrl: cfg.redirectUrl, | |
| 91 | scopes: cfg.scopes, | |
| 92 | state: this.pendingState, | |
| 93 | }) | |
| 94 | this.pendingVerifier = codeVerifier | |
| 95 | return url | |
| 96 | } | |
| 97 | ||
| 98 | /** Handle the OAuth redirect: exchange the code and store the tokens. */ | |
| 99 | async handleCallback(code: string, state: string) { | |
| 100 | const cfg = this.config() | |
| 101 | if (!code) throw new Error('X/Twitter did not return an authorization code.') | |
| 102 | if (this.pendingState && state !== this.pendingState) { | |
| 103 | throw new Error('X/Twitter OAuth state mismatch. Please start the connection again.') | |
| 104 | } | |
| 105 | const codeVerifier = this.pendingVerifier | |
| 106 | if (!codeVerifier) throw new Error('Missing PKCE verifier — please start the X/Twitter connection again.') | |
| 107 | this.pendingState = null | |
| 108 | this.pendingVerifier = null | |
| 109 | ||
| 110 | const token = await this.driver.exchangeCode({ | |
| 111 | clientId: cfg.clientId, | |
| 112 | clientSecret: cfg.clientSecret || undefined, | |
| 113 | redirectUrl: cfg.redirectUrl, | |
| 114 | code, | |
| 115 | codeVerifier, | |
| 116 | }) | |
| 117 | ||
| 118 | const profile = await this.driver.getProfile(token.accessToken) | |
| 119 | const identity = await this.saveSession({ | |
| 120 | accessToken: token.accessToken, | |
| 121 | refreshToken: token.refreshToken, | |
| 122 | expiresIn: token.expiresIn, | |
| 123 | userId: profile.id, | |
| 124 | username: profile.username, | |
| 125 | name: profile.name, | |
| 126 | }) | |
| 127 | return this.publicIdentity(identity) | |
| 128 | } | |
| 129 | ||
| 130 | /** Connect using a pre-obtained token from the environment. */ | |
| 131 | async connectFromEnv() { | |
| 132 | const cfg = this.config() | |
| 133 | if (!cfg.accessToken) { | |
| 134 | throw new Error('Set TWITTER_ACCESS_TOKEN (or connect via OAuth) before using X/Twitter.') | |
| 135 | } | |
| 136 | ||
| 137 | const profile = await this.driver.getProfile(cfg.accessToken) | |
| 138 | const identity = await this.saveSession({ | |
| 139 | accessToken: cfg.accessToken, | |
| 140 | userId: profile.id, | |
| 141 | username: profile.username, | |
| 142 | name: profile.name, | |
| 143 | }) | |
| 144 | return this.publicIdentity(identity) | |
| 145 | } | |
| 146 | ||
| 147 | /** | |
| 148 | * Publish an already-created post row to X/Twitter. Never throws — failures | |
| 149 | * are recorded on the target and returned so a crosspost to other providers | |
| 150 | * can still succeed. | |
| 151 | */ | |
| 152 | async publishToPost( | |
| 153 | post: { id: number, body: string }, | |
| 154 | content?: PublishContent, | |
| 155 | ): Promise<CrosspostTargetResult> { | |
| 156 | const driver = await this.ensureDriver() | |
| 157 | ||
| 158 | let identity: SocialIdentityRow | |
| 159 | try { | |
| 160 | identity = await this.requireIdentity() | |
| 161 | } | |
| 162 | catch (error) { | |
| 163 | return { provider: 'twitter', ok: false, error: messageOf(error) } | |
| 164 | } | |
| 165 | ||
| 166 | if (post.body.length > this.driver.characterLimit) { | |
| 167 | return { | |
| 168 | provider: 'twitter', | |
| 169 | ok: false, | |
| 170 | error: `Twitter posts must be ${this.driver.characterLimit} characters or fewer.`, | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | const targetUuid = uuid() | |
| 175 | const createdAt = now() | |
| 176 | await database.insertInto('post_targets').values({ | |
| 177 | uuid: targetUuid, | |
| 178 | provider: 'twitter', | |
| 179 | status: 'publishing', | |
| 180 | post_id: post.id, | |
| 181 | social_driver_id: driver.id, | |
| 182 | social_identity_id: identity.id, | |
| 183 | created_at: createdAt, | |
| 184 | updated_at: createdAt, | |
| 185 | }).execute() | |
| 186 | ||
| 187 | const target = await database | |
| 188 | .selectFrom('post_targets') | |
| 189 | .selectAll() | |
| 190 | .where('uuid', '=', targetUuid) | |
| 191 | .executeTakeFirstOrThrow() | |
| 192 | ||
| 193 | try { | |
| 194 | const published = await this.driver.publish({ | |
| 195 | handle: identity.handle, | |
| 196 | did: identity.external_id || undefined, | |
| 197 | accessToken: identity.access_token || undefined, | |
| 198 | }, { | |
| 199 | text: post.body, | |
| 200 | ...(content?.media?.length ? { media: content.media } : {}), | |
| 201 | ...(content?.reply ? { reply: content.reply } : {}), | |
| 202 | }) | |
| 203 | ||
| 204 | await database.updateTable('post_targets').set({ | |
| 205 | status: 'published', | |
| 206 | remote_uri: published.uri || null, | |
| 207 | remote_cid: published.cid || null, | |
| 208 | failure_reason: null, | |
| 209 | updated_at: now(), | |
| 210 | }).where('id', '=', target.id).execute() | |
| 211 | ||
| 212 | return { | |
| 213 | provider: 'twitter', | |
| 214 | ok: true, | |
| 215 | url: published.url, | |
| 216 | uri: published.uri, | |
| 217 | cid: published.cid, | |
| 218 | targetId: Number(target.id), | |
| 219 | } | |
| 220 | } | |
| 221 | catch (error) { | |
| 222 | const message = messageOf(error) | |
| 223 | if (error instanceof TwitterApiError && error.isAuthError) { | |
| 224 | await this.markExpired(identity.id) | |
| 225 | } | |
| 226 | await database.updateTable('post_targets').set({ | |
| 227 | status: 'failed', | |
| 228 | failure_reason: message, | |
| 229 | updated_at: now(), | |
| 230 | }).where('id', '=', target.id).execute() | |
| 231 | ||
| 232 | return { provider: 'twitter', ok: false, error: message, targetId: Number(target.id) } | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | private async requireIdentity(): Promise<SocialIdentityRow> { | |
| 237 | const existing = await this.findIdentity() | |
| 238 | if (existing?.access_token && existing.auth_status === 'connected') { | |
| 239 | return await this.refreshIfExpiring(existing) | |
| 240 | } | |
| 241 | ||
| 242 | if (this.config().accessToken) { | |
| 243 | await this.connectFromEnv() | |
| 244 | const connected = await this.findIdentity() | |
| 245 | if (connected?.access_token) return connected | |
| 246 | } | |
| 247 | ||
| 248 | throw new Error('Connect X/Twitter before publishing.') | |
| 249 | } | |
| 250 | ||
| 251 | /** | |
| 252 | * Refresh the access token when it's within its pre-expiry window and a | |
| 253 | * refresh token is on file (present when connected with the `offline.access` | |
| 254 | * scope). Best-effort: any failure leaves the current token in place so a real | |
| 255 | * auth failure still surfaces at publish time. | |
| 256 | */ | |
| 257 | private async refreshIfExpiring(identity: SocialIdentityRow): Promise<SocialIdentityRow> { | |
| 258 | const cfg = this.config() | |
| 259 | if (!identity.refresh_token || !isExpiringSoon(identity.token_expires_at)) return identity | |
| 260 | if (!cfg.clientId) return identity | |
| 261 | ||
| 262 | try { | |
| 263 | const refreshed = await this.driver.refreshAccessToken({ | |
| 264 | clientId: cfg.clientId, | |
| 265 | clientSecret: cfg.clientSecret || undefined, | |
| 266 | refreshToken: identity.refresh_token, | |
| 267 | }) | |
| 268 | const nextExpiry = expiresAt(refreshed.expiresIn) | |
| 269 | // X rotates the refresh token on every refresh — always keep the new one. | |
| 270 | const nextRefresh = refreshed.refreshToken ?? identity.refresh_token | |
| 271 | await database.updateTable('social_identities').set({ | |
| 272 | access_token: refreshed.accessToken, | |
| 273 | refresh_token: nextRefresh, | |
| 274 | token_expires_at: nextExpiry, | |
| 275 | auth_status: 'connected', | |
| 276 | updated_at: now(), | |
| 277 | }).where('id', '=', identity.id).execute() | |
| 278 | ||
| 279 | return { ...identity, access_token: refreshed.accessToken, refresh_token: nextRefresh, token_expires_at: nextExpiry } | |
| 280 | } | |
| 281 | catch { | |
| 282 | return identity | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | private async findIdentity(): Promise<SocialIdentityRow | undefined> { | |
| 287 | return await database | |
| 288 | .selectFrom('social_identities') | |
| 289 | .selectAll() | |
| 290 | .where('provider', '=', 'twitter') | |
| 291 | .orderBy('updated_at', 'desc') | |
| 292 | .executeTakeFirst() | |
| 293 | } | |
| 294 | ||
| 295 | private async saveSession(input: { accessToken: string, refreshToken?: string, expiresIn?: number, userId: string, username: string, name?: string }): Promise<SocialIdentityRow> { | |
| 296 | const accountId = await ensureAccount() | |
| 297 | const driver = await this.ensureDriver() | |
| 298 | const existing = await this.findIdentity() | |
| 299 | const savedAt = now() | |
| 300 | ||
| 301 | const values = { | |
| 302 | handle: input.username, | |
| 303 | display_name: input.name || input.username, | |
| 304 | provider: 'twitter', | |
| 305 | external_id: input.userId, | |
| 306 | auth_status: 'connected', | |
| 307 | access_token: input.accessToken, | |
| 308 | refresh_token: input.refreshToken ?? null, | |
| 309 | token_expires_at: expiresAt(input.expiresIn), | |
| 310 | account_id: accountId, | |
| 311 | social_driver_id: driver.id, | |
| 312 | updated_at: savedAt, | |
| 313 | } | |
| 314 | ||
| 315 | if (existing) { | |
| 316 | await database.updateTable('social_identities').set(values).where('id', '=', existing.id).execute() | |
| 317 | } | |
| 318 | else { | |
| 319 | await database.insertInto('social_identities').values({ | |
| 320 | uuid: uuid(), | |
| 321 | ...values, | |
| 322 | created_at: savedAt, | |
| 323 | }).execute() | |
| 324 | } | |
| 325 | ||
| 326 | return await this.findIdentity() as SocialIdentityRow | |
| 327 | } | |
| 328 | ||
| 329 | private async markExpired(id: number): Promise<void> { | |
| 330 | await database.updateTable('social_identities').set({ | |
| 331 | auth_status: 'expired', | |
| 332 | updated_at: now(), | |
| 333 | }).where('id', '=', id).execute() | |
| 334 | } | |
| 335 | ||
| 336 | private async ensureDriver(): Promise<SocialDriverRow> { | |
| 337 | const existing = await database | |
| 338 | .selectFrom('social_drivers') | |
| 339 | .selectAll() | |
| 340 | .where('provider', '=', 'twitter') | |
| 341 | .executeTakeFirst() | |
| 342 | ||
| 343 | if (existing) { | |
| 344 | if (existing.status !== 'active' || existing.character_limit !== this.driver.characterLimit) { | |
| 345 | await database.updateTable('social_drivers').set({ | |
| 346 | status: 'active', | |
| 347 | character_limit: this.driver.characterLimit, | |
| 348 | updated_at: now(), | |
| 349 | }).where('id', '=', existing.id).execute() | |
| 350 | } | |
| 351 | ||
| 352 | return await database | |
| 353 | .selectFrom('social_drivers') | |
| 354 | .selectAll() | |
| 355 | .where('id', '=', existing.id) | |
| 356 | .executeTakeFirstOrThrow() | |
| 357 | } | |
| 358 | ||
| 359 | const driverUuid = uuid() | |
| 360 | await database.insertInto('social_drivers').values({ | |
| 361 | uuid: driverUuid, | |
| 362 | provider: 'twitter', | |
| 363 | display_name: 'X (Twitter)', | |
| 364 | status: 'active', | |
| 365 | character_limit: this.driver.characterLimit, | |
| 366 | capabilities: JSON.stringify({ posts: true, timelines: false, oauth: true }), | |
| 367 | created_at: now(), | |
| 368 | updated_at: now(), | |
| 369 | }).execute() | |
| 370 | ||
| 371 | return await database | |
| 372 | .selectFrom('social_drivers') | |
| 373 | .selectAll() | |
| 374 | .where('uuid', '=', driverUuid) | |
| 375 | .executeTakeFirstOrThrow() | |
| 376 | } | |
| 377 | ||
| 378 | private publicIdentity(row: SocialIdentityRow | undefined) { | |
| 379 | if (!row) { | |
| 380 | return { | |
| 381 | connected: false, | |
| 382 | provider: 'twitter', | |
| 383 | handle: null, | |
| 384 | displayName: null, | |
| 385 | did: null, | |
| 386 | authStatus: 'missing', | |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | const connected = row.auth_status === 'connected' && Boolean(row.access_token) | |
| 391 | return { | |
| 392 | connected, | |
| 393 | provider: 'twitter', | |
| 394 | handle: row.handle, | |
| 395 | displayName: row.display_name || null, | |
| 396 | did: row.external_id || null, | |
| 397 | authStatus: connected ? 'connected' : 'missing', | |
| 398 | } | |
| 399 | } | |
| 400 | } | |
| 401 | ||
| 402 | function messageOf(error: unknown): string { | |
| 403 | return error instanceof Error ? error.message : String(error) | |
| 404 | } | |
| 405 | ||
| 406 | export const twitter = new TwitterService() | |
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | <div> |
| 13 | 13 | <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Accounts</p> |
| 14 | 14 | <h1 class="m-0 font-bold text-[22px] text-neutral-950 tracking-normal">Publishing drivers</h1> |
| 15 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Connect Bluesky, LinkedIn, Instagram, Threads, and Mastodon to publish and crosspost from Postline.</p> | |
| 15 | <p class="mb-0 mt-1.5 text-[13px] text-zinc-600">Connect Bluesky, X, LinkedIn, Instagram, Threads, and Mastodon to publish and crosspost from Postline.</p> | |
| 16 | 16 | </div> |
| 17 | 17 | </header> |
| 18 | 18 | |
| @@ -97,13 +97,17 @@ | ||
| 97 | 97 | <p class="sm:col-span-3 m-0 text-xs text-zinc-500">Create a token in your instance's Preferences → Development → New application (needs the <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">write:statuses</code> + <code class="px-1 py-0.5 text-xs bg-zinc-100 rounded">write:media</code> scopes).</p> |
| 98 | 98 | </form> |
| 99 | 99 | </article> |
| 100 | <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80"> | |
| 100 | <article class="flex gap-5 items-center justify-between p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="twitter"> | |
| 101 | 101 | <div> |
| 102 | <h2 class="my-0 font-bold text-[15px] text-zinc-700">Twitter/X</h2> | |
| 103 | <p class="my-1.5 text-[13px] text-zinc-600">Planned</p> | |
| 104 | <span class="text-xs text-zinc-500">Driver adapter coming after the current set is stable.</span> | |
| 102 | <h2 class="my-0 font-bold text-[15px]">X (Twitter)</h2> | |
| 103 | <p class="my-1.5 text-[13px] text-slate-700" data-twitter-channel-handle>Not connected</p> | |
| 104 | <span class="text-xs text-zinc-500" data-twitter-channel-detail>Connect X to crosspost tweets. Posting needs a paid X API tier.</span> | |
| 105 | </div> | |
| 106 | <div class="flex flex-col gap-2 items-end"> | |
| 107 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-twitter-channel-status>Setup</span> | |
| 108 | <button class="inline-flex items-center justify-center px-3 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-xl disabled:opacity-50 transition-colors" type="button" data-twitter-connect>Connect with X</button> | |
| 109 | <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-twitter-connect-env>Use token from .env</button> | |
| 105 | 110 | </div> |
| 106 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full">Planned</span> | |
| 107 | 111 | </article> |
| 108 | 112 | </div> |
| 109 | 113 | </section> |
| @@ -289,6 +293,105 @@ | ||
| 289 | 293 | startLinkedIn() |
| 290 | 294 | } |
| 291 | 295 | |
| 296 | // --- X (Twitter) --- | |
| 297 | const twitterHandle = root.querySelector('[data-twitter-channel-handle]') | |
| 298 | const twitterDetail = root.querySelector('[data-twitter-channel-detail]') | |
| 299 | const twitterStatusPill = root.querySelector('[data-twitter-channel-status]') | |
| 300 | const twitterConnect = root.querySelector('[data-twitter-connect]') | |
| 301 | const twitterConnectEnv = root.querySelector('[data-twitter-connect-env]') | |
| 302 | ||
| 303 | const renderTwitter = (data) => { | |
| 304 | const connected = Boolean(data?.canPublish) | |
| 305 | const label = data?.handle ? `@${data.handle}` : 'Not connected' | |
| 306 | if (twitterHandle) { | |
| 307 | twitterHandle.textContent = connected ? label : (data?.handle ? `${label} — reconnect` : 'Not connected') | |
| 308 | } | |
| 309 | if (twitterDetail) { | |
| 310 | twitterDetail.textContent = connected | |
| 311 | ? 'Ready to crosspost tweets from Postline.' | |
| 312 | : data?.oauthConfigured | |
| 313 | ? 'Connect X to crosspost tweets. Posting needs a paid X API tier.' | |
| 314 | : data?.configuredFromEnv | |
| 315 | ? 'Token found in .env — connect to start crossposting.' | |
| 316 | : 'Add X (Twitter) API keys to .env, then connect.' | |
| 317 | } | |
| 318 | if (twitterStatusPill) { | |
| 319 | twitterStatusPill.textContent = connected ? 'Connected' : (data?.handle ? 'Reconnect' : 'Setup') | |
| 320 | twitterStatusPill.className = `whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold ${ | |
| 321 | connected | |
| 322 | ? 'border-emerald-200 bg-emerald-50 text-emerald-700' | |
| 323 | : data?.handle | |
| 324 | ? 'border-amber-200 bg-amber-50 text-amber-800' | |
| 325 | : 'border-zinc-200 text-zinc-500' | |
| 326 | }` | |
| 327 | } | |
| 328 | if (twitterConnect) { | |
| 329 | twitterConnect.textContent = connected ? 'Reconnect X' : 'Connect with X' | |
| 330 | if (data && !data.oauthConfigured) twitterConnect.setAttribute('disabled', 'disabled') | |
| 331 | else twitterConnect.removeAttribute('disabled') | |
| 332 | } | |
| 333 | if (twitterConnectEnv) twitterConnectEnv.classList.toggle('hidden', !data?.configuredFromEnv) | |
| 334 | } | |
| 335 | ||
| 336 | const refreshTwitter = async () => { | |
| 337 | try { | |
| 338 | const response = await fetch('/api/postline/twitter/status') | |
| 339 | const payload = await response.json() | |
| 340 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load X/Twitter status.') | |
| 341 | renderTwitter(payload.data) | |
| 342 | } | |
| 343 | catch (error) { | |
| 344 | renderTwitter(null) | |
| 345 | notify(error instanceof Error ? error.message : String(error), 'error', { title: 'X/Twitter' }) | |
| 346 | } | |
| 347 | } | |
| 348 | ||
| 349 | twitterConnect?.addEventListener('click', () => { | |
| 350 | notify('Redirecting to X…', 'info', { title: 'X/Twitter' }) | |
| 351 | window.location.href = '/api/postline/twitter/auth' | |
| 352 | }) | |
| 353 | ||
| 354 | twitterConnectEnv?.addEventListener('click', async () => { | |
| 355 | twitterConnectEnv.setAttribute('disabled', 'disabled') | |
| 356 | notify('Connecting X from .env…', 'info', { title: 'X/Twitter' }) | |
| 357 | try { | |
| 358 | const response = await fetch('/api/postline/twitter/connect', { method: 'POST' }) | |
| 359 | const payload = await response.json() | |
| 360 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'X connect failed.') | |
| 361 | await refreshTwitter() | |
| 362 | notify('X connected. You can crosspost from the composer.', 'success', { title: 'Connected' }) | |
| 363 | } | |
| 364 | catch (error) { | |
| 365 | notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Connect failed' }) | |
| 366 | } | |
| 367 | finally { | |
| 368 | twitterConnectEnv.removeAttribute('disabled') | |
| 369 | } | |
| 370 | }) | |
| 371 | ||
| 372 | const twitterParams = new URLSearchParams(window.location.search) | |
| 373 | const twitterResult = twitterParams.get('twitter') | |
| 374 | if (twitterResult === 'connected') { | |
| 375 | notify('X connected. You can crosspost from the composer.', 'success', { title: 'Connected' }) | |
| 376 | } | |
| 377 | else if (twitterResult === 'error') { | |
| 378 | notify(twitterParams.get('message') || 'X connection failed.', 'error', { title: 'X/Twitter' }) | |
| 379 | } | |
| 380 | if (twitterResult) { | |
| 381 | twitterParams.delete('twitter') | |
| 382 | twitterParams.delete('message') | |
| 383 | const query = twitterParams.toString() | |
| 384 | window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`) | |
| 385 | } | |
| 386 | ||
| 387 | const startTwitter = () => void refreshTwitter() | |
| 388 | if (document.readyState === 'loading') { | |
| 389 | document.addEventListener('DOMContentLoaded', startTwitter, { once: true }) | |
| 390 | } | |
| 391 | else { | |
| 392 | startTwitter() | |
| 393 | } | |
| 394 | ||
| 292 | 395 | // --- Instagram --- |
| 293 | 396 | const instagramHandle = root.querySelector('[data-instagram-channel-handle]') |
| 294 | 397 | const instagramDetail = root.querySelector('[data-instagram-channel-detail]') |
| @@ -57,6 +57,13 @@ route.group({ prefix: '/postline/threads' }, () => { | ||
| 57 | 57 | route.get('/callback', 'Actions/Postline/ThreadsCallbackAction').skipCsrf() |
| 58 | 58 | }) |
| 59 | 59 | |
| 60 | route.group({ prefix: '/postline/twitter' }, () => { | |
| 61 | route.get('/status', 'Actions/Postline/TwitterStatusAction').middleware('auth').skipCsrf() | |
| 62 | route.post('/connect', 'Actions/Postline/TwitterConnectAction').middleware('auth').skipCsrf() | |
| 63 | route.get('/auth', 'Actions/Postline/TwitterAuthAction').skipCsrf() | |
| 64 | route.get('/callback', 'Actions/Postline/TwitterCallbackAction').skipCsrf() | |
| 65 | }) | |
| 66 | ||
| 60 | 67 | route.group({ prefix: '/postline/mastodon' }, () => { |
| 61 | 68 | route.get('/status', 'Actions/Postline/MastodonStatusAction').middleware('auth').skipCsrf() |
| 62 | 69 | route.post('/connect', 'Actions/Postline/MastodonConnectAction').middleware('auth').skipCsrf() |
| @@ -0,0 +1,160 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { TwitterApiError, TwitterDriver } from '../../app/Services/Social/Drivers/TwitterDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | const identity = { handle: 'glenn', did: '77', accessToken: 'AT' } | |
| 8 | ||
| 9 | interface Captured { url: string, method: string, body: any, headers: any } | |
| 10 | ||
| 11 | function mockTwitterApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void { | |
| 12 | globalThis.fetch = (async (input: any, init: any = {}) => { | |
| 13 | const url = String(input) | |
| 14 | const method = init.method || 'GET' | |
| 15 | const over = overrides(url) | |
| 16 | if (over) { captured.push({ url, method, body: init.body, headers: init.headers }); return over } | |
| 17 | ||
| 18 | if (url.includes('/2/oauth2/token')) { | |
| 19 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 20 | return new Response(JSON.stringify({ token_type: 'bearer', expires_in: 7200, access_token: 'NEW_AT', refresh_token: 'NEW_RT', scope: 'tweet.write' }), { status: 200 }) | |
| 21 | } | |
| 22 | if (url.includes('/2/users/me')) { | |
| 23 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 24 | return new Response(JSON.stringify({ data: { id: '77', username: 'glenn', name: 'Glenn' } }), { status: 200 }) | |
| 25 | } | |
| 26 | if (url.includes('/2/media/upload')) { | |
| 27 | captured.push({ url, method, body: init.body, headers: init.headers }) | |
| 28 | return new Response(JSON.stringify({ data: { id: 'media123' } }), { status: 200 }) | |
| 29 | } | |
| 30 | // /2/tweets | |
| 31 | const body = init.body ? JSON.parse(String(init.body)) : undefined | |
| 32 | captured.push({ url, method, body, headers: init.headers }) | |
| 33 | return new Response(JSON.stringify({ data: { id: 'tweet999' } }), { status: 200 }) | |
| 34 | }) as typeof fetch | |
| 35 | } | |
| 36 | ||
| 37 | function tweetRequest(captured: Captured[]): Captured | undefined { | |
| 38 | return captured.find(c => c.url.endsWith('/2/tweets')) | |
| 39 | } | |
| 40 | ||
| 41 | describe('TwitterDriver.createAuthorization (PKCE)', () => { | |
| 42 | test('builds a consent URL with an S256 challenge and returns the verifier', async () => { | |
| 43 | const driver = new TwitterDriver() | |
| 44 | const { url, codeVerifier } = await driver.createAuthorization({ | |
| 45 | clientId: 'cid', | |
| 46 | redirectUrl: 'https://app.example/cb', | |
| 47 | scopes: ['tweet.read', 'tweet.write', 'offline.access'], | |
| 48 | state: 'st8', | |
| 49 | }) | |
| 50 | ||
| 51 | expect(url).toContain('twitter.com/i/oauth2/authorize') | |
| 52 | expect(url).toContain('client_id=cid') | |
| 53 | expect(url).toContain('state=st8') | |
| 54 | expect(url).toContain('code_challenge=') | |
| 55 | expect(url).toContain('code_challenge_method=S256') | |
| 56 | expect(url).toContain('scope=tweet.read+tweet.write+offline.access') | |
| 57 | expect(codeVerifier.length).toBeGreaterThan(20) | |
| 58 | }) | |
| 59 | }) | |
| 60 | ||
| 61 | describe('TwitterDriver tokens', () => { | |
| 62 | test('exchangeCode posts the code + PKCE verifier and returns tokens', async () => { | |
| 63 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 64 | const driver = new TwitterDriver() | |
| 65 | const token = await driver.exchangeCode({ clientId: 'cid', redirectUrl: 'https://app/cb', code: 'abc', codeVerifier: 'ver123' }) | |
| 66 | ||
| 67 | const call = cap.find(c => c.url.includes('/2/oauth2/token')) | |
| 68 | expect(String(call?.body)).toContain('grant_type=authorization_code') | |
| 69 | expect(String(call?.body)).toContain('code_verifier=ver123') | |
| 70 | expect(token.accessToken).toBe('NEW_AT') | |
| 71 | expect(token.refreshToken).toBe('NEW_RT') | |
| 72 | expect(token.expiresIn).toBe(7200) | |
| 73 | }) | |
| 74 | ||
| 75 | test('exchangeCode adds HTTP Basic auth for a confidential client', async () => { | |
| 76 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 77 | const driver = new TwitterDriver() | |
| 78 | await driver.exchangeCode({ clientId: 'cid', clientSecret: 'secret', redirectUrl: 'https://app/cb', code: 'abc', codeVerifier: 'v' }) | |
| 79 | ||
| 80 | const call = cap.find(c => c.url.includes('/2/oauth2/token')) | |
| 81 | expect(call?.headers.authorization).toBe(`Basic ${btoa('cid:secret')}`) | |
| 82 | }) | |
| 83 | ||
| 84 | test('refreshAccessToken uses the refresh_token grant', async () => { | |
| 85 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 86 | const driver = new TwitterDriver() | |
| 87 | const token = await driver.refreshAccessToken({ clientId: 'cid', refreshToken: 'RT1' }) | |
| 88 | ||
| 89 | const call = cap.find(c => c.url.includes('/2/oauth2/token')) | |
| 90 | expect(String(call?.body)).toContain('grant_type=refresh_token') | |
| 91 | expect(String(call?.body)).toContain('refresh_token=RT1') | |
| 92 | expect(token.accessToken).toBe('NEW_AT') | |
| 93 | }) | |
| 94 | ||
| 95 | test('getProfile resolves the authenticated account', async () => { | |
| 96 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 97 | const driver = new TwitterDriver() | |
| 98 | const profile = await driver.getProfile('AT') | |
| 99 | expect(cap[0].url).toContain('/2/users/me') | |
| 100 | expect(profile).toEqual({ id: '77', username: 'glenn', name: 'Glenn' }) | |
| 101 | }) | |
| 102 | }) | |
| 103 | ||
| 104 | describe('TwitterDriver.publish', () => { | |
| 105 | test('posts a plain tweet and returns the id as both uri and cid', async () => { | |
| 106 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 107 | const driver = new TwitterDriver() | |
| 108 | const result = await driver.publish(identity, { text: 'hello world' }) | |
| 109 | ||
| 110 | expect(tweetRequest(cap)?.body.text).toBe('hello world') | |
| 111 | expect(result.uri).toBe('tweet999') | |
| 112 | expect(result.cid).toBe('tweet999') | |
| 113 | expect(result.url).toContain('x.com/glenn/status/tweet999') | |
| 114 | expect(cap.find(c => c.url.includes('/2/media/upload'))).toBeUndefined() | |
| 115 | }) | |
| 116 | ||
| 117 | test('chains a reply via in_reply_to_tweet_id', async () => { | |
| 118 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 119 | const driver = new TwitterDriver() | |
| 120 | await driver.publish(identity, { | |
| 121 | text: 'second', | |
| 122 | reply: { root: { uri: 't1', cid: 't1' }, parent: { uri: 't1', cid: 't1' } }, | |
| 123 | }) | |
| 124 | expect(tweetRequest(cap)?.body.reply.in_reply_to_tweet_id).toBe('t1') | |
| 125 | }) | |
| 126 | ||
| 127 | test('uploads image bytes and attaches media_ids', async () => { | |
| 128 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 129 | const driver = new TwitterDriver() | |
| 130 | await driver.publish(identity, { text: 'photo', media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png' }] }) | |
| 131 | ||
| 132 | expect(cap.find(c => c.url.includes('/2/media/upload'))).toBeDefined() | |
| 133 | expect(tweetRequest(cap)?.body.media.media_ids).toEqual(['media123']) | |
| 134 | }) | |
| 135 | ||
| 136 | test('rejects text over the character limit before calling the API', async () => { | |
| 137 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 138 | const driver = new TwitterDriver() | |
| 139 | await expect(driver.publish(identity, { text: 'x'.repeat(281) })).rejects.toThrow(/280 characters/) | |
| 140 | expect(cap).toHaveLength(0) | |
| 141 | }) | |
| 142 | ||
| 143 | test('rejects when the identity has no access token', async () => { | |
| 144 | const cap: Captured[] = []; mockTwitterApi(cap) | |
| 145 | const driver = new TwitterDriver() | |
| 146 | await expect(driver.publish({ handle: 'glenn' }, { text: 'hi' })).rejects.toThrow(/access token/) | |
| 147 | expect(cap).toHaveLength(0) | |
| 148 | }) | |
| 149 | ||
| 150 | test('surfaces an API error as TwitterApiError with status', async () => { | |
| 151 | const cap: Captured[] = [] | |
| 152 | mockTwitterApi(cap, url => url.endsWith('/2/tweets') ? new Response('{"title":"Unauthorized"}', { status: 401 }) : null) | |
| 153 | const driver = new TwitterDriver() | |
| 154 | let caught: unknown | |
| 155 | await driver.publish(identity, { text: 'hi' }).catch((e) => { caught = e }) | |
| 156 | expect(caught).toBeInstanceOf(TwitterApiError) | |
| 157 | expect((caught as TwitterApiError).status).toBe(401) | |
| 158 | expect((caught as TwitterApiError).isAuthError).toBe(true) | |
| 159 | }) | |
| 160 | }) | |