diff --git a/packages/social/threads/src/index.test.ts b/packages/social/threads/src/index.test.ts index e7c78aef..740e8b8e 100644 --- a/packages/social/threads/src/index.test.ts +++ b/packages/social/threads/src/index.test.ts @@ -1,4 +1,139 @@ -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: { threadsUserId: 'me' }, + samplePost: { body: 'hello from sh1pt contract tests' }, + requiredSecrets: ['THREADS_ACCESS_TOKEN'], +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('social-threads publishing', () => { + it('creates and publishes a text Thread', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 'container_123' }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 'thread_123' }), + } as Response); + + const ctx = { + ...fakeConnectContext({ THREADS_ACCESS_TOKEN: 'threads-token' }), + dryRun: false, + }; + + const result = await adapter.post(ctx as any, { + body: 'Release shipped', + hashtags: ['ship', 'typescript'], + link: 'https://sh1pt.com', + }, { + threadsUserId: 'me', + }); + + expect(result).toEqual({ + id: 'thread_123', + url: 'https://www.threads.net/', + platform: 'threads', + publishedAt: expect.any(String), + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://graph.threads.net/v1.0/me/threads'); + expect((fetchMock.mock.calls[0]?.[1] as RequestInit).headers).toMatchObject({ + 'content-type': 'application/x-www-form-urlencoded', + }); + expect(Object.fromEntries(new URLSearchParams(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body)))).toEqual({ + media_type: 'TEXT', + text: 'Release shipped\nhttps://sh1pt.com #ship #typescript', + access_token: 'threads-token', + }); + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://graph.threads.net/v1.0/me/threads_publish'); + expect(Object.fromEntries(new URLSearchParams(String((fetchMock.mock.calls[1]?.[1] as RequestInit).body)))).toEqual({ + creation_id: 'container_123', + access_token: 'threads-token', + }); + }); + + it('creates an image container when media is a public URL', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 'container_456' }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 'thread_456' }), + } as Response); + + const ctx = { + ...fakeConnectContext({ THREADS_ACCESS_TOKEN: 'threads-token' }), + dryRun: false, + }; + + await adapter.post(ctx as any, { + body: 'Image post', + media: [{ file: 'https://cdn.example.com/thread.jpg', kind: 'image' }], + }, { + threadsUserId: '17841400000000000', + apiVersion: 'v1.0', + }); + + const payload = Object.fromEntries(new URLSearchParams(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body))); + expect(payload).toMatchObject({ + media_type: 'IMAGE', + image_url: 'https://cdn.example.com/thread.jpg', + text: 'Image post', + access_token: 'threads-token', + }); + }); + + it('rejects local media paths because Threads fetches media from public URLs', async () => { + const ctx = { + ...fakeConnectContext({ THREADS_ACCESS_TOKEN: 'threads-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + body: 'Image post', + media: [{ file: '/tmp/thread.jpg', kind: 'image' }], + }, { + threadsUserId: 'me', + })).rejects.toThrow('public http(s) URL'); + }); + + it('surfaces Threads API errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ + error: { + message: 'Invalid OAuth access token', + type: 'OAuthException', + code: 190, + }, + }), + } as Response); + + const ctx = { + ...fakeConnectContext({ THREADS_ACCESS_TOKEN: 'threads-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + body: 'Release shipped', + }, { + threadsUserId: 'me', + })).rejects.toThrow('Invalid OAuth access token'); + }); +}); diff --git a/packages/social/threads/src/index.ts b/packages/social/threads/src/index.ts index 868e78a4..1c96a107 100644 --- a/packages/social/threads/src/index.ts +++ b/packages/social/threads/src/index.ts @@ -1,9 +1,20 @@ -import { defineSocial, oauthSetup } from '@profullstack/sh1pt-core'; +import { defineSocial, oauthSetup, type MediaAttachment, type SocialPost } from '@profullstack/sh1pt-core'; // Threads (Meta). Public API launched 2024. Same auth pattern as // Instagram / Facebook Graph but a distinct endpoint surface. interface Config { threadsUserId: string; + apiVersion?: string; +} + +interface ThreadsResponse { + id?: string; + error?: { + message?: string; + type?: string; + code?: number; + error_subcode?: number; + }; } export default defineSocial({ @@ -15,11 +26,22 @@ export default defineSocial({ return { accountId: config.threadsUserId }; }, async post(ctx, post, config) { + const token = ctx.secret('THREADS_ACCESS_TOKEN'); + if (!token) throw new Error('THREADS_ACCESS_TOKEN not in vault'); ctx.log(`threads post · ${post.body.length} chars`); if (ctx.dryRun) return { id: 'dry-run', url: 'https://threads.net/', platform: 'threads', publishedAt: new Date().toISOString() }; - // TODO: POST /{threadsUserId}/threads with { media_type: TEXT|IMAGE|VIDEO, text, image_url } → container - // then POST /{threadsUserId}/threads_publish with { creation_id } - return { id: `th_${Date.now()}`, url: 'https://www.threads.net/', platform: 'threads', publishedAt: new Date().toISOString() }; + + const container = await createContainer(config, token, post); + if (!container.id) throw new Error('Threads container response did not include an id'); + const published = await publishContainer(config, token, container.id); + if (!published.id) throw new Error('Threads publish response did not include an id'); + + return { + id: published.id, + url: 'https://www.threads.net/', + platform: 'threads', + publishedAt: new Date().toISOString(), + }; }, setup: oauthSetup({ @@ -33,3 +55,84 @@ export default defineSocial({ ], }), }); + +function endpoint(config: Config, edge: 'threads' | 'threads_publish'): string { + const version = config.apiVersion ?? 'v1.0'; + return `https://graph.threads.net/${version}/${encodeURIComponent(config.threadsUserId)}/${edge}`; +} + +async function createContainer(config: Config, token: string, post: SocialPost): Promise { + const res = await fetch(endpoint(config, 'threads'), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: createContainerBody(token, post), + }); + const data = await readThreadsResponse(res); + if (!res.ok) throw new Error(threadsErrorMessage(data, res.statusText)); + return data; +} + +async function publishContainer(config: Config, token: string, creationId: string): Promise { + const body = new URLSearchParams({ + creation_id: creationId, + access_token: token, + }); + const res = await fetch(endpoint(config, 'threads_publish'), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + const data = await readThreadsResponse(res); + if (!res.ok) throw new Error(threadsErrorMessage(data, res.statusText)); + return data; +} + +function createContainerBody(token: string, post: SocialPost): URLSearchParams { + const body = new URLSearchParams({ + media_type: mediaType(post.media), + text: formatText(post), + access_token: token, + }); + const media = firstMedia(post.media); + if (media?.kind === 'image') body.set('image_url', publicMediaUrl(media)); + if (media?.kind === 'video') body.set('video_url', publicMediaUrl(media)); + return body; +} + +function mediaType(media: MediaAttachment[] | undefined): 'TEXT' | 'IMAGE' | 'VIDEO' { + const selected = firstMedia(media); + if (!selected) return 'TEXT'; + if (selected.kind === 'image') return 'IMAGE'; + if (selected.kind === 'video') return 'VIDEO'; + throw new Error('Threads supports text, image, and video posts only'); +} + +function firstMedia(media: MediaAttachment[] | undefined): MediaAttachment | undefined { + return media?.find((item) => item.kind === 'image' || item.kind === 'video' || item.kind === 'gif'); +} + +function publicMediaUrl(media: MediaAttachment): string { + if (!/^https?:\/\//.test(media.file)) { + throw new Error('Threads media posts require media.file to be a public http(s) URL'); + } + return media.file; +} + +function formatText(post: SocialPost): string { + const link = post.link ? `\n${post.link}` : ''; + const hashtags = (post.hashtags ?? []).slice(0, 10).map((tag) => `#${tag}`).join(' '); + const text = `${post.body}${link}${hashtags ? ` ${hashtags}` : ''}`; + return text.slice(0, 500); +} + +async function readThreadsResponse(res: Response): Promise { + try { + return await res.json() as ThreadsResponse; + } catch { + return { error: { message: res.statusText } }; + } +} + +function threadsErrorMessage(data: ThreadsResponse, fallback: string): string { + return data.error?.message ?? fallback; +}