diff --git a/blocks/blocks/x.ts b/blocks/blocks/x.ts new file mode 100644 index 0000000000..b8013b0893 --- /dev/null +++ b/blocks/blocks/x.ts @@ -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 = { + 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' }, + }, + ], + }, +} diff --git a/blocks/index.ts b/blocks/index.ts index 6aeee6c161..dc39b2be07 100644 --- a/blocks/index.ts +++ b/blocks/index.ts @@ -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 = { youtube_search: YouTubeSearchBlock, notion_reader: NotionBlock, gmail_block: GmailBlock, + x_block: XBlock, } // Build a reverse mapping of tools to block types diff --git a/components/icons.tsx b/components/icons.tsx index 65d030cfd7..2957e62f98 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -1219,6 +1219,17 @@ export const xAIIcon = (props: SVGProps) => { ) } +export const xIcon = (props: SVGProps) => { + return ( + + + + ) +} + export const GoogleSheetsIcon = (props: SVGProps) => { return ( diff --git a/executor/index.ts b/executor/index.ts index bf4a93ba0b..0a2b0a2dac 100644 --- a/executor/index.ts +++ b/executor/index.ts @@ -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, context: ExecutionContext ): Promise { + // 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, diff --git a/tools/index.ts b/tools/index.ts index 65f1f28b65..78be57bbd9 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -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 = { 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 diff --git a/tools/x/read.ts b/tools/x/read.ts new file mode 100644 index 0000000000..1230e6420d --- /dev/null +++ b/tools/x/read.ts @@ -0,0 +1,103 @@ +import { ToolConfig } from '../types' +import { XReadParams, XReadResponse, XTweet } from './types' + +export const readTool: ToolConfig = { + 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' + }, +} diff --git a/tools/x/search.ts b/tools/x/search.ts new file mode 100644 index 0000000000..1ad5ccf830 --- /dev/null +++ b/tools/x/search.ts @@ -0,0 +1,133 @@ +import { ToolConfig } from '../types' +import { XSearchParams, XSearchResponse, XTweet, XUser } from './types' + +export const searchTool: ToolConfig = { + 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' + }, +} diff --git a/tools/x/types.ts b/tools/x/types.ts new file mode 100644 index 0000000000..717f885239 --- /dev/null +++ b/tools/x/types.ts @@ -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[] + } +} diff --git a/tools/x/user.ts b/tools/x/user.ts new file mode 100644 index 0000000000..bd5b7ec08d --- /dev/null +++ b/tools/x/user.ts @@ -0,0 +1,116 @@ +import { ToolConfig } from '../types' +import { XTweet, XUser, XUserParams, XUserResponse } from './types' + +export const userTool: ToolConfig = { + 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' + }, +} diff --git a/tools/x/write.ts b/tools/x/write.ts new file mode 100644 index 0000000000..54434aa900 --- /dev/null +++ b/tools/x/write.ts @@ -0,0 +1,101 @@ +import { ToolConfig } from '../types' +import { XWriteParams, XWriteResponse } from './types' + +export const writeTool: ToolConfig = { + 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' + }, +}