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
131 changes: 129 additions & 2 deletions packages/social/stackernews/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,131 @@
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: { territory: 'meta' },
samplePost: { title: 'Hello Stacker News', body: 'hello from sh1pt contract tests' },
requiredSecrets: ['STACKERNEWS_COOKIE'],
});

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

describe('social-stackernews GraphQL posting', () => {
it('creates a link post with the Stacker News upsertLink mutation', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: {
upsertLink: {
id: 55,
createdAt: '2026-05-21T00:00:00.000Z',
item: {
id: 123,
title: 'Release shipped',
url: 'https://sh1pt.com',
createdAt: '2026-05-21T00:01:00.000Z',
},
},
},
}),
} as any);

const ctx = {
...fakeConnectContext({ STACKERNEWS_COOKIE: 'next-auth.session-token=test' }),
dryRun: false,
};

const result = await adapter.post(ctx as any, {
title: 'Release shipped',
body: 'Article body',
link: 'https://sh1pt.com',
hashtags: ['bitcoin', 'shipping', 'automation', 'ignored'],
}, {
territory: 'bitcoin',
apiUrl: 'https://stacker.test/api/graphql',
});

expect(result).toEqual({
id: '123',
url: 'https://stacker.news/items/123',
platform: 'stackernews',
publishedAt: '2026-05-21T00:01:00.000Z',
});
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://stacker.test/api/graphql');
expect(init.method).toBe('POST');
expect(init.headers).toMatchObject({
'content-type': 'application/json',
cookie: 'next-auth.session-token=test',
});

const body = JSON.parse(String(init.body));
expect(body.query).toContain('upsertLink');
expect(body.variables).toEqual({
subNames: ['bitcoin'],
title: 'Release shipped',
url: 'https://sh1pt.com',
text: 'Article body\n\n#bitcoin #shipping #automation',
});
});

it('creates a discussion post when no link is present', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: {
upsertDiscussion: {
id: 56,
createdAt: '2026-05-21T00:00:00.000Z',
item: { id: 124, title: 'Ask SN', createdAt: '2026-05-21T00:02:00.000Z' },
},
},
}),
} as any);

const ctx = {
...fakeConnectContext({ STACKERNEWS_COOKIE: 'next-auth.session-token=test' }),
dryRun: false,
};

await expect(adapter.post(ctx as any, {
title: 'Ask SN',
body: 'What should sh1pt support next?',
}, { territory: 'meta', apiUrl: 'https://stacker.test/api/graphql' })).resolves.toMatchObject({
id: '124',
url: 'https://stacker.news/items/124',
});

const body = JSON.parse(String((fetchMock.mock.calls[0] as [string, RequestInit])[1].body));
expect(body.query).toContain('upsertDiscussion');
expect(body.variables).toMatchObject({
subNames: ['meta'],
title: 'Ask SN',
text: 'What should sh1pt support next?',
});
});

it('throws GraphQL error messages', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ errors: [{ message: 'insufficient sats' }] }),
} as any);

const ctx = {
...fakeConnectContext({ STACKERNEWS_COOKIE: 'next-auth.session-token=test' }),
dryRun: false,
};

await expect(adapter.post(ctx as any, {
title: 'Release shipped',
body: 'Article body',
link: 'https://sh1pt.com',
}, { apiUrl: 'https://stacker.test/api/graphql' })).rejects.toThrow('insufficient sats');
});
});
136 changes: 129 additions & 7 deletions packages/social/stackernews/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,49 @@
import { defineSocial, tokenSetup } from '@profullstack/sh1pt-core';
import { defineSocial, tokenSetup, type SocialPost } from '@profullstack/sh1pt-core';

// Stacker News (stacker.news) — Bitcoin Lightning-themed HN alternative.
// Submissions cost satoshis to post (anti-spam). GraphQL API at
// stacker.news/api/graphql. Auth via cookie-based login or nostr
// NIP-46 — browser-mode is the current practical path.
interface Config {
mode: 'browser';
mode?: 'api' | 'browser';
territory?: string; // e.g. 'bitcoin', 'ai', 'meta'
cookieKey?: string;
apiUrl?: string;
}

const DEFAULT_API = 'https://stacker.news/api/graphql';
const DEFAULT_COOKIE_KEY = 'STACKERNEWS_COOKIE';

export default defineSocial<Config>({
id: 'social-stackernews',
label: 'Stacker News',
requires: { maxBodyChars: 20_000, maxHashtags: 3 },
async connect(ctx) {
if (!ctx.secret('STACKERNEWS_COOKIE')) {
throw new Error('STACKERNEWS_COOKIE not in vault (export cookie from logged-in browser)');
if (!ctx.secret(DEFAULT_COOKIE_KEY)) {
throw new Error(`${DEFAULT_COOKIE_KEY} not in vault (export cookie from logged-in browser)`);
}
return { accountId: 'stackernews' };
},
async post(ctx, post, config) {
if (!post.title) throw new Error('Stacker News requires a title');
const cookieKey = config.cookieKey ?? DEFAULT_COOKIE_KEY;
const cookie = ctx.secret(cookieKey);
if (!cookie) throw new Error(`${cookieKey} not in vault (export cookie from logged-in browser)`);

ctx.log(`stacker news · territory=${config.territory ?? 'meta'} · note: submissions cost sats`);
if (ctx.dryRun) return { id: 'dry-run', url: 'https://stacker.news/', platform: 'stackernews', publishedAt: new Date().toISOString() };
// TODO: POST to /api/graphql with createItem mutation — note users must
// have satoshi balance on their SN account before calling.
return { id: `sn_${Date.now()}`, url: 'https://stacker.news/', platform: 'stackernews', publishedAt: new Date().toISOString() };

const payIn = post.link
? await upsertLink(ctx, post, config, cookie)
: await upsertDiscussion(ctx, post, config, cookie);
const item = payIn.item;
const id = String(item?.id ?? payIn.id);
return {
id,
url: item?.id ? `https://stacker.news/items/${item.id}` : 'https://stacker.news/',
platform: 'stackernews',
publishedAt: new Date(item?.createdAt ?? payIn.createdAt ?? Date.now()).toISOString(),
};
},

setup: tokenSetup({
Expand All @@ -38,3 +57,106 @@ export default defineSocial<Config>({
],
}),
});

async function upsertLink(
ctx: { log(m: string): void },
post: SocialPost,
config: Config,
cookie: string,
): Promise<StackerPayIn> {
const data = await stackerGraphql<{ upsertLink: StackerPayIn }>(config, cookie, {
query: `
mutation UpsertStackerNewsLink($subNames: [String!], $title: String!, $url: String!, $text: String) {
upsertLink(subNames: $subNames, title: $title, url: $url, text: $text) {
id
createdAt
item { id title url createdAt }
}
}
`,
variables: {
subNames: [config.territory ?? 'meta'],
title: post.title,
url: post.link,
text: formatText(post),
},
});
ctx.log(`stacker news link submitted · payIn=${data.upsertLink.id}`);
return data.upsertLink;
}

async function upsertDiscussion(
ctx: { log(m: string): void },
post: SocialPost,
config: Config,
cookie: string,
): Promise<StackerPayIn> {
const data = await stackerGraphql<{ upsertDiscussion: StackerPayIn }>(config, cookie, {
query: `
mutation UpsertStackerNewsDiscussion($subNames: [String!], $title: String!, $text: String) {
upsertDiscussion(subNames: $subNames, title: $title, text: $text) {
id
createdAt
item { id title createdAt }
}
}
`,
variables: {
subNames: [config.territory ?? 'meta'],
title: post.title,
text: formatText(post),
},
});
ctx.log(`stacker news discussion submitted · payIn=${data.upsertDiscussion.id}`);
return data.upsertDiscussion;
}

async function stackerGraphql<T>(
config: Config,
cookie: string,
body: { query: string; variables: Record<string, unknown> },
): Promise<T> {
const res = await fetch(config.apiUrl ?? DEFAULT_API, {
method: 'POST',
headers: {
'content-type': 'application/json',
cookie,
},
body: JSON.stringify(body),
});
const payload = await readStackerResponse<T>(res);
if (!res.ok || payload.errors?.length) {
throw new Error(payload.errors?.map((err) => err.message).join('; ') || `Stacker News API error ${res.status}`);
}
if (!payload.data) throw new Error('Stacker News API response did not include data');
return payload.data;
}

async function readStackerResponse<T>(res: Response): Promise<StackerGraphqlResponse<T>> {
try {
return await res.json() as StackerGraphqlResponse<T>;
} catch {
return { errors: [{ message: res.statusText }] };
}
}

function formatText(post: SocialPost): string {
const tags = (post.hashtags ?? []).slice(0, 3).map((tag) => `#${tag}`).join(' ');
return [post.body, tags].filter(Boolean).join('\n\n').slice(0, 20_000);
}

interface StackerGraphqlResponse<T> {
data?: T;
errors?: Array<{ message: string }>;
}

interface StackerPayIn {
id: number | string;
createdAt?: string;
item?: {
id: number | string;
title?: string;
url?: string;
createdAt?: string;
} | null;
}
Loading