Skip to content

Commit ed80e22

Browse files
bidahclaude
andcommitted
✨ feat(x-bot): implement webhook-based X bot with two-tweet reply flow
- Switch from polling to webhook-based event handling (CRC + POST handler) - Add subscription/credit check before generation (canUserSendMessage) - Implement two-tweet reply: "Creating your app..." then "App is ready" with links - Send error reply tweet on generation failure - Add schema fields: authorUsername, firstReplyTweetId, firstReplyContent, firstRepliedAt - Create webhook registration script (pnpm run x-bot:register-webhook) - Rename all capsulethis references to rnvibecode/reactnativevibecode.com - New env vars needed: TWITTER_WEBHOOK_SECRET, TWITTER_BEARER_TOKEN Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a181c3b commit ed80e22

37 files changed

Lines changed: 2766 additions & 344 deletions

File tree

‎apps/web/app/(app)/api/create-container/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ export async function POST(req: NextRequest) {
334334
// Set API Base URL for the sandbox app
335335
try {
336336
const { updateSandboxEnvFile } = await import('@/lib/convex/sandbox-utils')
337-
const apiBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://www.capsulethis.com'
337+
const apiBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://www.reactnativevibecode.com'
338338
await updateSandboxEnvFile(sandbox, 'EXPO_PUBLIC_API_BASE_URL', apiBaseUrl)
339339
console.log(`[Create Container] Set EXPO_PUBLIC_API_BASE_URL to ${apiBaseUrl}`)
340340
} catch (error) {
@@ -498,7 +498,7 @@ export async function POST(req: NextRequest) {
498498
// Set API Base URL for the sandbox app
499499
try {
500500
const { updateSandboxEnvFile } = await import('@/lib/convex/sandbox-utils')
501-
const apiBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://www.capsulethis.com'
501+
const apiBaseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://www.reactnativevibecode.com'
502502
await updateSandboxEnvFile(sandbox, 'EXPO_PUBLIC_API_BASE_URL', apiBaseUrl)
503503
console.log(`[Create Container] Set EXPO_PUBLIC_API_BASE_URL to ${apiBaseUrl}`)
504504
} catch (error) {

‎apps/web/app/(app)/api/x-bot/create-project/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const octokit = new Octokit({
1515
})
1616

1717
const githubService = new GitHubService({
18-
owner: process.env.GITHUB_OWNER || 'capsule-this',
18+
owner: process.env.GITHUB_OWNER || 'react-native-vibe-code',
1919
token: process.env.GITHUB_TOKEN!,
2020
})
2121

@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
105105
console.log(`[X-Bot Create Project] Created project: ${projectId}`)
106106

107107
// Create GitHub repository
108-
const owner = process.env.GITHUB_OWNER || 'capsule-this'
108+
const owner = process.env.GITHUB_OWNER || 'react-native-vibe-code'
109109
try {
110110
await octokit.repos.get({ owner, repo: repositoryName })
111111
console.log(`[X-Bot Create Project] GitHub repo already exists`)

‎apps/web/app/(app)/api/x-bot/generate/route.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from 'next/server'
2+
import { Client, auth } from 'twitter-api-sdk'
23
import { db } from '@/lib/db'
34
import { xBotReplies, projects } from '@react-native-vibe-code/database'
45
import { eq } from 'drizzle-orm'
@@ -10,6 +11,51 @@ export const maxDuration = 300 // 5 minutes
1011
// Secret key for x-bot internal calls
1112
const X_BOT_SECRET = process.env.X_BOT_SECRET
1213

14+
/**
15+
* Get authenticated Twitter client for sending error replies
16+
*/
17+
async function getAuthClient(): Promise<Client> {
18+
const refreshToken = process.env.TWITTER_REFRESH_TOKEN
19+
if (!refreshToken) {
20+
throw new Error('TWITTER_REFRESH_TOKEN environment variable is required')
21+
}
22+
23+
const oauth2Client = new auth.OAuth2User({
24+
client_id: process.env.TWITTER_CLIENT_ID as string,
25+
client_secret: process.env.TWITTER_CLIENT_SECRET as string,
26+
callback: 'https://reactnativevibecode.com/api/x-bot/auth/callback',
27+
scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'],
28+
})
29+
30+
oauth2Client.token = { refresh_token: refreshToken }
31+
await oauth2Client.refreshAccessToken()
32+
return new Client(oauth2Client)
33+
}
34+
35+
/**
36+
* Send an error reply tweet when generation fails
37+
*/
38+
async function sendErrorReply(
39+
tweetId: string,
40+
authorUsername: string | null,
41+
firstReplyTweetId: string | null
42+
): Promise<void> {
43+
try {
44+
const client = await getAuthClient()
45+
const username = authorUsername || 'there'
46+
const errorText = `Sorry @${username}, there was an issue creating your app. Please try again or visit reactnativevibecode.com to build it manually.`
47+
const replyToId = firstReplyTweetId || tweetId
48+
49+
await client.tweets.createTweet({
50+
text: errorText,
51+
reply: { in_reply_to_tweet_id: replyToId },
52+
})
53+
console.log(`[X-Bot Generate] Error reply sent for tweet ${tweetId}`)
54+
} catch (replyError) {
55+
console.error(`[X-Bot Generate] Failed to send error reply:`, replyError)
56+
}
57+
}
58+
1359
interface GenerateRequest {
1460
projectId: string
1561
userId: string
@@ -109,10 +155,24 @@ export async function POST(request: NextRequest) {
109155
await db
110156
.update(xBotReplies)
111157
.set({
158+
status: 'failed',
112159
generationStatus: 'failed',
113160
errorMessage: error,
114161
})
115162
.where(eq(xBotReplies.tweetId, tweetId))
163+
164+
// Send error reply tweet
165+
const record = await db
166+
.select()
167+
.from(xBotReplies)
168+
.where(eq(xBotReplies.tweetId, tweetId))
169+
.limit(1)
170+
171+
await sendErrorReply(
172+
tweetId,
173+
record[0]?.authorUsername || null,
174+
record[0]?.firstReplyTweetId || null
175+
)
116176
},
117177
}
118178
)
@@ -133,11 +193,25 @@ export async function POST(request: NextRequest) {
133193
await db
134194
.update(xBotReplies)
135195
.set({
196+
status: 'failed',
136197
generationStatus: 'failed',
137198
errorMessage: error.message || 'Unexpected error',
138199
})
139200
.where(eq(xBotReplies.tweetId, tweetId))
140201

202+
// Send error reply tweet
203+
const record = await db
204+
.select()
205+
.from(xBotReplies)
206+
.where(eq(xBotReplies.tweetId, tweetId))
207+
.limit(1)
208+
209+
await sendErrorReply(
210+
tweetId,
211+
record[0]?.authorUsername || null,
212+
record[0]?.firstReplyTweetId || null
213+
)
214+
141215
return NextResponse.json(
142216
{ error: error.message || 'Generation failed' },
143217
{ status: 500 }

‎apps/web/app/(app)/api/x-bot/reply/route.ts‎

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async function getAuthClient(): Promise<Client> {
2525
const oauth2Client = new auth.OAuth2User({
2626
client_id: process.env.TWITTER_CLIENT_ID as string,
2727
client_secret: process.env.TWITTER_CLIENT_SECRET as string,
28-
callback: 'http://www.capsulethis.com/api/x-bot/auth/callback',
28+
callback: 'https://reactnativevibecode.com/api/x-bot/auth/callback',
2929
scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'],
3030
})
3131

@@ -35,7 +35,6 @@ async function getAuthClient(): Promise<Client> {
3535

3636
await oauth2Client.refreshAccessToken()
3737

38-
// Log if token was rotated
3938
if (
4039
oauth2Client.token?.refresh_token &&
4140
oauth2Client.token.refresh_token !== refreshToken
@@ -49,6 +48,42 @@ async function getAuthClient(): Promise<Client> {
4948
return new Client(oauth2Client)
5049
}
5150

51+
/**
52+
* Build the final reply text within Twitter's 280 character limit.
53+
* Includes app title, brief description, edit link, and remix link.
54+
*/
55+
function buildReplyText(
56+
title: string,
57+
appDescription: string | null,
58+
projectId: string
59+
): string {
60+
const baseUrl =
61+
process.env.NEXT_PUBLIC_APP_URL || 'https://reactnativevibecode.com'
62+
const editUrl = `${baseUrl}/p/${projectId}`
63+
const remixUrl = `${baseUrl}/p/${projectId}/remix`
64+
65+
const links = `\nEdit your app: ${editUrl}\nRemix it: ${remixUrl}`
66+
const header = `Your app "${title}" is ready!`
67+
68+
// Calculate remaining space for description
69+
// 280 char limit, account for header + links + newlines
70+
const fixedLength = header.length + links.length + 2 // 2 for newlines between sections
71+
const maxDescLength = 280 - fixedLength
72+
73+
if (!appDescription || maxDescLength <= 0) {
74+
const text = `${header}${links}`
75+
return text.length <= 280 ? text : text.substring(0, 280)
76+
}
77+
78+
const desc =
79+
appDescription.length > maxDescLength
80+
? appDescription.substring(0, maxDescLength - 3) + '...'
81+
: appDescription
82+
83+
const text = `${header}\n\n${desc}${links}`
84+
return text.length <= 280 ? text : `${header}${links}`
85+
}
86+
5287
export async function POST(request: NextRequest) {
5388
try {
5489
const body: ReplyRequest = await request.json()
@@ -66,7 +101,9 @@ export async function POST(request: NextRequest) {
66101
)
67102
}
68103

69-
console.log(`[X-Bot Reply] Sending reply for tweet ${tweetId}, project ${projectId}`)
104+
console.log(
105+
`[X-Bot Reply] Sending final reply for tweet ${tweetId}, project ${projectId}`
106+
)
70107

71108
// Get project details
72109
const projectResults = await db
@@ -81,23 +118,36 @@ export async function POST(request: NextRequest) {
81118

82119
const project = projectResults[0]
83120

84-
// Build reply text
85-
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://capsulethis.com'
86-
const projectUrl = `${baseUrl}/p/${projectId}`
121+
// Get xBotReplies record to find firstReplyTweetId and appDescription
122+
const replyRecord = await db
123+
.select()
124+
.from(xBotReplies)
125+
.where(eq(xBotReplies.tweetId, tweetId))
126+
.limit(1)
87127

88-
const replyText = `Your app "${project.title}" is ready! 🚀\n\nView and edit: ${projectUrl}`
128+
// Determine which tweet to reply to for proper threading
129+
// Reply to the first reply tweet to create a thread, fallback to original tweet
130+
const replyToTweetId = replyRecord[0]?.firstReplyTweetId || tweetId
131+
132+
// Build reply text with app details
133+
const appDescription = replyRecord[0]?.appDescription || null
134+
const replyText = buildReplyText(
135+
project.title || 'Untitled App',
136+
appDescription,
137+
projectId
138+
)
89139

90140
// Send reply via Twitter API
91141
const client = await getAuthClient()
92142
const response = await client.tweets.createTweet({
93143
text: replyText,
94-
reply: { in_reply_to_tweet_id: tweetId },
144+
reply: { in_reply_to_tweet_id: replyToTweetId },
95145
})
96146

97147
console.log(`[X-Bot Reply] Response:`, JSON.stringify(response))
98148

99149
if (response.data?.id) {
100-
// Update xBotReplies with reply info
150+
// Update xBotReplies with final reply info
101151
await db
102152
.update(xBotReplies)
103153
.set({
@@ -116,7 +166,9 @@ export async function POST(request: NextRequest) {
116166
replyText,
117167
})
118168
} else {
119-
console.error(`[X-Bot Reply] No reply ID returned for tweet ${tweetId}`)
169+
console.error(
170+
`[X-Bot Reply] No reply ID returned for tweet ${tweetId}`
171+
)
120172

121173
await db
122174
.update(xBotReplies)

0 commit comments

Comments
 (0)