openvolo 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +175 -0
- package/components.json +20 -0
- package/dist/cli.js +992 -0
- package/drizzle.config.ts +14 -0
- package/next.config.mjs +7 -0
- package/package.json +91 -0
- package/postcss.config.mjs +7 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/openvolo-logo-black.png +0 -0
- package/public/assets/openvolo-logo-name.png +0 -0
- package/public/assets/openvolo-logo-transparent.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/site.webmanifest +19 -0
- package/src/app/api/analytics/agents/route.ts +30 -0
- package/src/app/api/analytics/content/route.ts +24 -0
- package/src/app/api/analytics/engagement/route.ts +24 -0
- package/src/app/api/analytics/overview/route.ts +22 -0
- package/src/app/api/analytics/sync-health/route.ts +22 -0
- package/src/app/api/contacts/[id]/identities/[identityId]/route.ts +24 -0
- package/src/app/api/contacts/[id]/identities/route.ts +61 -0
- package/src/app/api/contacts/[id]/route.ts +72 -0
- package/src/app/api/contacts/route.ts +91 -0
- package/src/app/api/content/[id]/route.ts +61 -0
- package/src/app/api/content/route.ts +48 -0
- package/src/app/api/platforms/gmail/auth/route.ts +50 -0
- package/src/app/api/platforms/gmail/callback/route.ts +126 -0
- package/src/app/api/platforms/gmail/route.ts +60 -0
- package/src/app/api/platforms/gmail/sync/route.ts +96 -0
- package/src/app/api/platforms/linkedin/auth/route.ts +40 -0
- package/src/app/api/platforms/linkedin/callback/route.ts +128 -0
- package/src/app/api/platforms/linkedin/import/route.ts +40 -0
- package/src/app/api/platforms/linkedin/route.ts +60 -0
- package/src/app/api/platforms/linkedin/sync/route.ts +85 -0
- package/src/app/api/platforms/x/auth/route.ts +52 -0
- package/src/app/api/platforms/x/browser-session/route.ts +79 -0
- package/src/app/api/platforms/x/callback/route.ts +130 -0
- package/src/app/api/platforms/x/compose/route.ts +247 -0
- package/src/app/api/platforms/x/engage/route.ts +113 -0
- package/src/app/api/platforms/x/enrich/route.ts +79 -0
- package/src/app/api/platforms/x/route.ts +63 -0
- package/src/app/api/platforms/x/sync/route.ts +142 -0
- package/src/app/api/settings/route.ts +43 -0
- package/src/app/api/settings/search-api/route.ts +180 -0
- package/src/app/api/tasks/[id]/route.ts +60 -0
- package/src/app/api/tasks/route.ts +39 -0
- package/src/app/api/workflows/[id]/progress/route.ts +45 -0
- package/src/app/api/workflows/[id]/route.ts +20 -0
- package/src/app/api/workflows/route.ts +30 -0
- package/src/app/api/workflows/run-agent/route.ts +44 -0
- package/src/app/api/workflows/templates/[id]/activate/route.ts +64 -0
- package/src/app/api/workflows/templates/[id]/route.ts +75 -0
- package/src/app/api/workflows/templates/route.ts +60 -0
- package/src/app/dashboard/analytics/analytics-dashboard.tsx +535 -0
- package/src/app/dashboard/analytics/page.tsx +15 -0
- package/src/app/dashboard/contacts/[id]/contact-detail-client.tsx +334 -0
- package/src/app/dashboard/contacts/[id]/page.tsx +21 -0
- package/src/app/dashboard/contacts/contact-list-client.tsx +213 -0
- package/src/app/dashboard/contacts/page.tsx +38 -0
- package/src/app/dashboard/content/[id]/engagement-actions.tsx +167 -0
- package/src/app/dashboard/content/[id]/page.tsx +253 -0
- package/src/app/dashboard/content/content-list-client.tsx +428 -0
- package/src/app/dashboard/content/page.tsx +39 -0
- package/src/app/dashboard/help/page.tsx +1247 -0
- package/src/app/dashboard/layout.tsx +19 -0
- package/src/app/dashboard/page.tsx +187 -0
- package/src/app/dashboard/settings/page.tsx +1664 -0
- package/src/app/dashboard/workflows/[id]/page.tsx +90 -0
- package/src/app/dashboard/workflows/[id]/workflow-detail-steps.tsx +55 -0
- package/src/app/dashboard/workflows/[id]/workflow-run-live.tsx +195 -0
- package/src/app/dashboard/workflows/activate-dialog.tsx +251 -0
- package/src/app/dashboard/workflows/page.tsx +41 -0
- package/src/app/dashboard/workflows/template-gallery.tsx +201 -0
- package/src/app/dashboard/workflows/workflow-quick-actions.tsx +121 -0
- package/src/app/dashboard/workflows/workflow-view-switcher.tsx +62 -0
- package/src/app/globals.css +232 -0
- package/src/app/layout.tsx +57 -0
- package/src/app/page.tsx +5 -0
- package/src/components/add-contact-dialog.tsx +74 -0
- package/src/components/add-task-dialog.tsx +153 -0
- package/src/components/animated-stat.tsx +53 -0
- package/src/components/app-sidebar.tsx +130 -0
- package/src/components/charts/area-chart-card.tsx +99 -0
- package/src/components/charts/bar-chart-card.tsx +128 -0
- package/src/components/charts/chart-skeleton.tsx +43 -0
- package/src/components/charts/donut-chart-card.tsx +100 -0
- package/src/components/charts/ranked-table-card.tsx +127 -0
- package/src/components/charts/stat-cards-row.tsx +45 -0
- package/src/components/compose-dialog.tsx +344 -0
- package/src/components/contact-form.tsx +218 -0
- package/src/components/dashboard-greeting.tsx +27 -0
- package/src/components/dashboard-header.tsx +87 -0
- package/src/components/empty-state.tsx +32 -0
- package/src/components/enrich-button.tsx +107 -0
- package/src/components/enrichment-score-badge.tsx +30 -0
- package/src/components/funnel-stage-badge.tsx +19 -0
- package/src/components/funnel-visualization.tsx +66 -0
- package/src/components/identities-section.tsx +219 -0
- package/src/components/pagination-controls.tsx +115 -0
- package/src/components/platform-connection-card.tsx +292 -0
- package/src/components/priority-badge.tsx +17 -0
- package/src/components/step-output-renderer.tsx +63 -0
- package/src/components/tweet-input.tsx +126 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +357 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/progress.tsx +31 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/components/workflow-graph-view.tsx +205 -0
- package/src/components/workflow-kanban-view.tsx +69 -0
- package/src/components/workflow-list-view.tsx +201 -0
- package/src/components/workflow-progress-card.tsx +150 -0
- package/src/components/workflow-run-card.tsx +144 -0
- package/src/components/workflow-step-timeline.tsx +173 -0
- package/src/components/workflow-swimlane-view.tsx +87 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-workflow-polling.ts +85 -0
- package/src/lib/agents/router.ts +79 -0
- package/src/lib/agents/run-agent-workflow.ts +605 -0
- package/src/lib/agents/tools/browser-scrape.ts +118 -0
- package/src/lib/agents/tools/enrich-contact.ts +128 -0
- package/src/lib/agents/tools/search-web.ts +473 -0
- package/src/lib/agents/tools/update-progress.ts +40 -0
- package/src/lib/agents/tools/url-fetch.ts +152 -0
- package/src/lib/agents/types.ts +79 -0
- package/src/lib/analytics/utils.ts +33 -0
- package/src/lib/auth/claude-auth.ts +134 -0
- package/src/lib/auth/crypto.ts +58 -0
- package/src/lib/browser/anti-detection.ts +79 -0
- package/src/lib/browser/extractors/profile-merger.ts +71 -0
- package/src/lib/browser/extractors/profile-parser.ts +133 -0
- package/src/lib/browser/platforms/x-scraper.ts +269 -0
- package/src/lib/browser/scraper.ts +92 -0
- package/src/lib/browser/session.ts +229 -0
- package/src/lib/browser/types.ts +80 -0
- package/src/lib/db/client.ts +24 -0
- package/src/lib/db/enrichment.ts +90 -0
- package/src/lib/db/migrate-identities.ts +95 -0
- package/src/lib/db/migrate.ts +33 -0
- package/src/lib/db/migrations/0000_tired_thanos.sql +296 -0
- package/src/lib/db/migrations/meta/0000_snapshot.json +2169 -0
- package/src/lib/db/migrations/meta/_journal.json +13 -0
- package/src/lib/db/queries/analytics.ts +449 -0
- package/src/lib/db/queries/contacts.ts +170 -0
- package/src/lib/db/queries/content.ts +215 -0
- package/src/lib/db/queries/dashboard.ts +79 -0
- package/src/lib/db/queries/engagements.ts +35 -0
- package/src/lib/db/queries/identities.ts +51 -0
- package/src/lib/db/queries/platform-accounts.ts +53 -0
- package/src/lib/db/queries/sync.ts +74 -0
- package/src/lib/db/queries/tasks.ts +88 -0
- package/src/lib/db/queries/workflow-templates.ts +213 -0
- package/src/lib/db/queries/workflows.ts +167 -0
- package/src/lib/db/schema.ts +437 -0
- package/src/lib/db/seed-templates.ts +221 -0
- package/src/lib/db/types.ts +78 -0
- package/src/lib/pagination.ts +12 -0
- package/src/lib/platforms/adapter.ts +75 -0
- package/src/lib/platforms/gmail/adapter.ts +112 -0
- package/src/lib/platforms/gmail/auth.ts +137 -0
- package/src/lib/platforms/gmail/client.ts +255 -0
- package/src/lib/platforms/gmail/mappers.ts +125 -0
- package/src/lib/platforms/gmail/oauth-state-store.ts +65 -0
- package/src/lib/platforms/index.ts +22 -0
- package/src/lib/platforms/linkedin/adapter.ts +164 -0
- package/src/lib/platforms/linkedin/auth.ts +124 -0
- package/src/lib/platforms/linkedin/client.ts +183 -0
- package/src/lib/platforms/linkedin/csv-import.ts +283 -0
- package/src/lib/platforms/linkedin/mappers.ts +123 -0
- package/src/lib/platforms/linkedin/oauth-state-store.ts +65 -0
- package/src/lib/platforms/rate-limiter.ts +88 -0
- package/src/lib/platforms/sync-contacts.ts +121 -0
- package/src/lib/platforms/sync-content.ts +225 -0
- package/src/lib/platforms/sync-gmail-contacts.ts +186 -0
- package/src/lib/platforms/sync-gmail-metadata.ts +158 -0
- package/src/lib/platforms/sync-linkedin-contacts.ts +148 -0
- package/src/lib/platforms/sync-x-profiles.ts +280 -0
- package/src/lib/platforms/x/adapter.ts +129 -0
- package/src/lib/platforms/x/auth.ts +165 -0
- package/src/lib/platforms/x/client.ts +390 -0
- package/src/lib/platforms/x/mappers.ts +134 -0
- package/src/lib/platforms/x/pkce-store.ts +67 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/workflows/format-error.test.ts +177 -0
- package/src/lib/workflows/format-error.ts +207 -0
- package/src/lib/workflows/run-sync-workflow.ts +141 -0
- package/src/lib/workflows/types.ts +71 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { randomBytes, createHash } from "crypto";
|
|
3
|
+
import { getXClientCredentials } from "@/lib/platforms/x/auth";
|
|
4
|
+
import { savePkceState } from "@/lib/platforms/x/pkce-store";
|
|
5
|
+
|
|
6
|
+
// Free tier: CRM engagement + messaging (requires "Read and write and Direct message" in X Developer Portal)
|
|
7
|
+
const FREE_SCOPES = "tweet.read tweet.write tweet.moderate.write users.read like.read like.write bookmark.read bookmark.write dm.read dm.write offline.access";
|
|
8
|
+
// Basic+ tier: adds contact management scopes (follows, lists, mute, block)
|
|
9
|
+
const EXTENDED_SCOPES = "tweet.read tweet.write tweet.moderate.write users.read like.read like.write bookmark.read bookmark.write dm.read dm.write follows.read follows.write list.read list.write mute.read mute.write block.read block.write space.read offline.access";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /api/platforms/x/auth
|
|
13
|
+
* Generate PKCE challenge and return the X OAuth 2.0 authorization URL.
|
|
14
|
+
* Pass ?extended=true to request Basic+ tier scopes (follows.read/write).
|
|
15
|
+
*/
|
|
16
|
+
export async function GET(req: NextRequest) {
|
|
17
|
+
try {
|
|
18
|
+
const { clientId } = getXClientCredentials();
|
|
19
|
+
const extended = req.nextUrl.searchParams.get("extended") === "true";
|
|
20
|
+
|
|
21
|
+
// Determine redirect URI from the request origin
|
|
22
|
+
const origin = req.headers.get("origin") || req.nextUrl.origin;
|
|
23
|
+
const redirectUri = `${origin}/api/platforms/x/callback`;
|
|
24
|
+
|
|
25
|
+
// Generate PKCE code verifier and challenge
|
|
26
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
27
|
+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
28
|
+
|
|
29
|
+
// Generate state parameter for CSRF protection
|
|
30
|
+
const state = randomBytes(16).toString("hex");
|
|
31
|
+
|
|
32
|
+
// Store PKCE state with tier metadata (in-memory, 10-min TTL)
|
|
33
|
+
savePkceState(state, codeVerifier, extended);
|
|
34
|
+
|
|
35
|
+
const params = new URLSearchParams({
|
|
36
|
+
response_type: "code",
|
|
37
|
+
client_id: clientId,
|
|
38
|
+
redirect_uri: redirectUri,
|
|
39
|
+
scope: extended ? EXTENDED_SCOPES : FREE_SCOPES,
|
|
40
|
+
state,
|
|
41
|
+
code_challenge: codeChallenge,
|
|
42
|
+
code_challenge_method: "S256",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const authUrl = `https://x.com/i/oauth2/authorize?${params.toString()}`;
|
|
46
|
+
|
|
47
|
+
return NextResponse.json({ authUrl, state });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : "Failed to generate auth URL";
|
|
50
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
hasSession,
|
|
4
|
+
loadSession,
|
|
5
|
+
clearSession,
|
|
6
|
+
setupSession,
|
|
7
|
+
validateSession,
|
|
8
|
+
} from "@/lib/browser/session";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POST /api/platforms/x/browser-session
|
|
12
|
+
* Manage browser session for X.
|
|
13
|
+
* Body: { action: "setup" | "validate" }
|
|
14
|
+
*/
|
|
15
|
+
export async function POST(req: NextRequest) {
|
|
16
|
+
try {
|
|
17
|
+
const body = await req.json().catch(() => ({}));
|
|
18
|
+
const action = body.action || "setup";
|
|
19
|
+
|
|
20
|
+
switch (action) {
|
|
21
|
+
case "setup": {
|
|
22
|
+
// Launch headed browser for manual login
|
|
23
|
+
const session = await setupSession("x");
|
|
24
|
+
return NextResponse.json({
|
|
25
|
+
status: "session_created",
|
|
26
|
+
createdAt: session.createdAt,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
case "validate": {
|
|
31
|
+
const isValid = await validateSession("x");
|
|
32
|
+
return NextResponse.json({
|
|
33
|
+
status: isValid ? "valid" : "invalid",
|
|
34
|
+
isValid,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
default:
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ error: `Unknown action: ${action}` },
|
|
41
|
+
{ status: 400 }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const message =
|
|
46
|
+
error instanceof Error ? error.message : "Browser session operation failed";
|
|
47
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* GET /api/platforms/x/browser-session
|
|
53
|
+
* Check browser session status.
|
|
54
|
+
*/
|
|
55
|
+
export async function GET() {
|
|
56
|
+
const exists = hasSession("x");
|
|
57
|
+
|
|
58
|
+
if (!exists) {
|
|
59
|
+
return NextResponse.json({
|
|
60
|
+
hasSession: false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const session = loadSession("x");
|
|
65
|
+
return NextResponse.json({
|
|
66
|
+
hasSession: true,
|
|
67
|
+
lastValidatedAt: session?.lastValidatedAt ?? null,
|
|
68
|
+
createdAt: session?.createdAt ?? null,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* DELETE /api/platforms/x/browser-session
|
|
74
|
+
* Clear stored browser session.
|
|
75
|
+
*/
|
|
76
|
+
export async function DELETE() {
|
|
77
|
+
clearSession("x");
|
|
78
|
+
return NextResponse.json({ status: "cleared" });
|
|
79
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getXClientCredentials, saveXCredentials } from "@/lib/platforms/x/auth";
|
|
3
|
+
import { getPkceState } from "@/lib/platforms/x/pkce-store";
|
|
4
|
+
import { encrypt } from "@/lib/auth/crypto";
|
|
5
|
+
import {
|
|
6
|
+
createPlatformAccount,
|
|
7
|
+
getPlatformAccountByPlatform,
|
|
8
|
+
updatePlatformAccount,
|
|
9
|
+
} from "@/lib/db/queries/platform-accounts";
|
|
10
|
+
import type { PlatformCredentials } from "@/lib/platforms/adapter";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* GET /api/platforms/x/callback?code=...&state=...
|
|
14
|
+
* OAuth 2.0 callback — exchanges code for tokens, fetches user profile, stores account.
|
|
15
|
+
*/
|
|
16
|
+
export async function GET(req: NextRequest) {
|
|
17
|
+
const { searchParams } = new URL(req.url);
|
|
18
|
+
const code = searchParams.get("code");
|
|
19
|
+
const state = searchParams.get("state");
|
|
20
|
+
const error = searchParams.get("error");
|
|
21
|
+
|
|
22
|
+
// Handle OAuth denial
|
|
23
|
+
if (error) {
|
|
24
|
+
return NextResponse.redirect(
|
|
25
|
+
new URL(`/dashboard/settings?error=${encodeURIComponent(error)}`, req.url)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!code || !state) {
|
|
30
|
+
return NextResponse.redirect(
|
|
31
|
+
new URL("/dashboard/settings?error=missing_params", req.url)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate PKCE state
|
|
36
|
+
const pkceEntry = getPkceState(state);
|
|
37
|
+
if (!pkceEntry) {
|
|
38
|
+
return NextResponse.redirect(
|
|
39
|
+
new URL("/dashboard/settings?error=invalid_state", req.url)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const { codeVerifier } = pkceEntry;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const { clientId, clientSecret } = getXClientCredentials();
|
|
46
|
+
const origin = req.nextUrl.origin;
|
|
47
|
+
const redirectUri = `${origin}/api/platforms/x/callback`;
|
|
48
|
+
|
|
49
|
+
// Exchange authorization code for tokens
|
|
50
|
+
const tokenRes = await fetch("https://api.x.com/2/oauth2/token", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
54
|
+
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
|
|
55
|
+
},
|
|
56
|
+
body: new URLSearchParams({
|
|
57
|
+
grant_type: "authorization_code",
|
|
58
|
+
code,
|
|
59
|
+
redirect_uri: redirectUri,
|
|
60
|
+
code_verifier: codeVerifier,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!tokenRes.ok) {
|
|
65
|
+
const errText = await tokenRes.text();
|
|
66
|
+
console.error("Token exchange failed:", errText);
|
|
67
|
+
return NextResponse.redirect(
|
|
68
|
+
new URL("/dashboard/settings?error=token_exchange_failed", req.url)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tokenData = await tokenRes.json();
|
|
73
|
+
// X returns the actually-granted scopes in the token response
|
|
74
|
+
const grantedScopes: string = tokenData.scope ?? "";
|
|
75
|
+
const creds: PlatformCredentials = {
|
|
76
|
+
accessToken: tokenData.access_token,
|
|
77
|
+
refreshToken: tokenData.refresh_token,
|
|
78
|
+
expiresAt: Math.floor(Date.now() / 1000) + tokenData.expires_in,
|
|
79
|
+
grantedScopes,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Fetch user profile from X API
|
|
83
|
+
const profileRes = await fetch(
|
|
84
|
+
"https://api.x.com/2/users/me?user.fields=name,username,description,location,url,profile_image_url,public_metrics",
|
|
85
|
+
{
|
|
86
|
+
headers: { Authorization: `Bearer ${creds.accessToken}` },
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!profileRes.ok) {
|
|
91
|
+
const errText = await profileRes.text();
|
|
92
|
+
console.error("Profile fetch failed:", errText);
|
|
93
|
+
return NextResponse.redirect(
|
|
94
|
+
new URL("/dashboard/settings?error=profile_fetch_failed", req.url)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const profileData = await profileRes.json();
|
|
99
|
+
const xUser = profileData.data;
|
|
100
|
+
|
|
101
|
+
// Upsert platform account
|
|
102
|
+
const existing = getPlatformAccountByPlatform("x");
|
|
103
|
+
if (existing) {
|
|
104
|
+
updatePlatformAccount(existing.id, {
|
|
105
|
+
displayName: `@${xUser.username}`,
|
|
106
|
+
credentialsEncrypted: encrypt(JSON.stringify(creds)),
|
|
107
|
+
status: "active",
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
const account = createPlatformAccount({
|
|
111
|
+
platform: "x",
|
|
112
|
+
displayName: `@${xUser.username}`,
|
|
113
|
+
authType: "oauth",
|
|
114
|
+
credentialsEncrypted: encrypt(JSON.stringify(creds)),
|
|
115
|
+
status: "active",
|
|
116
|
+
});
|
|
117
|
+
// Save credentials after creation (already encrypted in createPlatformAccount call above)
|
|
118
|
+
void account; // credentials already stored inline
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return NextResponse.redirect(
|
|
122
|
+
new URL("/dashboard/settings?connected=x", req.url)
|
|
123
|
+
);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error("OAuth callback error:", err);
|
|
126
|
+
return NextResponse.redirect(
|
|
127
|
+
new URL("/dashboard/settings?error=callback_failed", req.url)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { nanoid } from "nanoid";
|
|
4
|
+
import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
|
|
5
|
+
import { createContentItem, updateContentItem, getContentItem, getThreadItems } from "@/lib/db/queries/content";
|
|
6
|
+
import {
|
|
7
|
+
getAuthenticatedUser,
|
|
8
|
+
postTweet,
|
|
9
|
+
postThread,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
TierRestrictedError,
|
|
12
|
+
} from "@/lib/platforms/x/client";
|
|
13
|
+
|
|
14
|
+
const composeSchema = z.object({
|
|
15
|
+
tweets: z.array(z.string().min(1).max(280)).min(1).max(25),
|
|
16
|
+
saveAsDraft: z.boolean().optional(),
|
|
17
|
+
draftId: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* POST /api/platforms/x/compose
|
|
22
|
+
* Compose and publish tweets/threads, or save as drafts.
|
|
23
|
+
*/
|
|
24
|
+
export async function POST(req: NextRequest) {
|
|
25
|
+
try {
|
|
26
|
+
const account = getPlatformAccountByPlatform("x");
|
|
27
|
+
if (!account) {
|
|
28
|
+
return NextResponse.json({ error: "No X account connected" }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (account.status === "needs_reauth") {
|
|
32
|
+
return NextResponse.json({ error: "X account needs re-authentication" }, { status: 401 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const body = await req.json();
|
|
36
|
+
const { tweets, saveAsDraft, draftId } = composeSchema.parse(body);
|
|
37
|
+
|
|
38
|
+
const isThread = tweets.length > 1;
|
|
39
|
+
const threadId = draftId
|
|
40
|
+
? getExistingThreadId(draftId)
|
|
41
|
+
: (isThread ? nanoid() : null);
|
|
42
|
+
|
|
43
|
+
// --- Save as draft ---
|
|
44
|
+
if (saveAsDraft) {
|
|
45
|
+
const items = saveDraftItems(tweets, threadId, draftId, account.id);
|
|
46
|
+
return NextResponse.json({ success: true, draft: true, items });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Publish ---
|
|
50
|
+
if (isThread) {
|
|
51
|
+
const result = await postThread(account.id, tweets);
|
|
52
|
+
const me = await getAuthenticatedUser(account.id);
|
|
53
|
+
|
|
54
|
+
// Create content items for each posted tweet
|
|
55
|
+
const items = result.posted.map((tweet, i) => {
|
|
56
|
+
const item = createContentItem(
|
|
57
|
+
{
|
|
58
|
+
body: tweets[i],
|
|
59
|
+
contentType: i === 0 ? "thread" : "reply",
|
|
60
|
+
status: "published",
|
|
61
|
+
origin: "authored",
|
|
62
|
+
direction: "outbound",
|
|
63
|
+
platformAccountId: account.id,
|
|
64
|
+
threadId: threadId!,
|
|
65
|
+
parentItemId: i > 0 ? undefined : undefined, // linked via threadId
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
platformAccountId: account.id,
|
|
69
|
+
platformPostId: tweet.id,
|
|
70
|
+
platformUrl: `https://x.com/${me.username}/status/${tweet.id}`,
|
|
71
|
+
publishedAt: Math.floor(Date.now() / 1000),
|
|
72
|
+
status: "published",
|
|
73
|
+
engagementSnapshot: JSON.stringify({
|
|
74
|
+
likes: 0,
|
|
75
|
+
replies: 0,
|
|
76
|
+
retweets: 0,
|
|
77
|
+
quotes: 0,
|
|
78
|
+
}),
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
return item;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Clean up draft items if publishing from a draft
|
|
85
|
+
if (draftId) {
|
|
86
|
+
cleanupDraftItems(draftId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (result.error) {
|
|
90
|
+
return NextResponse.json({
|
|
91
|
+
success: true,
|
|
92
|
+
partial: true,
|
|
93
|
+
error: result.error,
|
|
94
|
+
published: items.length,
|
|
95
|
+
total: tweets.length,
|
|
96
|
+
items,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return NextResponse.json({ success: true, items });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Single tweet
|
|
104
|
+
const tweet = await postTweet(account.id, tweets[0]);
|
|
105
|
+
const me = await getAuthenticatedUser(account.id);
|
|
106
|
+
|
|
107
|
+
const item = createContentItem(
|
|
108
|
+
{
|
|
109
|
+
body: tweets[0],
|
|
110
|
+
contentType: "post",
|
|
111
|
+
status: "published",
|
|
112
|
+
origin: "authored",
|
|
113
|
+
direction: "outbound",
|
|
114
|
+
platformAccountId: account.id,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
platformAccountId: account.id,
|
|
118
|
+
platformPostId: tweet.id,
|
|
119
|
+
platformUrl: `https://x.com/${me.username}/status/${tweet.id}`,
|
|
120
|
+
publishedAt: Math.floor(Date.now() / 1000),
|
|
121
|
+
status: "published",
|
|
122
|
+
engagementSnapshot: JSON.stringify({
|
|
123
|
+
likes: 0,
|
|
124
|
+
replies: 0,
|
|
125
|
+
retweets: 0,
|
|
126
|
+
quotes: 0,
|
|
127
|
+
}),
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Clean up draft if publishing from one
|
|
132
|
+
if (draftId) {
|
|
133
|
+
cleanupDraftItems(draftId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return NextResponse.json({ success: true, items: [item] });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof z.ZodError) {
|
|
139
|
+
return NextResponse.json({ error: error.errors }, { status: 400 });
|
|
140
|
+
}
|
|
141
|
+
if (error instanceof RateLimitError) {
|
|
142
|
+
return NextResponse.json(
|
|
143
|
+
{ error: `Rate limited. Try again in ${error.retryAfter} seconds.`, retryAfter: error.retryAfter },
|
|
144
|
+
{ status: 429 }
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (error instanceof TierRestrictedError) {
|
|
148
|
+
return NextResponse.json(
|
|
149
|
+
{ error: "Posting requires a higher X API tier.", code: "TIER_RESTRICTED" },
|
|
150
|
+
{ status: 403 }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const message = error instanceof Error ? error.message : "Compose failed";
|
|
154
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Get existing threadId from a draft's first content item. */
|
|
159
|
+
function getExistingThreadId(draftId: string): string | null {
|
|
160
|
+
const item = getContentItem(draftId);
|
|
161
|
+
return item?.threadId ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Save tweets as draft content items. If draftId provided, update existing draft. */
|
|
165
|
+
function saveDraftItems(
|
|
166
|
+
tweets: string[],
|
|
167
|
+
threadId: string | null,
|
|
168
|
+
draftId: string | undefined,
|
|
169
|
+
accountId: string
|
|
170
|
+
) {
|
|
171
|
+
const isThread = tweets.length > 1;
|
|
172
|
+
|
|
173
|
+
// If updating existing draft, update the first item and handle additions/removals
|
|
174
|
+
if (draftId) {
|
|
175
|
+
const existing = getContentItem(draftId);
|
|
176
|
+
if (existing) {
|
|
177
|
+
const existingThreadItems = existing.threadId
|
|
178
|
+
? getThreadItems(existing.threadId)
|
|
179
|
+
: [existing];
|
|
180
|
+
|
|
181
|
+
// Update existing items with new text, create new items if needed
|
|
182
|
+
const updatedItems = [];
|
|
183
|
+
for (let i = 0; i < tweets.length; i++) {
|
|
184
|
+
if (i < existingThreadItems.length) {
|
|
185
|
+
// Update existing item
|
|
186
|
+
const updated = updateContentItem(existingThreadItems[i].id, {
|
|
187
|
+
body: tweets[i],
|
|
188
|
+
contentType: isThread ? (i === 0 ? "thread" : "reply") : "post",
|
|
189
|
+
});
|
|
190
|
+
if (updated) updatedItems.push(updated);
|
|
191
|
+
} else {
|
|
192
|
+
// Create new item for additional tweets
|
|
193
|
+
const item = createContentItem({
|
|
194
|
+
body: tweets[i],
|
|
195
|
+
contentType: isThread ? "reply" : "post",
|
|
196
|
+
status: "draft",
|
|
197
|
+
origin: "authored",
|
|
198
|
+
direction: "outbound",
|
|
199
|
+
platformAccountId: accountId,
|
|
200
|
+
threadId: threadId,
|
|
201
|
+
});
|
|
202
|
+
updatedItems.push(item);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Delete excess items if the new draft has fewer tweets
|
|
207
|
+
for (let i = tweets.length; i < existingThreadItems.length; i++) {
|
|
208
|
+
updateContentItem(existingThreadItems[i].id, { status: "draft" });
|
|
209
|
+
// We can't delete easily, so just leave extras — they'll be cleaned up on publish
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return updatedItems;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Create new draft items
|
|
217
|
+
const items = tweets.map((text, i) => {
|
|
218
|
+
return createContentItem({
|
|
219
|
+
body: text,
|
|
220
|
+
contentType: isThread ? (i === 0 ? "thread" : "reply") : "post",
|
|
221
|
+
status: "draft",
|
|
222
|
+
origin: "authored",
|
|
223
|
+
direction: "outbound",
|
|
224
|
+
platformAccountId: accountId,
|
|
225
|
+
threadId: isThread ? threadId : null,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return items;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Remove draft items after successful publish. */
|
|
233
|
+
function cleanupDraftItems(draftId: string) {
|
|
234
|
+
const item = getContentItem(draftId);
|
|
235
|
+
if (!item) return;
|
|
236
|
+
|
|
237
|
+
if (item.threadId) {
|
|
238
|
+
const threadItems = getThreadItems(item.threadId);
|
|
239
|
+
for (const ti of threadItems) {
|
|
240
|
+
if (ti.status === "draft") {
|
|
241
|
+
updateContentItem(ti.id, { status: "published" });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
updateContentItem(draftId, { status: "published" });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
|
|
4
|
+
import { createEngagement } from "@/lib/db/queries/engagements";
|
|
5
|
+
import {
|
|
6
|
+
getAuthenticatedUser,
|
|
7
|
+
likeTweet,
|
|
8
|
+
unlikeTweet,
|
|
9
|
+
retweet,
|
|
10
|
+
unretweet,
|
|
11
|
+
replyToTweet,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
TierRestrictedError,
|
|
14
|
+
} from "@/lib/platforms/x/client";
|
|
15
|
+
|
|
16
|
+
const engageSchema = z.object({
|
|
17
|
+
action: z.enum(["like", "unlike", "retweet", "unretweet", "reply"]),
|
|
18
|
+
tweetId: z.string().min(1),
|
|
19
|
+
contentPostId: z.string().optional(),
|
|
20
|
+
text: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* POST /api/platforms/x/engage
|
|
25
|
+
* Perform an engagement action (like, retweet, reply) on a tweet.
|
|
26
|
+
*/
|
|
27
|
+
export async function POST(req: NextRequest) {
|
|
28
|
+
try {
|
|
29
|
+
const account = getPlatformAccountByPlatform("x");
|
|
30
|
+
if (!account) {
|
|
31
|
+
return NextResponse.json({ error: "No X account connected" }, { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (account.status === "needs_reauth") {
|
|
35
|
+
return NextResponse.json({ error: "X account needs re-authentication" }, { status: 401 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const body = await req.json();
|
|
39
|
+
const { action, tweetId, contentPostId, text } = engageSchema.parse(body);
|
|
40
|
+
|
|
41
|
+
if (action === "reply" && !text) {
|
|
42
|
+
return NextResponse.json({ error: "Reply text is required" }, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Get the authenticated user's platform ID (needed for like/retweet endpoints)
|
|
46
|
+
const me = await getAuthenticatedUser(account.id);
|
|
47
|
+
|
|
48
|
+
let result: unknown;
|
|
49
|
+
|
|
50
|
+
switch (action) {
|
|
51
|
+
case "like":
|
|
52
|
+
result = await likeTweet(account.id, me.id, tweetId);
|
|
53
|
+
break;
|
|
54
|
+
case "unlike":
|
|
55
|
+
result = await unlikeTweet(account.id, me.id, tweetId);
|
|
56
|
+
break;
|
|
57
|
+
case "retweet":
|
|
58
|
+
result = await retweet(account.id, me.id, tweetId);
|
|
59
|
+
break;
|
|
60
|
+
case "unretweet":
|
|
61
|
+
result = await unretweet(account.id, me.id, tweetId);
|
|
62
|
+
break;
|
|
63
|
+
case "reply":
|
|
64
|
+
result = await replyToTweet(account.id, tweetId, text!);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Record the engagement in the database
|
|
69
|
+
const engagementTypeMap = {
|
|
70
|
+
like: "like",
|
|
71
|
+
unlike: "like",
|
|
72
|
+
retweet: "retweet",
|
|
73
|
+
unretweet: "retweet",
|
|
74
|
+
reply: "reply",
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
createEngagement({
|
|
78
|
+
contactId: null,
|
|
79
|
+
platformAccountId: account.id,
|
|
80
|
+
engagementType: engagementTypeMap[action],
|
|
81
|
+
direction: "outbound",
|
|
82
|
+
content: action === "reply" ? text ?? null : null,
|
|
83
|
+
templateId: null,
|
|
84
|
+
workflowRunId: null,
|
|
85
|
+
contentPostId: contentPostId ?? null,
|
|
86
|
+
platform: "x",
|
|
87
|
+
platformEngagementId: null,
|
|
88
|
+
threadId: null,
|
|
89
|
+
source: "manual",
|
|
90
|
+
platformData: JSON.stringify({ action, tweetId, result }),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return NextResponse.json({ success: true, action, result });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof z.ZodError) {
|
|
96
|
+
return NextResponse.json({ error: error.errors }, { status: 400 });
|
|
97
|
+
}
|
|
98
|
+
if (error instanceof RateLimitError) {
|
|
99
|
+
return NextResponse.json(
|
|
100
|
+
{ error: `Rate limited. Try again in ${error.retryAfter} seconds.`, retryAfter: error.retryAfter },
|
|
101
|
+
{ status: 429 }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (error instanceof TierRestrictedError) {
|
|
105
|
+
return NextResponse.json(
|
|
106
|
+
{ error: "This action requires a higher X API tier.", code: "TIER_RESTRICTED" },
|
|
107
|
+
{ status: 403 }
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const message = error instanceof Error ? error.message : "Engagement action failed";
|
|
111
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { getPlatformAccountByPlatform } from "@/lib/db/queries/platform-accounts";
|
|
3
|
+
import { listSyncCursors } from "@/lib/db/queries/sync";
|
|
4
|
+
import { hasSession } from "@/lib/browser/session";
|
|
5
|
+
import { syncXProfiles } from "@/lib/platforms/sync-x-profiles";
|
|
6
|
+
import { runSyncWorkflow } from "@/lib/workflows/run-sync-workflow";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/platforms/x/enrich
|
|
10
|
+
* Trigger browser-based profile enrichment for X contacts.
|
|
11
|
+
* Body: { contactIds?: string[], maxProfiles?: number }
|
|
12
|
+
*/
|
|
13
|
+
export async function POST(req: NextRequest) {
|
|
14
|
+
try {
|
|
15
|
+
const account = getPlatformAccountByPlatform("x");
|
|
16
|
+
if (!account) {
|
|
17
|
+
return NextResponse.json(
|
|
18
|
+
{ error: "No X platform account found" },
|
|
19
|
+
{ status: 400 }
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!hasSession("x")) {
|
|
24
|
+
return NextResponse.json(
|
|
25
|
+
{ error: "No browser session configured. Set up in Settings." },
|
|
26
|
+
{ status: 400 }
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const body = await req.json().catch(() => ({}));
|
|
31
|
+
const maxProfiles = body.maxProfiles ?? 15;
|
|
32
|
+
|
|
33
|
+
const { workflowRun, syncResult } = await runSyncWorkflow({
|
|
34
|
+
workflowType: "enrich",
|
|
35
|
+
syncSubType: "x_enrich",
|
|
36
|
+
platformAccountId: account.id,
|
|
37
|
+
syncFunction: () =>
|
|
38
|
+
syncXProfiles(account.id, {
|
|
39
|
+
contactIds: body.contactIds,
|
|
40
|
+
maxProfiles,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return NextResponse.json({ success: true, result: syncResult, workflowRunId: workflowRun.id });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const message = error instanceof Error ? error.message : "Enrichment failed";
|
|
47
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* GET /api/platforms/x/enrich
|
|
53
|
+
* Get enrichment sync status.
|
|
54
|
+
*/
|
|
55
|
+
export async function GET() {
|
|
56
|
+
const account = getPlatformAccountByPlatform("x");
|
|
57
|
+
if (!account) {
|
|
58
|
+
return NextResponse.json({ configured: false });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hasBrowserSession = hasSession("x");
|
|
62
|
+
|
|
63
|
+
// Find the x_profiles sync cursor if it exists
|
|
64
|
+
const cursors = listSyncCursors(account.id);
|
|
65
|
+
const enrichCursor = cursors.find((c) => c.dataType === "x_profiles");
|
|
66
|
+
|
|
67
|
+
return NextResponse.json({
|
|
68
|
+
configured: true,
|
|
69
|
+
hasBrowserSession,
|
|
70
|
+
enrichment: enrichCursor
|
|
71
|
+
? {
|
|
72
|
+
status: enrichCursor.syncStatus,
|
|
73
|
+
totalEnriched: enrichCursor.totalItemsSynced,
|
|
74
|
+
lastEnrichedAt: enrichCursor.lastSyncCompletedAt,
|
|
75
|
+
lastError: enrichCursor.lastError,
|
|
76
|
+
}
|
|
77
|
+
: null,
|
|
78
|
+
});
|
|
79
|
+
}
|