Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/types/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface NodeCredentials {
bearerToken?: string
/** TwitterAPI.io API key (third-party, simpler) */
twitterApiIoKey?: string
/** Xquik API key for read-only X/Twitter search */
xquikApiKey?: string
/** OAuth 1.0a Consumer Key (API Key) */
consumerKey?: string
/** OAuth 1.0a Consumer Secret (API Secret) */
Expand Down
2 changes: 1 addition & 1 deletion packages/docs-mcp/src/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ Search Twitter/X for posts matching keywords.
- **Estimated Duration:** 15s
- **Capabilities:** supportsRerun
- **Services:** Required: twitter
- **Input:** keywords (required), excludeRetweets, minLikes, maxResults, lang, sinceDays
- **Input:** keywords (required), excludeRetweets, minLikes, maxResults, lang, sinceDays, apiProvider (auto|twitterapi_io|xquik)
- **Output:** posts (TwitterPost[]), totalFound, hasMore, cursor

### linkedin_monitor
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/integrations/social/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,7 @@ export const twitterCredential = defineOAuth2Credential({
consumerSecret: z.string().optional(),
accessTokenSecret: z.string().optional(),
bearerToken: z.string().optional(),
twitterApiIoKey: z.string().optional(),
xquikApiKey: z.string().optional(),
}),
});
95 changes: 95 additions & 0 deletions packages/nodes/src/integrations/social/twitter-extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
twitterCreateTweetNode,
twitterDeleteTweetNode,
twitterLikeTweetNode,
twitterMonitorNode,
twitterRetweetNode,
twitterSearchTweetsNode,
twitterSendDMNode,
Expand Down Expand Up @@ -290,3 +291,97 @@ describe('twitter_get_user_by_username', () => {
);
});
});

describe('twitter_monitor', () => {
it('searches with Xquik when requested', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
tweets: [
{
id: 't1',
text: 'hello from xquik',
createdAt: '2026-02-20T00:00:00.000Z',
likeCount: 12,
replyCount: 3,
retweetCount: 4,
viewCount: 100,
author: {
username: 'jam',
name: 'Jam',
followers: 250,
},
},
],
has_next_page: true,
next_cursor: 'next_1',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
);
vi.stubGlobal('fetch', fetchMock);

const result = await twitterMonitorNode.executor(
{
keywords: ['jam nodes'],
maxResults: 10,
apiProvider: 'xquik',
},
{
...baseContext,
credentials: { twitter: { xquikApiKey: 'xquik_key' } },
}
);

expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('https://xquik.com/api/v1/x/tweets/search?');
expect(url).toContain('q=%28%22jam+nodes%22%29+-is%3Aretweet');
expect(url).toContain('limit=10');
const headers = init.headers as Record<string, string>;
expect(headers['x-api-key']).toBe('xquik_key');
expect(headers['xquik-api-contract']).toBe('2026-04-29');
expect(result.output?.posts[0]?.authorHandle).toBe('jam');
expect(result.output?.cursor).toBe('next_1');
});

it('uses Xquik automatically when it is the only search key', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ tweets: [], has_next_page: false }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchMock);

const result = await twitterMonitorNode.executor(
{ keywords: ['jam'] },
{
...baseContext,
credentials: { twitter: { xquikApiKey: 'xquik_key' } },
}
);

expect(result.success).toBe(true);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain(
'https://xquik.com/api/v1/x/tweets/search?'
);
});

it('returns a clear error when the requested Xquik key is missing', async () => {
const result = await twitterMonitorNode.executor(
{
keywords: ['jam'],
apiProvider: 'xquik',
},
baseContext
);

expect(result.success).toBe(false);
expect(result.error).toContain('context.credentials.twitter.xquikApiKey');
});
});
190 changes: 162 additions & 28 deletions packages/nodes/src/integrations/social/twitter-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { fetchWithRetry } from '../../utils/http.js';
// =============================================================================

const TWITTERAPI_BASE_URL = 'https://api.twitterapi.io';
const XQUIK_BASE_URL = 'https://xquik.com/api/v1';
const XQUIK_API_CONTRACT = '2026-04-29';

type TwitterSearchProvider = 'auto' | 'twitterapi_io' | 'xquik';

// =============================================================================
// Types
Expand Down Expand Up @@ -51,6 +55,33 @@ interface TwitterApiSearchResponse {
next_cursor: string;
}

interface XquikTweet {
id: string;
url?: string;
text: string;
retweetCount?: number;
replyCount?: number;
likeCount?: number;
quoteCount?: number;
viewCount?: number;
createdAt?: string;
author?: {
id?: string;
username?: string;
userName?: string;
name?: string;
followers?: number;
isVerified?: boolean;
verified?: boolean;
};
}

interface XquikSearchResponse {
tweets?: XquikTweet[];
has_next_page?: boolean;
next_cursor?: string;
}

/**
* Twitter/X post in unified social format
*/
Expand Down Expand Up @@ -89,6 +120,8 @@ export const TwitterMonitorInputSchema = z.object({
lang: z.string().optional(),
/** Search tweets from last N days */
sinceDays: z.number().optional(),
/** Search provider to use */
apiProvider: z.enum(['auto', 'twitterapi_io', 'xquik']).optional().default('auto'),
});

export type TwitterMonitorInput = z.infer<typeof TwitterMonitorInputSchema>;
Expand Down Expand Up @@ -191,15 +224,127 @@ async function searchTwitter(
return response.json() as Promise<TwitterApiSearchResponse>;
}

async function searchXquik(
apiKey: string,
query: string,
queryType: 'Latest' | 'Top',
limit: number
): Promise<XquikSearchResponse> {
const url = new URL(`${XQUIK_BASE_URL}/x/tweets/search`);
url.searchParams.set('q', query);
url.searchParams.set('queryType', queryType);
url.searchParams.set('limit', String(limit));

const response = await fetchWithRetry(
url.toString(),
{
method: 'GET',
headers: {
'x-api-key': apiKey,
'xquik-api-contract': XQUIK_API_CONTRACT,
},
},
{ maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 }
);

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Xquik API error: ${response.status} - ${errorText}`);
}

return response.json() as Promise<XquikSearchResponse>;
}

function getSearchProvider(
inputProvider: TwitterSearchProvider,
credentials: {
twitterApiIoKey?: string;
xquikApiKey?: string;
} = {}
): { provider: Exclude<TwitterSearchProvider, 'auto'>; apiKey: string } | { error: string } {
if (inputProvider === 'twitterapi_io') {
if (!credentials.twitterApiIoKey) {
return {
error: 'TwitterAPI.io key not configured. Please provide context.credentials.twitter.twitterApiIoKey.',
};
}
return { provider: 'twitterapi_io', apiKey: credentials.twitterApiIoKey };
}

if (inputProvider === 'xquik') {
if (!credentials.xquikApiKey) {
return {
error: 'Xquik API key not configured. Please provide context.credentials.twitter.xquikApiKey.',
};
}
return { provider: 'xquik', apiKey: credentials.xquikApiKey };
}

if (credentials.twitterApiIoKey) {
return { provider: 'twitterapi_io', apiKey: credentials.twitterApiIoKey };
}

if (credentials.xquikApiKey) {
return { provider: 'xquik', apiKey: credentials.xquikApiKey };
}

return {
error: 'Twitter search key not configured. Provide context.credentials.twitter.twitterApiIoKey or context.credentials.twitter.xquikApiKey.',
};
}

function normalizeTwitterApiPost(tweet: TwitterApiTweet): TwitterPost {
return {
id: tweet.id,
platform: 'twitter',
url: tweet.url,
text: tweet.text,
authorName: tweet.author.name,
authorHandle: tweet.author.userName,
authorUrl: `https://twitter.com/${tweet.author.userName}`,
authorFollowers: tweet.author.followers,
engagement: {
likes: tweet.likeCount,
comments: tweet.replyCount,
shares: tweet.retweetCount,
views: tweet.viewCount || 0,
},
postedAt: tweet.createdAt,
};
}

function normalizeXquikPost(tweet: XquikTweet): TwitterPost {
const author = tweet.author || {};
const authorHandle = author.username || author.userName || 'unknown';

return {
id: tweet.id,
platform: 'twitter',
url: tweet.url || `https://x.com/${authorHandle}/status/${tweet.id}`,
text: tweet.text,
authorName: author.name || authorHandle,
authorHandle,
authorUrl: authorHandle === 'unknown' ? '' : `https://x.com/${authorHandle}`,
authorFollowers: author.followers || 0,
engagement: {
likes: tweet.likeCount || 0,
comments: tweet.replyCount || 0,
shares: tweet.retweetCount || 0,
views: tweet.viewCount || 0,
},
postedAt: tweet.createdAt || '',
};
}

// =============================================================================
// Node Definition
// =============================================================================

/**
* Twitter Monitor Node
*
* Searches Twitter/X for posts matching keywords using TwitterAPI.io.
* Requires `context.credentials.twitter.twitterApiIoKey` to be provided.
* Searches Twitter/X for posts matching keywords using TwitterAPI.io or Xquik.
* Requires `context.credentials.twitter.twitterApiIoKey` or `xquikApiKey`.
*
* @example
* ```typescript
Expand Down Expand Up @@ -232,12 +377,14 @@ export const twitterMonitorNode = defineNode({
};
}

// Check for API key (prefer TwitterAPI.io key)
const apiKey = context.credentials?.twitter?.twitterApiIoKey;
if (!apiKey) {
const provider = getSearchProvider(
input.apiProvider || 'auto',
context.credentials?.twitter
);
if ('error' in provider) {
return {
success: false,
error: 'Twitter API key not configured. Please provide context.credentials.twitter.twitterApiIoKey.',
error: provider.error,
};
}

Expand All @@ -255,36 +402,23 @@ export const twitterMonitorNode = defineNode({
lang: input.lang,
});

// Search tweets
const response = await searchTwitter(apiKey, query, 'Latest');
const maxResults = input.maxResults || 50;
const response = provider.provider === 'xquik'
? await searchXquik(provider.apiKey, query, 'Latest', maxResults)
: await searchTwitter(provider.apiKey, query, 'Latest');

// Transform to unified format
const posts: TwitterPost[] = (response.tweets || [])
.slice(0, input.maxResults || 50)
.map((tweet) => ({
id: tweet.id,
platform: 'twitter' as const,
url: tweet.url,
text: tweet.text,
authorName: tweet.author.name,
authorHandle: tweet.author.userName,
authorUrl: `https://twitter.com/${tweet.author.userName}`,
authorFollowers: tweet.author.followers,
engagement: {
likes: tweet.likeCount,
comments: tweet.replyCount,
shares: tweet.retweetCount,
views: tweet.viewCount || 0,
},
postedAt: tweet.createdAt,
}));
.slice(0, maxResults)
.map((tweet) => provider.provider === 'xquik'
? normalizeXquikPost(tweet as XquikTweet)
: normalizeTwitterApiPost(tweet as TwitterApiTweet));

return {
success: true,
output: {
posts,
totalFound: posts.length,
hasMore: response.has_next_page,
hasMore: Boolean(response.has_next_page),
cursor: response.next_cursor || undefined,
},
};
Expand Down
1 change: 1 addition & 0 deletions packages/playground-web/src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const CREDENTIAL_SCHEMAS: Record<
{ name: 'clientId', label: 'Client ID', type: 'text' },
{ name: 'clientSecret', label: 'Client Secret', type: 'password' },
{ name: 'twitterApiIoKey', label: 'TwitterAPI.io Key', type: 'password' },
{ name: 'xquikApiKey', label: 'Xquik API Key', type: 'password' },
],
linkedin: [{ name: 'accessToken', label: 'Access Token', type: 'password' }],
reddit: [
Expand Down
Loading