also looking at this
Mastodon crosspost driver
#26
11 files
+752
-2
| @@ -0,0 +1,29 @@ | ||
| 1 | import type { RequestInstance } from '@stacksjs/types' | |
| 2 | import { Action } from '@stacksjs/actions' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | import { mastodon } from '../../Services/Social/MastodonService' | |
| 5 | ||
| 6 | export default new Action({ | |
| 7 | name: 'Postline Mastodon Connect', | |
| 8 | description: 'Connect a Mastodon account with an instance URL + access token.', | |
| 9 | method: 'POST', | |
| 10 | ||
| 11 | async handle(request: RequestInstance) { | |
| 12 | // With no body, connect from MASTODON_* env vars. | |
| 13 | const instance = String(request.get('instance') || '').trim() | |
| 14 | const accessToken = String(request.get('access_token') || request.get('token') || '').trim() | |
| 15 | ||
| 16 | try { | |
| 17 | const data = instance || accessToken | |
| 18 | ? await mastodon.connect({ instance, accessToken }) | |
| 19 | : await mastodon.connectFromEnv() | |
| 20 | return response.json({ ok: true, data }) | |
| 21 | } | |
| 22 | catch (error) { | |
| 23 | return response.json({ | |
| 24 | ok: false, | |
| 25 | error: error instanceof Error ? error.message : String(error), | |
| 26 | }, { status: 422 }) | |
| 27 | } | |
| 28 | }, | |
| 29 | }) | |
| @@ -0,0 +1,21 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { response } from '@stacksjs/router' | |
| 3 | import { mastodon } from '../../Services/Social/MastodonService' | |
| 4 | ||
| 5 | export default new Action({ | |
| 6 | name: 'Postline Mastodon Status', | |
| 7 | description: 'Return the Mastodon connection status.', | |
| 8 | method: 'GET', | |
| 9 | ||
| 10 | async handle() { | |
| 11 | try { | |
| 12 | return response.json({ ok: true, data: await mastodon.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 | }) | |
| @@ -4,6 +4,7 @@ import { blog } from '../../Services/Social/BlogService' | ||
| 4 | 4 | import { bluesky } from '../../Services/Social/BlueskyService' |
| 5 | 5 | import { instagram } from '../../Services/Social/InstagramService' |
| 6 | 6 | import { linkedin } from '../../Services/Social/LinkedInService' |
| 7 | import { mastodon } from '../../Services/Social/MastodonService' | |
| 7 | 8 | import { threads } from '../../Services/Social/ThreadsService' |
| 8 | 9 | |
| 9 | 10 | export default new Action({ |
| @@ -18,6 +19,7 @@ export default new Action({ | ||
| 18 | 19 | linkedin.status(), |
| 19 | 20 | instagram.status(), |
| 20 | 21 | threads.status(), |
| 22 | mastodon.status(), | |
| 21 | 23 | blog.status(), |
| 22 | 24 | ]) |
| 23 | 25 | |
| @@ -5,6 +5,7 @@ import { blog } from './BlogService' | ||
| 5 | 5 | import { bluesky } from './BlueskyService' |
| 6 | 6 | import { instagram } from './InstagramService' |
| 7 | 7 | import { linkedin } from './LinkedInService' |
| 8 | import { mastodon } from './MastodonService' | |
| 8 | 9 | import { threads } from './ThreadsService' |
| 9 | 10 | import { ensureAccount, now, uuid } from './support' |
| 10 | 11 | |
| @@ -20,6 +21,7 @@ const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = { | ||
| 20 | 21 | linkedin, |
| 21 | 22 | instagram, |
| 22 | 23 | threads, |
| 24 | mastodon, | |
| 23 | 25 | blog, |
| 24 | 26 | } |
| 25 | 27 | |
| @@ -0,0 +1,168 @@ | ||
| 1 | import type { | |
| 2 | MastodonAccount, | |
| 3 | MastodonCredentials, | |
| 4 | MastodonPublished, | |
| 5 | MastodonPublishInput, | |
| 6 | } from '../../../Support/Social/mastodon' | |
| 7 | ||
| 8 | /** | |
| 9 | * Full app-owned Mastodon publishing driver (the published @stacksjs/socials | |
| 10 | * ships none). Talks the Mastodon REST API against the user's chosen | |
| 11 | * instance with a personal access token. | |
| 12 | */ | |
| 13 | ||
| 14 | export class MastodonApiError extends Error { | |
| 15 | constructor( | |
| 16 | message: string, | |
| 17 | public status: number, | |
| 18 | public body: string, | |
| 19 | ) { | |
| 20 | super(message) | |
| 21 | this.name = 'MastodonApiError' | |
| 22 | } | |
| 23 | ||
| 24 | get isAuthError(): boolean { | |
| 25 | return this.status === 401 || this.status === 403 | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | /** Normalize "mastodon.social" / "https://mastodon.social/" → "https://mastodon.social". */ | |
| 30 | export function normalizeInstance(value: string): string { | |
| 31 | const trimmed = String(value || '').trim().replace(/\/+$/, '') | |
| 32 | if (!trimmed) | |
| 33 | throw new Error('Mastodon instance URL is required.') | |
| 34 | const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` | |
| 35 | try { | |
| 36 | const url = new URL(withScheme) | |
| 37 | return `${url.protocol}//${url.host}` | |
| 38 | } | |
| 39 | catch { | |
| 40 | throw new Error('Mastodon instance URL is invalid.') | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | interface StatusResponse { | |
| 45 | id: string | |
| 46 | url?: string | |
| 47 | uri?: string | |
| 48 | } | |
| 49 | ||
| 50 | interface MediaResponse { | |
| 51 | id: string | |
| 52 | } | |
| 53 | ||
| 54 | interface AccountResponse { | |
| 55 | id: string | |
| 56 | username: string | |
| 57 | display_name?: string | |
| 58 | url: string | |
| 59 | } | |
| 60 | ||
| 61 | export class MastodonPublishingDriver { | |
| 62 | readonly provider: 'mastodon' = 'mastodon' | |
| 63 | // Mastodon's default; instances can raise it, but 500 is the safe floor. | |
| 64 | characterLimit = 500 | |
| 65 | ||
| 66 | /** Verify a token and return the account it belongs to. */ | |
| 67 | async verifyCredentials(credentials: MastodonCredentials): Promise<MastodonAccount> { | |
| 68 | const instance = normalizeInstance(credentials.instance) | |
| 69 | if (!credentials.accessToken) | |
| 70 | throw new Error('Mastodon access token is required.') | |
| 71 | ||
| 72 | const account = await this.request<AccountResponse>( | |
| 73 | `${instance}/api/v1/accounts/verify_credentials`, | |
| 74 | { headers: { authorization: `Bearer ${credentials.accessToken}` } }, | |
| 75 | ) | |
| 76 | ||
| 77 | return { | |
| 78 | accountId: account.id, | |
| 79 | username: account.username, | |
| 80 | displayName: account.display_name || undefined, | |
| 81 | url: account.url, | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /** Upload one image and return its media id for attachment. */ | |
| 86 | async uploadMedia(credentials: MastodonCredentials, bytes: Uint8Array, mimeType: string, altText?: string): Promise<string> { | |
| 87 | const instance = normalizeInstance(credentials.instance) | |
| 88 | const form = new FormData() | |
| 89 | form.set('file', new Blob([bytes as unknown as BlobPart], { type: mimeType || 'image/jpeg' }), 'upload') | |
| 90 | if (altText) form.set('description', altText) | |
| 91 | ||
| 92 | const media = await this.request<MediaResponse>( | |
| 93 | `${instance}/api/v2/media`, | |
| 94 | { method: 'POST', headers: { authorization: `Bearer ${credentials.accessToken}` }, body: form }, | |
| 95 | ) | |
| 96 | return media.id | |
| 97 | } | |
| 98 | ||
| 99 | async publish(credentials: MastodonCredentials, post: MastodonPublishInput): Promise<MastodonPublished> { | |
| 100 | const instance = normalizeInstance(credentials.instance) | |
| 101 | if (!credentials.accessToken) | |
| 102 | throw new Error('Mastodon access token is required.') | |
| 103 | if (post.text.length > this.characterLimit) | |
| 104 | throw new Error(`Mastodon posts must be ${this.characterLimit} characters or fewer.`) | |
| 105 | ||
| 106 | const mediaIds: string[] = [] | |
| 107 | for (const item of (post.media || []).slice(0, 4)) { | |
| 108 | let bytes = item.bytes | |
| 109 | let mimeType = item.mimeType | |
| 110 | if (!bytes?.length && item.url) { | |
| 111 | const response = await fetch(item.url) | |
| 112 | if (!response.ok) continue | |
| 113 | bytes = new Uint8Array(await response.arrayBuffer()) | |
| 114 | mimeType = mimeType || response.headers.get('content-type') || 'image/jpeg' | |
| 115 | } | |
| 116 | if (bytes?.length) | |
| 117 | mediaIds.push(await this.uploadMedia(credentials, bytes, mimeType || 'image/jpeg', item.altText)) | |
| 118 | } | |
| 119 | ||
| 120 | const body: Record<string, unknown> = { | |
| 121 | status: post.text, | |
| 122 | visibility: post.visibility || 'public', | |
| 123 | } | |
| 124 | if (mediaIds.length) body.media_ids = mediaIds | |
| 125 | // Mastodon threads via the parent status id, carried in reply.parent.uri. | |
| 126 | if (post.reply?.parent?.uri) body.in_reply_to_id = post.reply.parent.uri | |
| 127 | ||
| 128 | const status = await this.request<StatusResponse>( | |
| 129 | `${instance}/api/v1/statuses`, | |
| 130 | { | |
| 131 | method: 'POST', | |
| 132 | headers: { | |
| 133 | 'authorization': `Bearer ${credentials.accessToken}`, | |
| 134 | 'content-type': 'application/json', | |
| 135 | }, | |
| 136 | body: JSON.stringify(body), | |
| 137 | }, | |
| 138 | ) | |
| 139 | ||
| 140 | return { | |
| 141 | provider: 'mastodon', | |
| 142 | // Mastodon uses one status id; expose it as both uri and cid so the | |
| 143 | // crosspost thread-chaining logic (which needs both) can reply-chain. | |
| 144 | uri: status.id, | |
| 145 | cid: status.id, | |
| 146 | url: status.url || status.uri || `${instance}/@me/${status.id}`, | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | protected async request<T>(url: string, init: RequestInit): Promise<T> { | |
| 151 | const response = await fetch(url, init) | |
| 152 | const text = await response.text() | |
| 153 | ||
| 154 | if (!response.ok) { | |
| 155 | throw new MastodonApiError( | |
| 156 | `Mastodon API failed (${response.status}): ${text || response.statusText}`, | |
| 157 | response.status, | |
| 158 | text, | |
| 159 | ) | |
| 160 | } | |
| 161 | ||
| 162 | return text ? JSON.parse(text) as T : {} as T | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | // Consumers import this as `MastodonDriver` (matching the sibling drivers' | |
| 167 | // exported name), even though this one is a full local implementation. | |
| 168 | export { MastodonPublishingDriver as MastodonDriver } | |
| @@ -0,0 +1,278 @@ | ||
| 1 | import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { env } from '@stacksjs/env' | |
| 4 | import { MastodonApiError, MastodonDriver, normalizeInstance } from './Drivers/MastodonDriver' | |
| 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: 'mastodon' | |
| 14 | external_id?: string | null // the instance base URL | |
| 15 | auth_status: 'connected' | 'expired' | 'revoked' | 'missing' | |
| 16 | access_token?: string | null | |
| 17 | account_id?: number | null | |
| 18 | social_driver_id?: number | null | |
| 19 | } | |
| 20 | ||
| 21 | type SocialDriverRow = { | |
| 22 | id: number | |
| 23 | provider: 'mastodon' | |
| 24 | display_name: string | |
| 25 | status: 'active' | 'planned' | 'disabled' | |
| 26 | character_limit: number | |
| 27 | } | |
| 28 | ||
| 29 | export class MastodonService { | |
| 30 | private driver = new MastodonDriver() | |
| 31 | ||
| 32 | private config() { | |
| 33 | return { | |
| 34 | instance: String(env.MASTODON_INSTANCE_URL || '').trim(), | |
| 35 | accessToken: String(env.MASTODON_ACCESS_TOKEN || '').trim(), | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | async status() { | |
| 40 | const identity = await this.findIdentity() | |
| 41 | const cfg = this.config() | |
| 42 | const connected = identity?.auth_status === 'connected' && Boolean(identity.access_token) | |
| 43 | ||
| 44 | return { | |
| 45 | connected, | |
| 46 | provider: 'mastodon', | |
| 47 | handle: identity?.handle || null, | |
| 48 | displayName: identity?.display_name || null, | |
| 49 | instance: identity?.external_id || null, | |
| 50 | authStatus: connected ? 'connected' : (identity?.handle ? identity.auth_status : 'missing'), | |
| 51 | characterLimit: this.driver.characterLimit, | |
| 52 | canPublish: connected, | |
| 53 | requiresMedia: false, | |
| 54 | configuredFromEnv: Boolean(cfg.instance && cfg.accessToken), | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | /** Connect with an instance URL + personal access token. */ | |
| 59 | async connect(input: { instance: string, accessToken: string }) { | |
| 60 | const instance = normalizeInstance(input.instance) | |
| 61 | const accessToken = String(input.accessToken || '').trim() | |
| 62 | if (!accessToken) | |
| 63 | throw new Error('A Mastodon access token is required.') | |
| 64 | ||
| 65 | const account = await this.driver.verifyCredentials({ instance, accessToken }) | |
| 66 | const identity = await this.saveSession({ instance, accessToken, account }) | |
| 67 | return this.publicIdentity(identity) | |
| 68 | } | |
| 69 | ||
| 70 | async connectFromEnv() { | |
| 71 | const cfg = this.config() | |
| 72 | if (!cfg.instance || !cfg.accessToken) | |
| 73 | throw new Error('Set MASTODON_INSTANCE_URL and MASTODON_ACCESS_TOKEN, or connect on the Accounts page.') | |
| 74 | return this.connect(cfg) | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Publish an already-created post row to Mastodon. Never throws — failures | |
| 79 | * are recorded on the target and returned so other crosspost providers | |
| 80 | * still succeed. | |
| 81 | */ | |
| 82 | async publishToPost( | |
| 83 | post: { id: number, body: string }, | |
| 84 | content?: PublishContent, | |
| 85 | ): Promise<CrosspostTargetResult> { | |
| 86 | const driver = await this.ensureDriver() | |
| 87 | ||
| 88 | let identity: SocialIdentityRow | |
| 89 | try { | |
| 90 | identity = await this.requireIdentity() | |
| 91 | } | |
| 92 | catch (error) { | |
| 93 | return { provider: 'mastodon', ok: false, error: messageOf(error) } | |
| 94 | } | |
| 95 | ||
| 96 | if (post.body.length > this.driver.characterLimit) { | |
| 97 | return { | |
| 98 | provider: 'mastodon', | |
| 99 | ok: false, | |
| 100 | error: `Mastodon posts must be ${this.driver.characterLimit} characters or fewer.`, | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | const targetUuid = uuid() | |
| 105 | const createdAt = now() | |
| 106 | await database.insertInto('post_targets').values({ | |
| 107 | uuid: targetUuid, | |
| 108 | provider: 'mastodon', | |
| 109 | status: 'publishing', | |
| 110 | post_id: post.id, | |
| 111 | social_driver_id: driver.id, | |
| 112 | social_identity_id: identity.id, | |
| 113 | created_at: createdAt, | |
| 114 | updated_at: createdAt, | |
| 115 | }).execute() | |
| 116 | ||
| 117 | const target = await database | |
| 118 | .selectFrom('post_targets') | |
| 119 | .selectAll() | |
| 120 | .where('uuid', '=', targetUuid) | |
| 121 | .executeTakeFirstOrThrow() | |
| 122 | ||
| 123 | try { | |
| 124 | const published = await this.driver.publish( | |
| 125 | { instance: identity.external_id || '', accessToken: identity.access_token || '' }, | |
| 126 | { text: post.body, media: content?.media, reply: content?.reply }, | |
| 127 | ) | |
| 128 | ||
| 129 | await database.updateTable('post_targets').set({ | |
| 130 | status: 'published', | |
| 131 | remote_uri: published.url || published.uri || null, | |
| 132 | remote_cid: published.cid || null, | |
| 133 | failure_reason: null, | |
| 134 | updated_at: now(), | |
| 135 | }).where('id', '=', target.id).execute() | |
| 136 | ||
| 137 | return { | |
| 138 | provider: 'mastodon', | |
| 139 | ok: true, | |
| 140 | url: published.url, | |
| 141 | uri: published.uri, | |
| 142 | cid: published.cid, | |
| 143 | targetId: Number(target.id), | |
| 144 | } | |
| 145 | } | |
| 146 | catch (error) { | |
| 147 | const message = messageOf(error) | |
| 148 | if (error instanceof MastodonApiError && error.isAuthError) | |
| 149 | await this.markExpired(identity.id) | |
| 150 | await database.updateTable('post_targets').set({ | |
| 151 | status: 'failed', | |
| 152 | failure_reason: message, | |
| 153 | updated_at: now(), | |
| 154 | }).where('id', '=', target.id).execute() | |
| 155 | ||
| 156 | return { provider: 'mastodon', ok: false, error: message, targetId: Number(target.id) } | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | private async requireIdentity(): Promise<SocialIdentityRow> { | |
| 161 | const existing = await this.findIdentity() | |
| 162 | if (existing?.access_token && existing.auth_status === 'connected') return existing | |
| 163 | ||
| 164 | const cfg = this.config() | |
| 165 | if (cfg.instance && cfg.accessToken) { | |
| 166 | await this.connectFromEnv() | |
| 167 | const connected = await this.findIdentity() | |
| 168 | if (connected?.access_token) return connected | |
| 169 | } | |
| 170 | ||
| 171 | throw new Error('Connect Mastodon before publishing.') | |
| 172 | } | |
| 173 | ||
| 174 | private async findIdentity(): Promise<SocialIdentityRow | undefined> { | |
| 175 | return await database | |
| 176 | .selectFrom('social_identities') | |
| 177 | .selectAll() | |
| 178 | .where('provider', '=', 'mastodon') | |
| 179 | .orderBy('updated_at', 'desc') | |
| 180 | .executeTakeFirst() | |
| 181 | } | |
| 182 | ||
| 183 | private async saveSession(input: { instance: string, accessToken: string, account: { accountId: string, username: string, displayName?: string, url: string } }): Promise<SocialIdentityRow> { | |
| 184 | const accountId = await ensureAccount() | |
| 185 | const driver = await this.ensureDriver() | |
| 186 | const existing = await this.findIdentity() | |
| 187 | const savedAt = now() | |
| 188 | const host = input.instance.replace(/^https?:\/\//, '') | |
| 189 | const handle = `@${input.account.username}@${host}` | |
| 190 | ||
| 191 | const values = { | |
| 192 | handle, | |
| 193 | display_name: input.account.displayName || input.account.username, | |
| 194 | provider: 'mastodon', | |
| 195 | external_id: input.instance, | |
| 196 | auth_status: 'connected', | |
| 197 | access_token: input.accessToken, | |
| 198 | refresh_token: null, | |
| 199 | account_id: accountId, | |
| 200 | social_driver_id: driver.id, | |
| 201 | updated_at: savedAt, | |
| 202 | } | |
| 203 | ||
| 204 | if (existing) { | |
| 205 | await database.updateTable('social_identities').set(values).where('id', '=', existing.id).execute() | |
| 206 | } | |
| 207 | else { | |
| 208 | await database.insertInto('social_identities').values({ | |
| 209 | uuid: uuid(), | |
| 210 | ...values, | |
| 211 | created_at: savedAt, | |
| 212 | }).execute() | |
| 213 | } | |
| 214 | ||
| 215 | return await this.findIdentity() as SocialIdentityRow | |
| 216 | } | |
| 217 | ||
| 218 | private async markExpired(id: number): Promise<void> { | |
| 219 | await database.updateTable('social_identities') | |
| 220 | .set({ auth_status: 'expired', updated_at: now() }) | |
| 221 | .where('id', '=', id) | |
| 222 | .execute() | |
| 223 | } | |
| 224 | ||
| 225 | private async ensureDriver(): Promise<SocialDriverRow> { | |
| 226 | const existing = await database | |
| 227 | .selectFrom('social_drivers') | |
| 228 | .selectAll() | |
| 229 | .where('provider', '=', 'mastodon') | |
| 230 | .executeTakeFirst() | |
| 231 | ||
| 232 | if (existing) { | |
| 233 | if (existing.status !== 'active' || existing.character_limit !== this.driver.characterLimit) { | |
| 234 | await database.updateTable('social_drivers').set({ | |
| 235 | status: 'active', | |
| 236 | character_limit: this.driver.characterLimit, | |
| 237 | updated_at: now(), | |
| 238 | }).where('id', '=', existing.id).execute() | |
| 239 | } | |
| 240 | return await database.selectFrom('social_drivers').selectAll().where('id', '=', existing.id).executeTakeFirstOrThrow() | |
| 241 | } | |
| 242 | ||
| 243 | const driverUuid = uuid() | |
| 244 | await database.insertInto('social_drivers').values({ | |
| 245 | uuid: driverUuid, | |
| 246 | provider: 'mastodon', | |
| 247 | display_name: 'Mastodon', | |
| 248 | status: 'active', | |
| 249 | character_limit: this.driver.characterLimit, | |
| 250 | capabilities: JSON.stringify({ posts: true, timelines: false, oauth: false, requiresMedia: false }), | |
| 251 | created_at: now(), | |
| 252 | updated_at: now(), | |
| 253 | }).execute() | |
| 254 | ||
| 255 | return await database.selectFrom('social_drivers').selectAll().where('uuid', '=', driverUuid).executeTakeFirstOrThrow() | |
| 256 | } | |
| 257 | ||
| 258 | private publicIdentity(row: SocialIdentityRow | undefined) { | |
| 259 | if (!row) { | |
| 260 | return { connected: false, provider: 'mastodon', handle: null, displayName: null, instance: null, authStatus: 'missing' } | |
| 261 | } | |
| 262 | const connected = row.auth_status === 'connected' && Boolean(row.access_token) | |
| 263 | return { | |
| 264 | connected, | |
| 265 | provider: 'mastodon', | |
| 266 | handle: row.handle, | |
| 267 | displayName: row.display_name || null, | |
| 268 | instance: row.external_id || null, | |
| 269 | authStatus: connected ? 'connected' : 'missing', | |
| 270 | } | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | function messageOf(error: unknown): string { | |
| 275 | return error instanceof Error ? error.message : String(error) | |
| 276 | } | |
| 277 | ||
| 278 | export const mastodon = new MastodonService() | |
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * App-owned Mastodon types. | |
| 3 | * | |
| 4 | * The published `@stacksjs/socials` ships no Mastodon driver, so Postline | |
| 5 | * owns this one outright (see ./Services/Social/Drivers/MastodonDriver.ts). | |
| 6 | * Mastodon is token-based: the user creates an access token in their | |
| 7 | * instance's Preferences → Development, and posting is a plain REST call to | |
| 8 | * that instance — no OAuth callback needed. | |
| 9 | */ | |
| 10 | ||
| 11 | export interface MastodonCredentials { | |
| 12 | /** Instance base URL, e.g. https://mastodon.social */ | |
| 13 | instance: string | |
| 14 | accessToken: string | |
| 15 | } | |
| 16 | ||
| 17 | export interface MastodonAccount { | |
| 18 | accountId: string | |
| 19 | username: string | |
| 20 | displayName?: string | |
| 21 | url: string | |
| 22 | } | |
| 23 | ||
| 24 | export interface MastodonPublishInput { | |
| 25 | text: string | |
| 26 | media?: Array<{ | |
| 27 | url?: string | |
| 28 | bytes?: Uint8Array | |
| 29 | mimeType?: string | |
| 30 | altText?: string | |
| 31 | }> | |
| 32 | /** | |
| 33 | * Thread chaining. The shared PublishContent uses an ATProto shape | |
| 34 | * ({ root, parent } with uri/cid); Mastodon only needs the parent status | |
| 35 | * id, which we carry in `parent.uri`. | |
| 36 | */ | |
| 37 | reply?: { | |
| 38 | root: { uri: string, cid: string } | |
| 39 | parent: { uri: string, cid: string } | |
| 40 | } | |
| 41 | visibility?: 'public' | 'unlisted' | 'private' | 'direct' | |
| 42 | } | |
| 43 | ||
| 44 | export interface MastodonPublished { | |
| 45 | provider: 'mastodon' | |
| 46 | uri: string | |
| 47 | cid?: string | |
| 48 | url?: string | |
| 49 | } | |
| @@ -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, and Threads to publish and crosspost from Postline.</p> | |
| 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> | |
| 16 | 16 | </div> |
| 17 | 17 | </header> |
| 18 | 18 | |
| @@ -81,11 +81,27 @@ | ||
| 81 | 81 | <button class="hidden text-xs text-zinc-500 underline hover:text-zinc-700 transition-colors" type="button" data-threads-connect-env>Use token from .env</button> |
| 82 | 82 | </div> |
| 83 | 83 | </article> |
| 84 | <article class="grid gap-3 p-3.5 bg-white border border-zinc-200 rounded-xl" data-channel="mastodon"> | |
| 85 | <div class="flex gap-5 items-center justify-between"> | |
| 86 | <div> | |
| 87 | <h2 class="my-0 font-bold text-[15px]">Mastodon</h2> | |
| 88 | <p class="my-1.5 text-[13px] text-slate-700" data-mastodon-channel-handle>Not connected</p> | |
| 89 | <span class="text-xs text-zinc-500" data-mastodon-channel-detail>Connect any Mastodon instance with an access token to crosspost.</span> | |
| 90 | </div> | |
| 91 | <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full" data-mastodon-channel-status>Setup</span> | |
| 92 | </div> | |
| 93 | <form class="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_auto]" data-mastodon-connect-form> | |
| 94 | <input class="px-3 h-9 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="instance" placeholder="mastodon.social" autocomplete="url" data-mastodon-instance required> | |
| 95 | <input class="px-3 h-9 text-neutral-950 text-sm bg-white border border-zinc-200 outline-none rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-100" name="access_token" type="password" placeholder="Access token" autocomplete="off" data-mastodon-token required> | |
| 96 | <button class="inline-flex items-center justify-center px-4 h-9 font-bold text-sm text-white bg-neutral-950 hover:bg-neutral-800 rounded-lg disabled:opacity-50 transition-colors" type="submit" data-mastodon-connect-submit>Connect</button> | |
| 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 | </form> | |
| 99 | </article> | |
| 84 | 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"> |
| 85 | 101 | <div> |
| 86 | 102 | <h2 class="my-0 font-bold text-[15px] text-zinc-700">Twitter/X</h2> |
| 87 | 103 | <p class="my-1.5 text-[13px] text-zinc-600">Planned</p> |
| 88 | <span class="text-xs text-zinc-500">Driver adapter coming after Bluesky, LinkedIn, Instagram, and Threads are stable.</span> | |
| 104 | <span class="text-xs text-zinc-500">Driver adapter coming after the current set is stable.</span> | |
| 89 | 105 | </div> |
| 90 | 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> |
| 91 | 107 | </article> |
| @@ -465,6 +481,80 @@ | ||
| 465 | 481 | startThreads() |
| 466 | 482 | } |
| 467 | 483 | |
| 484 | // --- Mastodon --- | |
| 485 | const mastodonForm = root.querySelector('[data-mastodon-connect-form]') | |
| 486 | const mastodonInstance = root.querySelector('[data-mastodon-instance]') | |
| 487 | const mastodonToken = root.querySelector('[data-mastodon-token]') | |
| 488 | const mastodonSubmit = root.querySelector('[data-mastodon-connect-submit]') | |
| 489 | const mastodonHandle = root.querySelector('[data-mastodon-channel-handle]') | |
| 490 | const mastodonDetail = root.querySelector('[data-mastodon-channel-detail]') | |
| 491 | const mastodonStatusPill = root.querySelector('[data-mastodon-channel-status]') | |
| 492 | ||
| 493 | const renderMastodon = (data) => { | |
| 494 | const connected = Boolean(data?.canPublish) | |
| 495 | if (mastodonHandle) mastodonHandle.textContent = connected ? data.handle : (data?.handle ? `${data.handle} — reconnect` : 'Not connected') | |
| 496 | if (mastodonInstance && data?.instance) mastodonInstance.value = data.instance | |
| 497 | if (mastodonDetail) { | |
| 498 | mastodonDetail.textContent = connected | |
| 499 | ? 'Ready to crosspost from Postline.' | |
| 500 | : data?.configuredFromEnv | |
| 501 | ? 'Token found in .env — connect to start crossposting.' | |
| 502 | : 'Connect any Mastodon instance with an access token to crosspost.' | |
| 503 | } | |
| 504 | if (mastodonStatusPill) { | |
| 505 | mastodonStatusPill.textContent = connected ? 'Connected' : (data?.handle ? 'Reconnect' : 'Setup') | |
| 506 | mastodonStatusPill.className = `whitespace-nowrap rounded-full border px-2.5 py-1 text-[11px] font-bold ${ | |
| 507 | connected | |
| 508 | ? 'border-emerald-200 bg-emerald-50 text-emerald-700' | |
| 509 | : data?.handle | |
| 510 | ? 'border-amber-200 bg-amber-50 text-amber-800' | |
| 511 | : 'border-zinc-200 text-zinc-500' | |
| 512 | }` | |
| 513 | } | |
| 514 | if (mastodonSubmit) mastodonSubmit.textContent = connected ? 'Reconnect' : 'Connect' | |
| 515 | } | |
| 516 | ||
| 517 | const refreshMastodon = async () => { | |
| 518 | try { | |
| 519 | const response = await fetch('/api/postline/mastodon/status') | |
| 520 | const payload = await response.json() | |
| 521 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Could not load Mastodon status.') | |
| 522 | renderMastodon(payload.data) | |
| 523 | } | |
| 524 | catch (error) { | |
| 525 | renderMastodon(null) | |
| 526 | notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Mastodon' }) | |
| 527 | } | |
| 528 | } | |
| 529 | ||
| 530 | mastodonForm?.addEventListener('submit', async (event) => { | |
| 531 | event.preventDefault() | |
| 532 | mastodonSubmit?.setAttribute('disabled', 'disabled') | |
| 533 | notify('Connecting to Mastodon…', 'info', { title: 'Mastodon' }) | |
| 534 | try { | |
| 535 | const response = await fetch('/api/postline/mastodon/connect', { method: 'POST', body: new FormData(mastodonForm) }) | |
| 536 | const payload = await response.json() | |
| 537 | if (!response.ok || !payload.ok) throw new Error(payload.error || 'Mastodon connect failed.') | |
| 538 | if (mastodonToken) mastodonToken.value = '' | |
| 539 | await refreshMastodon() | |
| 540 | notify(`${payload.data.handle} is connected. You can crosspost from the composer.`, 'success', { title: 'Connected' }) | |
| 541 | } | |
| 542 | catch (error) { | |
| 543 | notify(error instanceof Error ? error.message : String(error), 'error', { title: 'Connect failed' }) | |
| 544 | } | |
| 545 | finally { | |
| 546 | mastodonSubmit?.removeAttribute('disabled') | |
| 547 | } | |
| 548 | }) | |
| 549 | ||
| 550 | const startMastodon = () => void refreshMastodon() | |
| 551 | if (document.readyState === 'loading') { | |
| 552 | document.addEventListener('DOMContentLoaded', startMastodon, { once: true }) | |
| 553 | } | |
| 554 | else { | |
| 555 | startMastodon() | |
| 556 | } | |
| 557 | ||
| 468 | 558 | void refreshStatus() |
| 469 | 559 | })() |
| 470 | 560 | </script> |
| @@ -52,6 +52,11 @@ route.group({ prefix: '/postline/threads' }, () => { | ||
| 52 | 52 | route.get('/callback', 'Actions/Postline/ThreadsCallbackAction').skipCsrf() |
| 53 | 53 | }) |
| 54 | 54 | |
| 55 | route.group({ prefix: '/postline/mastodon' }, () => { | |
| 56 | route.get('/status', 'Actions/Postline/MastodonStatusAction').middleware('auth').skipCsrf() | |
| 57 | route.post('/connect', 'Actions/Postline/MastodonConnectAction').middleware('auth').skipCsrf() | |
| 58 | }) | |
| 59 | ||
| 55 | 60 | route.group({ prefix: '/postline' }, () => { |
| 56 | 61 | route.get('/providers', 'Actions/Postline/ProvidersStatusAction').middleware('auth').skipCsrf() |
| 57 | 62 | route.post('/publish', 'Actions/Postline/CrosspostPublishAction').middleware('auth').skipCsrf() |
| @@ -0,0 +1,102 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { MastodonApiError, MastodonPublishingDriver, normalizeInstance } from '../../app/Services/Social/Drivers/MastodonDriver' | |
| 3 | ||
| 4 | const realFetch = globalThis.fetch | |
| 5 | afterEach(() => { globalThis.fetch = realFetch }) | |
| 6 | ||
| 7 | const creds = { instance: 'https://mastodon.social', accessToken: 'tok' } | |
| 8 | ||
| 9 | interface Captured { url: string, method: string, body: any, headers: any } | |
| 10 | ||
| 11 | function mockApi(captured: Captured[], overrides: (url: string) => Response | null = () => null): void { | |
| 12 | globalThis.fetch = (async (input: any, init: any = {}) => { | |
| 13 | const url = String(input) | |
| 14 | const over = overrides(url) | |
| 15 | if (over) { captured.push({ url, method: init.method || 'GET', body: init.body, headers: init.headers }); return over } | |
| 16 | ||
| 17 | if (url.includes('verify_credentials')) { | |
| 18 | captured.push({ url, method: 'GET', body: undefined, headers: init.headers }) | |
| 19 | return new Response(JSON.stringify({ id: '42', username: 'glenn', display_name: 'Glenn', url: 'https://mastodon.social/@glenn' }), { status: 200 }) | |
| 20 | } | |
| 21 | if (url.includes('/api/v2/media')) { | |
| 22 | captured.push({ url, method: 'POST', body: init.body, headers: init.headers }) | |
| 23 | return new Response(JSON.stringify({ id: `media${captured.length}` }), { status: 200 }) | |
| 24 | } | |
| 25 | // statuses | |
| 26 | const body = init.body ? JSON.parse(String(init.body)) : undefined | |
| 27 | captured.push({ url, method: init.method, body, headers: init.headers }) | |
| 28 | return new Response(JSON.stringify({ id: `sid${captured.length}`, url: `https://mastodon.social/@glenn/sid${captured.length}` }), { status: 200 }) | |
| 29 | }) as typeof fetch | |
| 30 | } | |
| 31 | ||
| 32 | describe('normalizeInstance', () => { | |
| 33 | test('adds https and strips trailing slash / path', () => { | |
| 34 | expect(normalizeInstance('mastodon.social')).toBe('https://mastodon.social') | |
| 35 | expect(normalizeInstance('https://fosstodon.org/')).toBe('https://fosstodon.org') | |
| 36 | expect(normalizeInstance('https://hachyderm.io/home')).toBe('https://hachyderm.io') | |
| 37 | }) | |
| 38 | test('rejects empty', () => { | |
| 39 | expect(() => normalizeInstance('')).toThrow(/required/i) | |
| 40 | }) | |
| 41 | }) | |
| 42 | ||
| 43 | describe('MastodonPublishingDriver', () => { | |
| 44 | test('verifyCredentials returns the account', async () => { | |
| 45 | const cap: Captured[] = []; mockApi(cap) | |
| 46 | const driver = new MastodonPublishingDriver() | |
| 47 | const account = await driver.verifyCredentials(creds) | |
| 48 | expect(cap[0].url).toContain('/api/v1/accounts/verify_credentials') | |
| 49 | expect(account).toEqual({ accountId: '42', username: 'glenn', displayName: 'Glenn', url: 'https://mastodon.social/@glenn' }) | |
| 50 | }) | |
| 51 | ||
| 52 | test('publishes a plain status', async () => { | |
| 53 | const cap: Captured[] = []; mockApi(cap) | |
| 54 | const driver = new MastodonPublishingDriver() | |
| 55 | const result = await driver.publish(creds, { text: 'hello fediverse' }) | |
| 56 | const post = cap.find(c => c.url.endsWith('/api/v1/statuses')) | |
| 57 | expect(post?.body.status).toBe('hello fediverse') | |
| 58 | expect(post?.body.visibility).toBe('public') | |
| 59 | expect(post?.body.media_ids).toBeUndefined() | |
| 60 | expect(result.uri).toBe(result.cid) // status id doubles as both | |
| 61 | expect(result.url).toContain('mastodon.social/@glenn') | |
| 62 | }) | |
| 63 | ||
| 64 | test('uploads image bytes and attaches media_ids', async () => { | |
| 65 | const cap: Captured[] = []; mockApi(cap) | |
| 66 | const driver = new MastodonPublishingDriver() | |
| 67 | await driver.publish(creds, { text: 'photo', media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png', altText: 'chart' }] }) | |
| 68 | const upload = cap.find(c => c.url.includes('/api/v2/media')) | |
| 69 | expect(upload).toBeDefined() | |
| 70 | const post = cap.find(c => c.url.endsWith('/api/v1/statuses')) | |
| 71 | expect(post?.body.media_ids).toHaveLength(1) | |
| 72 | }) | |
| 73 | ||
| 74 | test('threads via in_reply_to_id from reply.parent.uri', async () => { | |
| 75 | const cap: Captured[] = []; mockApi(cap) | |
| 76 | const driver = new MastodonPublishingDriver() | |
| 77 | await driver.publish(creds, { | |
| 78 | text: 'second toot', | |
| 79 | reply: { root: { uri: 'sid1', cid: 'sid1' }, parent: { uri: 'sid1', cid: 'sid1' } }, | |
| 80 | }) | |
| 81 | const post = cap.find(c => c.url.endsWith('/api/v1/statuses')) | |
| 82 | expect(post?.body.in_reply_to_id).toBe('sid1') | |
| 83 | }) | |
| 84 | ||
| 85 | test('rejects text over the character limit before calling the API', async () => { | |
| 86 | const cap: Captured[] = []; mockApi(cap) | |
| 87 | const driver = new MastodonPublishingDriver() | |
| 88 | await expect(driver.publish(creds, { text: 'x'.repeat(501) })).rejects.toThrow(/500 characters/) | |
| 89 | expect(cap).toHaveLength(0) | |
| 90 | }) | |
| 91 | ||
| 92 | test('surfaces API errors as MastodonApiError with status', async () => { | |
| 93 | const cap: Captured[] = [] | |
| 94 | mockApi(cap, url => url.endsWith('/api/v1/statuses') ? new Response('{"error":"Unauthorized"}', { status: 401 }) : null) | |
| 95 | const driver = new MastodonPublishingDriver() | |
| 96 | let caught: unknown | |
| 97 | await driver.publish(creds, { text: 'hi' }).catch((e) => { caught = e }) | |
| 98 | expect(caught).toBeInstanceOf(MastodonApiError) | |
| 99 | expect((caught as MastodonApiError).status).toBe(401) | |
| 100 | expect((caught as MastodonApiError).isAuthError).toBe(true) | |
| 101 | }) | |
| 102 | }) | |