From ba7ff529010e31d1f1f93696045e4fa4abb8b129 Mon Sep 17 00:00:00 2001 From: Erica AI Date: Tue, 19 May 2026 09:34:29 -0400 Subject: [PATCH] feat(social-openwork): implement real adapter for openwork.bot marketplace - Replace stub adapter with working implementation based on actual API docs - Auth via Bearer token (OPENWORK_API_KEY from agent registration) - connect() calls GET /api/agents/me to verify auth and get agent profile - post() submits work to a job via POST /api/jobs/:id/submit - Supports config.jobId or extracting job ID from title pattern - Attaches links as artifacts ({type: 'url', url: ...}) - Attaches hashtags as description context - Add comprehensive test suite: contract tests, connect, submit, jobId extraction, dry-run, error handling - Update setup steps with real API registration flow --- packages/social/openwork/src/index.test.ts | 135 ++++++++++++++++++++- packages/social/openwork/src/index.ts | 116 +++++++++++++++--- 2 files changed, 231 insertions(+), 20 deletions(-) diff --git a/packages/social/openwork/src/index.test.ts b/packages/social/openwork/src/index.test.ts index e7c78aef..315f0692 100644 --- a/packages/social/openwork/src/index.test.ts +++ b/packages/social/openwork/src/index.test.ts @@ -1,4 +1,135 @@ -import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { contractTestSocial, fakeConnectContext } from '@profullstack/sh1pt-core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import adapter from './index.js'; -smokeTest(adapter, { idPrefix: 'social' }); +contractTestSocial(adapter, { + sampleConfig: { jobId: 'abc-123' }, + samplePost: { body: 'Here is my submission for the job.' }, + requiredSecrets: ['OPENWORK_API_KEY'], +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('social-openwork adapter', () => { + it('connects with valid API key and returns agent info', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: 'agent-42', name: 'erica-ai' }), + } as any); + + const ctx = fakeConnectContext({ OPENWORK_API_KEY: 'test-key' }); + const result = await adapter.connect(ctx as any, {}); + + expect(result.accountId).toBe('agent-42'); + expect(fetch).toHaveBeenCalledWith( + 'https://www.openwork.bot/api/agents/me', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-key' }, + }), + ); + }); + + it('throws when OPENWORK_API_KEY is missing from connect', async () => { + const ctx = fakeConnectContext({}); + await expect(adapter.connect(ctx as any, {})).rejects.toThrow('OPENWORK_API_KEY not in vault'); + }); + + it('submits work to a job via POST /jobs/:id/submit', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ submission: { id: 'sub-789' } }), + } as any); + + const ctx = { + secret: (k: string) => k === 'OPENWORK_API_KEY' ? 'test-key' : undefined, + log: () => {}, + dryRun: false, + }; + + const result = await adapter.post(ctx as any, { + body: 'My completed work', + link: 'https://github.com/example/pr/1', + }, { jobId: 'abc-123' }); + + expect(result.id).toBe('sub-789'); + expect(result.url).toBe('https://www.openwork.bot/jobs/abc-123'); + expect(result.platform).toBe('openwork'); + + const [url, init] = fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://www.openwork.bot/api/jobs/abc-123/submit'); + expect(init.method).toBe('POST'); + const body = JSON.parse(String(init.body)); + expect(body.content).toBe('My completed work'); + expect(body.artifacts).toEqual([{ type: 'url', url: 'https://github.com/example/pr/1' }]); + }); + + it('throws if no jobId is provided', async () => { + const ctx = { + secret: (k: string) => k === 'OPENWORK_API_KEY' ? 'test-key' : undefined, + log: () => {}, + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { body: 'test' }, {})).rejects.toThrow('jobId'); + }); + + it('extracts jobId from title pattern "job: "', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: 'ow_123' }), + } as any); + + const ctx = { + secret: (k: string) => k === 'OPENWORK_API_KEY' ? 'test-key' : undefined, + log: () => {}, + dryRun: false, + }; + + const result = await adapter.post(ctx as any, { + body: 'Work submission', + title: 'job: f47ac10b-58cc-4372-a567-0e02b2c3d479', + }, {}); + + expect(result.id).toBeTruthy(); + }); + + it('returns dry-run result without making API calls', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const ctx = { + secret: (k: string) => k === 'OPENWORK_API_KEY' ? 'test-key' : undefined, + log: () => {}, + dryRun: true, + }; + + const result = await adapter.post(ctx as any, { + body: 'Test submission', + }, { jobId: 'dry-run-job' }); + + expect(result.id).toBe('dry-run'); + expect(result.url).toBe('https://www.openwork.bot/jobs/dry-run-job'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('throws on API error response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 422, + text: async () => 'Job already submitted', + } as any); + + const ctx = { + secret: (k: string) => k === 'OPENWORK_API_KEY' ? 'test-key' : undefined, + log: () => {}, + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { body: 'Work' }, { jobId: 'abc-123' })) + .rejects.toThrow('openwork submit failed: HTTP 422'); + }); +}); \ No newline at end of file diff --git a/packages/social/openwork/src/index.ts b/packages/social/openwork/src/index.ts index 501c21a4..7c96beed 100644 --- a/packages/social/openwork/src/index.ts +++ b/packages/social/openwork/src/index.ts @@ -1,33 +1,113 @@ import { defineSocial, oauthSetup } from '@profullstack/sh1pt-core'; -// Openwork — no public API documentation available to me; placeholder -// adapter. Fill in `requires`, auth, and endpoint URLs once docs are -// provided. Delete the adapter entirely if it turns out the platform -// doesn't exist or doesn't support programmatic posting. +// Openwork (openwork.bot) — Agent marketplace with competitive bidding. +// Auth: Bearer token (API key from agent registration). +// "Posting" maps to submitting work to an open job on Openwork. +// +// API base: https://www.openwork.bot/api +// Docs: https://www.openwork.bot/api/docs +// Key endpoints: +// POST /api/agents/register — register a new agent +// GET /api/agents/me — get authenticated agent profile +// PATCH /api/agents/me — update profile (webhook, specialties, etc.) +// GET /api/jobs — list open jobs +// GET /api/jobs/:id — get job details +// POST /api/jobs/:id/submit — submit work to a job +// POST /api/agents/:id/hire — directly hire an agent +// +// Rate limits: not documented; avoid bursting > ~10 req/min. + +const OPENWORK_API = 'https://www.openwork.bot/api'; + interface Config { - // TODO: fill in once API docs are available + /** Job ID to submit to. If set, post() submits work to this job. */ + jobId?: string; + /** Agent specialties for profile updates: ['coding', 'research', 'writing'] */ + specialties?: string[]; + /** Short tagline for profile */ + description?: string; } export default defineSocial({ id: 'social-openwork', label: 'Openwork', - requires: { maxBodyChars: 5000, maxHashtags: 10, hashtagsInBody: true }, - async connect(ctx) { - if (!ctx.secret('OPENWORK_TOKEN')) throw new Error('OPENWORK_TOKEN not in vault'); - return { accountId: 'openwork' }; + requires: { maxBodyChars: 10_000, maxHashtags: 10, hashtagsInBody: false }, + + async connect(ctx, config) { + const token = ctx.secret('OPENWORK_API_KEY'); + if (!token) throw new Error('OPENWORK_API_KEY not in vault — see setup()'); + + const res = await fetch(`${OPENWORK_API}/agents/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`openwork auth check failed: HTTP ${res.status}`); + const data = await res.json() as { id?: string; name?: string }; + const name = data.name ?? 'openwork-agent'; + ctx.log(`openwork connected · ${name}`); + return { accountId: data.id ?? name }; }, - async post(ctx, post) { - ctx.log(`openwork post (stub — needs API docs)`); - if (ctx.dryRun) return { id: 'dry-run', url: '', platform: 'openwork', publishedAt: new Date().toISOString() }; - return { id: `openwork_${Date.now()}`, url: '', platform: 'openwork', publishedAt: new Date().toISOString() }; + + async post(ctx, post, config) { + const token = ctx.secret('OPENWORK_API_KEY'); + if (!token) throw new Error('OPENWORK_API_KEY not in vault'); + + const jobId = config.jobId ?? post.title?.match(/job[:\s]+([a-f0-9-]+)/i)?.[1]; + if (!jobId) { + throw new Error('openwork post requires a jobId in config — use config.jobId or include "job: " in the title'); + } + + ctx.log(`openwork submit · job ${jobId} · ${post.body.length} chars`); + + if (ctx.dryRun) { + return { id: 'dry-run', url: `https://www.openwork.bot/jobs/${jobId}`, platform: 'openwork', publishedAt: new Date().toISOString() }; + } + + const payload: Record = { + content: post.body, + }; + + // Attach link as an artifact if present + if (post.link) { + payload.artifacts = [{ type: 'url', url: post.link }]; + } + + // Attach tags as description context + if (post.hashtags?.length) { + payload.description = post.hashtags.join(', '); + } + + const res = await fetch(`${OPENWORK_API}/jobs/${jobId}/submit`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`openwork submit failed: HTTP ${res.status} — ${err}`); + } + + const data = await res.json() as { submission?: { id?: string }; id?: string }; + const submissionId = data.submission?.id ?? data.id ?? `ow_${Date.now()}`; + const url = `https://www.openwork.bot/jobs/${jobId}`; + + ctx.log(`openwork submitted · ${url}`); + return { id: submissionId, url, platform: 'openwork', publishedAt: new Date().toISOString() }; }, setup: oauthSetup({ - secretKey: "OPENWORK_API_KEY", - label: "OpenWork", - vendorDocUrl: "https://openwork.com/", + secretKey: 'OPENWORK_API_KEY', + label: 'Openwork', + vendorDocUrl: 'https://www.openwork.bot/api/docs', steps: [ - "No public API yet \u2014 contact the OpenWork team", + 'Go to https://www.openwork.bot and register an agent', + 'POST /api/agents/register with { name, profile, specialties } to get an API key', + 'Save the API key (shown once) — this is your Bearer token', + 'Store it as OPENWORK_API_KEY in your sh1pt secrets vault', + 'Optionally set config.jobId to target a specific job for submissions', ], }), -}); +}); \ No newline at end of file