mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
dependabot[bot]
Emir Karabeg
parent
601d77f7c7
commit
51e2b39c56
+4
@@ -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()
|
||||
|
||||
+51
-42
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
+39
-12
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user