From 5bc76f5ce38b87d8aabcfb3caf7eada153e53ba6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 10:25:42 +0000 Subject: [PATCH 1/2] feat: add phase 10 gtm production activation - Add GTM orchestration across SaaS, FinTech, Healthcare, MSSP ICPs - Discover/score/enrich leads, generate outreach and HubSpot drafts - Human approval gates, audit logging, dashboard integration - CLI scripts for activation, deploy, and Hetzner verification - Dry-run mode for CI and local testing without API keys Co-authored-by: MD TAZIZUL ISLAM --- control/.env.gtm.example | 29 ++ control/src/commands/help.command.ts | 5 + .../revenue-engine/src/discovery-service.ts | 99 +++--- .../revenue-engine/src/gtm-activation.ts | 282 ++++++++++++++++++ integrations/revenue-engine/src/index.ts | 2 + .../revenue-engine/src/revenue-engine.test.ts | 61 ++++ integrations/revenue-engine/src/service.ts | 23 ++ .../revenue-engine/src/task-parser.ts | 10 + integrations/revenue-engine/src/types.ts | 1 + package.json | 4 + projects/zonforge/icp/fintech.yaml | 45 +++ projects/zonforge/icp/healthcare.yaml | 45 +++ projects/zonforge/icp/mssp.yaml | 45 +++ projects/zonforge/icp/saas.yaml | 44 +++ reports/phase10-gtm-activation.md | 110 +++++++ scripts/deploy-phase10-gtm.sh | 85 ++++++ scripts/run-gtm-activation.mjs | 46 +++ scripts/verify-phase10-gtm.sh | 91 ++++++ 18 files changed, 990 insertions(+), 37 deletions(-) create mode 100644 control/.env.gtm.example create mode 100644 integrations/revenue-engine/src/gtm-activation.ts create mode 100644 projects/zonforge/icp/fintech.yaml create mode 100644 projects/zonforge/icp/healthcare.yaml create mode 100644 projects/zonforge/icp/mssp.yaml create mode 100644 projects/zonforge/icp/saas.yaml create mode 100644 reports/phase10-gtm-activation.md create mode 100755 scripts/deploy-phase10-gtm.sh create mode 100755 scripts/run-gtm-activation.mjs create mode 100755 scripts/verify-phase10-gtm.sh diff --git a/control/.env.gtm.example b/control/.env.gtm.example new file mode 100644 index 0000000..25e4367 --- /dev/null +++ b/control/.env.gtm.example @@ -0,0 +1,29 @@ +# OpenClaw Phase 10 — GTM Production Activation +# Copy relevant keys to control/.env.whatsapp (server-only, never commit) + +# === REQUIRED: Lead Discovery === +TAVILY_API_KEY=your_tavily_key_here +SERPER_API_KEY=your_serper_key_here +APOLLO_API_KEY=your_apollo_key_here + +# === Enrichment (recommended for decision makers) === +LEAD_ENRICHMENT_MODE=live +HUNTER_API_KEY=your_hunter_key_here +ENABLE_HUNTER=true +ENABLE_APOLLO=true + +# === GTM Activation Tuning === +GTM_DISCOVERY_TARGET=100 +GTM_OUTREACH_TOP_N=20 +OPENCLAW_ASYNC_TASKS=1 +OPENCLAW_PROJECT=zonforge + +# Lead generation batch size (per segment run) +LEAD_GENERATION_MIN_PROSPECTS=100 +LEAD_GENERATION_ENRICH_BATCH=50 +LEAD_GENERATION_ENRICH_DELAY_MS=300 + +# === Safety: no auto-send === +# Gmail/HubSpot sends require explicit /task approve + send commands +GMAIL_MODE=mock +HUBSPOT_MODE=mock diff --git a/control/src/commands/help.command.ts b/control/src/commands/help.command.ts index 485ae57..5a5c628 100644 --- a/control/src/commands/help.command.ts +++ b/control/src/commands/help.command.ts @@ -61,6 +61,11 @@ export function runHelpCommand(config?: OpenClawConfig): CommandResult { '/task revenue report | marketing report | lead report | kpi dashboard', '/task list approvals | run revenue engine', '', + 'Phase 10 — GTM Production Activation:', + '/task run gtm activation — discover 100 leads/segment, score, outreach drafts, CRM, report', + 'npm run openclaw:gtm:activate:dry — local dry-run (no API keys)', + 'npm run openclaw:gtm:activate — production activation (requires API keys on server)', + '', '/task show company status | what should I do today', '/run_daily — execute daily GTM automation', '/daily — run daily GTM + founder brief', diff --git a/integrations/revenue-engine/src/discovery-service.ts b/integrations/revenue-engine/src/discovery-service.ts index 1f9c946..03ff25c 100644 --- a/integrations/revenue-engine/src/discovery-service.ts +++ b/integrations/revenue-engine/src/discovery-service.ts @@ -9,25 +9,43 @@ import { formatSegmentLabel, resolveSegmentProfile } from './lead-discovery.js' import { saveDiscoveryRun, upsertPipelineLead } from './store.js' import type { LeadSegment, RevenueEnginePaths, RevenueTaskRequest } from './types.js' +function priorityToScore(priority: string): number { + if (priority === 'High') return 85 + if (priority === 'Medium') return 65 + return 45 +} + export async function runLeadDiscovery( paths: RevenueEnginePaths, req: RevenueTaskRequest, segment: LeadSegment, + targetLeads?: number, ): Promise<{ ok: boolean; message: string; data?: Record }> { const profile = resolveSegmentProfile(segment) const label = formatSegmentLabel(segment) const project = loadProjectContext(req.root) const leadPaths = resolveLeadGenerationPaths(req.root, project.activeProjectId) - const result = await runLeadGeneration({ - root: req.root, - outputDir: leadPaths.outputDir, - kpiFile: leadPaths.kpiFile, - env: process.env, - projectId: project.activeProjectId, - productName: project.profile.productName, - projectProfile: profile, - }) + const prevMin = process.env['LEAD_GENERATION_MIN_PROSPECTS'] + if (targetLeads && targetLeads > 0) { + process.env['LEAD_GENERATION_MIN_PROSPECTS'] = String(targetLeads) + } + + let result + try { + result = await runLeadGeneration({ + root: req.root, + outputDir: leadPaths.outputDir, + kpiFile: leadPaths.kpiFile, + env: process.env, + projectId: project.activeProjectId, + productName: project.profile.productName, + projectProfile: profile, + }) + } finally { + if (prevMin !== undefined) process.env['LEAD_GENERATION_MIN_PROSPECTS'] = prevMin + else if (targetLeads) delete process.env['LEAD_GENERATION_MIN_PROSPECTS'] + } if (!result.ok) { return { @@ -56,40 +74,47 @@ export async function runLeadDiscovery( if (existsSync(result.jsonPath)) { try { const { readFileSync } = await import('node:fs') - const prospects = JSON.parse(readFileSync(result.jsonPath, 'utf8')) as Array<{ - company?: string - domain?: string - priority?: string - score?: number - contacts?: Array<{ name?: string; email?: string; title?: string }> - }> - - for (const prospect of prospects.slice(0, 5)) { - if (!prospect.domain) continue - topDomains.push(prospect.domain) - - const contact = prospect.contacts?.[0] + const payload = JSON.parse(readFileSync(result.jsonPath, 'utf8')) as { + prospects?: Array<{ + company?: string + website?: string + priority?: string + contact_name?: string + email?: string + job_title?: string + }> + } + + const prospects = payload.prospects ?? [] + + for (const prospect of prospects) { + const domain = prospect.website?.replace(/^https?:\/\//, '').replace(/\/.*$/, '') ?? '' + if (!domain && !prospect.company) continue + topDomains.push(domain || prospect.company || '') + upsertPipelineLead(paths, { - company: prospect.company ?? prospect.domain, - domain: prospect.domain, + company: prospect.company ?? domain, + domain: domain || `${prospect.company?.toLowerCase().replace(/\s+/g, '-')}.com`, segment, stage: 'discovered', - score: prospect.score ?? (prospect.priority === 'high' ? 85 : prospect.priority === 'medium' ? 65 : 45), - decisionMaker: contact?.name, - email: contact?.email, - title: contact?.title, + score: priorityToScore(prospect.priority ?? 'Low'), + decisionMaker: prospect.contact_name, + email: prospect.email, + title: prospect.job_title, source: `discovery:${segment}`, }) + } - if (prospect.priority === 'high' && prospect.domain) { - const enrich = await handleEnrichmentTask({ - task: `enrich domain ${prospect.domain}`, - senderId: req.senderId, - channel: req.channel, - root: req.root, - }) - if (enrich.ok) enrichedCount += 1 - } + for (const prospect of prospects.filter(p => p.priority === 'High').slice(0, 10)) { + const domain = prospect.website?.replace(/^https?:\/\//, '').replace(/\/.*$/, '') + if (!domain) continue + const enrich = await handleEnrichmentTask({ + task: `enrich domain ${domain}`, + senderId: req.senderId, + channel: req.channel, + root: req.root, + }) + if (enrich.ok) enrichedCount += 1 } } catch { // Non-fatal — discovery output still valid diff --git a/integrations/revenue-engine/src/gtm-activation.ts b/integrations/revenue-engine/src/gtm-activation.ts new file mode 100644 index 0000000..2d1d19a --- /dev/null +++ b/integrations/revenue-engine/src/gtm-activation.ts @@ -0,0 +1,282 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { handleHubspotTask } from '@openclaw/hubspot' +import { logRevenueAction } from './audit.js' +import { runLeadDiscovery, scorePipelineLeads } from './discovery-service.js' +import { forecastRevenue } from './crm.js' +import { createEmailOutreach } from './outreach.js' +import { generateLeadReport, generateRevenueReport } from './executive-reports.js' +import { formatApprovalsList } from './approvals.js' +import { listPipelineLeads, upsertPipelineLead } from './store.js' +import type { LeadSegment, RevenueEnginePaths, RevenueTaskRequest } from './types.js' + +export const GTM_SEGMENTS: Exclude[] = ['saas', 'fintech', 'healthcare', 'mssp'] + +export interface GtmActivationOptions { + root: string + senderId: string + channel: string + targetPerSegment?: number + outreachTopN?: number + dryRun?: boolean +} + +export interface GtmSegmentResult { + segment: LeadSegment + ok: boolean + discovered: number + high: number + medium: number + low: number + jsonPath?: string + message: string +} + +export interface GtmActivationResult { + ok: boolean + startedAt: string + completedAt: string + segments: GtmSegmentResult[] + pipeline: { + totalLeads: number + averageScore: number + bySegment: Record + byStage: Record + } + topProspects: Array<{ + company: string + segment: string + score: number + email?: string + stage: string + }> + outreachDrafts: Array<{ + sequenceId: string + company: string + email: string + requiresApproval: boolean + }> + hubspotDrafts: Array<{ draftId?: string; company: string; status: string }> + estimatedPipelineValue: string + executiveReport: string + leadReport: string + pendingApprovals: number + reportPath: string + message: string +} + +function priorityToScore(priority: string): number { + if (priority === 'High') return 85 + if (priority === 'Medium') return 65 + return 45 +} + +function seedDryRunPipeline(paths: RevenueEnginePaths, segment: LeadSegment, count: number): void { + for (let i = 0; i < count; i++) { + const n = i + 1 + upsertPipelineLead(paths, { + company: `${segment.toUpperCase()} Prospect ${n}`, + domain: `${segment}-prospect-${n}.example.com`, + segment, + stage: i < 10 ? 'qualified' : 'discovered', + score: 90 - (i % 30), + decisionMaker: `Contact ${n}`, + email: i % 3 === 0 ? `contact${n}@${segment}-prospect-${n}.example.com` : undefined, + title: 'CISO', + source: `gtm-dry-run:${segment}`, + }) + } +} + +export async function runGtmActivation( + paths: RevenueEnginePaths, + options: GtmActivationOptions, +): Promise { + const startedAt = new Date().toISOString() + const target = options.targetPerSegment ?? (Number(process.env['GTM_DISCOVERY_TARGET'] ?? '100') || 100) + const outreachTopN = options.outreachTopN ?? (Number(process.env['GTM_OUTREACH_TOP_N'] ?? '20') || 20) + const req: RevenueTaskRequest = { + task: 'gtm activation', + senderId: options.senderId, + channel: options.channel, + root: options.root, + } + + const segments: GtmSegmentResult[] = [] + const prevMin = process.env['LEAD_GENERATION_MIN_PROSPECTS'] + process.env['LEAD_GENERATION_MIN_PROSPECTS'] = String(target) + + try { + for (const segment of GTM_SEGMENTS) { + if (options.dryRun) { + seedDryRunPipeline(paths, segment, Math.min(target, 25)) + segments.push({ + segment, + ok: true, + discovered: Math.min(target, 25), + high: 8, + medium: 10, + low: 7, + message: `Dry-run: seeded ${Math.min(target, 25)} ${segment} leads`, + }) + continue + } + + const discovery = await runLeadDiscovery(paths, req, segment, target) + segments.push({ + segment, + ok: discovery.ok, + discovered: Number(discovery.data?.total ?? 0), + high: Number(discovery.data?.high ?? 0), + medium: 0, + low: 0, + jsonPath: discovery.data?.jsonPath as string | undefined, + message: discovery.message, + }) + } + } finally { + if (prevMin !== undefined) process.env['LEAD_GENERATION_MIN_PROSPECTS'] = prevMin + else delete process.env['LEAD_GENERATION_MIN_PROSPECTS'] + } + + await scorePipelineLeads(paths, req) + + const allLeads = listPipelineLeads(paths) + const bySegment: Record = {} + const byStage: Record = {} + let scoreSum = 0 + + for (const lead of allLeads) { + bySegment[lead.segment] = (bySegment[lead.segment] ?? 0) + 1 + byStage[lead.stage] = (byStage[lead.stage] ?? 0) + 1 + scoreSum += lead.score + } + + const topProspects = allLeads + .sort((a, b) => b.score - a.score) + .slice(0, 20) + .map(l => ({ + company: l.company, + segment: l.segment, + score: l.score, + email: l.email, + stage: l.stage, + })) + + const outreachDrafts: GtmActivationResult['outreachDrafts'] = [] + const hubspotDrafts: GtmActivationResult['hubspotDrafts'] = [] + + const outreachCandidates = allLeads + .filter(l => l.email && l.email.includes('@')) + .sort((a, b) => b.score - a.score) + .slice(0, outreachTopN) + + for (const lead of outreachCandidates) { + if (!lead.email) continue + + const outreach = await createEmailOutreach( + paths, + req, + lead.email, + lead.company, + lead.decisionMaker, + ) + + outreachDrafts.push({ + sequenceId: String(outreach.data?.sequenceId ?? randomUUID()), + company: lead.company, + email: lead.email, + requiresApproval: true, + }) + + upsertPipelineLead(paths, { ...lead, stage: 'outreach' }) + + if (!options.dryRun) { + const hubspot = await handleHubspotTask({ + task: `draft hubspot contact name ${lead.decisionMaker ?? lead.company} email ${lead.email} company ${lead.company}`, + senderId: options.senderId, + channel: options.channel, + root: options.root, + }) + hubspotDrafts.push({ + draftId: hubspot.data?.draftId as string | undefined, + company: lead.company, + status: hubspot.ok ? 'pending_approval' : 'failed', + }) + } else { + hubspotDrafts.push({ + company: lead.company, + status: 'dry-run-pending_approval', + }) + } + } + + const forecast = forecastRevenue(paths) + const revenueReport = await generateRevenueReport(paths, req) + const leadReport = await generateLeadReport(paths, req) + const approvals = formatApprovalsList(paths) + + const completedAt = new Date().toISOString() + const result: GtmActivationResult = { + ok: segments.every(s => s.ok) || options.dryRun === true, + startedAt, + completedAt, + segments, + pipeline: { + totalLeads: allLeads.length, + averageScore: allLeads.length > 0 ? Math.round(scoreSum / allLeads.length) : 0, + bySegment, + byStage, + }, + topProspects, + outreachDrafts, + hubspotDrafts, + estimatedPipelineValue: forecast.message.split('\n')[2] ?? 'N/A', + executiveReport: revenueReport.message, + leadReport: leadReport.message, + pendingApprovals: approvals.count, + reportPath: '', + message: '', + } + + if (!existsSync(paths.outputDir)) mkdirSync(paths.outputDir, { recursive: true }) + const reportPath = path.join(paths.outputDir, `gtm-activation-${Date.now()}.json`) + writeFileSync(reportPath, JSON.stringify(result, null, 2), 'utf8') + result.reportPath = path.relative(paths.openclawRoot, reportPath).replace(/\\/g, '/') + + result.message = [ + 'GTM Production Activation Complete', + '', + `Total leads: ${result.pipeline.totalLeads}`, + `Average score: ${result.pipeline.averageScore}`, + `Outreach drafts: ${outreachDrafts.length} (approval required before send)`, + `HubSpot drafts: ${hubspotDrafts.length} (approval required before create)`, + `Pending approvals: ${approvals.count}`, + result.estimatedPipelineValue, + '', + 'Top prospects:', + ...topProspects.slice(0, 5).map(p => `• ${p.company} (${p.segment}) — score ${p.score}`), + '', + `Report: ${result.reportPath}`, + '', + 'Next: /task list approvals → approve drafts → /task revenue report', + ].join('\n') + + logRevenueAction(paths, { + action: 'report_generated', + senderId: options.senderId, + channel: options.channel, + details: `GTM activation: ${result.pipeline.totalLeads} leads, ${outreachDrafts.length} outreach drafts`, + metadata: { + totalLeads: result.pipeline.totalLeads, + averageScore: result.pipeline.averageScore, + outreachDrafts: outreachDrafts.length, + reportPath: result.reportPath, + }, + }) + + return result +} + +export { priorityToScore } diff --git a/integrations/revenue-engine/src/index.ts b/integrations/revenue-engine/src/index.ts index d3b4ae9..01b2918 100644 --- a/integrations/revenue-engine/src/index.ts +++ b/integrations/revenue-engine/src/index.ts @@ -7,6 +7,8 @@ export { } from './service.js' export { buildRevenueDashboardForRoot, buildRevenueDashboardForRoot as buildRevenueDashboard } from './executive-reports.js' export { listPendingApprovals } from './approvals.js' +export { runGtmActivation, GTM_SEGMENTS } from './gtm-activation.js' +export type { GtmActivationResult, GtmActivationOptions } from './gtm-activation.js' export type { RevenueTaskRequest, RevenueTaskResult, diff --git a/integrations/revenue-engine/src/revenue-engine.test.ts b/integrations/revenue-engine/src/revenue-engine.test.ts index 49ed35a..ff04c0d 100644 --- a/integrations/revenue-engine/src/revenue-engine.test.ts +++ b/integrations/revenue-engine/src/revenue-engine.test.ts @@ -8,6 +8,9 @@ import { parseRevenueIntent, buildRevenueDashboardForRoot, listPendingApprovals, + buildRevenueEnginePaths, + runGtmActivation, + GTM_SEGMENTS, } from './index.js' const tempDirs: string[] = [] @@ -50,6 +53,12 @@ describe('task parser', () => { expect(parseRevenueIntent('lead report')?.kind).toBe('lead-report') expect(parseRevenueIntent('list approvals')?.kind).toBe('list-approvals') }) + + it('parses gtm activation intent', () => { + expect(parseRevenueIntent('run gtm activation')?.kind).toBe('run-gtm-activation') + expect(parseRevenueIntent('gtm production activation')?.kind).toBe('run-gtm-activation') + expect(isRevenueEngineTask('run gtm activation')).toBe(true) + }) }) describe('revenue engine service', () => { @@ -108,4 +117,56 @@ describe('revenue engine service', () => { const items = listPendingApprovals(makeRoot()) expect(Array.isArray(items)).toBe(true) }) + + it('runs gtm activation dry-run across all segments', async () => { + const root = makeRoot() + const paths = buildRevenueEnginePaths(root) + const prev = process.env['GTM_ACTIVATION_DRY_RUN'] + process.env['GTM_ACTIVATION_DRY_RUN'] = '1' + + try { + const result = await runGtmActivation(paths, { + root, + senderId: 'test', + channel: 'test', + targetPerSegment: 25, + outreachTopN: 5, + dryRun: true, + }) + + expect(result.ok).toBe(true) + expect(result.segments).toHaveLength(GTM_SEGMENTS.length) + expect(result.pipeline.totalLeads).toBe(GTM_SEGMENTS.length * 25) + expect(result.pipeline.averageScore).toBeGreaterThan(0) + expect(result.outreachDrafts.length).toBeGreaterThan(0) + expect(result.outreachDrafts.every(d => d.requiresApproval)).toBe(true) + expect(result.message).toContain('GTM Production Activation Complete') + expect(result.reportPath).toMatch(/gtm-activation-\d+\.json/) + } finally { + if (prev !== undefined) process.env['GTM_ACTIVATION_DRY_RUN'] = prev + else delete process.env['GTM_ACTIVATION_DRY_RUN'] + } + }) + + it('handles gtm activation via task service', async () => { + const root = makeRoot() + const prev = process.env['GTM_ACTIVATION_DRY_RUN'] + process.env['GTM_ACTIVATION_DRY_RUN'] = '1' + + try { + const result = await handleRevenueEngineTask({ + task: 'run gtm activation', + senderId: 'founder', + channel: 'whatsapp', + root, + }) + expect(result.ok).toBe(true) + expect(result.message).toContain('GTM Production Activation Complete') + expect(result.data?.totalLeads).toBeGreaterThan(0) + expect(result.requiresApproval).toBe(true) + } finally { + if (prev !== undefined) process.env['GTM_ACTIVATION_DRY_RUN'] = prev + else delete process.env['GTM_ACTIVATION_DRY_RUN'] + } + }) }) diff --git a/integrations/revenue-engine/src/service.ts b/integrations/revenue-engine/src/service.ts index 3440fad..c0f4165 100644 --- a/integrations/revenue-engine/src/service.ts +++ b/integrations/revenue-engine/src/service.ts @@ -22,6 +22,7 @@ import { } from './executive-reports.js' import { formatApprovalsList } from './approvals.js' import { listPipelineLeads } from './store.js' +import { runGtmActivation } from './gtm-activation.js' import type { RevenueTaskRequest, RevenueTaskResult } from './types.js' export { isRevenueEngineTask, parseRevenueIntent, classifyAsyncRevenueTask } from './task-parser.js' @@ -44,6 +45,7 @@ export async function handleRevenueEngineTask(req: RevenueTaskRequest): Promise< ' support triage: customer cannot login', ' revenue report | marketing report | lead report | kpi dashboard', ' list approvals | run revenue engine', + ' run gtm activation', ].join('\n'), } } @@ -120,6 +122,27 @@ export async function handleRevenueEngineTask(req: RevenueTaskRequest): Promise< result = { ok: approvals.ok, message: approvals.message, data: { count: approvals.count } } break } + case 'run-gtm-activation': { + const activation = await runGtmActivation(paths, { + root: req.root, + senderId: req.senderId, + channel: req.channel, + dryRun: process.env['GTM_ACTIVATION_DRY_RUN'] === '1', + }) + result = { + ok: activation.ok, + message: activation.message, + data: { + totalLeads: activation.pipeline.totalLeads, + averageScore: activation.pipeline.averageScore, + outreachDrafts: activation.outreachDrafts.length, + reportPath: activation.reportPath, + requiresApproval: activation.outreachDrafts.length > 0, + }, + requiresApproval: activation.outreachDrafts.length > 0, + } + break + } case 'run-revenue-engine': { const discovery = await runLeadDiscovery(paths, req, 'all') result = { diff --git a/integrations/revenue-engine/src/task-parser.ts b/integrations/revenue-engine/src/task-parser.ts index ddc50b9..6cd4403 100644 --- a/integrations/revenue-engine/src/task-parser.ts +++ b/integrations/revenue-engine/src/task-parser.ts @@ -16,6 +16,8 @@ export function isRevenueEngineTask(task: string): boolean { if (/\bsupport triage\b/.test(t)) return true if (/\bcrm pipeline\b/.test(t)) return true if (/\brevenue forecast\b/.test(t)) return true + if (/\bgtm activation\b/.test(t)) return true + if (/\brun gtm\b/.test(t)) return true return false } @@ -33,6 +35,10 @@ export function parseRevenueIntent(task: string): RevenueIntent | undefined { const t = task.trim() const lower = t.toLowerCase() + if (/\brun gtm activation\b/i.test(t) || /\bgtm production activation\b/i.test(t)) { + return { kind: 'run-gtm-activation' } + } + if (/\brun revenue engine\b/i.test(t)) { return { kind: 'run-revenue-engine' } } @@ -148,5 +154,9 @@ export function classifyAsyncRevenueTask(task: string): { return { type: 'workflow', workflowId: 'revenue-engine' } } + if (intent.kind === 'run-gtm-activation') { + return { type: 'revenue' } + } + return { type: 'revenue' } } diff --git a/integrations/revenue-engine/src/types.ts b/integrations/revenue-engine/src/types.ts index ffedb36..f6bfdec 100644 --- a/integrations/revenue-engine/src/types.ts +++ b/integrations/revenue-engine/src/types.ts @@ -146,6 +146,7 @@ export type RevenueIntent = | { kind: 'kpi-dashboard' } | { kind: 'list-approvals' } | { kind: 'run-revenue-engine' } + | { kind: 'run-gtm-activation' } export interface PendingApprovalItem { id: string diff --git a/package.json b/package.json index acb47f2..c640b2a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,10 @@ "openclaw:executive:test": "npm --workspace @openclaw/executive run test", "openclaw:revenue:build": "npm --workspace @openclaw/revenue-engine run build", "openclaw:revenue:test": "npm --workspace @openclaw/revenue-engine run test", + "openclaw:gtm:activate": "npm run openclaw:revenue:build && node scripts/run-gtm-activation.mjs", + "openclaw:gtm:activate:dry": "GTM_ACTIVATION_DRY_RUN=1 npm run openclaw:gtm:activate", + "openclaw:gtm:verify": "bash scripts/verify-phase10-gtm.sh", + "openclaw:gtm:deploy": "bash scripts/deploy-phase10-gtm.sh", "openclaw:build": "node scripts/build-openclaw.mjs", "openclaw:start": "npm --workspace @openclaw/control run start", "openclaw:dev": "npm --workspace @openclaw/control run build && npm --workspace @openclaw/control run dev", diff --git a/projects/zonforge/icp/fintech.yaml b/projects/zonforge/icp/fintech.yaml new file mode 100644 index 0000000..0c11d2e --- /dev/null +++ b/projects/zonforge/icp/fintech.yaml @@ -0,0 +1,45 @@ +segment: fintech +displayName: FinTech Companies +targetLeads: 100 +description: FinTech, payment, and banking technology security buyers + +icpSegments: + - FinTech + - Payment Processors + - Banking Technology + - InsurTech + - RegTech + +discoveryQueries: + - fintech cybersecurity companies USA + - payment security fintech startups + - banking technology security vendors + - open banking fintech companies + - fraud prevention fintech SaaS + - regtech compliance fintech companies + - digital banking security vendors + - insurtech cybersecurity companies + +apolloKeywordTags: + - fintech + - payments + - banking + - financial services + - regtech + +icpIndustryPattern: "\\b(fintech|financial|banking|payments|insurtech|regtech)\\b" +highTitlePattern: "\\b(ciso|chief risk|vp security|head of compliance|cto|director of security|cco)\\b" + +idealCustomer: + employeeRange: 100-1000 + industries: + - FinTech + - Payments + - Digital Banking + painPoints: + - Regulatory compliance burden + - Fraud detection automation + - Security operations scaling + budgetSignal: SOC 2 / PCI-DSS requirements + +outreachAngle: Automate compliance reporting and security operations for regulated FinTech teams diff --git a/projects/zonforge/icp/healthcare.yaml b/projects/zonforge/icp/healthcare.yaml new file mode 100644 index 0000000..b32b967 --- /dev/null +++ b/projects/zonforge/icp/healthcare.yaml @@ -0,0 +1,45 @@ +segment: healthcare +displayName: Healthcare & HealthTech +targetLeads: 100 +description: Healthcare IT, HealthTech, and HIPAA-regulated security buyers + +icpSegments: + - Healthcare IT + - HealthTech + - HIPAA Security + - Medical SaaS + - Hospital Systems + +discoveryQueries: + - healthcare cybersecurity companies HIPAA USA + - healthtech security SaaS vendors + - hospital IT security managed services + - medical device security companies + - HIPAA compliance security vendors + - healthcare MSSP managed security + - telehealth security platform companies + - EHR security software vendors + +apolloKeywordTags: + - healthcare + - healthtech + - hipaa + - medical + - hospital + +icpIndustryPattern: "\\b(healthcare|healthtech|hospital|medical|hipaa|ehr|telehealth)\\b" +highTitlePattern: "\\b(ciso|chief information officer|vp it|director of security|compliance officer|cmio)\\b" + +idealCustomer: + employeeRange: 200-5000 + industries: + - HealthTech + - Hospital Systems + - Medical Devices + painPoints: + - HIPAA compliance automation + - Patient data security + - Incident response gaps + budgetSignal: HIPAA audit / breach prevention budget + +outreachAngle: HIPAA-aligned security automation and incident response for healthcare IT leaders diff --git a/projects/zonforge/icp/mssp.yaml b/projects/zonforge/icp/mssp.yaml new file mode 100644 index 0000000..6deb20a --- /dev/null +++ b/projects/zonforge/icp/mssp.yaml @@ -0,0 +1,45 @@ +segment: mssp +displayName: MSSP & MSP +targetLeads: 100 +description: Managed security service providers and MSPs + +icpSegments: + - MSSP + - MSP + - Managed Security + - SOC Providers + - MDR Providers + +discoveryQueries: + - MSSP managed security service provider companies USA + - MSP cybersecurity managed services + - SOC as a service MSSP companies + - managed detection response MSSP + - IT managed service provider security + - cybersecurity MSSP regional providers + - co-managed SOC MSSP companies + - XDR managed security providers + +apolloKeywordTags: + - mssp + - msp + - managed security + - soc + - mdr + +icpIndustryPattern: "\\b(mssp|msp|managed service|managed security|soc|mdr|xdr)\\b" +highTitlePattern: "\\b(ciso|vp security|director of security|owner|ceo|head of soc|security manager|cto)\\b" + +idealCustomer: + employeeRange: 20-500 + industries: + - MSSP + - MSP + - Co-managed SOC + painPoints: + - Analyst burnout + - Multi-client workflow chaos + - Revenue ops disconnected from SOC + budgetSignal: Expanding SOC client base + +outreachAngle: Founder-operated revenue engine + SOC automation for lean MSSP teams diff --git a/projects/zonforge/icp/saas.yaml b/projects/zonforge/icp/saas.yaml new file mode 100644 index 0000000..4ac44d0 --- /dev/null +++ b/projects/zonforge/icp/saas.yaml @@ -0,0 +1,44 @@ +segment: saas +displayName: SaaS Companies +targetLeads: 100 +description: B2B SaaS and cloud security software companies + +icpSegments: + - SaaS Companies + - B2B Software + - Cloud Security SaaS + - DevTools SaaS + - SOC Automation SaaS + +discoveryQueries: + - B2B SaaS cybersecurity startup companies USA + - cloud security SaaS vendor companies + - SOC automation SaaS platform companies + - identity access management SaaS vendors + - vulnerability management SaaS companies + - SIEM SaaS security platform vendors + - zero trust security SaaS startups + - endpoint detection response SaaS vendors + +apolloKeywordTags: + - saas + - b2b software + - cybersecurity + - cloud security + +icpIndustryPattern: "\\b(saas|software|b2b|cloud|platform|devtools)\\b" +highTitlePattern: "\\b(ciso|cto|vp engineering|head of product|director of security|ceo|founder|vp sales)\\b" + +idealCustomer: + employeeRange: 50-500 + industries: + - Cybersecurity SaaS + - Cloud Security + - DevTools + painPoints: + - Alert fatigue + - SOC automation gaps + - Manual GTM workflows + budgetSignal: Series A-C funded + +outreachAngle: Reduce alert fatigue and automate SOC workflows with founder-operated AI diff --git a/reports/phase10-gtm-activation.md b/reports/phase10-gtm-activation.md new file mode 100644 index 0000000..4b58ee4 --- /dev/null +++ b/reports/phase10-gtm-activation.md @@ -0,0 +1,110 @@ +# Phase 10 — GTM Production Activation Report + +**Status:** Complete (code + verification); Hetzner production run requires server API keys +**Date:** 2026-07-01 +**Package:** `@openclaw/revenue-engine` + ICP configs + deploy scripts + +## Summary + +Phase 10 activates the OpenClaw Revenue Engine for real customer acquisition. It orchestrates four ICP segments (SaaS, FinTech, Healthcare, MSSP), discovers up to 100 leads per segment, enriches high-priority prospects, scores the pipeline, generates outreach and HubSpot drafts (approval required), populates CRM, and produces executive reports — all with full audit logging and dashboard integration. + +## Delivered + +| Component | Purpose | +|-----------|---------| +| `integrations/revenue-engine/src/gtm-activation.ts` | Full GTM orchestration (4 segments → score → outreach → CRM → report) | +| `projects/zonforge/icp/*.yaml` | ICP definitions for SaaS, FinTech, Healthcare, MSSP | +| `control/.env.gtm.example` | API key + tuning template (copy to server `.env.whatsapp`) | +| `scripts/run-gtm-activation.mjs` | CLI entry for production or dry-run | +| `scripts/deploy-phase10-gtm.sh` | Hetzner deploy (pull, build, systemd, health) | +| `scripts/verify-phase10-gtm.sh` | Hetzner verification (build, ICPs, keys, services, dry-run) | + +## API Keys (Server-Only) + +Configure on Hetzner in `control/.env.whatsapp` (never commit): + +```bash +TAVILY_API_KEY=... +SERPER_API_KEY=... +APOLLO_API_KEY=... +GTM_DISCOVERY_TARGET=100 +GTM_OUTREACH_TOP_N=20 +OPENCLAW_ASYNC_TASKS=1 +``` + +See `control/.env.gtm.example` for full template. + +## ICP Segments + +| Segment | Target | Config | +|---------|--------|--------| +| SaaS | 100 | `projects/zonforge/icp/saas.yaml` | +| FinTech | 100 | `projects/zonforge/icp/fintech.yaml` | +| Healthcare | 100 | `projects/zonforge/icp/healthcare.yaml` | +| MSSP | 100 | `projects/zonforge/icp/mssp.yaml` | + +## Activation Flow + +``` +/task run gtm activation (or npm run openclaw:gtm:activate) + → For each segment: lead discovery (Tavily + Serper + Apollo) + → Enrich top high-priority domains + → Score all pipeline leads + → Generate outreach email drafts (top N by score) + → Draft HubSpot contacts (pending approval) + → Revenue + lead executive reports + → JSON activation report in projects/zonforge/output/revenue-engine/ + → Audit log: logs/revenue-engine-audit.jsonl + → Dashboard: GET /api/revenue, GET /api/approvals +``` + +## Safety + +- **No automatic sending** — Gmail sends require `/task approve email ` → `/task send email ` +- **HubSpot creates** require `/task approve hubspot contact ` → `/task create hubspot contact ` +- **Enrichment pushes** require `/task approve enrichment ` +- **Human approval** enforced on all outreach drafts (`requiresApproval: true`) +- **Full audit logging** to `logs/revenue-engine-audit.jsonl` + +## WhatsApp Commands + +``` +/task run gtm activation +/task list approvals +/task revenue report +/task lead report +/task crm pipeline +``` + +## Local Verification + +```bash +npm run openclaw:revenue:test +npm run openclaw:build +npm run openclaw:gtm:activate:dry # 25 leads/segment, no API keys +``` + +## Hetzner Production + +```bash +# On VPS after adding API keys to control/.env.whatsapp +bash scripts/deploy-phase10-gtm.sh +bash scripts/verify-phase10-gtm.sh +node scripts/run-gtm-activation.mjs +# Or via WhatsApp: /task run gtm activation +``` + +## Output Artifacts + +After activation, expect: + +- **Lead count** — up to 400 total (100 × 4 segments) +- **Average score** — in activation report + dashboard +- **Top prospects** — top 20 by score in report +- **Outreach drafts** — top N (default 20) with approval gates +- **Estimated pipeline value** — from CRM revenue forecast +- **Executive report** — revenue + lead reports via `/task revenue report` + +## Dry-Run Mode + +Set `GTM_ACTIVATION_DRY_RUN=1` to seed 25 leads per segment without API calls — used by CI and `verify-phase10-gtm.sh`. diff --git a/scripts/deploy-phase10-gtm.sh b/scripts/deploy-phase10-gtm.sh new file mode 100755 index 0000000..6b5e966 --- /dev/null +++ b/scripts/deploy-phase10-gtm.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# OpenClaw Phase 10 — GTM Production Activation deploy (Hetzner VPS) +set -euo pipefail + +OPENCLAW_ROOT="${OPENCLAW_ROOT:-/opt/openclaw}" +ENV_FILE="${ENV_FILE:-$OPENCLAW_ROOT/control/.env.whatsapp}" +GTM_EXAMPLE="$OPENCLAW_ROOT/control/.env.gtm.example" + +log() { echo "[$(date -Iseconds)] phase10-deploy: $*"; } + +cd "$OPENCLAW_ROOT" + +log "Pull latest code (Phase 9 + 10)..." +git pull --ff-only origin main + +log "Install dependencies..." +npm install --no-audit --no-fund + +log "Build all packages..." +npm run openclaw:build +npm run openclaw:command-center:build + +log "Fix runtime permissions..." +if [[ -f scripts/fix-runtime-permissions.sh ]]; then + bash scripts/fix-runtime-permissions.sh +fi + +log "Verify API key configuration..." +REQUIRED_KEYS=(TAVILY_API_KEY SERPER_API_KEY APOLLO_API_KEY) +MISSING=0 +if [[ -f "$ENV_FILE" ]]; then + for key in "${REQUIRED_KEYS[@]}"; do + if grep -q "^${key}=" "$ENV_FILE" && ! grep -q "^${key}=$" "$ENV_FILE" && ! grep -q "^${key}=your_" "$ENV_FILE"; then + log "OK: $key configured" + else + log "MISSING: $key — add to $ENV_FILE (see $GTM_EXAMPLE)" + MISSING=$((MISSING + 1)) + fi + done +else + log "ERROR: $ENV_FILE not found" + exit 1 +fi + +if [[ $MISSING -gt 0 ]]; then + log "WARN: $MISSING API keys missing. Discovery will fail until configured." +fi + +log "Enable async worker + GTM settings in env..." +for setting in OPENCLAW_ASYNC_TASKS=1 GTM_DISCOVERY_TARGET=100 GTM_OUTREACH_TOP_N=20 LEAD_GENERATION_MIN_PROSPECTS=100; do + key="${setting%%=*}" + if ! grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + echo "$setting" >> "$ENV_FILE" + log "Added $setting to $ENV_FILE" + fi +done + +log "Install systemd units..." +for unit in deploy/openclaw-*.service deploy/openclaw-*.timer; do + [[ -f "$unit" ]] || continue + sudo cp "$unit" /etc/systemd/system/ +done +sudo systemctl daemon-reload + +log "Restart services..." +sudo systemctl enable openclaw-whatsapp.service openclaw-agent-worker.service openclaw-command-center.service || true +sudo systemctl restart openclaw-command-center.service || true +sudo systemctl restart openclaw-whatsapp.service || true +sudo systemctl restart openclaw-agent-worker.service || true + +log "Health checks..." +curl -sf "http://127.0.0.1:3027/health" && echo " API OK" || echo " API FAIL" +curl -sf "http://127.0.0.1:3028/login" > /dev/null && echo " Dashboard OK" || echo " Dashboard FAIL" +bash scripts/health-agent-worker.sh && echo " Worker OK" || echo " Worker FAIL" + +log "Verify Phase 10 scripts..." +test -f scripts/run-gtm-activation.mjs +test -f scripts/verify-phase10-gtm.sh +test -f integrations/revenue-engine/dist/index.js + +log "Phase 10 deployment complete." +log "Next steps:" +log " 1. Ensure API keys in $ENV_FILE" +log " 2. bash scripts/verify-phase10-gtm.sh" +log " 3. node scripts/run-gtm-activation.mjs (or WhatsApp: /task run gtm activation)" diff --git a/scripts/run-gtm-activation.mjs b/scripts/run-gtm-activation.mjs new file mode 100755 index 0000000..2aa9d6a --- /dev/null +++ b/scripts/run-gtm-activation.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Phase 10 — GTM Production Activation CLI + * Discovers 100 leads per ICP segment, scores, generates outreach drafts, + * populates CRM, and produces executive report. + * + * Usage: + * GTM_ACTIVATION_DRY_RUN=1 node scripts/run-gtm-activation.mjs # test without API keys + * node scripts/run-gtm-activation.mjs # production (requires keys) + */ +import { existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const openclawRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const revenueDist = path.join(openclawRoot, 'integrations/revenue-engine/dist/index.js') + +if (!existsSync(revenueDist)) { + console.error( + JSON.stringify({ + ok: false, + message: `Revenue engine not built. Run: cd ${openclawRoot} && npm run openclaw:build`, + }), + ) + process.exit(1) +} + +const { buildRevenueEnginePaths, runGtmActivation } = await import(revenueDist) + +const root = process.env['OPENCLAW_ROOT'] ?? openclawRoot +const paths = buildRevenueEnginePaths(root) +const dryRun = process.env['GTM_ACTIVATION_DRY_RUN'] === '1' + +console.log(`[gtm-activation] Starting Phase 10 GTM activation (dryRun=${dryRun})...`) + +const result = await runGtmActivation(paths, { + root, + senderId: process.env['GTM_SENDER_ID'] ?? 'cli:gtm-activation', + channel: 'cli', + targetPerSegment: Number(process.env['GTM_DISCOVERY_TARGET'] ?? '100') || 100, + outreachTopN: Number(process.env['GTM_OUTREACH_TOP_N'] ?? '20') || 20, + dryRun, +}) + +console.log(JSON.stringify(result, null, 2)) +process.exit(result.ok ? 0 : 1) diff --git a/scripts/verify-phase10-gtm.sh b/scripts/verify-phase10-gtm.sh new file mode 100755 index 0000000..9d942d4 --- /dev/null +++ b/scripts/verify-phase10-gtm.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# OpenClaw Phase 10 — GTM activation verification (Hetzner VPS) +set -euo pipefail + +OPENCLAW_ROOT="${OPENCLAW_ROOT:-/opt/openclaw}" +ENV_FILE="${ENV_FILE:-$OPENCLAW_ROOT/control/.env.whatsapp}" +PASS=0 +FAIL=0 + +check() { + local name="$1" + shift + if "$@" > /dev/null 2>&1; then + echo " PASS: $name" + PASS=$((PASS + 1)) + else + echo " FAIL: $name" + FAIL=$((FAIL + 1)) + fi +} + +echo "==> Phase 10 GTM Verification" +echo "" + +cd "$OPENCLAW_ROOT" + +echo "-- Build artifacts --" +check "revenue-engine built" test -f integrations/revenue-engine/dist/index.js +check "control built" test -f control/dist/index-whatsapp.js +check "command-center built" test -f command-center/dist/index.js + +echo "" +echo "-- ICP configs --" +for icp in saas fintech healthcare mssp; do + check "ICP $icp" test -f "projects/zonforge/icp/${icp}.yaml" +done + +echo "" +echo "-- API keys (no values printed) --" +if [[ -f "$ENV_FILE" ]]; then + for key in TAVILY_API_KEY SERPER_API_KEY APOLLO_API_KEY; do + if grep -q "^${key}=" "$ENV_FILE" && ! grep -qE "^${key}=(|your_)" "$ENV_FILE"; then + echo " PASS: $key set" + PASS=$((PASS + 1)) + else + echo " FAIL: $key missing or placeholder" + FAIL=$((FAIL + 1)) + fi + done +else + echo " FAIL: env file missing ($ENV_FILE)" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "-- Services --" +check "whatsapp active" systemctl is-active --quiet openclaw-whatsapp.service +check "worker active" systemctl is-active --quiet openclaw-agent-worker.service +check "command-center active" systemctl is-active --quiet openclaw-command-center.service + +echo "" +echo "-- API health --" +check "command-center /health" curl -sf "http://127.0.0.1:3027/health" +check "dashboard reachable" curl -sf "http://127.0.0.1:3028/login" +check "revenue API" curl -sf "http://127.0.0.1:3027/api/revenue" -H "Authorization: Bearer ${OPENCLAW_CC_TOKEN:-invalid}" + +echo "" +echo "-- Dry-run activation --" +export GTM_ACTIVATION_DRY_RUN=1 +export OPENCLAW_ROOT="$OPENCLAW_ROOT" +if node scripts/run-gtm-activation.mjs > /tmp/gtm-verify.json 2>&1; then + echo " PASS: dry-run activation" + PASS=$((PASS + 1)) + if grep -q '"totalLeads"' /tmp/gtm-verify.json; then + echo " PASS: activation report generated" + PASS=$((PASS + 1)) + else + echo " FAIL: activation report missing totalLeads" + FAIL=$((FAIL + 1)) + fi +else + echo " FAIL: dry-run activation" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "==> Results: $PASS passed, $FAIL failed" +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi +echo "Phase 10 verification complete." From 9e7f763ca8d6802787c632fa3afd85b4743a3b43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 13:04:20 +0000 Subject: [PATCH 2/2] feat: enable gemini linkedin content generation (phase 10.1) - Add Gemini 2.5 Flash provider with 3 retries and template fallback - Add linkedin_post workflow and wire marketing/create linkedin post - Store JSON artifacts under projects/zonforge/output/linkedin/ - Update /generate_post script and WhatsApp response formatting - Add audit logging and tests for provider, executor, and workflow Co-authored-by: MD TAZIZUL ISLAM --- control/src/commands/generate-post.command.ts | 19 +- control/src/commands/help.command.ts | 5 + integrations/revenue-engine/src/audit.ts | 2 + .../revenue-engine/src/gemini-content.test.ts | 197 ++++++++++++++++++ .../revenue-engine/src/gemini-content.ts | 184 ++++++++++++++++ integrations/revenue-engine/src/index.ts | 9 + .../revenue-engine/src/linkedin-post.ts | 118 +++++++++++ integrations/revenue-engine/src/marketing.ts | 42 ++-- .../revenue-engine/src/revenue-engine.test.ts | 51 ++++- integrations/workflows/src/registry.ts | 29 +++ integrations/workflows/src/task-parser.ts | 2 + integrations/workflows/src/types.ts | 1 + integrations/workflows/src/workflows.test.ts | 48 ++++- reports/phase10-1-gemini-linkedin.md | 62 ++++++ scripts/generate-post.mjs | 71 ++++--- 15 files changed, 774 insertions(+), 66 deletions(-) create mode 100644 integrations/revenue-engine/src/gemini-content.test.ts create mode 100644 integrations/revenue-engine/src/gemini-content.ts create mode 100644 integrations/revenue-engine/src/linkedin-post.ts create mode 100644 reports/phase10-1-gemini-linkedin.md diff --git a/control/src/commands/generate-post.command.ts b/control/src/commands/generate-post.command.ts index 2ea3d9a..755846c 100644 --- a/control/src/commands/generate-post.command.ts +++ b/control/src/commands/generate-post.command.ts @@ -7,15 +7,24 @@ export async function runGeneratePostCommand(config: OpenClawConfig): Promise { + const originalFetch = global.fetch + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('extracts linkedin topic from task text', () => { + expect(extractLinkedInTopic('create linkedin post about MSSP automation')).toBe('MSSP automation') + expect(extractLinkedInTopic('run linkedin post workflow about zero trust')).toBe('zero trust') + }) + + it('formats whatsapp response', () => { + const artifact = buildLinkedInTemplate('SOC automation') + const message = formatLinkedInWhatsAppMessage(artifact) + expect(message).toContain('LinkedIn post generated successfully.') + expect(message).toContain('Title:') + expect(message).toContain('Post:') + expect(message).toContain('Hashtags:') + expect(message).toContain('Image prompt:') + }) + + it('generates content from gemini api', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + title: 'AI SOC Triage', + linkedin_post: 'Real generated post body.', + hashtags: ['CyberSecurity', 'SOC'], + cta: 'DM me for the playbook.', + image_prompt: 'Dark SOC dashboard illustration', + }), + }, + ], + }, + }, + ], + }), + }) as typeof fetch + + const result = await generateLinkedInPostContent('SOC automation', { + apiKey: 'test-key', + model: 'gemini-2.5-flash', + maxRetries: 3, + retryDelayMs: 1, + }) + + expect(result.source).toBe('gemini') + expect(result.artifact.linkedin_post).toBe('Real generated post body.') + expect(result.attempts).toBe(1) + }) + + it('retries gemini and falls back to template after failures', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('network error')) as typeof fetch + + const result = await generateLinkedInPostContent('alert fatigue', { + apiKey: 'test-key', + model: 'gemini-2.5-flash', + maxRetries: 3, + retryDelayMs: 1, + }) + + expect(global.fetch).toHaveBeenCalledTimes(3) + expect(result.source).toBe('template-fallback') + expect(result.artifact.title).toContain('alert fatigue') + }) + + it('falls back when api key missing', async () => { + const result = await generateLinkedInPostContent('MSSP growth', { + apiKey: undefined, + model: 'gemini-2.5-flash', + maxRetries: 2, + retryDelayMs: 1, + }) + expect(result.source).toBe('template-fallback') + }) +}) + +describe('linkedin_post workflow executor', () => { + const tempDirs: string[] = [] + const originalFetch = global.fetch + + beforeEach(() => { + process.env['OPENCLAW_PROJECT'] = 'zonforge' + process.env['GEMINI_API_KEY'] = 'test-key' + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + title: 'Founder insight on MSSP automation', + linkedin_post: 'Generated LinkedIn copy for MSSP teams.', + hashtags: ['MSSP', 'CyberSecurity'], + cta: 'Comment PLAYBOOK for the guide.', + image_prompt: 'Minimal cybersecurity founder post visual', + }), + }, + ], + }, + }, + ], + }), + }) as typeof fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + delete process.env['GEMINI_API_KEY'] + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) rmSync(dir, { recursive: true, force: true }) + } + }) + + function makeRoot() { + const root = mkdtempSync(path.join(os.tmpdir(), 'linkedin-post-')) + tempDirs.push(root) + const projectDir = path.join(root, 'projects', 'zonforge') + const outputDir = path.join(projectDir, 'output', 'linkedin') + const logsDir = path.join(root, 'logs') + const dataDir = path.join(root, 'data', 'revenue-engine') + for (const dir of [outputDir, logsDir, dataDir, projectDir]) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + } + writeFileSync( + path.join(projectDir, 'config.yaml'), + 'id: zonforge\ndisplayName: ZonForge\nproductName: ZonForge Sentinel\n', + 'utf8', + ) + return root + } + + it('stores artifact under projects/zonforge/output/linkedin', async () => { + const root = makeRoot() + const paths = buildRevenueEnginePaths(root) + + const result = await executeLinkedInPostWorkflow(paths, { + task: 'create linkedin post about MSSP automation', + senderId: 'test', + channel: 'whatsapp', + root, + }) + + expect(result.ok).toBe(true) + expect(result.source).toBe('gemini') + expect(result.message).toContain('LinkedIn post generated successfully.') + expect(result.artifactPath).toMatch(/projects\/zonforge\/output\/linkedin\/linkedin-post-\d+\.json/) + + const fullPath = path.join(root, result.artifactPath) + expect(existsSync(fullPath)).toBe(true) + const saved = JSON.parse(readFileSync(fullPath, 'utf8')) as { + title: string + linkedin_post: string + hashtags: string[] + image_prompt: string + } + expect(saved.title).toBe('Founder insight on MSSP automation') + expect(saved.linkedin_post).toContain('Generated LinkedIn copy') + expect(saved.hashtags).toContain('MSSP') + expect(existsSync(paths.auditLogFile)).toBe(true) + }) +}) + +describe('gemini config', () => { + it('loads defaults from environment', () => { + const prev = process.env['GEMINI_MODEL'] + process.env['GEMINI_MODEL'] = 'gemini-2.5-flash' + expect(loadGeminiConfig().model).toBe('gemini-2.5-flash') + if (prev !== undefined) process.env['GEMINI_MODEL'] = prev + else delete process.env['GEMINI_MODEL'] + }) +}) diff --git a/integrations/revenue-engine/src/gemini-content.ts b/integrations/revenue-engine/src/gemini-content.ts new file mode 100644 index 0000000..be3ffad --- /dev/null +++ b/integrations/revenue-engine/src/gemini-content.ts @@ -0,0 +1,184 @@ +export interface LinkedInPostArtifact { + title: string + linkedin_post: string + hashtags: string[] + cta: string + image_prompt: string +} + +export interface GeminiGenerationResult { + ok: boolean + artifact: LinkedInPostArtifact + source: 'gemini' | 'template-fallback' + attempts: number + error?: string +} + +export interface GeminiConfig { + apiKey?: string | undefined + model: string + maxRetries: number + retryDelayMs: number +} + +const DEFAULT_MODEL = 'gemini-2.5-flash' + +export function loadGeminiConfig(env: NodeJS.ProcessEnv = process.env): GeminiConfig { + return { + apiKey: env['GEMINI_API_KEY']?.trim() || undefined, + model: env['GEMINI_MODEL']?.trim() || DEFAULT_MODEL, + maxRetries: Number(env['GEMINI_MAX_RETRIES'] ?? '3') || 3, + retryDelayMs: Number(env['GEMINI_RETRY_DELAY_MS'] ?? '500') || 500, + } +} + +export function buildLinkedInTemplate(topic: string): LinkedInPostArtifact { + return { + title: `Why ${topic} matters for security teams`, + linkedin_post: [ + `Most security teams don't have a tools problem — they have an alert fatigue problem.`, + '', + `Here's what I'm seeing around ${topic}:`, + '1) Too many alerts, not enough context', + '2) Analysts context-switch across 6+ tools', + '3) "AI" features bolted on without workflow change', + '', + "The fix isn't more dashboards. It's better signal, faster triage, and automation that respects the analyst.", + '', + "If you're dealing with this — what's your biggest bottleneck?", + ].join('\n'), + hashtags: ['CyberSecurity', 'SOC', 'InfoSec', 'AISecurity', 'MSSP'], + cta: 'Comment or DM if you want our triage playbook.', + image_prompt: `Professional LinkedIn banner about ${topic}, dark cybersecurity theme, minimal, modern SaaS style`, + } +} + +function buildPrompt(topic: string): string { + return [ + 'You are a founder-led cybersecurity SaaS content writer.', + `Write a high-quality LinkedIn post about: ${topic}`, + '', + 'Return ONLY valid JSON (no markdown fences) with exactly these keys:', + '- title: short post title', + '- linkedin_post: full post body (use line breaks, 150-250 words, authentic founder voice)', + '- hashtags: array of 4-6 hashtag strings WITHOUT the # prefix', + '- cta: one clear call-to-action sentence', + '- image_prompt: detailed prompt for a LinkedIn header image', + '', + 'Focus on MSSP/SOC automation, alert fatigue, and practical security operations.', + ].join('\n') +} + +function parseArtifact(text: string, topic: string): LinkedInPostArtifact { + const trimmed = text.trim() + const jsonMatch = trimmed.match(/\{[\s\S]*\}/) + const raw = jsonMatch ? jsonMatch[0] : trimmed + const parsed = JSON.parse(raw) as Partial + + const hashtags = Array.isArray(parsed.hashtags) + ? parsed.hashtags.map(h => String(h).replace(/^#/, '')) + : buildLinkedInTemplate(topic).hashtags + + return { + title: String(parsed.title ?? buildLinkedInTemplate(topic).title), + linkedin_post: String(parsed.linkedin_post ?? buildLinkedInTemplate(topic).linkedin_post), + hashtags, + cta: String(parsed.cta ?? buildLinkedInTemplate(topic).cta), + image_prompt: String(parsed.image_prompt ?? buildLinkedInTemplate(topic).image_prompt), + } +} + +async function callGeminiApi( + config: GeminiConfig, + topic: string, +): Promise { + if (!config.apiKey) { + throw new Error('GEMINI_API_KEY not configured') + } + + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(config.model)}:generateContent?key=${encodeURIComponent(config.apiKey)}` + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ role: 'user', parts: [{ text: buildPrompt(topic) }] }], + generationConfig: { + temperature: 0.8, + responseMimeType: 'application/json', + }, + }), + }) + + if (!response.ok) { + const body = await response.text() + throw new Error(`Gemini API ${response.status}: ${body.slice(0, 300)}`) + } + + const payload = (await response.json()) as { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> + } + + const text = payload.candidates?.[0]?.content?.parts?.[0]?.text + if (!text) { + throw new Error('Gemini returned empty content') + } + + return parseArtifact(text, topic) +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export async function generateLinkedInPostContent( + topic: string, + config: GeminiConfig = loadGeminiConfig(), + log?: (message: string, meta?: Record) => void, +): Promise { + const logger = log ?? (() => {}) + let lastError = 'unknown error' + + for (let attempt = 1; attempt <= config.maxRetries; attempt++) { + try { + logger('gemini_attempt', { attempt, model: config.model, topic }) + const artifact = await callGeminiApi(config, topic) + logger('gemini_success', { attempt, topic, title: artifact.title }) + return { ok: true, artifact, source: 'gemini', attempts: attempt } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + logger('gemini_retry', { attempt, error: lastError }) + if (attempt < config.maxRetries) { + await sleep(config.retryDelayMs * attempt) + } + } + } + + logger('gemini_fallback_template', { topic, error: lastError }) + return { + ok: true, + artifact: buildLinkedInTemplate(topic), + source: 'template-fallback', + attempts: config.maxRetries, + error: lastError, + } +} + +export function formatLinkedInWhatsAppMessage(artifact: LinkedInPostArtifact): string { + const hashtagLine = artifact.hashtags.map(h => `#${h.replace(/^#/, '')}`).join(' ') + return [ + 'LinkedIn post generated successfully.', + '', + 'Title:', + artifact.title, + '', + 'Post:', + artifact.linkedin_post, + '', + 'Hashtags:', + hashtagLine, + '', + 'Image prompt:', + artifact.image_prompt, + ].join('\n') +} diff --git a/integrations/revenue-engine/src/index.ts b/integrations/revenue-engine/src/index.ts index 01b2918..f94ac04 100644 --- a/integrations/revenue-engine/src/index.ts +++ b/integrations/revenue-engine/src/index.ts @@ -9,6 +9,15 @@ export { buildRevenueDashboardForRoot, buildRevenueDashboardForRoot as buildReve export { listPendingApprovals } from './approvals.js' export { runGtmActivation, GTM_SEGMENTS } from './gtm-activation.js' export type { GtmActivationResult, GtmActivationOptions } from './gtm-activation.js' +export { executeLinkedInPostWorkflow, extractLinkedInTopic } from './linkedin-post.js' +export type { LinkedInPostResult } from './linkedin-post.js' +export { + generateLinkedInPostContent, + formatLinkedInWhatsAppMessage, + buildLinkedInTemplate, + loadGeminiConfig, +} from './gemini-content.js' +export type { LinkedInPostArtifact, GeminiGenerationResult } from './gemini-content.js' export type { RevenueTaskRequest, RevenueTaskResult, diff --git a/integrations/revenue-engine/src/linkedin-post.ts b/integrations/revenue-engine/src/linkedin-post.ts new file mode 100644 index 0000000..53c5023 --- /dev/null +++ b/integrations/revenue-engine/src/linkedin-post.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { buildProjectPaths, resolveProjectId } from '@openclaw/core' +import { logRevenueAction } from './audit.js' +import { + formatLinkedInWhatsAppMessage, + generateLinkedInPostContent, + loadGeminiConfig, + type LinkedInPostArtifact, +} from './gemini-content.js' +import type { RevenueEnginePaths, RevenueTaskRequest } from './types.js' + +export interface LinkedInPostResult { + ok: boolean + message: string + artifact: LinkedInPostArtifact + artifactPath: string + source: 'gemini' | 'template-fallback' + data?: Record +} + +export function extractLinkedInTopic(task: string): string { + const patterns = [ + /run linkedin post workflow(?: about| on)? (.+)/i, + /create linkedin post about (.+)/i, + /generate linkedin post(?: about| on)? (.+)/i, + /linkedin post(?: about| on)? (.+)/i, + ] + + for (const pattern of patterns) { + const match = task.match(pattern) + if (match?.[1]) return match[1].trim() + } + + return 'SOC automation and alert fatigue' +} + +export function resolveLinkedInOutputDir(root: string): string { + const projectId = resolveProjectId(process.env) + const projectPaths = buildProjectPaths(root, projectId) + return path.join(projectPaths.output, 'linkedin') +} + +export async function executeLinkedInPostWorkflow( + paths: RevenueEnginePaths, + req: RevenueTaskRequest, + topic?: string, +): Promise { + const resolvedTopic = topic?.trim() || extractLinkedInTopic(req.task) + const config = loadGeminiConfig() + + logRevenueAction(paths, { + action: 'linkedin_content_started', + senderId: req.senderId, + channel: req.channel, + details: `LinkedIn post generation started: ${resolvedTopic}`, + metadata: { topic: resolvedTopic, model: config.model }, + }) + + const generation = await generateLinkedInPostContent(resolvedTopic, config, (event, meta) => { + logRevenueAction(paths, { + action: 'linkedin_content_generated', + senderId: req.senderId, + channel: req.channel, + details: event, + metadata: { topic: resolvedTopic, ...(meta ?? {}) }, + }) + }) + + const outputDir = resolveLinkedInOutputDir(req.root) + if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }) + + const stamp = Date.now() + const artifactPath = path.join(outputDir, `linkedin-post-${stamp}.json`) + const artifactRecord = { + id: randomUUID(), + topic: resolvedTopic, + source: generation.source, + attempts: generation.attempts, + generatedAt: new Date().toISOString(), + ...generation.artifact, + } + + writeFileSync(artifactPath, JSON.stringify(artifactRecord, null, 2), 'utf8') + + const relativeArtifact = path + .relative(paths.openclawRoot, artifactPath) + .replace(/\\/g, '/') + + logRevenueAction(paths, { + action: 'linkedin_content_generated', + senderId: req.senderId, + channel: req.channel, + details: `LinkedIn post saved (${generation.source}): ${relativeArtifact}`, + metadata: { + topic: resolvedTopic, + source: generation.source, + artifactPath: relativeArtifact, + title: generation.artifact.title, + }, + }) + + return { + ok: true, + message: formatLinkedInWhatsAppMessage(generation.artifact), + artifact: generation.artifact, + artifactPath: relativeArtifact, + source: generation.source, + data: { + topic: resolvedTopic, + source: generation.source, + artifactPath: relativeArtifact, + attempts: generation.attempts, + fallbackUsed: generation.source === 'template-fallback', + }, + } +} diff --git a/integrations/revenue-engine/src/marketing.ts b/integrations/revenue-engine/src/marketing.ts index ecd1c2a..e4d3d5e 100644 --- a/integrations/revenue-engine/src/marketing.ts +++ b/integrations/revenue-engine/src/marketing.ts @@ -2,26 +2,11 @@ import { randomUUID } from 'node:crypto' import { mkdirSync, writeFileSync, existsSync } from 'node:fs' import path from 'node:path' import { logRevenueAction } from './audit.js' +import { executeLinkedInPostWorkflow } from './linkedin-post.js' import { listMarketingItems, saveMarketingItem } from './store.js' import type { MarketingContentType, RevenueEnginePaths, RevenueTaskRequest } from './types.js' -const CONTENT_TEMPLATES: Record { title: string; body: string }> = { - linkedin: topic => ({ - title: `LinkedIn: ${topic}`, - body: [ - `🔐 ${topic}`, - '', - '3 lessons from building autonomous security operations:', - '', - '1. Alert fatigue kills response time — automate triage first', - '2. MSSPs win when workflows connect CRM → outreach → reporting', - '3. Founder-operated AI beats bloated SOC tooling for lean teams', - '', - 'Building in public with OpenClaw + ZonForge Sentinel.', - '', - '#cybersecurity #MSSP #SOC #startup', - ].join('\n'), - }), +const CONTENT_TEMPLATES: Record, (topic: string) => { title: string; body: string }> = { blog: topic => ({ title: `Blog: ${topic}`, body: [ @@ -86,6 +71,27 @@ export async function createMarketingContent( type: MarketingContentType, topic: string, ): Promise<{ ok: boolean; message: string }> { + if (type === 'linkedin') { + const linkedIn = await executeLinkedInPostWorkflow(paths, req, topic) + const scheduledFor = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString() + + saveMarketingItem(paths, { + id: randomUUID(), + type: 'linkedin', + title: linkedIn.artifact.title, + body: linkedIn.artifact.linkedin_post, + scheduledFor, + status: 'draft', + tags: ['linkedin', 'gemini', linkedIn.source], + createdAt: new Date().toISOString(), + }) + + return { + ok: linkedIn.ok, + message: linkedIn.message, + } + } + const template = CONTENT_TEMPLATES[type](topic) const scheduledFor = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString() @@ -103,7 +109,7 @@ export async function createMarketingContent( saveMarketingItem(paths, item) if (!existsSync(paths.outputDir)) mkdirSync(paths.outputDir, { recursive: true }) - const ext = type === 'linkedin' ? 'md' : 'md' + const ext = 'md' const file = path.join(paths.outputDir, `marketing-${type}-${Date.now()}.${ext}`) writeFileSync(file, template.body, 'utf8') diff --git a/integrations/revenue-engine/src/revenue-engine.test.ts b/integrations/revenue-engine/src/revenue-engine.test.ts index ff04c0d..969e48f 100644 --- a/integrations/revenue-engine/src/revenue-engine.test.ts +++ b/integrations/revenue-engine/src/revenue-engine.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -75,14 +75,47 @@ describe('revenue engine service', () => { it('generates marketing content', async () => { const root = makeRoot() - const result = await handleRevenueEngineTask({ - task: 'create linkedin post about MSSP automation', - senderId: 'founder', - channel: 'whatsapp', - root, - }) - expect(result.ok).toBe(true) - expect(result.message).toContain('Marketing content created') + const prevKey = process.env['GEMINI_API_KEY'] + process.env['GEMINI_API_KEY'] = 'test-key' + const originalFetch = global.fetch + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + title: 'MSSP Automation', + linkedin_post: 'Generated post about MSSP automation.', + hashtags: ['MSSP', 'SOC'], + cta: 'DM for playbook.', + image_prompt: 'Cybersecurity dashboard visual', + }), + }, + ], + }, + }, + ], + }), + }) as typeof fetch + + try { + const result = await handleRevenueEngineTask({ + task: 'create linkedin post about MSSP automation', + senderId: 'founder', + channel: 'whatsapp', + root, + }) + expect(result.ok).toBe(true) + expect(result.message).toContain('LinkedIn post generated successfully.') + expect(result.message).toContain('MSSP Automation') + } finally { + global.fetch = originalFetch + if (prevKey !== undefined) process.env['GEMINI_API_KEY'] = prevKey + else delete process.env['GEMINI_API_KEY'] + } }) it('forecasts revenue from empty pipeline', async () => { diff --git a/integrations/workflows/src/registry.ts b/integrations/workflows/src/registry.ts index beb121d..aa3d1e9 100644 --- a/integrations/workflows/src/registry.ts +++ b/integrations/workflows/src/registry.ts @@ -65,6 +65,21 @@ async function revenueEngineStep(ctx: WorkflowContext, task: string) { return { ok: r.ok, message: r.message } } +async function linkedInPostStep(ctx: WorkflowContext, task: string) { + const revenueModule = '@openclaw/revenue-engine' + const revenue = (await import(revenueModule)) as { + executeLinkedInPostWorkflow: ( + paths: unknown, + req: WorkflowContext, + topic?: string, + ) => Promise<{ ok: boolean; message: string }> + buildRevenueEnginePaths: (root: string) => unknown + } + const paths = revenue.buildRevenueEnginePaths(ctx.root) + const r = await revenue.executeLinkedInPostWorkflow(paths, { ...ctx, task }, undefined) + return { ok: r.ok, message: r.message } +} + function saveRun(paths: ReturnType, run: WorkflowRun): void { if (!existsSync(paths.outputDir)) mkdirSync(paths.outputDir, { recursive: true }) writeFileSync(path.join(paths.outputDir, `${run.id}.json`), JSON.stringify(run, null, 2), 'utf8') @@ -234,6 +249,20 @@ export const WORKFLOW_REGISTRY: Record = { { id: 'approvals', name: 'Pending approvals', task: 'list approvals', handler: revenueEngineStep, approval: true }, ]), }, + linkedin_post: { + id: 'linkedin_post', + name: 'LinkedIn Post Generation', + description: 'Generate LinkedIn post with Gemini 2.5 Flash', + run: ctx => + executeWorkflow('linkedin_post', ctx, [ + { + id: 'generate', + name: 'Generate LinkedIn post', + task: ctx.task || 'create linkedin post about SOC automation', + handler: linkedInPostStep, + }, + ]), + }, } export function getWorkflow(id: WorkflowId): WorkflowDefinition | undefined { diff --git a/integrations/workflows/src/task-parser.ts b/integrations/workflows/src/task-parser.ts index 9857c1c..9850a9f 100644 --- a/integrations/workflows/src/task-parser.ts +++ b/integrations/workflows/src/task-parser.ts @@ -18,6 +18,8 @@ const WORKFLOW_MAP: Array<[RegExp, WorkflowId]> = [ [/\brun devops ops workflow\b/i, 'devops-ops'], [/\brun revenue engine\b/i, 'revenue-engine'], [/\brun revenue engine workflow\b/i, 'revenue-engine'], + [/\brun linkedin post workflow\b/i, 'linkedin_post'], + [/\blinkedin post workflow\b/i, 'linkedin_post'], ] export function isWorkflowTask(task: string): boolean { diff --git a/integrations/workflows/src/types.ts b/integrations/workflows/src/types.ts index 7920d93..3398f69 100644 --- a/integrations/workflows/src/types.ts +++ b/integrations/workflows/src/types.ts @@ -10,6 +10,7 @@ export type WorkflowId = | 'customer-ops' | 'devops-ops' | 'revenue-engine' + | 'linkedin_post' export type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'awaiting_approval' diff --git a/integrations/workflows/src/workflows.test.ts b/integrations/workflows/src/workflows.test.ts index f4e2c85..65fe141 100644 --- a/integrations/workflows/src/workflows.test.ts +++ b/integrations/workflows/src/workflows.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildWorkflowPaths } from './paths.js' import { listWorkflows } from './registry.js' import { handleWorkflowTask, isWorkflowTask } from './service.js' @@ -20,6 +20,7 @@ describe('parseWorkflowIntent', () => { it('parses workflow intents', () => { expect(parseWorkflowIntent('run daily GTM workflow')?.kind).toBe('run-workflow') expect(parseWorkflowIntent('run lead enrichment workflow')?.kind).toBe('run-workflow') + expect(parseWorkflowIntent('run linkedin post workflow about SOC automation')?.kind).toBe('run-workflow') expect(parseWorkflowIntent('prepare outreach sequence')?.kind).toBe('prepare-outreach') }) }) @@ -31,7 +32,8 @@ describe('workflow registry', () => { expect(ids).toContain('revenue-ops') expect(ids).toContain('customer-ops') expect(ids).toContain('devops-ops') - expect(ids.length).toBeGreaterThanOrEqual(7) + expect(ids).toContain('linkedin_post') + expect(ids.length).toBeGreaterThanOrEqual(8) }) }) @@ -92,4 +94,46 @@ describe('handleWorkflowTask', () => { }) expect(result.message).toMatch(/approval/i) }) + + it('runs linkedin_post workflow with gemini content', async () => { + process.env['GEMINI_API_KEY'] = 'test-key' + const originalFetch = global.fetch + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + title: 'Workflow LinkedIn Post', + linkedin_post: 'Workflow generated LinkedIn content.', + hashtags: ['SOC', 'Security'], + cta: 'Share your SOC bottleneck below.', + image_prompt: 'Workflow visual prompt', + }), + }, + ], + }, + }, + ], + }), + }) as typeof fetch + + try { + const result = await handleWorkflowTask({ + task: 'run linkedin post workflow about SOC automation', + senderId: 'founder-1', + channel: 'whatsapp', + root, + }) + expect(result.ok).toBe(true) + expect(result.message).toContain('# Workflow: linkedin_post') + expect(result.message).toContain('LinkedIn post generated successfully.') + } finally { + global.fetch = originalFetch + delete process.env['GEMINI_API_KEY'] + } + }) }) diff --git a/reports/phase10-1-gemini-linkedin.md b/reports/phase10-1-gemini-linkedin.md new file mode 100644 index 0000000..d3db2f5 --- /dev/null +++ b/reports/phase10-1-gemini-linkedin.md @@ -0,0 +1,62 @@ +# Phase 10.1 — Gemini LinkedIn Content Generation + +**Status:** Complete +**Date:** 2026-07-01 + +## Summary + +Replaced template-only LinkedIn post generation with Gemini 2.5 Flash content provider. The `linkedin_post` workflow and `/task create linkedin post` now generate real AI content when `GEMINI_API_KEY` is valid, with 3 retries and template fallback on failure. + +## Key Files + +| File | Purpose | +|------|---------| +| `integrations/revenue-engine/src/gemini-content.ts` | Gemini API provider, retry, fallback, WhatsApp formatter | +| `integrations/revenue-engine/src/linkedin-post.ts` | Workflow executor, artifact storage | +| `integrations/revenue-engine/src/marketing.ts` | Routes linkedin type to Gemini executor | +| `integrations/workflows/src/registry.ts` | `linkedin_post` workflow | +| `scripts/generate-post.mjs` | `/generate_post` CLI via Gemini | + +## Output + +Artifacts saved to: `projects/zonforge/output/linkedin/linkedin-post-.json` + +```json +{ + "title": "...", + "linkedin_post": "...", + "hashtags": ["..."], + "cta": "...", + "image_prompt": "..." +} +``` + +## Environment + +```bash +GEMINI_API_KEY=... +GEMINI_MODEL=gemini-2.5-flash # default +GEMINI_MAX_RETRIES=3 +``` + +## Commands + +``` +/task create linkedin post about SOC automation +/task run linkedin post workflow about alert fatigue +/generate_post +``` + +## Safety + +- Draft-only — no auto-publish +- Audit log: `logs/revenue-engine-audit.jsonl` +- Template fallback only after 3 Gemini failures + +## Verification + +```bash +npm run openclaw:revenue:test +npm run openclaw:workflows:test +npm run openclaw:build +``` diff --git a/scripts/generate-post.mjs b/scripts/generate-post.mjs index bb160ad..f025f07 100644 --- a/scripts/generate-post.mjs +++ b/scripts/generate-post.mjs @@ -1,43 +1,50 @@ #!/usr/bin/env node /** - * OpenClaw — Generate LinkedIn post draft (Stage 1 stub) - * Invoked only via whitelisted Telegram command /generate_post + * OpenClaw — Generate LinkedIn post via Gemini 2.5 Flash + * Invoked via whitelisted command /generate_post */ -import { mkdirSync, writeFileSync, existsSync } from 'node:fs' +import { existsSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') -const outputDir = path.join(repoRoot, 'openclaw', 'output', 'linkedin') -const stamp = new Date().toISOString().slice(0, 10) +const openclawRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const revenueDist = path.join(openclawRoot, 'integrations/revenue-engine/dist/index.js') -if (!existsSync(outputDir)) { - mkdirSync(outputDir, { recursive: true }) +if (!existsSync(revenueDist)) { + console.error( + JSON.stringify({ + ok: false, + message: `Revenue engine not built. Run: cd ${openclawRoot} && npm run openclaw:build`, + }), + ) + process.exit(1) } -const draft = [ - 'Most SOC teams do not have a talent problem.', - 'They have a triage problem.', - '', - 'Your analysts spend 70% of their day on alerts that never matter.', - 'Meanwhile, the one real credential attack waits in queue #847.', - '', - 'AI-native investigation changes the math:', - '→ Autonomous triage on every P1/P2', - '→ Evidence chain + verdict in under 60 seconds', - '→ Analysts focus on response, not noise', - '', - 'If alert fatigue is your bottleneck, DM me "SOC" and I will share how we run live investigations.', - '', - '#CyberSecurity #SOC #InfoSec #AISecurity', -].join('\n') +const { buildRevenueEnginePaths, executeLinkedInPostWorkflow } = await import(revenueDist) -const filePath = path.join(outputDir, `linkedin-post-${stamp}.txt`) -writeFileSync(filePath, draft, 'utf8') +const root = process.env['OPENCLAW_ROOT'] ?? openclawRoot +const paths = buildRevenueEnginePaths(root) +const topic = process.env['LINKEDIN_POST_TOPIC'] ?? 'SOC automation and alert fatigue' -console.log(JSON.stringify({ - ok: true, - message: 'LinkedIn post draft generated.', - filePath, - preview: draft.slice(0, 280), -}, null, 2)) +const result = await executeLinkedInPostWorkflow(paths, { + task: `generate linkedin post about ${topic}`, + senderId: process.env['LINKEDIN_SENDER_ID'] ?? 'cli:generate-post', + channel: 'cli', + root, +}) + +console.log( + JSON.stringify( + { + ok: result.ok, + message: result.message, + artifactPath: result.artifactPath, + source: result.source, + artifact: result.artifact, + }, + null, + 2, + ), +) + +process.exit(result.ok ? 0 : 1)