← back to Norma
agents/twitter-agent/lib/twitter.js
248 lines
/**
* Twitter/X API v2 Helper Functions
*
* Wraps the twitter-api-v2 library for search, post, reply, and metrics.
* All methods gracefully handle missing credentials.
* Credentials loaded from DB first, with env var fallback.
*/
const { TwitterApi } = require('twitter-api-v2');
const { loadCredentials } = require('../../shared/credentials');
// Default hashtags for advocacy topics
const ADVOCACY_HASHTAGS = [
'#CivicAction',
'#Advocacy',
'#NonprofitAdvocacy',
'#SocialJustice',
'#CivicEngagement',
'#GrassrootsAction',
'#PolicyReform',
'#CommunityAction',
];
/**
* Check if Twitter API credentials are configured (DB-first, env-fallback).
* @returns {Promise<boolean>}
*/
async function hasCredentials() {
const creds = await loadCredentials('twitter');
return !!(
creds?.TWITTER_BEARER_TOKEN ||
(creds?.TWITTER_API_KEY && creds?.TWITTER_ACCESS_TOKEN)
);
}
/**
* Create a read-only Twitter client (uses bearer token).
* @returns {Promise<TwitterApi|null>}
*/
let _bearerWarnedOnce = false;
async function getReadClient() {
const creds = await loadCredentials('twitter');
const bearerToken = creds?.TWITTER_BEARER_TOKEN || '';
if (!bearerToken) {
if (!_bearerWarnedOnce) {
console.warn('[twitter] No TWITTER_BEARER_TOKEN configured — read operations will fail (warning suppressed for subsequent calls)');
_bearerWarnedOnce = true;
}
return null;
}
return new TwitterApi(bearerToken);
}
/**
* Create a read-write Twitter client (uses OAuth 1.0a user tokens).
* @returns {Promise<TwitterApi|null>}
*/
async function getWriteClient() {
const creds = await loadCredentials('twitter');
const apiKey = creds?.TWITTER_API_KEY || '';
const apiSecret = creds?.TWITTER_API_SECRET || '';
const accessToken = creds?.TWITTER_ACCESS_TOKEN || '';
const accessSecret = creds?.TWITTER_ACCESS_SECRET || '';
if (!apiKey || !apiSecret || !accessToken || !accessSecret) {
console.warn('[twitter] OAuth 1.0a credentials not fully configured — write operations will fail');
return null;
}
return new TwitterApi({
appKey: apiKey,
appSecret: apiSecret,
accessToken: accessToken,
accessSecret: accessSecret,
});
}
/**
* Search recent tweets matching a query.
*
* @param {string} query - Search query (supports Twitter search operators)
* @param {number} [maxResults=10] - Max results (10-100)
* @returns {Promise<{ tweets: Array, meta: Object }>}
*/
async function searchTweets(query, maxResults = 10) {
const client = await getReadClient();
if (!client) {
return {
tweets: [],
meta: { error: 'No Twitter credentials configured' },
simulated: true,
};
}
try {
const result = await client.v2.search(query, {
max_results: Math.min(Math.max(maxResults, 10), 100),
'tweet.fields': [
'created_at',
'public_metrics',
'author_id',
'conversation_id',
'in_reply_to_user_id',
'lang',
],
'user.fields': ['name', 'username', 'public_metrics'],
expansions: ['author_id'],
});
const tweets = (result.data?.data || []).map((tweet) => {
const author = result.includes?.users?.find((u) => u.id === tweet.author_id);
return {
id: tweet.id,
text: tweet.text,
created_at: tweet.created_at,
author: author
? {
id: author.id,
name: author.name,
username: author.username,
followers: author.public_metrics?.followers_count || 0,
}
: null,
metrics: tweet.public_metrics || {},
conversation_id: tweet.conversation_id,
lang: tweet.lang,
};
});
return {
tweets,
meta: {
result_count: result.data?.meta?.result_count || 0,
newest_id: result.data?.meta?.newest_id,
oldest_id: result.data?.meta?.oldest_id,
next_token: result.data?.meta?.next_token,
},
};
} catch (err) {
console.error(`[twitter] searchTweets error:`, err.message);
// Check for rate limit response
if (err.code === 429 || err.rateLimit) {
return {
tweets: [],
meta: {
error: 'Twitter API rate limited',
reset: err.rateLimit?.reset,
},
rate_limited: true,
};
}
throw err;
}
}
/**
* Post a tweet.
*
* @param {string} text - Tweet text (max 280 chars)
* @param {string} [replyTo] - Tweet ID to reply to (optional)
* @returns {Promise<{ id: string, text: string }>}
*/
async function postTweet(text, replyTo = null) {
const client = await getWriteClient();
if (!client) {
return {
id: null,
text,
simulated: true,
note: 'No Twitter write credentials configured — tweet not posted',
};
}
try {
const params = {};
if (replyTo) {
params.reply = { in_reply_to_tweet_id: replyTo };
}
const result = await client.v2.tweet(text, params);
return {
id: result.data.id,
text: result.data.text,
posted: true,
};
} catch (err) {
console.error(`[twitter] postTweet error:`, err.message);
if (err.code === 429) {
return {
id: null,
text,
rate_limited: true,
error: 'Twitter API rate limited',
};
}
throw err;
}
}
/**
* Get engagement metrics for a tweet.
*
* @param {string} tweetId - Tweet ID
* @returns {Promise<{ id: string, metrics: Object, text: string }>}
*/
async function getTweetMetrics(tweetId) {
const client = await getReadClient();
if (!client) {
return {
id: tweetId,
metrics: {},
simulated: true,
note: 'No Twitter credentials configured',
};
}
try {
const result = await client.v2.singleTweet(tweetId, {
'tweet.fields': ['public_metrics', 'created_at', 'author_id'],
});
return {
id: tweetId,
text: result.data.text,
created_at: result.data.created_at,
metrics: result.data.public_metrics || {},
};
} catch (err) {
console.error(`[twitter] getTweetMetrics error for ${tweetId}:`, err.message);
throw err;
}
}
module.exports = {
searchTweets,
postTweet,
getTweetMetrics,
hasCredentials,
getReadClient,
getWriteClient,
ADVOCACY_HASHTAGS,
};