Skip to content
Closed
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
86 changes: 84 additions & 2 deletions packages/social/mastodon/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,86 @@
import { smokeTest } from '@profullstack/sh1pt-core/testing';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { contractTestSocial, fakeConnectContext } from '@profullstack/sh1pt-core/testing';
import adapter from './index.js';

smokeTest(adapter, { idPrefix: 'social' });
contractTestSocial(adapter, {
sampleConfig: { instance: 'mastodon.social', visibility: 'unlisted' },
samplePost: { title: 'Ignored title', body: 'hello from sh1pt contract tests' },
requiredSecrets: ['MASTODON_TOKEN_MASTODON_SOCIAL'],
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('social-mastodon posting', () => {
it('creates a Mastodon status with token auth and visibility', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: '109246',
url: 'https://mastodon.social/@sh1pt/109246',
created_at: '2026-05-11T20:00:00Z',
}),
} as Response);

const ctx = {
...fakeConnectContext({ MASTODON_TOKEN_MASTODON_SOCIAL: 'mastodon-token' }),
dryRun: false,
};

const result = await adapter.post(ctx as any, {
body: 'Release notes',
hashtags: ['sh1pt', 'typescript'],
link: 'https://sh1pt.com',
}, { instance: 'https://mastodon.social/', visibility: 'unlisted' });

expect(result).toEqual({
id: '109246',
url: 'https://mastodon.social/@sh1pt/109246',
platform: 'mastodon',
publishedAt: '2026-05-11T20:00:00.000Z',
});
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('https://mastodon.social/api/v1/statuses');
expect((init as RequestInit).method).toBe('POST');
expect((init as RequestInit).headers).toMatchObject({
authorization: 'Bearer mastodon-token',
'content-type': 'application/json',
});
expect(JSON.parse(String((init as RequestInit).body))).toEqual({
status: 'Release notes\n\nhttps://sh1pt.com #sh1pt #typescript',
visibility: 'unlisted',
});
});

it('throws Mastodon API error messages when status creation fails', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
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 notes',
media: [{ file: './image.png', kind: 'image' }],
}, { instance: 'mastodon.social' })).rejects.toThrow('media uploads');
});
});
77 changes: 68 additions & 9 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,26 +12,85 @@ export default defineSocial<Config>({
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) {
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() };
// TODO: POST https://${instance}/api/v1/statuses with { status, visibility, media_ids }
return { id: `mst_${Date.now()}`, url: `https://${config.instance}/`, platform: 'mastodon', publishedAt: new Date().toISOString() };
if (post.media?.length) throw new Error('Mastodon media uploads are not implemented yet');
const instance = normalizeInstance(config.instance);
const token = ctx.secret(secretKeyForInstance(instance));
if (!token) throw new Error(`Mastodon token for ${instance} not in vault`);
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',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: JSON.stringify({
status,
visibility: config.visibility ?? 'public',
}),
});
if (!res.ok) throw new Error(await readMastodonError(res));

const toot = await res.json() as MastodonStatus;
if (!toot.id) throw new Error('Mastodon status response did not include an id');
return {
id: toot.id,
url: toot.url ?? toot.uri ?? `https://${instance}/`,
platform: 'mastodon',
publishedAt: new Date(toot.created_at ?? Date.now()).toISOString(),
};
},

setup: oauthSetup({
secretKey: "MASTODON_ACCESS_TOKEN",
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",
],
}),
});

interface MastodonStatus {
id?: string;
url?: string;
uri?: string;
created_at?: string;
}

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(/\/+$/, '');
}

function secretKeyForInstance(instance: string): string {
return `MASTODON_TOKEN_${instance.replace(/\./g, '_').toUpperCase()}`;
}

async function readMastodonError(res: Response): Promise<string> {
const text = await res.text().catch(() => '');
if (!text) return res.statusText;
try {
const data = JSON.parse(text) as { error?: string };
return data.error ?? text;
} catch {
return text;
}
}