feat: Twitter/X integration (read-only free tier)
- New client: src/clients/twitter.ts
- Tools: twitter_search_tweets, twitter_get_user_profile, twitter_get_user_tweets, twitter_create_tweet
- REST endpoints: GET /api/twitter/search, /api/twitter/user, /api/twitter/tweets, POST /api/twitter/tweet
- Multi-account env var: TWITTER_{ACCOUNT}_BEARER_TOKEN
- twitter_create_tweet returns clear error about paid tier requirement
Total tools: 36
This commit is contained in:
136
src/clients/twitter.ts
Normal file
136
src/clients/twitter.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
const TWITTER_API_BASE = 'https://api.twitter.com/2';
|
||||
|
||||
function getBearerToken(account: string): string {
|
||||
const envKey = `TWITTER_${account.toUpperCase()}_BEARER_TOKEN`;
|
||||
return process.env[envKey] ?? '';
|
||||
}
|
||||
|
||||
async function twitterRequest(
|
||||
endpoint: string,
|
||||
bearerToken: string,
|
||||
params?: Record<string, string>
|
||||
) {
|
||||
const url = new URL(`${TWITTER_API_BASE}${endpoint}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) url.searchParams.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${bearerToken}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(`Twitter API error (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function searchTweets(args: {
|
||||
query: string;
|
||||
max_results?: number;
|
||||
account?: string;
|
||||
}): Promise<Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
author_id?: string;
|
||||
created_at?: string;
|
||||
}>> {
|
||||
const bearerToken = getBearerToken(args.account ?? 'default');
|
||||
if (!bearerToken) {
|
||||
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
|
||||
}
|
||||
|
||||
const data = await twitterRequest('/tweets/search/recent', bearerToken, {
|
||||
query: args.query,
|
||||
max_results: String(Math.min(args.max_results ?? 10, 100)),
|
||||
'tweet.fields': 'created_at,author_id',
|
||||
});
|
||||
|
||||
return (data.data ?? []) as Array<{ id: string; text: string; author_id?: string; created_at?: string }>;
|
||||
}
|
||||
|
||||
export async function getUserProfile(args: {
|
||||
username: string;
|
||||
account?: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
description?: string;
|
||||
followers_count?: number;
|
||||
following_count?: number;
|
||||
tweet_count?: number;
|
||||
}> {
|
||||
const bearerToken = getBearerToken(args.account ?? 'default');
|
||||
if (!bearerToken) {
|
||||
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
|
||||
}
|
||||
|
||||
const data = await twitterRequest(`/users/by/username/${args.username}`, bearerToken, {
|
||||
'user.fields': 'description,public_metrics',
|
||||
});
|
||||
|
||||
const user = data.data as { id: string; name: string; username: string; description?: string; public_metrics?: { followers_count?: number; following_count?: number; tweet_count?: number } };
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
description: user.description,
|
||||
followers_count: user.public_metrics?.followers_count,
|
||||
following_count: user.public_metrics?.following_count,
|
||||
tweet_count: user.public_metrics?.tweet_count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserTweets(args: {
|
||||
username: string;
|
||||
max_results?: number;
|
||||
account?: string;
|
||||
}): Promise<Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
created_at?: string;
|
||||
}>> {
|
||||
const bearerToken = getBearerToken(args.account ?? 'default');
|
||||
if (!bearerToken) {
|
||||
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
|
||||
}
|
||||
|
||||
// First get user ID
|
||||
const userData = await twitterRequest(`/users/by/username/${args.username}`, bearerToken);
|
||||
const userId = userData.data?.id;
|
||||
if (!userId) {
|
||||
throw new Error(`User @${args.username} not found`);
|
||||
}
|
||||
|
||||
const data = await twitterRequest(`/users/${userId}/tweets`, bearerToken, {
|
||||
max_results: String(Math.min(args.max_results ?? 10, 100)),
|
||||
'tweet.fields': 'created_at',
|
||||
});
|
||||
|
||||
return (data.data ?? []) as Array<{ id: string; text: string; created_at?: string }>;
|
||||
}
|
||||
|
||||
export async function createTweet(args: {
|
||||
text: string;
|
||||
account?: string;
|
||||
}): Promise<{ message: string }> {
|
||||
const bearerToken = getBearerToken(args.account ?? 'default');
|
||||
if (!bearerToken) {
|
||||
throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN');
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Twitter/X posting requires a paid API tier. ' +
|
||||
'The free tier is read-only (500 tweets/month). ' +
|
||||
'Upgrade to Basic ($100/month) or Pro ($5,000/month) at https://developer.twitter.com/en/portal/products'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user