diff --git a/apps/sim/blocks/blocks/x.ts b/apps/sim/blocks/blocks/x.ts index bc2ac2a340..724d409a7d 100644 --- a/apps/sim/blocks/blocks/x.ts +++ b/apps/sim/blocks/blocks/x.ts @@ -145,32 +145,23 @@ export const XBlock: BlockConfig = { params: (params) => { const { credential, ...rest } = params - // Convert string values to appropriate types const parsedParams: Record = { credential: credential, } - // Add other params Object.keys(rest).forEach((key) => { const 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) { + } else if (key === 'maxResults' && value) { parsedParams[key] = Number.parseInt(value as string, 10) - } - // Handle mediaIds conversion from comma-separated string to array - else if (key === 'mediaIds' && typeof value === 'string') { + } else if (key === 'mediaIds' && typeof value === 'string') { parsedParams[key] = value .split(',') .map((id) => id.trim()) .filter((id) => id !== '') - } - // Keep other values as is - else { + } else { parsedParams[key] = value } }) @@ -197,13 +188,49 @@ export const XBlock: BlockConfig = { includeRecentTweets: { type: 'boolean', description: 'Include recent tweets' }, }, outputs: { - tweet: { type: 'json', description: 'Tweet data' }, - replies: { type: 'json', description: 'Tweet replies' }, - context: { type: 'json', description: 'Tweet context' }, - tweets: { type: 'json', description: 'Tweets data' }, - includes: { type: 'json', description: 'Additional data' }, - meta: { type: 'json', description: 'Response metadata' }, - user: { type: 'json', description: 'User profile data' }, - recentTweets: { type: 'json', description: 'Recent tweets data' }, + // Write and Read operation outputs + tweet: { + type: 'json', + description: 'Tweet data including contextAnnotations and publicMetrics', + condition: { field: 'operation', value: ['x_write', 'x_read'] }, + }, + // Read operation outputs + replies: { + type: 'json', + description: 'Tweet replies (when includeReplies is true)', + condition: { field: 'operation', value: 'x_read' }, + }, + context: { + type: 'json', + description: 'Tweet context (parent and quoted tweets)', + condition: { field: 'operation', value: 'x_read' }, + }, + // Search operation outputs + tweets: { + type: 'json', + description: 'Tweets data including contextAnnotations and publicMetrics', + condition: { field: 'operation', value: 'x_search' }, + }, + includes: { + type: 'json', + description: 'Additional data (users, media, polls)', + condition: { field: 'operation', value: 'x_search' }, + }, + meta: { + type: 'json', + description: 'Response metadata', + condition: { field: 'operation', value: 'x_search' }, + }, + // User operation outputs + user: { + type: 'json', + description: 'User profile data', + condition: { field: 'operation', value: 'x_user' }, + }, + recentTweets: { + type: 'json', + description: 'Recent tweets data', + condition: { field: 'operation', value: 'x_user' }, + }, }, } diff --git a/apps/sim/tools/x/read.ts b/apps/sim/tools/x/read.ts index e974b7f1e0..5db39eb856 100644 --- a/apps/sim/tools/x/read.ts +++ b/apps/sim/tools/x/read.ts @@ -1,5 +1,9 @@ +import { createLogger } from '@/lib/logs/console/logger' import type { ToolConfig } from '@/tools/types' import type { XReadParams, XReadResponse, XTweet } from '@/tools/x/types' +import { transformTweet } from '@/tools/x/types' + +const logger = createLogger('XReadTool') export const xReadTool: ToolConfig = { id: 'x_read', @@ -39,11 +43,36 @@ export const xReadTool: ToolConfig = { 'author_id', 'in_reply_to_user_id', 'referenced_tweets.id', + 'referenced_tweets.id.author_id', 'attachments.media_keys', 'attachments.poll_ids', ].join(',') - return `https://api.twitter.com/2/tweets/${params.tweetId}?expansions=${expansions}` + const tweetFields = [ + 'created_at', + 'conversation_id', + 'in_reply_to_user_id', + 'attachments', + 'context_annotations', + 'public_metrics', + ].join(',') + + const userFields = [ + 'name', + 'username', + 'description', + 'profile_image_url', + 'verified', + 'public_metrics', + ].join(',') + + const queryParams = new URLSearchParams({ + expansions, + 'tweet.fields': tweetFields, + 'user.fields': userFields, + }) + + return `https://api.twitter.com/2/tweets/${params.tweetId}?${queryParams.toString()}` }, method: 'GET', headers: (params) => ({ @@ -52,39 +81,79 @@ export const xReadTool: ToolConfig = { }), }, - transformResponse: async (response) => { + transformResponse: async (response, params) => { 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], - }, - }) + if (data.errors && !data.data) { + logger.error('X Read API Error:', JSON.stringify(data, null, 2)) + return { + success: false, + error: data.errors?.[0]?.detail || data.errors?.[0]?.message || 'Failed to fetch tweet', + output: { + tweet: {} as XTweet, + }, + } + } 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') + const quotedTweetRef = referencedTweets.find((ref: any) => ref.type === 'quoted') 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) + if (!parentTweetRef && quotedTweetRef) { + const quotedTweet = data.includes.tweets.find((t: any) => t.id === quotedTweetRef.id) + if (quotedTweet) context.rootTweet = transformTweet(quotedTweet) + } + } + + let replies: XTweet[] = [] + if (params?.includeReplies && mainTweet.id) { + try { + const repliesExpansions = ['author_id', 'referenced_tweets.id'].join(',') + const repliesTweetFields = [ + 'created_at', + 'conversation_id', + 'in_reply_to_user_id', + 'public_metrics', + ].join(',') + + const conversationId = mainTweet.conversationId || mainTweet.id + const searchQuery = `conversation_id:${conversationId}` + const searchParams = new URLSearchParams({ + query: searchQuery, + expansions: repliesExpansions, + 'tweet.fields': repliesTweetFields, + max_results: '100', // Max allowed + }) + + const repliesResponse = await fetch( + `https://api.twitter.com/2/tweets/search/recent?${searchParams.toString()}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${params?.accessToken || ''}`, + 'Content-Type': 'application/json', + }, + } + ) + + const repliesData = await repliesResponse.json() + + if (repliesData.data && Array.isArray(repliesData.data)) { + replies = repliesData.data + .filter((tweet: any) => tweet.id !== mainTweet.id) + .map(transformTweet) + } + } catch (error) { + logger.warn('Failed to fetch replies:', error) } } @@ -92,7 +161,8 @@ export const xReadTool: ToolConfig = { success: true, output: { tweet: mainTweet, - context, + replies: replies.length > 0 ? replies : undefined, + context: Object.keys(context).length > 0 ? context : undefined, }, } }, diff --git a/apps/sim/tools/x/search.ts b/apps/sim/tools/x/search.ts index bbbec7e17e..6591041839 100644 --- a/apps/sim/tools/x/search.ts +++ b/apps/sim/tools/x/search.ts @@ -1,6 +1,7 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ToolConfig } from '@/tools/types' -import type { XSearchParams, XSearchResponse, XTweet, XUser } from '@/tools/x/types' +import type { XSearchParams, XSearchResponse } from '@/tools/x/types' +import { transformTweet, transformUser } from '@/tools/x/types' const logger = createLogger('XSearchTool') @@ -67,7 +68,8 @@ export const xSearchTool: ToolConfig = { const queryParams = new URLSearchParams({ query, expansions, - 'tweet.fields': 'created_at,conversation_id,in_reply_to_user_id,attachments', + 'tweet.fields': + 'created_at,conversation_id,in_reply_to_user_id,attachments,context_annotations,public_metrics', 'user.fields': 'name,username,description,profile_image_url,verified,public_metrics', }) @@ -92,7 +94,6 @@ export const xSearchTool: ToolConfig = { transformResponse: async (response) => { const data = await response.json() - // Check if data.data is undefined/null or not an array if (!data.data || !Array.isArray(data.data)) { logger.error('X Search API Error:', JSON.stringify(data, null, 2)) return { @@ -118,33 +119,6 @@ export const xSearchTool: ToolConfig = { } } - 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: { diff --git a/apps/sim/tools/x/types.ts b/apps/sim/tools/x/types.ts index 407e0c11cd..ac4cf2b701 100644 --- a/apps/sim/tools/x/types.ts +++ b/apps/sim/tools/x/types.ts @@ -1,6 +1,34 @@ import type { ToolResponse } from '@/tools/types' -// Common Types +/** + * Context annotation domain from X API + */ +export interface XContextAnnotationDomain { + id: string + name: string + description?: string +} + +/** + * Context annotation entity from X API + */ +export interface XContextAnnotationEntity { + id: string + name: string + description?: string +} + +/** + * Context annotation from X API - provides semantic context about tweet content + */ +export interface XContextAnnotation { + domain: XContextAnnotationDomain + entity: XContextAnnotationEntity +} + +/** + * Tweet object from X API + */ export interface XTweet { id: string text: string @@ -12,6 +40,13 @@ export interface XTweet { mediaKeys?: string[] pollId?: string } + contextAnnotations?: XContextAnnotation[] + publicMetrics?: { + retweetCount: number + replyCount: number + likeCount: number + quoteCount: number + } } export interface XUser { @@ -107,3 +142,45 @@ export interface XUserResponse extends ToolResponse { } export type XResponse = XWriteResponse | XReadResponse | XSearchResponse | XUserResponse + +/** + * Transforms raw X API tweet data (snake_case) into the XTweet format (camelCase) + */ +export 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], + }, + contextAnnotations: tweet.context_annotations, + publicMetrics: tweet.public_metrics + ? { + retweetCount: tweet.public_metrics.retweet_count, + replyCount: tweet.public_metrics.reply_count, + likeCount: tweet.public_metrics.like_count, + quoteCount: tweet.public_metrics.quote_count, + } + : undefined, +}) + +/** + * Transforms raw X API user data (snake_case) into the XUser format (camelCase) + */ +export 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 || 0, + followingCount: user.public_metrics?.following_count || 0, + tweetCount: user.public_metrics?.tweet_count || 0, + }, +}) diff --git a/apps/sim/tools/x/user.ts b/apps/sim/tools/x/user.ts index 4680616265..8f166ac001 100644 --- a/apps/sim/tools/x/user.ts +++ b/apps/sim/tools/x/user.ts @@ -1,6 +1,7 @@ import { createLogger } from '@/lib/logs/console/logger' import type { ToolConfig } from '@/tools/types' -import type { XUser, XUserParams, XUserResponse } from '@/tools/x/types' +import type { XUserParams, XUserResponse } from '@/tools/x/types' +import { transformUser } from '@/tools/x/types' const logger = createLogger('XUserTool') @@ -85,21 +86,7 @@ export const xUserTool: ToolConfig = { } 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, - }, - } + const user = transformUser(userData) return { success: true, diff --git a/apps/sim/tools/x/write.ts b/apps/sim/tools/x/write.ts index 4faa2f3aa7..832a957fda 100644 --- a/apps/sim/tools/x/write.ts +++ b/apps/sim/tools/x/write.ts @@ -1,5 +1,6 @@ import type { ToolConfig } from '@/tools/types' import type { XWriteParams, XWriteResponse } from '@/tools/x/types' +import { transformTweet } from '@/tools/x/types' export const xWriteTool: ToolConfig = { id: 'x_write', @@ -81,18 +82,7 @@ export const xWriteTool: ToolConfig = { 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], - }, - }, + tweet: transformTweet(data.data), }, } },