From 51e2b39c5641968c95e2d06c2b73cf22ca0650e2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Mar 2025 03:34:11 -0700 Subject: [PATCH] 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 --- .../components/oauth-required-modal.tsx | 4 + .../credential-selector.tsx | 93 +++++----- .../components/credentials/credentials.tsx | 51 +++-- .../components/toolbar/toolbar.tsx | 23 +-- sim/app/w/marketplace/marketplace.tsx | 9 +- sim/blocks/blocks/x.ts | 60 ++++-- sim/lib/auth.ts | 101 ++++++++-- sim/tools/x/read.ts | 15 +- sim/tools/x/search.ts | 17 +- sim/tools/x/types.ts | 17 +- sim/tools/x/user.ts | 174 ++++++++++-------- sim/tools/x/write.ts | 20 +- 12 files changed, 372 insertions(+), 212 deletions(-) diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 430186acac..1d8c763248 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -47,6 +47,9 @@ const SCOPE_DESCRIPTIONS: Record = { '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('pending_oauth_scopes', requiredScopes) saveToStorage('pending_oauth_return_url', window.location.href) saveToStorage('pending_oauth_provider_id', providerId) + saveToStorage('from_oauth_modal', true) // Close the modal onClose() diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx index ef406a9698..377605436d 100644 --- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx @@ -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('pending_service_id', effectiveServiceId) saveToStorage('pending_oauth_scopes', requiredScopes) saveToStorage('pending_oauth_return_url', window.location.href) - saveToStorage('pending_oauth_provider_id', providerId) + saveToStorage('pending_oauth_provider_id', effectiveProviderId) // Show the OAuth modal setShowOAuthModal(true) @@ -184,7 +191,7 @@ export function CredentialSelector({ return ( <> - + + )} @@ -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} >
diff --git a/sim/app/w/marketplace/components/toolbar/toolbar.tsx b/sim/app/w/marketplace/components/toolbar/toolbar.tsx index 0ba7a81108..4585a0e6ab 100644 --- a/sim/app/w/marketplace/components/toolbar/toolbar.tsx +++ b/sim/app/w/marketplace/components/toolbar/toolbar.tsx @@ -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)} ))} diff --git a/sim/app/w/marketplace/marketplace.tsx b/sim/app/w/marketplace/marketplace.tsx index d30a6dff9d..db867ebc26 100644 --- a/sim/app/w/marketplace/marketplace.tsx +++ b/sim/app/w/marketplace/marketplace.tsx @@ -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() {
{ if (el) { sectionRefs.current[category] = el diff --git a/sim/blocks/blocks/x.ts b/sim/blocks/blocks/x.ts index 7c5e54f809..97e8fafb13 100644 --- a/sim/blocks/blocks/x.ts +++ b/sim/blocks/blocks/x.ts @@ -28,14 +28,16 @@ export const XBlock: BlockConfig = { ], 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 = { 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 = { return 'x_write' } }, + params: (params) => { + const { credential, ...rest } = params + + // Convert string values to appropriate types + const parsedParams: Record = { + 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 }, diff --git a/sim/lib/auth.ts b/sim/lib/auth.ts index 5e317b0079..581d4070e1 100644 --- a/sim/lib/auth.ts +++ b/sim/lib/auth.ts @@ -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 } }, }, diff --git a/sim/tools/x/read.ts b/sim/tools/x/read.ts index a14fcd1fd0..39f196b945 100644 --- a/sim/tools/x/read.ts +++ b/sim/tools/x/read.ts @@ -7,12 +7,17 @@ export const readTool: ToolConfig = { 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 = { }, method: 'GET', headers: (params) => ({ - Authorization: `Bearer ${params.apiKey}`, + Authorization: `Bearer ${params.accessToken}`, 'Content-Type': 'application/json', }), }, @@ -93,7 +98,7 @@ export const readTool: ToolConfig = { 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.' diff --git a/sim/tools/x/search.ts b/sim/tools/x/search.ts index 3428a1449b..20eeb76ec8 100644 --- a/sim/tools/x/search.ts +++ b/sim/tools/x/search.ts @@ -7,12 +7,17 @@ export const searchTool: ToolConfig = { 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 = { }, method: 'GET', headers: (params) => ({ - Authorization: `Bearer ${params.apiKey}`, + Authorization: `Bearer ${params.accessToken}`, 'Content-Type': 'application/json', }), }, @@ -123,11 +128,11 @@ export const searchTool: ToolConfig = { 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' }, } diff --git a/sim/tools/x/types.ts b/sim/tools/x/types.ts index 717f885239..e4f3dcd6e0 100644 --- a/sim/tools/x/types.ts +++ b/sim/tools/x/types.ts @@ -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 } diff --git a/sim/tools/x/user.ts b/sim/tools/x/user.ts index aee91374fa..c025534f8b 100644 --- a/sim/tools/x/user.ts +++ b/sim/tools/x/user.ts @@ -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 = { 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 }, } diff --git a/sim/tools/x/write.ts b/sim/tools/x/write.ts index de74fbdf51..7b1e770d91 100644 --- a/sim/tools/x/write.ts +++ b/sim/tools/x/write.ts @@ -7,12 +7,17 @@ export const writeTool: ToolConfig = { 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 = { 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 = { transformResponse: async (response) => { const data = await response.json() - return { success: true, output: { @@ -91,10 +95,10 @@ export const writeTool: ToolConfig = { 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' },