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
135 changes: 133 additions & 2 deletions packages/social/openwork/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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: <id>"', 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');
});
});
116 changes: 98 additions & 18 deletions packages/social/openwork/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<Config>({
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: <id>" 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<string, unknown> = {
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',
],
}),
});
});
Loading