mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
Added x block/tools, added additional check in executor to check for disconnected, disabled blocks at execution time
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { xIcon } from '@/components/icons'
|
||||
import { XReadResponse, XSearchResponse, XUserResponse, XWriteResponse } from '@/tools/x/types'
|
||||
import { BlockConfig } from '../types'
|
||||
|
||||
type XResponse = XWriteResponse | XReadResponse | XSearchResponse | XUserResponse
|
||||
|
||||
export const XBlock: BlockConfig<XResponse> = {
|
||||
type: 'x_block',
|
||||
toolbar: {
|
||||
title: 'X (Twitter)',
|
||||
description: 'Interact with X',
|
||||
bgColor: '#000000', // X's black color
|
||||
icon: xIcon,
|
||||
category: 'tools',
|
||||
},
|
||||
tools: {
|
||||
access: ['x_write', 'x_read', 'x_search', 'x_user'],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'x_write':
|
||||
return 'x_write'
|
||||
case 'x_read':
|
||||
return 'x_read'
|
||||
case 'x_search':
|
||||
return 'x_search'
|
||||
case 'x_user':
|
||||
return 'x_user'
|
||||
default:
|
||||
return 'x_write'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
inputs: {
|
||||
operation: { type: 'string', required: true },
|
||||
apiKey: { type: 'string', required: true },
|
||||
// Write operation
|
||||
text: { type: 'string', required: false },
|
||||
replyTo: { type: 'string', required: false },
|
||||
mediaIds: { type: 'string', required: false },
|
||||
poll: { type: 'json', required: false },
|
||||
// Read operation
|
||||
tweetId: { type: 'string', required: false },
|
||||
includeReplies: { type: 'boolean', required: false },
|
||||
// Search operation
|
||||
query: { type: 'string', required: false },
|
||||
maxResults: { type: 'number', required: false },
|
||||
startTime: { type: 'string', required: false },
|
||||
endTime: { type: 'string', required: false },
|
||||
sortOrder: { type: 'string', required: false },
|
||||
// User operation
|
||||
username: { type: 'string', required: false },
|
||||
includeRecentTweets: { type: 'boolean', required: false },
|
||||
},
|
||||
outputs: {
|
||||
response: {
|
||||
type: {
|
||||
tweet: 'json',
|
||||
replies: 'any',
|
||||
context: 'any',
|
||||
tweets: 'json',
|
||||
includes: 'any',
|
||||
meta: 'json',
|
||||
user: 'json',
|
||||
recentTweets: 'any',
|
||||
},
|
||||
},
|
||||
},
|
||||
subBlocks: [
|
||||
// Operation selector
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
layout: 'full',
|
||||
options: [
|
||||
{ label: 'Post a New Tweet', id: 'x_write' },
|
||||
{ label: 'Get Tweet Details', id: 'x_read' },
|
||||
{ label: 'Search Tweets', id: 'x_search' },
|
||||
{ label: 'Get User Profile', id: 'x_user' },
|
||||
],
|
||||
value: () => 'x_write',
|
||||
},
|
||||
// API Key (common)
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter your X Bearer token',
|
||||
password: true,
|
||||
},
|
||||
// Write operation inputs
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Tweet Text',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: "What's happening?",
|
||||
condition: { field: 'operation', value: 'x_write' },
|
||||
},
|
||||
{
|
||||
id: 'replyTo',
|
||||
title: 'Reply To (Tweet ID)',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tweet ID to reply to',
|
||||
condition: { field: 'operation', value: 'x_write' },
|
||||
},
|
||||
{
|
||||
id: 'mediaIds',
|
||||
title: 'Media IDs',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter comma-separated media IDs',
|
||||
condition: { field: 'operation', value: 'x_write' },
|
||||
},
|
||||
// Read operation inputs
|
||||
{
|
||||
id: 'tweetId',
|
||||
title: 'Tweet ID',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tweet ID to read',
|
||||
condition: { field: 'operation', value: 'x_read' },
|
||||
},
|
||||
{
|
||||
id: 'includeReplies',
|
||||
title: 'Include Replies',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: ['true', 'false'],
|
||||
value: () => 'false',
|
||||
condition: { field: 'operation', value: 'x_read' },
|
||||
},
|
||||
// Search operation inputs
|
||||
{
|
||||
id: 'query',
|
||||
title: 'Search Query',
|
||||
type: 'long-input',
|
||||
layout: 'full',
|
||||
placeholder: 'Enter search terms (supports X search operators)',
|
||||
condition: { field: 'operation', value: 'x_search' },
|
||||
},
|
||||
{
|
||||
id: 'maxResults',
|
||||
title: 'Max Results',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: '10',
|
||||
condition: { field: 'operation', value: 'x_search' },
|
||||
},
|
||||
{
|
||||
id: 'sortOrder',
|
||||
title: 'Sort Order',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: ['recency', 'relevancy'],
|
||||
value: () => 'recency',
|
||||
condition: { field: 'operation', value: 'x_search' },
|
||||
},
|
||||
{
|
||||
id: 'startTime',
|
||||
title: 'Start Time',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'YYYY-MM-DDTHH:mm:ssZ',
|
||||
condition: { field: 'operation', value: 'x_search' },
|
||||
},
|
||||
{
|
||||
id: 'endTime',
|
||||
title: 'End Time',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'YYYY-MM-DDTHH:mm:ssZ',
|
||||
condition: { field: 'operation', value: 'x_search' },
|
||||
},
|
||||
// User operation inputs
|
||||
{
|
||||
id: 'username',
|
||||
title: 'Username',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter username (without @)',
|
||||
condition: { field: 'operation', value: 'x_user' },
|
||||
},
|
||||
{
|
||||
id: 'includeRecentTweets',
|
||||
title: 'Include Recent Tweets',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: ['true', 'false'],
|
||||
value: () => 'false',
|
||||
condition: { field: 'operation', value: 'x_user' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { SerperBlock } from './blocks/serper'
|
||||
import { SlackMessageBlock } from './blocks/slack'
|
||||
import { TavilyExtractBlock, TavilySearchBlock } from './blocks/tavily'
|
||||
import { TranslateBlock } from './blocks/translate'
|
||||
import { XBlock } from './blocks/x'
|
||||
import { YouTubeSearchBlock } from './blocks/youtube'
|
||||
import { BlockConfig } from './types'
|
||||
|
||||
@@ -36,6 +37,7 @@ export {
|
||||
YouTubeSearchBlock,
|
||||
NotionBlock,
|
||||
GmailBlock,
|
||||
XBlock,
|
||||
}
|
||||
|
||||
// Registry of all block configurations
|
||||
@@ -57,6 +59,7 @@ const blocks: Record<string, BlockConfig> = {
|
||||
youtube_search: YouTubeSearchBlock,
|
||||
notion_reader: NotionBlock,
|
||||
gmail_block: GmailBlock,
|
||||
x_block: XBlock,
|
||||
}
|
||||
|
||||
// Build a reverse mapping of tools to block types
|
||||
|
||||
@@ -1219,6 +1219,17 @@ export const xAIIcon = (props: SVGProps<SVGSVGElement>) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const xIcon = (props: SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="1em" height="1em" {...props}>
|
||||
<path
|
||||
d="M 5.9199219 6 L 20.582031 27.375 L 6.2304688 44 L 9.4101562 44 L 21.986328 29.421875 L 31.986328 44 L 44 44 L 28.681641 21.669922 L 42.199219 6 L 39.029297 6 L 27.275391 19.617188 L 17.933594 6 L 5.9199219 6 z M 9.7167969 8 L 16.880859 8 L 40.203125 42 L 33.039062 42 L 9.7167969 8 z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const GoogleSheetsIcon = (props: SVGProps<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49 67" width="1em" height="1em" {...props}>
|
||||
|
||||
+13
-2
@@ -134,14 +134,20 @@ export class Executor {
|
||||
|
||||
// Filtering: only execute blocks that match router and conditional decisions.
|
||||
const executableBlocks = currentLayer.filter((blockId) => {
|
||||
// Verify if block lies on the router's chosen path.
|
||||
// First check if block is enabled
|
||||
const block = blocks.find((b) => b.id === blockId)
|
||||
if (!block || block.enabled === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify if block lies on the router's chosen path
|
||||
for (const [routerId, chosenPath] of routerDecisions) {
|
||||
if (!this.isInChosenPath(blockId, chosenPath, routerId)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Verify if block lies on the selected conditional path.
|
||||
// Verify if block lies on the selected conditional path
|
||||
for (const [conditionBlockId, selectedConditionId] of activeConditionalPaths) {
|
||||
const connection = connections.find(
|
||||
(conn) =>
|
||||
@@ -255,6 +261,11 @@ export class Executor {
|
||||
inputs: Record<string, any>,
|
||||
context: ExecutionContext
|
||||
): Promise<BlockOutput> {
|
||||
// Check if block is disabled
|
||||
if (block.enabled === false) {
|
||||
throw new Error(`Cannot execute disabled block: ${block.metadata?.title || block.id}`)
|
||||
}
|
||||
|
||||
const startTime = new Date()
|
||||
const blockLog: BlockLog = {
|
||||
blockId: block.id,
|
||||
|
||||
@@ -21,6 +21,10 @@ import { slackMessageTool } from './slack/message'
|
||||
import { extractTool as tavilyExtract } from './tavily/extract'
|
||||
import { searchTool as tavilySearch } from './tavily/search'
|
||||
import { ToolConfig, ToolResponse } from './types'
|
||||
import { readTool as xRead } from './x/read'
|
||||
import { searchTool as xSearch } from './x/search'
|
||||
import { userTool as xUser } from './x/user'
|
||||
import { writeTool as xWrite } from './x/write'
|
||||
import { chatTool as xaiChat } from './xai/chat'
|
||||
import { youtubeSearchTool } from './youtube/search'
|
||||
|
||||
@@ -50,6 +54,10 @@ export const tools: Record<string, ToolConfig> = {
|
||||
gmail_send: gmailSendTool,
|
||||
gmail_read: gmailReadTool,
|
||||
gmail_search: gmailSearchTool,
|
||||
x_write: xWrite,
|
||||
x_read: xRead,
|
||||
x_search: xSearch,
|
||||
x_user: xUser,
|
||||
}
|
||||
|
||||
// Get a tool by its ID
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { XReadParams, XReadResponse, XTweet } from './types'
|
||||
|
||||
export const readTool: ToolConfig<XReadParams, XReadResponse> = {
|
||||
id: 'x_read',
|
||||
name: 'X Read',
|
||||
description: 'Read tweet details, including replies and conversation context',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
requiredForToolCall: true,
|
||||
description: 'X API key for authentication',
|
||||
},
|
||||
tweetId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
requiredForToolCall: true,
|
||||
description: 'ID of the tweet to read',
|
||||
},
|
||||
includeReplies: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Whether to include replies to the tweet',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const expansions = [
|
||||
'author_id',
|
||||
'in_reply_to_user_id',
|
||||
'referenced_tweets.id',
|
||||
'attachments.media_keys',
|
||||
'attachments.poll_ids',
|
||||
].join(',')
|
||||
|
||||
return `https://api.twitter.com/2/tweets/${params.tweetId}?expansions=${expansions}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
|
||||
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 mainTweet = transformTweet(data.data)
|
||||
const context: { parentTweet?: XTweet; rootTweet?: XTweet } = {}
|
||||
|
||||
// Get parent and root tweets if available
|
||||
if (data.includes?.tweets) {
|
||||
const referencedTweets = data.data.referenced_tweets || []
|
||||
const parentTweetRef = referencedTweets.find((ref: any) => ref.type === 'replied_to')
|
||||
const rootTweetRef = referencedTweets.find((ref: any) => ref.type === 'replied_to_root')
|
||||
|
||||
if (parentTweetRef) {
|
||||
const parentTweet = data.includes.tweets.find((t: any) => t.id === parentTweetRef.id)
|
||||
if (parentTweet) context.parentTweet = transformTweet(parentTweet)
|
||||
}
|
||||
|
||||
if (rootTweetRef) {
|
||||
const rootTweet = data.includes.tweets.find((t: any) => t.id === rootTweetRef.id)
|
||||
if (rootTweet) context.rootTweet = transformTweet(rootTweet)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
tweet: mainTweet,
|
||||
context,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error) => {
|
||||
if (error.title === 'Unauthorized') {
|
||||
return 'Invalid API key. Please check your credentials.'
|
||||
}
|
||||
if (error.title === 'Not Found') {
|
||||
return 'The specified tweet was not found.'
|
||||
}
|
||||
return error.detail || 'An unexpected error occurred while reading from X'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { XSearchParams, XSearchResponse, XTweet, XUser } from './types'
|
||||
|
||||
export const searchTool: ToolConfig<XSearchParams, XSearchResponse> = {
|
||||
id: 'x_search',
|
||||
name: 'X Search',
|
||||
description: 'Search for tweets using keywords, hashtags, or advanced queries',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
requiredForToolCall: true,
|
||||
description: 'X API key for authentication',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Search query (supports X search operators)',
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Maximum number of results to return (default: 10, max: 100)',
|
||||
},
|
||||
startTime: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Start time for search (ISO 8601 format)',
|
||||
},
|
||||
endTime: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'End time for search (ISO 8601 format)',
|
||||
},
|
||||
sortOrder: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Sort order for results (recency or relevancy)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const query = encodeURIComponent(params.query)
|
||||
const expansions = [
|
||||
'author_id',
|
||||
'referenced_tweets.id',
|
||||
'attachments.media_keys',
|
||||
'attachments.poll_ids',
|
||||
].join(',')
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
query,
|
||||
expansions,
|
||||
'tweet.fields': 'created_at,conversation_id,in_reply_to_user_id,attachments',
|
||||
'user.fields': 'name,username,description,profile_image_url,verified,public_metrics',
|
||||
})
|
||||
|
||||
if (params.maxResults) queryParams.append('max_results', params.maxResults.toString())
|
||||
if (params.startTime) queryParams.append('start_time', params.startTime)
|
||||
if (params.endTime) queryParams.append('end_time', params.endTime)
|
||||
if (params.sortOrder) queryParams.append('sort_order', params.sortOrder)
|
||||
|
||||
return `https://api.twitter.com/2/tweets/search/recent?${queryParams.toString()}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
|
||||
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 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,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
tweets: data.data.map(transformTweet),
|
||||
includes: {
|
||||
users: data.includes?.users?.map(transformUser) || [],
|
||||
media: data.includes?.media || [],
|
||||
polls: data.includes?.polls || [],
|
||||
},
|
||||
meta: {
|
||||
resultCount: data.meta.result_count,
|
||||
newestId: data.meta.newest_id,
|
||||
oldestId: data.meta.oldest_id,
|
||||
nextToken: data.meta.next_token,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error) => {
|
||||
if (error.title === 'Unauthorized') {
|
||||
return 'Invalid API key. Please check your credentials.'
|
||||
}
|
||||
if (error.title === 'Invalid Request') {
|
||||
return 'Invalid search query. Please check your search parameters.'
|
||||
}
|
||||
return error.detail || 'An unexpected error occurred while searching X'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ToolResponse } from '../types'
|
||||
|
||||
// Common Types
|
||||
export interface XTweet {
|
||||
id: string
|
||||
text: string
|
||||
createdAt: string
|
||||
authorId: string
|
||||
conversationId?: string
|
||||
inReplyToUserId?: string
|
||||
attachments?: {
|
||||
mediaKeys?: string[]
|
||||
pollId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface XUser {
|
||||
id: string
|
||||
username: string
|
||||
name: string
|
||||
description?: string
|
||||
profileImageUrl?: string
|
||||
verified: boolean
|
||||
metrics: {
|
||||
followersCount: number
|
||||
followingCount: number
|
||||
tweetCount: number
|
||||
}
|
||||
}
|
||||
|
||||
// Write Operation
|
||||
export interface XWriteParams {
|
||||
apiKey: string
|
||||
text: string
|
||||
replyTo?: string
|
||||
mediaIds?: string[]
|
||||
poll?: {
|
||||
options: string[]
|
||||
durationMinutes: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface XWriteResponse extends ToolResponse {
|
||||
output: {
|
||||
tweet: XTweet
|
||||
}
|
||||
}
|
||||
|
||||
// Read Operation
|
||||
export interface XReadParams {
|
||||
apiKey: string
|
||||
tweetId: string
|
||||
includeReplies?: boolean
|
||||
}
|
||||
|
||||
export interface XReadResponse extends ToolResponse {
|
||||
output: {
|
||||
tweet: XTweet
|
||||
replies?: XTweet[]
|
||||
context?: {
|
||||
parentTweet?: XTweet
|
||||
rootTweet?: XTweet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search Operation
|
||||
export interface XSearchParams {
|
||||
apiKey: string
|
||||
query: string
|
||||
maxResults?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
sortOrder?: 'recency' | 'relevancy'
|
||||
}
|
||||
|
||||
export interface XSearchResponse extends ToolResponse {
|
||||
output: {
|
||||
tweets: XTweet[]
|
||||
includes?: {
|
||||
users: XUser[]
|
||||
media: any[]
|
||||
polls: any[]
|
||||
}
|
||||
meta: {
|
||||
resultCount: number
|
||||
newestId: string
|
||||
oldestId: string
|
||||
nextToken?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User Operation
|
||||
export interface XUserParams {
|
||||
apiKey: string
|
||||
username: string
|
||||
includeRecentTweets?: boolean
|
||||
}
|
||||
|
||||
export interface XUserResponse extends ToolResponse {
|
||||
output: {
|
||||
user: XUser
|
||||
recentTweets?: XTweet[]
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { XTweet, XUser, XUserParams, XUserResponse } from './types'
|
||||
|
||||
export const userTool: ToolConfig<XUserParams, XUserResponse> = {
|
||||
id: 'x_user',
|
||||
name: 'X User',
|
||||
description: 'Get user profile information and recent tweets',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
requiredForToolCall: true,
|
||||
description: 'X API key for authentication',
|
||||
},
|
||||
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(
|
||||
','
|
||||
)
|
||||
|
||||
return `https://api.twitter.com/2/users/by/username/${username}?user.fields=${userFields}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'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.twitter.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)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
user,
|
||||
recentTweets,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error) => {
|
||||
if (error.title === 'Unauthorized') {
|
||||
return 'Invalid API key. Please check your credentials.'
|
||||
}
|
||||
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'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ToolConfig } from '../types'
|
||||
import { XWriteParams, XWriteResponse } from './types'
|
||||
|
||||
export const writeTool: ToolConfig<XWriteParams, XWriteResponse> = {
|
||||
id: 'x_write',
|
||||
name: 'X Write',
|
||||
description: 'Post new tweets, reply to tweets, or create polls on X (Twitter)',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
requiredForToolCall: true,
|
||||
description: 'X API Bearer token for authentication',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The text content of your tweet',
|
||||
},
|
||||
replyTo: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'ID of the tweet to reply to',
|
||||
},
|
||||
mediaIds: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
description: 'Array of media IDs to attach to the tweet',
|
||||
},
|
||||
poll: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
description: 'Poll configuration for the tweet',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.twitter.com/2/tweets',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: any = {
|
||||
text: params.text,
|
||||
}
|
||||
|
||||
if (params.replyTo) {
|
||||
body.reply = { in_reply_to_tweet_id: params.replyTo }
|
||||
}
|
||||
|
||||
if (params.mediaIds?.length) {
|
||||
body.media = { media_ids: params.mediaIds }
|
||||
}
|
||||
|
||||
if (params.poll) {
|
||||
body.poll = {
|
||||
options: params.poll.options,
|
||||
duration_minutes: params.poll.durationMinutes,
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
tweet: {
|
||||
id: data.data.id,
|
||||
text: data.data.text,
|
||||
createdAt: data.data.created_at,
|
||||
authorId: data.data.author_id,
|
||||
conversationId: data.data.conversation_id,
|
||||
inReplyToUserId: data.data.in_reply_to_user_id,
|
||||
attachments: {
|
||||
mediaKeys: data.data.attachments?.media_keys,
|
||||
pollId: data.data.attachments?.poll_ids?.[0],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
transformError: (error) => {
|
||||
if (error.title === 'Unauthorized') {
|
||||
return 'Invalid Bearer token. Please check your credentials or token scopes.'
|
||||
}
|
||||
if (error.title === 'Not Found') {
|
||||
return 'The specified tweet or resource was not found.'
|
||||
}
|
||||
return error.detail || 'An unexpected error occurred while posting to X'
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user