diff --git a/packages/social/mastodon/src/index.test.ts b/packages/social/mastodon/src/index.test.ts index 0bbc0aaf..72371488 100644 --- a/packages/social/mastodon/src/index.test.ts +++ b/packages/social/mastodon/src/index.test.ts @@ -3,7 +3,7 @@ import { contractTestSocial, fakeConnectContext } from '@profullstack/sh1pt-core import adapter from './index.js'; contractTestSocial(adapter, { - sampleConfig: { instance: 'mastodon.social' }, + sampleConfig: { instance: 'mastodon.social', visibility: 'unlisted' }, samplePost: { body: 'hello from sh1pt contract tests' }, requiredSecrets: ['MASTODON_TOKEN_MASTODON_SOCIAL'], }); @@ -13,16 +13,16 @@ afterEach(() => { }); describe('social-mastodon posting', () => { - it('creates statuses on the selected Mastodon instance', async () => { + it('creates a Mastodon status with token auth, visibility, links, and hashtags', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, json: async () => ({ - id: '109876', - url: 'https://mastodon.social/@sh1pt/109876', - created_at: '2026-05-12T16:40:00Z', + id: '109246', + url: 'https://mastodon.social/@sh1pt/109246', + created_at: '2026-05-11T20:00:00Z', }), - } as any); + } as Response); const ctx = { ...fakeConnectContext({ MASTODON_TOKEN_MASTODON_SOCIAL: 'mastodon-token' }), @@ -30,18 +30,16 @@ describe('social-mastodon posting', () => { }; const result = await adapter.post(ctx as any, { - body: 'Release shipped', + body: 'Release notes', + hashtags: ['sh1pt', 'typescript'], link: 'https://sh1pt.com', - }, { - instance: 'mastodon.social', - visibility: 'unlisted', - }); + }, { instance: 'https://mastodon.social/', visibility: 'unlisted' }); expect(result).toEqual({ - id: '109876', - url: 'https://mastodon.social/@sh1pt/109876', + id: '109246', + url: 'https://mastodon.social/@sh1pt/109246', platform: 'mastodon', - publishedAt: '2026-05-12T16:40:00.000Z', + publishedAt: '2026-05-11T20:00:00.000Z', }); const [url, init] = fetchMock.mock.calls[0]!; expect(url).toBe('https://mastodon.social/api/v1/statuses'); @@ -51,28 +49,38 @@ describe('social-mastodon posting', () => { 'content-type': 'application/json', }); expect(JSON.parse(String((init as RequestInit).body))).toEqual({ - status: 'Release shipped\nhttps://sh1pt.com', + status: 'Release notes\n\nhttps://sh1pt.com #sh1pt #typescript', visibility: 'unlisted', }); }); - it('surfaces Mastodon API errors', async () => { + it('throws Mastodon API error messages when status creation fails', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, status: 422, statusText: 'Unprocessable Entity', - json: async () => ({ error: "Validation failed: Text can't be blank" }), - } as any); + text: async () => JSON.stringify({ error: 'Validation failed: Text character limit of 500 exceeded' }), + } as Response); + + const ctx = { + ...fakeConnectContext({ MASTODON_TOKEN_MASTODON_SOCIAL: 'mastodon-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + body: 'Release notes', + }, { instance: 'mastodon.social' })).rejects.toThrow('Text character limit'); + }); + it('does not silently drop media attachments', async () => { const ctx = { ...fakeConnectContext({ MASTODON_TOKEN_MASTODON_SOCIAL: 'mastodon-token' }), dryRun: false, }; await expect(adapter.post(ctx as any, { - body: 'Release shipped', - }, { - instance: 'mastodon.social', - })).rejects.toThrow('Validation failed'); + body: 'Release notes', + media: [{ file: './image.png', kind: 'image' }], + }, { instance: 'mastodon.social' })).rejects.toThrow('media uploads'); }); }); diff --git a/packages/social/mastodon/src/index.ts b/packages/social/mastodon/src/index.ts index e7a64685..c08512f2 100644 --- a/packages/social/mastodon/src/index.ts +++ b/packages/social/mastodon/src/index.ts @@ -1,4 +1,4 @@ -import { defineSocial, oauthSetup } from '@profullstack/sh1pt-core'; +import { defineSocial, oauthSetup, type SocialPost } from '@profullstack/sh1pt-core'; // Mastodon — federated. Each instance is its own server; same API. // POST /api/v1/statuses with access token scoped to 'write:statuses'. @@ -12,7 +12,6 @@ interface MastodonStatusResponse { url?: string; uri?: string; created_at?: string; - error?: string; } export default defineSocial({ @@ -20,17 +19,20 @@ export default defineSocial({ label: 'Mastodon (Fediverse)', requires: { maxBodyChars: 500, maxHashtags: 20, hashtagsInBody: true }, async connect(ctx, config) { - if (!ctx.secret(`MASTODON_TOKEN_${config.instance.replace(/\./g, '_').toUpperCase()}`)) { - throw new Error(`Mastodon token for ${config.instance} not in vault`); + const instance = normalizeInstance(config.instance); + if (!ctx.secret(secretKeyForInstance(instance))) { + throw new Error(`Mastodon token for ${instance} not in vault`); } - return { accountId: config.instance }; + return { accountId: instance }; }, async post(ctx, post, config) { + if (post.media?.length) throw new Error('Mastodon media uploads are not implemented yet'); const instance = normalizeInstance(config.instance); - const token = ctx.secret(tokenSecretKey(instance)); + const token = ctx.secret(secretKeyForInstance(instance)); if (!token) throw new Error(`Mastodon token for ${instance} not in vault`); - ctx.log(`mastodon post · ${config.instance} · ${post.body.length} chars`); - if (ctx.dryRun) return { id: 'dry-run', url: `https://${config.instance}/`, platform: 'mastodon', publishedAt: new Date().toISOString() }; + const status = formatMastodonStatus(post); + ctx.log(`mastodon post · ${instance} · ${status.length} chars`); + if (ctx.dryRun) return { id: 'dry-run', url: `https://${instance}/`, platform: 'mastodon', publishedAt: new Date().toISOString() }; const res = await fetch(`https://${instance}/api/v1/statuses`, { method: 'POST', @@ -39,12 +41,13 @@ export default defineSocial({ 'content-type': 'application/json', }, body: JSON.stringify({ - status: formatStatus(post), + status, visibility: config.visibility ?? 'public', }), }); + if (!res.ok) throw new Error(await readMastodonError(res)); + const data = await parseStatusResponse(res); - if (!res.ok) throw new Error(data.error ?? res.statusText); if (!data.id) throw new Error('Mastodon status response did not include a status id'); return { id: data.id, @@ -59,29 +62,39 @@ export default defineSocial({ label: "Mastodon", vendorDocUrl: "https://docs.joinmastodon.org/client/token/", steps: [ - "Open your Mastodon instance \u2192 Preferences \u2192 Development \u2192 New Application", + "Open your Mastodon instance -> Preferences -> Development -> New Application", "Scopes: write:statuses write:media read:accounts", "Copy the access token shown after creation", ], }), }); -function tokenSecretKey(instance: string): string { - return `MASTODON_TOKEN_${instance.replace(/\./g, '_').toUpperCase()}`; +function formatMastodonStatus(post: SocialPost): string { + const body = post.link ? `${post.body}\n\n${post.link}` : post.body; + const tags = (post.hashtags ?? []).slice(0, 20).map((tag) => `#${tag}`).join(' '); + const status = tags ? `${body} ${tags}` : body; + return status.length > 500 ? `${status.slice(0, 497)}...` : status; } function normalizeInstance(instance: string): string { - return instance.replace(/^https?:\/\//, '').replace(/\/$/, ''); + return instance.replace(/^https?:\/\//, '').replace(/\/+$/, ''); } -function formatStatus(post: { body: string; link?: string }): string { - return post.link ? `${post.body}\n${post.link}` : post.body; +function secretKeyForInstance(instance: string): string { + return `MASTODON_TOKEN_${instance.replace(/\./g, '_').toUpperCase()}`; } async function parseStatusResponse(res: Response): Promise { + return await res.json() as MastodonStatusResponse; +} + +async function readMastodonError(res: Response): Promise { + const text = await res.text().catch(() => ''); + if (!text) return res.statusText; try { - return await res.json() as MastodonStatusResponse; + const data = JSON.parse(text) as { error?: string }; + return data.error ?? text; } catch { - return { error: res.statusText }; + return text; } }