ReviewOS

also looking at this

stacks/postline

Mastodon crosspost driver

#26
Merged glennmichael123 wants to merge feat/mastodon-driver into main
11 files +752 -2
.env.examplemodified+4-0
Changes to .env.example
@@ -143,3 +143,7 @@ THREADS_GRAPH_VERSION=v1.0
143143THREADS_ACCESS_TOKEN=
144144THREADS_USER_ID=
145145THREADS_USERNAME=
146
147# Mastodon (any instance; token from Preferences → Development)
148MASTODON_INSTANCE_URL=
149MASTODON_ACCESS_TOKEN=
app/Actions/Postline/MastodonConnectAction.tsadded+29-0
Changes to app/Actions/Postline/MastodonConnectAction.ts
@@ -0,0 +1,29 @@
1import type { RequestInstance } from '@stacksjs/types'
2import { Action } from '@stacksjs/actions'
3import { response } from '@stacksjs/router'
4import { mastodon } from '../../Services/Social/MastodonService'
5
6export 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})
app/Actions/Postline/MastodonStatusAction.tsadded+21-0
Changes to app/Actions/Postline/MastodonStatusAction.ts
@@ -0,0 +1,21 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { mastodon } from '../../Services/Social/MastodonService'
4
5export 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})
app/Actions/Postline/ProvidersStatusAction.tsmodified+2-0
Changes to app/Actions/Postline/ProvidersStatusAction.ts
@@ -4,6 +4,7 @@ import { blog } from '../../Services/Social/BlogService'
44import { bluesky } from '../../Services/Social/BlueskyService'
55import { instagram } from '../../Services/Social/InstagramService'
66import { linkedin } from '../../Services/Social/LinkedInService'
7import { mastodon } from '../../Services/Social/MastodonService'
78import { threads } from '../../Services/Social/ThreadsService'
89
910export default new Action({
@@ -18,6 +19,7 @@ export default new Action({
1819 linkedin.status(),
1920 instagram.status(),
2021 threads.status(),
22 mastodon.status(),
2123 blog.status(),
2224 ])
2325
app/Services/Social/CrosspostService.tsmodified+2-0
Changes to app/Services/Social/CrosspostService.ts
@@ -5,6 +5,7 @@ import { blog } from './BlogService'
55import { bluesky } from './BlueskyService'
66import { instagram } from './InstagramService'
77import { linkedin } from './LinkedInService'
8import { mastodon } from './MastodonService'
89import { threads } from './ThreadsService'
910import { ensureAccount, now, uuid } from './support'
1011
@@ -20,6 +21,7 @@ const publishers: Partial<Record<SocialProvider, ProviderPublisher>> = {
2021 linkedin,
2122 instagram,
2223 threads,
24 mastodon,
2325 blog,
2426}
2527
app/Services/Social/Drivers/MastodonDriver.tsadded+168-0
Changes to app/Services/Social/Drivers/MastodonDriver.ts
@@ -0,0 +1,168 @@
1import 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
14export 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". */
30export 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
44interface StatusResponse {
45 id: string
46 url?: string
47 uri?: string
48}
49
50interface MediaResponse {
51 id: string
52}
53
54interface AccountResponse {
55 id: string
56 username: string
57 display_name?: string
58 url: string
59}
60
61export 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.
168export { MastodonPublishingDriver as MastodonDriver }
app/Services/Social/MastodonService.tsadded+278-0
Changes to app/Services/Social/MastodonService.ts
@@ -0,0 +1,278 @@
1import type { CrosspostTargetResult, PublishContent } from '../../Support/Social/types'
2import { db } from '@stacksjs/database'
3import { env } from '@stacksjs/env'
4import { MastodonApiError, MastodonDriver, normalizeInstance } from './Drivers/MastodonDriver'
5import { ensureAccount, now, uuid } from './support'
6
7const database = db as any
8
9type SocialIdentityRow = {
10 id: number
11 handle: string
12 display_name?: string | null
13 provider: '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
21type SocialDriverRow = {
22 id: number
23 provider: 'mastodon'
24 display_name: string
25 status: 'active' | 'planned' | 'disabled'
26 character_limit: number
27}
28
29export 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
274function messageOf(error: unknown): string {
275 return error instanceof Error ? error.message : String(error)
276}
277
278export const mastodon = new MastodonService()
app/Support/Social/mastodon.tsadded+49-0
Changes to app/Support/Social/mastodon.ts
@@ -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
11export interface MastodonCredentials {
12 /** Instance base URL, e.g. https://mastodon.social */
13 instance: string
14 accessToken: string
15}
16
17export interface MastodonAccount {
18 accountId: string
19 username: string
20 displayName?: string
21 url: string
22}
23
24export 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
44export interface MastodonPublished {
45 provider: 'mastodon'
46 uri: string
47 cid?: string
48 url?: string
49}
resources/views/accounts.stxmodified+92-2
Changes to resources/views/accounts.stx
@@ -12,7 +12,7 @@
1212 <div>
1313 <p class="mb-1.5 font-bold text-[11px] text-zinc-500 tracking-wider uppercase">Accounts</p>
1414 <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>
1616 </div>
1717 </header>
1818
@@ -81,11 +81,27 @@
8181 <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>
8282 </div>
8383 </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>
84100 <article class="flex gap-5 items-center justify-between p-3.5 bg-zinc-50/80 border border-zinc-200 rounded-xl opacity-80">
85101 <div>
86102 <h2 class="my-0 font-bold text-[15px] text-zinc-700">Twitter/X</h2>
87103 <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>
89105 </div>
90106 <span class="px-2.5 py-1 font-bold text-[11px] text-zinc-500 whitespace-nowrap border border-zinc-200 rounded-full">Planned</span>
91107 </article>
@@ -465,6 +481,80 @@
465481 startThreads()
466482 }
467483
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
468558 void refreshStatus()
469559 })()
470560 </script>
routes/api.tsmodified+5-0
Changes to routes/api.ts
@@ -52,6 +52,11 @@ route.group({ prefix: '/postline/threads' }, () => {
5252 route.get('/callback', 'Actions/Postline/ThreadsCallbackAction').skipCsrf()
5353})
5454
55route.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
5560route.group({ prefix: '/postline' }, () => {
5661 route.get('/providers', 'Actions/Postline/ProvidersStatusAction').middleware('auth').skipCsrf()
5762 route.post('/publish', 'Actions/Postline/CrosspostPublishAction').middleware('auth').skipCsrf()
tests/unit/mastodon-driver.test.tsadded+102-0
Changes to tests/unit/mastodon-driver.test.ts
@@ -0,0 +1,102 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { MastodonApiError, MastodonPublishingDriver, normalizeInstance } from '../../app/Services/Social/Drivers/MastodonDriver'
3
4const realFetch = globalThis.fetch
5afterEach(() => { globalThis.fetch = realFetch })
6
7const creds = { instance: 'https://mastodon.social', accessToken: 'tok' }
8
9interface Captured { url: string, method: string, body: any, headers: any }
10
11function 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
32describe('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
43describe('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})