feat(oauth): general oauth improvements, added x and supabase oauth (#186)

* added x oauth

* added x oauth to x tools

* fix(deps)(deps): bump vite in /sim in the dependencies group (#183)

* improvement(vars): variable rename reference change

* fix(ui): scrollbar styling across all browsers

* feat(error-handling): created error path and upgraded ports

* improvement(ui/ux): console

* fix(connection-block): spacing

* added supabase oauth

* ui improvements for action required banner in settings > credentials

* improvement(oauth): if there is already an account connected, don't display the connect button in the credential selector

* fixed typo in marketplace for customer_service

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
This commit is contained in:
Waleed Latif
2025-03-26 03:34:11 -07:00
committed by GitHub
co-authored by dependabot[bot] Emir Karabeg
parent 601d77f7c7
commit 51e2b39c56
12 changed files with 372 additions and 212 deletions
@@ -47,6 +47,9 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'read:page:confluence': 'Read Confluence pages',
'write:confluence-content': 'Write Confluence content',
'read:me': 'Read your profile information',
'database.read': 'Read your database',
'database.write': 'Write to your database',
'projects.read': 'Read your projects',
offline_access: 'Access your account when you are not using the application',
repo: 'Access your repositories',
workflow: 'Manage repository workflows',
@@ -105,6 +108,7 @@ export function OAuthRequiredModal({
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
saveToStorage<boolean>('from_oauth_modal', true)
// Close the modal
onClose()
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -50,27 +50,21 @@ export function CredentialSelector({
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState(value)
const initialFetchRef = useRef(false)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Derive service and provider IDs using useMemo
const effectiveServiceId = useMemo(() => {
return serviceId || getServiceIdFromScopes(provider, requiredScopes)
}, [provider, requiredScopes, serviceId])
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
const effectiveProviderId = useMemo(() => {
return getProviderIdFromServiceId(effectiveServiceId)
}
}, [effectiveServiceId])
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
const response = await fetch(`/api/auth/oauth/credentials?provider=${effectiveProviderId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
@@ -105,28 +99,44 @@ export function CredentialSelector({
} finally {
setIsLoading(false)
}
}, [provider, onChange, selectedId, getProviderId])
}, [effectiveProviderId, onChange, selectedId])
// Fetch credentials on initial mount and when dependencies change
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Also fetch credentials when opening the popover
useEffect(() => {
if (open) {
fetchCredentials()
}
}, [open, fetchCredentials])
fetchCredentials()
// This effect should only run once on mount, so empty dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Update local state when external value changes
useEffect(() => {
setSelectedId(value)
}, [value])
// Listen for visibility changes to update credentials when user returns from settings
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchCredentials()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [fetchCredentials])
// Handle popover open to fetch fresh credentials
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (isOpen) {
// Fetch fresh credentials when opening the dropdown
fetchCredentials()
}
}
// Get the selected credential
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
@@ -139,14 +149,11 @@ export function CredentialSelector({
// Handle adding a new credential
const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
saveToStorage<string>('pending_oauth_provider_id', effectiveProviderId)
// Show the OAuth modal
setShowOAuthModal(true)
@@ -184,7 +191,7 @@ export function CredentialSelector({
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -243,14 +250,16 @@ export function CredentialSelector({
))}
</CommandGroup>
)}
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className="flex items-center gap-2 text-primary">
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
@@ -263,7 +272,7 @@ export function CredentialSelector({
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
serviceId={effectiveServiceId}
/>
)}
</>
@@ -1,8 +1,8 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Check, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
@@ -29,6 +29,7 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
const searchParams = useSearchParams()
const { data: session } = useSession()
const userId = session?.user?.id
const pendingServiceRef = useRef<HTMLDivElement>(null)
const [services, setServices] = useState<ServiceInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -36,6 +37,7 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
const [pendingService, setPendingService] = useState<string | null>(null)
const [pendingScopes, setPendingScopes] = useState<string[]>([])
const [authSuccess, setAuthSuccess] = useState(false)
const [showActionRequired, setShowActionRequired] = useState(false)
// Define available services from our standardized OAuth providers
const defineServices = (): ServiceInfo[] => {
@@ -161,16 +163,21 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
const serviceId = loadFromStorage<string>('pending_service_id')
const scopes = loadFromStorage<string[]>('pending_oauth_scopes') || []
const returnUrl = loadFromStorage<string>('pending_oauth_return_url')
const fromOAuthModal = loadFromStorage<boolean>('from_oauth_modal')
if (serviceId) {
setPendingService(serviceId)
setPendingScopes(scopes)
// Only show action required notification if navigated from the OAuth modal
setShowActionRequired(!!fromOAuthModal)
// Clear the pending connection after a short delay
// This gives the user time to see the highlighted connection
setTimeout(() => {
removeFromStorage('pending_service_id')
removeFromStorage('pending_oauth_scopes')
removeFromStorage('from_oauth_modal')
}, 500)
}
@@ -238,7 +245,7 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
},
body: JSON.stringify({
provider: service.providerId.split('-')[0],
accountId,
providerId: service.providerId,
}),
})
@@ -285,6 +292,15 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
{} as Record<string, ServiceInfo[]>
)
const scrollToHighlightedService = () => {
if (pendingServiceRef.current) {
pendingServiceRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
return (
<div className="p-6 space-y-6">
<div>
@@ -308,17 +324,27 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
</div>
)}
{/* Pending service message */}
{pendingService && (
<div className="mb-6 p-4 bg-primary/10 border border-primary rounded-md text-sm flex items-start gap-2">
<div className="min-w-4 mt-0.5">
{/* Pending service message - only shown when coming from OAuth required modal */}
{pendingService && showActionRequired && (
<div className="mb-6 p-5 bg-primary/5 border border-primary/20 rounded-md text-sm flex items-start gap-3 shadow-sm">
<div className="min-w-5 mt-0.5">
<ExternalLink className="h-4 w-4 text-primary" />
</div>
<p>
<span className="font-medium text-primary">Action Required:</span> Please connect your
account to enable the requested features. The required service will be highlighted
below.
</p>
<div className="flex flex-col flex-1">
<p className="text-muted-foreground">
<span className="font-medium text-primary">Action Required:</span> Please connect your
account to enable the requested features. The required service is highlighted below.
</p>
<Button
variant="outline"
size="sm"
onClick={scrollToHighlightedService}
className="mt-3 self-start text-sm font-medium text-primary border-primary/20 hover:bg-primary/10 hover:text-primary hover:border-primary transition-colors flex items-center gap-1.5 px-3 h-8"
>
<span>Go to service</span>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
@@ -346,6 +372,7 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
'p-6 transition-all hover:shadow-md',
pendingService === service.id && 'border-primary shadow-md'
)}
ref={pendingService === service.id ? pendingServiceRef : undefined}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
@@ -1,22 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import {
BotMessageSquare,
Clock,
Code,
LineChart,
MailIcon,
PanelLeftClose,
PanelRight,
Sparkles,
Star,
Store,
} from 'lucide-react'
import { Clock, Star } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { CATEGORIES, getCategoryIcon } from '../../constants/categories'
import { CATEGORIES, getCategoryIcon, getCategoryLabel } from '../../constants/categories'
export type MarketplaceCategory = 'popular' | 'programming' | 'marketing' | 'all'
@@ -62,7 +49,11 @@ export function Toolbar({ scrollToSection, activeSection }: ToolbarProps) {
onClick={() => scrollToSection(category)}
>
{specialIcons[category] || getCategoryIcon(category)}
{category}
{category === 'popular'
? 'Popular'
: category === 'recent'
? 'Recent'
: getCategoryLabel(category)}
</Button>
))}
</nav>
+3 -6
View File
@@ -1,17 +1,14 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { AlertCircle, ArrowLeft, Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { AlertCircle } from 'lucide-react'
import { ControlBar } from './components/control-bar/control-bar'
import { ErrorMessage } from './components/error-message'
import { Section } from './components/section'
import { Toolbar } from './components/toolbar/toolbar'
import { WorkflowCard } from './components/workflow-card'
import { WorkflowCardSkeleton } from './components/workflow-card-skeleton'
import { CATEGORIES } from './constants/categories'
import { CATEGORIES, getCategoryLabel } from './constants/categories'
// Types
export interface Workflow {
@@ -545,7 +542,7 @@ export default function Marketplace() {
<Section
key={category}
id={category}
title={category}
title={getCategoryLabel(category)}
ref={(el) => {
if (el) {
sectionRefs.current[category] = el
+44 -16
View File
@@ -28,14 +28,16 @@ export const XBlock: BlockConfig<XResponse> = {
],
value: () => 'x_write',
},
// API Key (common)
// X OAuth Authentication
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
id: 'credential',
title: 'X Account',
type: 'oauth-input',
layout: 'full',
placeholder: 'Enter your X Bearer token',
password: true,
provider: 'x',
serviceId: 'x',
requiredScopes: ['tweet.read', 'tweet.write', 'users.read'],
placeholder: 'Select X account',
},
// Write operation inputs
{
@@ -131,15 +133,6 @@ export const XBlock: BlockConfig<XResponse> = {
placeholder: 'Enter username (without @)',
condition: { field: 'operation', value: 'x_user' },
},
{
id: 'includeRecentTweets',
title: 'Include Recent Tweets',
type: 'dropdown',
layout: 'full',
options: ['true', 'false'],
value: () => 'false',
condition: { field: 'operation', value: 'x_user' },
},
],
tools: {
access: ['x_write', 'x_read', 'x_search', 'x_user'],
@@ -158,11 +151,46 @@ export const XBlock: BlockConfig<XResponse> = {
return 'x_write'
}
},
params: (params) => {
const { credential, ...rest } = params
// Convert string values to appropriate types
const parsedParams: Record<string, any> = {
accessToken: credential,
}
// Add other params
Object.keys(rest).forEach((key) => {
let value = rest[key]
// Convert string boolean values to actual booleans
if (value === 'true' || value === 'false') {
parsedParams[key] = value === 'true'
}
// Convert numeric strings to numbers where appropriate
else if (key === 'maxResults' && value) {
parsedParams[key] = parseInt(value as string, 10)
}
// Handle mediaIds conversion from comma-separated string to array
else if (key === 'mediaIds' && typeof value === 'string') {
parsedParams[key] = value
.split(',')
.map((id) => id.trim())
.filter((id) => id !== '')
}
// Keep other values as is
else {
parsedParams[key] = value
}
})
return parsedParams
},
},
},
inputs: {
operation: { type: 'string', required: true },
apiKey: { type: 'string', required: true },
credential: { type: 'string', required: true },
// Write operation
text: { type: 'string', required: false },
replyTo: { type: 'string', required: false },
+81 -20
View File
@@ -52,7 +52,7 @@ export const auth = betterAuth({
accountLinking: {
enabled: true,
allowDifferentEmails: true,
trustedProviders: ['google', 'github', 'email-password', 'confluence'],
trustedProviders: ['google', 'github', 'email-password', 'confluence', 'supabase', 'x'],
},
},
socialProviders: {
@@ -249,13 +249,54 @@ export const auth = betterAuth({
clientId: process.env.SUPABASE_CLIENT_ID as string,
clientSecret: process.env.SUPABASE_CLIENT_SECRET as string,
authorizationUrl: 'https://api.supabase.com/v1/oauth/authorize',
accessType: 'offline',
tokenUrl: 'https://api.supabase.com/v1/oauth/token',
userInfoUrl: 'https://api.supabase.com/v1/oauth/userinfo',
// Supabase doesn't have a standard userInfo endpoint that works with our flow,
// so we use a dummy URL and rely on our custom getUserInfo implementation
userInfoUrl: 'https://dummy-not-used.supabase.co',
scopes: ['database.read', 'database.write', 'projects.read'],
responseType: 'code',
pkce: true,
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/supabase`,
getUserInfo: async (tokens) => {
try {
logger.info('Creating Supabase user profile from token data')
// Extract user identifier from tokens if possible
let userId = 'supabase-user'
if (tokens.idToken) {
try {
// Try to decode the JWT to get user information
const decodedToken = JSON.parse(
Buffer.from(tokens.idToken.split('.')[1], 'base64').toString()
)
if (decodedToken.sub) {
userId = decodedToken.sub
}
} catch (e) {
logger.warn('Failed to decode Supabase ID token', { error: e })
}
}
// Generate a unique enough identifier
const uniqueId = `${userId}-${Date.now()}`
const now = new Date()
// Create a synthetic user profile since we can't fetch one
return {
id: uniqueId,
name: 'Supabase User',
email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@supabase.user`,
image: null,
emailVerified: false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error creating Supabase user profile:', { error })
return null
}
},
},
// X provider
@@ -269,29 +310,49 @@ export const auth = betterAuth({
accessType: 'offline',
scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'],
pkce: true,
responseType: 'code',
prompt: 'consent',
redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/x`,
getUserInfo: async (tokens) => {
const response = await fetch(
'https://api.x.com/2/users/me?user.fields=profile_image_url',
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
try {
const response = await fetch(
'https://api.x.com/2/users/me?user.fields=profile_image_url,username,name,verified',
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
}
)
if (!response.ok) {
logger.error('Error fetching X user info:', {
status: response.status,
statusText: response.statusText,
})
return null
}
)
const profile = await response.json()
const profile = await response.json()
const now = new Date()
if (!profile.data) {
logger.error('Invalid X profile response:', profile)
return null
}
return {
id: profile.data.id,
name: profile.data.name,
email: profile.data.username || null, // Use username as email
image: profile.data.profile_image_url,
emailVerified: profile.data.verified || false,
createdAt: now,
updatedAt: now,
const now = new Date()
return {
id: profile.data.id,
name: profile.data.name || 'X User',
email: `${profile.data.username}@x.com`, // Create synthetic email with username
image: profile.data.profile_image_url,
emailVerified: profile.data.verified || false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in X getUserInfo:', { error })
return null
}
},
},
+10 -5
View File
@@ -7,12 +7,17 @@ export const readTool: ToolConfig<XReadParams, XReadResponse> = {
description: 'Read tweet details, including replies and conversation context',
version: '1.0.0',
oauth: {
required: true,
provider: 'x',
additionalScopes: ['tweet.read', 'users.read'],
},
params: {
apiKey: {
accessToken: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'X API key for authentication',
description: 'X OAuth access token',
},
tweetId: {
type: 'string',
@@ -41,7 +46,7 @@ export const readTool: ToolConfig<XReadParams, XReadResponse> = {
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
@@ -93,7 +98,7 @@ export const readTool: ToolConfig<XReadParams, XReadResponse> = {
transformError: (error) => {
if (error.title === 'Unauthorized') {
return 'Invalid API key. Please check your credentials.'
return 'Invalid or expired access token. Please reconnect your X account.'
}
if (error.title === 'Not Found') {
return 'The specified tweet was not found.'
+11 -6
View File
@@ -7,12 +7,17 @@ export const searchTool: ToolConfig<XSearchParams, XSearchResponse> = {
description: 'Search for tweets using keywords, hashtags, or advanced queries',
version: '1.0.0',
oauth: {
required: true,
provider: 'x',
additionalScopes: ['tweet.read', 'users.read'],
},
params: {
apiKey: {
accessToken: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'X API key for authentication',
description: 'X OAuth access token',
},
query: {
type: 'string',
@@ -67,7 +72,7 @@ export const searchTool: ToolConfig<XSearchParams, XSearchResponse> = {
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
@@ -123,11 +128,11 @@ export const searchTool: ToolConfig<XSearchParams, XSearchResponse> = {
transformError: (error) => {
if (error.title === 'Unauthorized') {
return 'Invalid API key. Please check your credentials.'
return 'Invalid or expired access token. Please reconnect your X account.'
}
if (error.title === 'Invalid Request') {
return 'Invalid search query. Please check your search parameters.'
}
return error.detail || 'An unexpected error occurred while searching X'
return error.detail || 'An error occurred while searching X'
},
}
+9 -8
View File
@@ -28,9 +28,13 @@ export interface XUser {
}
}
// Common parameters for all X endpoints
export interface XBaseParams {
accessToken: string
}
// Write Operation
export interface XWriteParams {
apiKey: string
export interface XWriteParams extends XBaseParams {
text: string
replyTo?: string
mediaIds?: string[]
@@ -47,8 +51,7 @@ export interface XWriteResponse extends ToolResponse {
}
// Read Operation
export interface XReadParams {
apiKey: string
export interface XReadParams extends XBaseParams {
tweetId: string
includeReplies?: boolean
}
@@ -65,8 +68,7 @@ export interface XReadResponse extends ToolResponse {
}
// Search Operation
export interface XSearchParams {
apiKey: string
export interface XSearchParams extends XBaseParams {
query: string
maxResults?: number
startTime?: string
@@ -92,8 +94,7 @@ export interface XSearchResponse extends ToolResponse {
}
// User Operation
export interface XUserParams {
apiKey: string
export interface XUserParams extends XBaseParams {
username: string
includeRecentTweets?: boolean
}
+101 -73
View File
@@ -1,116 +1,144 @@
import { createLogger } from '@/lib/logs/console-logger'
import { ToolConfig } from '../types'
import { XTweet, XUser, XUserParams, XUserResponse } from './types'
import { XUser, XUserParams, XUserResponse } from './types'
const logger = createLogger('XUserTool')
export const userTool: ToolConfig<XUserParams, XUserResponse> = {
id: 'x_user',
name: 'X User',
description: 'Get user profile information and recent tweets',
description: 'Get user profile information',
version: '1.0.0',
oauth: {
required: true,
provider: 'x',
additionalScopes: ['tweet.read', 'users.read'],
},
params: {
apiKey: {
accessToken: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'X API key for authentication',
description: 'X OAuth access token',
},
username: {
type: 'string',
required: true,
description: 'Username to look up (without @ symbol)',
},
includeRecentTweets: {
type: 'boolean',
required: false,
description: 'Whether to include recent tweets from the user',
},
},
request: {
url: (params) => {
const username = encodeURIComponent(params.username)
const userFields = ['description', 'profile_image_url', 'verified', 'public_metrics'].join(
','
)
// Keep fields minimal to reduce chance of rate limits
const userFields = 'description,profile_image_url,verified,public_metrics'
return `https://api.x.com/2/users/by/username/${username}?user.fields=${userFields}`
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const requestUrl = new URL(response.url)
const apiKey = response.headers.get('Authorization')?.split(' ')[1] || ''
const transformUser = (user: any): XUser => ({
id: user.id,
username: user.username,
name: user.name,
description: user.description,
profileImageUrl: user.profile_image_url,
verified: user.verified,
metrics: {
followersCount: user.public_metrics.followers_count,
followingCount: user.public_metrics.following_count,
tweetCount: user.public_metrics.tweet_count,
},
})
const transformTweet = (tweet: any): XTweet => ({
id: tweet.id,
text: tweet.text,
createdAt: tweet.created_at,
authorId: tweet.author_id,
conversationId: tweet.conversation_id,
inReplyToUserId: tweet.in_reply_to_user_id,
attachments: {
mediaKeys: tweet.attachments?.media_keys,
pollId: tweet.attachments?.poll_ids?.[0],
},
})
const user = transformUser(data.data)
let recentTweets: XTweet[] | undefined
// Check if includeRecentTweets was in the original request
const includeRecentTweets = requestUrl.searchParams.get('include_tweets') === 'true'
// Fetch recent tweets if requested
if (includeRecentTweets && apiKey) {
const tweetsResponse = await fetch(
`https://api.x.com/2/users/${user.id}/tweets?max_results=10&tweet.fields=created_at,conversation_id,in_reply_to_user_id,attachments`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}
)
const tweetsData = await tweetsResponse.json()
recentTweets = tweetsData.data.map(transformTweet)
transformResponse: async (response, params) => {
if (!params) {
throw new Error('Missing required parameters')
}
return {
success: true,
output: {
user,
recentTweets,
},
// Handle rate limit issues (429 status code)
if (response.status === 429) {
logger.warn('X API rate limit exceeded', {
status: response.status,
username: params.username,
headers: Object.fromEntries(response.headers.entries()),
})
// Try to extract rate limit reset time from headers if available
const resetTime = response.headers.get('x-rate-limit-reset')
const message = resetTime
? `Rate limit exceeded. Please try again after ${new Date(parseInt(resetTime) * 1000).toLocaleTimeString()}.`
: 'X API rate limit exceeded. Please try again later.'
throw new Error(message)
}
try {
const responseData = await response.json()
logger.debug('X API response', {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
responseData,
})
// Check if response contains expected data structure
if (!responseData.data) {
// If there's an error object in the response
if (responseData.errors && responseData.errors.length > 0) {
const error = responseData.errors[0]
throw new Error(`X API error: ${error.detail || error.message || JSON.stringify(error)}`)
}
throw new Error('Invalid response format from X API')
}
const userData = responseData.data
// Create the base user object with defensive coding for missing properties
const user: XUser = {
id: userData.id,
username: userData.username,
name: userData.name || '',
description: userData.description || '',
profileImageUrl: userData.profile_image_url || '',
verified: !!userData.verified,
metrics: {
followersCount: userData.public_metrics?.followers_count || 0,
followingCount: userData.public_metrics?.following_count || 0,
tweetCount: userData.public_metrics?.tweet_count || 0,
},
}
return {
success: true,
output: {
user,
},
}
} catch (error) {
logger.error('Error processing X API response', {
error,
status: response.status,
username: params.username,
})
throw error
}
},
transformError: (error) => {
if (error.status === 429) {
return 'X API rate limit exceeded. Please try again later.'
}
if (error.title === 'Unauthorized') {
return 'Invalid API key. Please check your credentials.'
return 'Invalid or expired access token. Please reconnect your X account.'
}
if (error.title === 'Not Found') {
return 'The specified user was not found.'
}
return error.detail || 'An unexpected error occurred while fetching user data from X'
if (error.detail) {
return `X API error: ${error.detail}`
}
// Extract the message from the error object
const errorMessage =
error.message || 'An unexpected error occurred while fetching user data from X'
if (errorMessage.includes('rate limit')) {
return 'X API rate limit exceeded. Please try again later or use a different X account.'
}
return errorMessage
},
}
+12 -8
View File
@@ -7,12 +7,17 @@ export const writeTool: ToolConfig<XWriteParams, XWriteResponse> = {
description: 'Post new tweets, reply to tweets, or create polls on X (Twitter)',
version: '1.0.0',
oauth: {
required: true,
provider: 'x',
additionalScopes: ['tweet.read', 'tweet.write', 'users.read'],
},
params: {
apiKey: {
accessToken: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'X API Bearer token',
description: 'X OAuth access token',
},
text: {
type: 'string',
@@ -40,7 +45,7 @@ export const writeTool: ToolConfig<XWriteParams, XWriteResponse> = {
url: 'https://api.x.com/2/tweets',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
@@ -69,7 +74,6 @@ export const writeTool: ToolConfig<XWriteParams, XWriteResponse> = {
transformResponse: async (response) => {
const data = await response.json()
return {
success: true,
output: {
@@ -91,10 +95,10 @@ export const writeTool: ToolConfig<XWriteParams, XWriteResponse> = {
transformError: (error) => {
if (error.title === 'Unauthorized') {
return 'Invalid Bearer token. Please check your credentials or token scopes.'
return 'Invalid or expired access token. Please reconnect your X account.'
}
if (error.title === 'Not Found') {
return 'The specified tweet or resource was not found.'
if (error.title === 'Forbidden') {
return 'You do not have permission to post tweets. Ensure your X app has tweet.write scope.'
}
return error.detail || 'An unexpected error occurred while posting to X'
},