← back to Norma
agents/twitter-agent/skills/discover.js
148 lines
/**
* Twitter Discover Skill
*
* Searches X/Twitter for advocacy hashtags and relevant conversations.
* Rate limit: 60/15min (Twitter API v2 search limit)
*/
const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { pushSocialFeed, pushTrending } = require('../../shared/pulse-reporter');
const { searchTweets, ADVOCACY_HASHTAGS } = require('../lib/twitter');
const AGENT = 'twitter-agent';
const PLATFORM = 'twitter';
const rateLimiter = new RateLimiter(AGENT, PLATFORM);
/**
* @param {Object} body - Request body
* @param {string} [body.query] - Custom search query (overrides default hashtags)
* @param {string[]} [body.hashtags] - Specific hashtags to search
* @param {number} [body.max_results=10] - Max results per query (10-100)
* @param {boolean} [body.push_to_pulse=true] - Push results to Pulse
* @returns {Promise<Object>}
*/
module.exports = async function discover(body) {
const maxResults = Math.min(body.max_results || 10, 100);
const pushToPulse = body.push_to_pulse !== false;
// Check rate limit
const limit = await rateLimiter.checkLimit('search');
if (!limit.allowed) {
await logAction({
agent: AGENT,
actionType: 'discover',
platform: PLATFORM,
status: 'rate_limited',
errorMessage: `Rate limited. Retry after ${Math.ceil(limit.retryAfterMs / 1000)}s`,
});
return {
rate_limited: true,
retry_after_seconds: Math.ceil(limit.retryAfterMs / 1000),
current: limit.current,
max: limit.max,
};
}
// Build search query
let query;
if (body.query) {
query = body.query;
} else {
const hashtags = body.hashtags || ADVOCACY_HASHTAGS.slice(0, 3);
query = hashtags.join(' OR ') + ' -is:retweet lang:en';
}
// Search Twitter
const searchResult = await searchTweets(query, maxResults);
// Record the rate limit action
await rateLimiter.recordAction('search');
// Calculate engagement stats
const tweets = searchResult.tweets || [];
const totalEngagement = tweets.reduce((sum, t) => {
const m = t.metrics || {};
return sum + (m.like_count || 0) + (m.retweet_count || 0) + (m.reply_count || 0);
}, 0);
// Identify trending topics from tweet content
const hashtagCounts = {};
tweets.forEach((tweet) => {
const matches = tweet.text.match(/#\w+/g) || [];
matches.forEach((tag) => {
const lower = tag.toLowerCase();
hashtagCounts[lower] = (hashtagCounts[lower] || 0) + 1;
});
});
const trending = Object.entries(hashtagCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([tag, count]) => ({
topic: tag,
platform: PLATFORM,
volume: count,
sentiment: 'neutral',
}));
// Push to Pulse if enabled
if (pushToPulse) {
// Push trending topics
if (trending.length > 0) {
await pushTrending(trending);
}
// Push high-engagement tweets as social feed items
const highEngagement = tweets.filter((t) => {
const m = t.metrics || {};
return (m.like_count || 0) + (m.retweet_count || 0) >= 5;
});
for (const tweet of highEngagement.slice(0, 5)) {
await pushSocialFeed({
agent: AGENT,
platform: PLATFORM,
type: 'mention',
title: `@${tweet.author?.username || 'unknown'}: ${tweet.text.substring(0, 80)}...`,
url: `https://x.com/${tweet.author?.username || 'i'}/status/${tweet.id}`,
content: tweet.text,
metadata: {
tweet_id: tweet.id,
metrics: tweet.metrics,
author_followers: tweet.author?.followers || 0,
},
});
}
}
// Log the action
await logAction({
agent: AGENT,
actionType: 'discover',
platform: PLATFORM,
content: query,
responseData: {
query,
tweetCount: tweets.length,
totalEngagement,
trendingHashtags: trending.length,
simulated: searchResult.simulated || false,
},
status: 'success',
});
return {
query,
count: tweets.length,
tweets,
trending,
total_engagement: totalEngagement,
meta: searchResult.meta,
simulated: searchResult.simulated || false,
fetched_at: new Date().toISOString(),
};
};