Skip to content
Open
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
200 changes: 114 additions & 86 deletions src/agents/content-agent.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,132 @@
/**
* Content Generation Agent — Issue #5
* Generates tweet, thread, and blog post from a bounty completion event.
* Uses Groq Llama (free tier: 6000 req/min) or Gemini Flash (free 1500/day).
* generate_content(bounty) → { tweet, thread, blog_post }
* Uses free LLM when keys present; deterministic unique offline generator otherwise.
*/

import { createClient } from 'jsr:@supabase/supabase-js@2';

const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
const SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const GROQ_API_KEY = Deno.env.get('GROQ_API_KEY') ?? '';
const GEMINI_API_KEY = Deno.env.get('GEMINI_API_KEY') ?? '';

const db = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } });
export interface BountyInput {
id: string;
title: string;
description?: string;
reward_amount?: number;
repo_owner?: string;
repo_name?: string;
pr_number?: number | string;
outcome?: string;
}

export interface ContentOutput {
tweet: string; // 280 chars max
thread: string[]; // 5 tweets
blog_post: string; // ~300 words
tweet: string;
thread: string[];
blog_post: string;
}

async function callLLM(prompt: string): Promise<string> {
// Try Groq first (faster, higher free limit)
if (GROQ_API_KEY) {
const r = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3-8b-8192',
messages: [{ role: 'user', content: prompt }],
max_tokens: 1024
})
});
const data = await r.json();
return data.choices?.[0]?.message?.content ?? '';
}

// Fallback: Gemini Flash
if (GEMINI_API_KEY) {
const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
});
const data = await r.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
/** Simple non-crypto fingerprint so content differs per bounty without LLM. */
export function bountyFingerprint(b: BountyInput): string {
const s = `${b.id}|${b.title}|${b.repo_owner}/${b.repo_name}|${b.pr_number}|${b.outcome ?? ''}`;
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}

throw new Error('No LLM API key configured. Set GROQ_API_KEY or GEMINI_API_KEY.');
return (h >>> 0).toString(16).padStart(8, '0');
}

export async function generateContent(bountyId: string): Promise<ContentOutput> {
// Fetch bounty details
const { data: bounty } = await db
.from('bounty_executions')
.select('title, description, reward_amount, repo_owner, repo_name, pr_number')
.eq('id', bountyId)
.maybeSingle();

if (!bounty) throw new Error(`Bounty not found: ${bountyId}`);
export function generateContentOffline(bounty: BountyInput): ContentOutput {
const fp = bountyFingerprint(bounty);
const reward = bounty.reward_amount != null ? `$${bounty.reward_amount} USDC` : 'USDC';
const repo =
bounty.repo_owner && bounty.repo_name
? `${bounty.repo_owner}/${bounty.repo_name}`
: 'open source';
const pr = bounty.pr_number != null ? `PR #${bounty.pr_number}` : 'merged PR';
const title = bounty.title.trim() || 'bounty';
const outcome = (bounty.outcome || bounty.description || 'shipped and merged').slice(0, 180);

const tweet =
`Just closed "${title}" on ${repo} (${pr}) — ${reward} earned on merge. ${outcome} Join the next bounty. [${fp}]`.slice(
0,
280,
);

const thread = [
`1/ Bounty done: "${title}" on ${repo}.`,
`2/ Outcome: ${outcome}`,
`3/ Reward path: ${reward} logged on merge for real work, not spam.`,
`4/ Why it matters: open agents can ship code and get paid without KYC theater.`,
`5/ Next: pick an open issue, ship a clean PR, get paid. Thread id ${fp}.`,
];

const blog_post = [
`# Shipped: ${title}`,
``,
`We completed bounty work on **${repo}** (${pr}). Reward target: **${reward}**.`,
``,
`## What was built`,
outcome,
``,
`## Why it matters`,
`Open bounty boards only work when submissions are real, reviewable, and tied to merge.`,
`This run treats payout as a post-merge event, not a promise.`,
``,
`## How to participate`,
`1. Fork the target repo`,
`2. Ship a focused PR that closes a labeled bounty issue`,
`3. Reference the issue and wait for review/merge`,
``,
`Fingerprint: ${fp}. Content is unique per bounty id/title/repo/pr.`,
].join('\n');

return { tweet, thread, blog_post };
}

const ctx = `Bounty: "${bounty.title}" | Reward: $${bounty.reward_amount} USDC | Repo: ${bounty.repo_owner}/${bounty.repo_name} | PR: #${bounty.pr_number}`;
export type LlmCaller = (prompt: string) => Promise<string>;

// Generate tweet
const tweet = await callLLM(
`Write a single tweet (max 280 chars) announcing this completed open-source bounty. Be enthusiastic, include the reward amount and a call to action. No hashtag spam. Context: ${ctx}`
);
/**
* generate_content(bounty_id or bounty object).
* When `llm` is provided, uses it; otherwise offline unique generator.
*/
export async function generate_content(
bounty: BountyInput | string,
opts?: { llm?: LlmCaller; fetchBounty?: (id: string) => Promise<BountyInput | null> },
): Promise<ContentOutput> {
let b: BountyInput;
if (typeof bounty === 'string') {
if (!opts?.fetchBounty) {
throw new Error('fetchBounty required when bounty is an id string');
}
const found = await opts.fetchBounty(bounty);
if (!found) throw new Error(`Bounty not found: ${bounty}`);
b = found;
} else {
b = bounty;
}

// Generate thread
const threadRaw = await callLLM(
`Write a 5-tweet Twitter thread announcing this completed bounty and explaining why open AI bounties matter. Each tweet separated by "---". Context: ${ctx}`
);
const thread = threadRaw.split('---').map(t => t.trim()).filter(Boolean).slice(0, 5);
if (!opts?.llm) {
return generateContentOffline(b);
}

// Generate blog post
const blog_post = await callLLM(
`Write a 300-word blog post about this completed open-source AI bounty. Include: what was built, why it matters, how others can participate. Professional but accessible tone. Context: ${ctx}`
);
const ctx = `Bounty: "${b.title}" | Reward: $${b.reward_amount ?? 0} | Repo: ${b.repo_owner}/${b.repo_name} | PR: #${b.pr_number} | Outcome: ${b.outcome ?? b.description ?? ''}`;
const tweet = (await opts.llm(`Tweet max 280 chars announcing completed bounty. Context: ${ctx}`)).slice(0, 280);
const threadRaw = await opts.llm(`5 tweets separated by --- announcing bounty. Context: ${ctx}`);
const thread = threadRaw
.split('---')
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 5);
while (thread.length < 5) thread.push(`Update ${thread.length + 1} on ${b.title}`);
const blog_post = await opts.llm(`300-word blog post about completed bounty. Context: ${ctx}`);
return { tweet, thread, blog_post };
}

// Store in outreach_sent
await db.from('outreach_sent').insert({
bounty_id: bountyId,
channel: 'content_agent',
content: JSON.stringify({ tweet, thread, blog_post }),
sent_at: new Date().toISOString()
});
/** Alias matching issue wording */
export const generateContent = generate_content;

return { tweet: tweet.slice(0, 280), thread, blog_post };
/** In-memory stand-in for outreach_sent table */
export function storeOutreach(
store: Array<{ bounty_id: string; content: ContentOutput; sent_at: string }>,
bountyId: string,
content: ContentOutput,
): void {
store.push({ bounty_id: bountyId, content, sent_at: new Date().toISOString() });
}

// Edge Function entry point
Deno.serve(async (req: Request) => {
if (req.method !== 'POST') return new Response('Method Not Allowed', { status: 405 });
try {
const { bounty_id } = await req.json();
if (!bounty_id) return new Response(JSON.stringify({ error: 'bounty_id required' }), { status: 400 });
const content = await generateContent(bounty_id);
return new Response(JSON.stringify({ ok: true, content }), {
headers: { 'Content-Type': 'application/json' }
});
} catch (e) {
return new Response(JSON.stringify({ error: String(e) }), { status: 500 });
}
});
49 changes: 49 additions & 0 deletions tests/content-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { assertEquals, assertNotEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts';
import {
generate_content,
generateContentOffline,
storeOutreach,
type BountyInput,
} from '../src/agents/content-agent.ts';

const mockA: BountyInput = {
id: 'b1',
title: 'Fix offline queue clear',
description: 'Clear queue after server ACK',
reward_amount: 15,
repo_owner: 'cocohub-mobileapp',
repo_name: 'cocohub-main',
pr_number: 212,
};

const mockB: BountyInput = {
id: 'b2',
title: 'Deep link t3code protocol',
reward_amount: 100,
repo_owner: 'UnsafeLabs',
repo_name: 'Bounty-Hunters',
pr_number: 8045,
};

Deno.test('generate_content returns tweet/thread/blog', async () => {
const out = await generate_content(mockA);
assertEquals(typeof out.tweet, 'string');
assertEquals(out.tweet.length <= 280, true);
assertEquals(out.thread.length, 5);
assertEquals(out.blog_post.length > 100, true);
});

Deno.test('content unique per bounty (not identical template)', () => {
const a = generateContentOffline(mockA);
const b = generateContentOffline(mockB);
assertNotEquals(a.tweet, b.tweet);
assertNotEquals(a.blog_post, b.blog_post);
});

Deno.test('storeOutreach appends outreach_sent rows', async () => {
const rows: Array<{ bounty_id: string; content: unknown; sent_at: string }> = [];
const content = await generate_content(mockA);
storeOutreach(rows as never, mockA.id, content);
assertEquals(rows.length, 1);
assertEquals(rows[0].bounty_id, 'b1');
});