Added x block/tools, added additional check in executor to check for disconnected, disabled blocks at execution time

This commit is contained in:
Waleed Latif
2025-02-10 11:52:26 -08:00
parent 4b92eb0209
commit 83c88f3990
10 changed files with 794 additions and 2 deletions
+200
View File
@@ -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' },
},
],
},
}
+3
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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,
+8
View File
@@ -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
View File
@@ -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'
},
}
+133
View File
@@ -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'
},
}
+106
View File
@@ -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
View File
@@ -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'
},
}
+101
View File
@@ -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'
},
}