Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 30 additions & 22 deletions packages/social/mastodon/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
Expand All @@ -13,35 +13,33 @@ 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' }),
dryRun: false,
};

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');
Expand All @@ -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');
});
});
49 changes: 31 additions & 18 deletions packages/social/mastodon/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'.
Expand All @@ -12,25 +12,27 @@ interface MastodonStatusResponse {
url?: string;
uri?: string;
created_at?: string;
error?: string;
}

export default defineSocial<Config>({
id: 'social-mastodon',
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',
Expand All @@ -39,12 +41,13 @@ export default defineSocial<Config>({
'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,
Expand All @@ -59,29 +62,39 @@ export default defineSocial<Config>({
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<MastodonStatusResponse> {
return await res.json() as MastodonStatusResponse;
}

async function readMastodonError(res: Response): Promise<string> {
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;
}
}
Loading