style: lint
This commit is contained in:
@@ -178,48 +178,48 @@ The configuration system has been optimized using the `defu` library for deep me
|
||||
|
||||
```typescript
|
||||
interface Config {
|
||||
// BrowserBase/Stagehand configuration
|
||||
browserbase: {
|
||||
apiKey: string
|
||||
projectId?: string
|
||||
endpoint?: string
|
||||
stagehand?: {
|
||||
modelName?: string // e.g., "gpt-4o" or "claude-3-5-sonnet-latest"
|
||||
modelClientOptions?: {
|
||||
apiKey: string // OpenAI or Anthropic API key
|
||||
}
|
||||
// Adapter configuration
|
||||
adapters: {
|
||||
airi?: {
|
||||
enabled: boolean
|
||||
token?: string
|
||||
url?: string
|
||||
}
|
||||
mcp?: {
|
||||
enabled: boolean
|
||||
port?: number
|
||||
}
|
||||
}
|
||||
|
||||
// Browser configuration
|
||||
browser: BrowserConfig
|
||||
|
||||
// Twitter configuration
|
||||
twitter: {
|
||||
credentials?: TwitterCredentials
|
||||
defaultOptions?: {
|
||||
timeline?: TimelineOptions
|
||||
search?: SearchOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter configuration
|
||||
adapters: {
|
||||
airi?: {
|
||||
url?: string
|
||||
token?: string
|
||||
enabled: boolean
|
||||
}
|
||||
mcp?: {
|
||||
port?: number
|
||||
enabled: boolean
|
||||
// BrowserBase/Stagehand configuration
|
||||
browserbase: {
|
||||
apiKey: string
|
||||
endpoint?: string
|
||||
projectId?: string
|
||||
stagehand?: {
|
||||
modelClientOptions?: {
|
||||
apiKey: string // OpenAI or Anthropic API key
|
||||
}
|
||||
modelName?: string // e.g., "gpt-4o" or "claude-3-5-sonnet-latest"
|
||||
}
|
||||
}
|
||||
|
||||
// System configuration
|
||||
system: {
|
||||
logLevel: string
|
||||
concurrency: number
|
||||
logLevel: string
|
||||
}
|
||||
|
||||
// Twitter configuration
|
||||
twitter: {
|
||||
credentials?: TwitterCredentials
|
||||
defaultOptions?: {
|
||||
search?: SearchOptions
|
||||
timeline?: TimelineOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -262,10 +262,10 @@ async function main() {
|
||||
await browser.initialize({
|
||||
headless: true,
|
||||
stagehand: {
|
||||
modelName: 'gpt-4o', // Or 'claude-3-5-sonnet-latest' for Anthropic
|
||||
modelClientOptions: {
|
||||
apiKey: process.env.OPENAI_API_KEY // Or process.env.ANTHROPIC_API_KEY
|
||||
}
|
||||
},
|
||||
modelName: 'gpt-4o' // Or 'claude-3-5-sonnet-latest' for Anthropic
|
||||
}
|
||||
})
|
||||
|
||||
@@ -304,8 +304,8 @@ async function startAIRIModule() {
|
||||
|
||||
// Create AIRI adapter
|
||||
const airiAdapter = new AIRIAdapter(twitter, {
|
||||
url: process.env.AIRI_URL,
|
||||
token: process.env.AIRI_TOKEN
|
||||
token: process.env.AIRI_TOKEN,
|
||||
url: process.env.AIRI_URL
|
||||
})
|
||||
|
||||
// Start adapter
|
||||
|
||||
@@ -14,9 +14,9 @@ export function useAdapter() {
|
||||
const { AiriAdapter } = await import('./airi-adapter')
|
||||
|
||||
adapters.airi = new AiriAdapter(ctx, {
|
||||
url: config.adapters.airi.url,
|
||||
token: config.adapters.airi.token,
|
||||
credentials: config.credentials || {},
|
||||
token: config.adapters.airi.token,
|
||||
url: config.adapters.airi.url,
|
||||
})
|
||||
|
||||
await adapters.airi.start()
|
||||
|
||||
@@ -20,36 +20,34 @@ import { errorToMessage } from '../utils/error'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
export interface AiriAdapterConfig {
|
||||
url?: string
|
||||
token?: string
|
||||
credentials: {
|
||||
apiKey?: string
|
||||
apiSecret?: string
|
||||
accessToken?: string
|
||||
accessTokenSecret?: string
|
||||
apiKey?: string
|
||||
apiSecret?: string
|
||||
}
|
||||
token?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface XConfig {
|
||||
apiKey?: string
|
||||
apiSecret?: string
|
||||
accessToken?: string
|
||||
accessTokenSecret?: string
|
||||
apiKey?: string
|
||||
apiSecret?: string
|
||||
}
|
||||
|
||||
export class AiriAdapter {
|
||||
private client: Client
|
||||
private config: AiriAdapterConfig
|
||||
private ctx: Context
|
||||
private twitterServices: TwitterServices
|
||||
private config: AiriAdapterConfig
|
||||
|
||||
constructor(ctx: Context, config: AiriAdapterConfig) {
|
||||
this.ctx = ctx
|
||||
this.config = config
|
||||
this.client = new Client({
|
||||
name: 'x',
|
||||
url: config.url || 'ws://localhost:6121/ws',
|
||||
token: config.token,
|
||||
possibleEvents: [
|
||||
'module:authenticate',
|
||||
'module:authenticated',
|
||||
@@ -57,6 +55,8 @@ export class AiriAdapter {
|
||||
'ui:configure',
|
||||
'input:text',
|
||||
],
|
||||
token: config.token,
|
||||
url: config.url || 'ws://localhost:6121/ws',
|
||||
})
|
||||
|
||||
this.twitterServices = {
|
||||
@@ -69,6 +69,234 @@ export class AiriAdapter {
|
||||
this.setupEventHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the AiriAdapter and connect to the AIRI server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
logger.main.log('Starting Airi adapter for X...')
|
||||
try {
|
||||
await this.client.connect()
|
||||
logger.main.log('Airi adapter for X started successfully')
|
||||
}
|
||||
catch (error) {
|
||||
logger.main.errorWithError('Failed to start Airi adapter for X:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the AiriAdapter and disconnect from the AIRI server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
logger.main.log('Stopping Airi adapter for X...')
|
||||
try {
|
||||
this.client.close()
|
||||
logger.main.log('Airi adapter for X stopped')
|
||||
}
|
||||
catch (error) {
|
||||
logger.main.errorWithError('Error stopping Airi adapter for X:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetTimeline(count: number): Promise<boolean> {
|
||||
const timelineOptions = { count }
|
||||
const tweets = await this.twitterServices.timeline.getTimeline(timelineOptions)
|
||||
logger.main.log(`Retrieved ${tweets.length} tweets from timeline`)
|
||||
// Return timeline to the user
|
||||
this.client.send({
|
||||
data: {
|
||||
text: `Latest ${tweets.length} tweets from your timeline:
|
||||
${tweets.map((t: Tweet) => `- ${t.author.displayName}: ${t.text.substring(0, 80)}...`).join('\n')}`,
|
||||
},
|
||||
type: 'input:text',
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async handleGetUser(content: string): Promise<boolean> {
|
||||
if (content) {
|
||||
const userProfile = await this.twitterServices.user.getUserProfile(content)
|
||||
logger.main.log(`Retrieved profile for user: @${content}`)
|
||||
// Return user info to the user
|
||||
this.client.send({
|
||||
data: {
|
||||
text: `User Profile for @${userProfile.username}:
|
||||
Display Name: ${userProfile.displayName}
|
||||
Bio: ${userProfile.bio || 'N/A'}
|
||||
Followers: ${userProfile.followersCount || 0}
|
||||
Following: ${userProfile.followingCount || 0}`,
|
||||
},
|
||||
type: 'input:text',
|
||||
})
|
||||
return true
|
||||
}
|
||||
else {
|
||||
throw new Error('Username is empty. Please provide a username to retrieve.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInput(input: string): Promise<void> {
|
||||
let responseSent = false
|
||||
try {
|
||||
// Parse and handle X commands
|
||||
logger.main.log('Processing X command:', input)
|
||||
|
||||
// Parse the command using the dedicated parsing function
|
||||
const parsedCommand = parseTwitterCommand(input)
|
||||
|
||||
if (!parsedCommand) {
|
||||
throw new Error(`Unknown X command: ${input}. Supported commands: "post tweet: <text>", "search tweets: <query>", "like tweet: <tweetId>", "retweet: <tweetId>", "get user: <username>", "get timeline [count: N]"`)
|
||||
}
|
||||
|
||||
// Execute the appropriate command handler based on the parsed command
|
||||
switch (parsedCommand.command) {
|
||||
case 'get timeline':
|
||||
responseSent = await this.handleGetTimeline(parsedCommand.count || 10)
|
||||
break
|
||||
|
||||
case 'get user':
|
||||
responseSent = await this.handleGetUser(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'like tweet':
|
||||
await this.handleLikeTweet(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'post tweet':
|
||||
await this.handlePostTweet(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'retweet':
|
||||
await this.handleRetweet(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'search tweets':
|
||||
responseSent = await this.handleSearchTweets(parsedCommand.content)
|
||||
break
|
||||
|
||||
default:
|
||||
// This should not happen if parseTwitterCommand is working correctly
|
||||
throw new Error(`Unknown X command: ${input}`)
|
||||
}
|
||||
|
||||
// Only send the original processing response if we haven't already sent a specific response
|
||||
if (!responseSent) {
|
||||
this.client.send({
|
||||
data: {
|
||||
text: `Processed X command: ${input}`,
|
||||
},
|
||||
type: 'input:text',
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
const errorMessage = errorToMessage(error)
|
||||
logger.main.errorWithError('Error handling input:', error)
|
||||
this.client.send({
|
||||
data: {
|
||||
text: `Error processing X command: ${errorMessage}`,
|
||||
},
|
||||
type: 'input:text',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLikeTweet(content: string): Promise<void> {
|
||||
if (content) {
|
||||
await this.twitterServices.tweet.likeTweet(content)
|
||||
logger.main.log(`Liked tweet: ${content}`)
|
||||
}
|
||||
else {
|
||||
throw new Error('Tweet ID is empty. Please provide a tweet ID to like.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePostTweet(content: string): Promise<void> {
|
||||
if (content) {
|
||||
await this.twitterServices.tweet.postTweet(content)
|
||||
logger.main.log('Posted tweet:', content)
|
||||
}
|
||||
else {
|
||||
throw new Error('Tweet text is empty. Please provide text to post.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRetweet(content: string): Promise<void> {
|
||||
if (content) {
|
||||
await this.twitterServices.tweet.retweet(content)
|
||||
logger.main.log(`Retweeted: ${content}`)
|
||||
}
|
||||
else {
|
||||
throw new Error('Tweet ID is empty. Please provide a tweet ID to retweet.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSearchTweets(content: string): Promise<boolean> {
|
||||
if (content) {
|
||||
const tweets = await this.twitterServices.tweet.searchTweets(content)
|
||||
logger.main.log(`Found ${tweets.length} tweets for query: ${content}`)
|
||||
// Return results to the user
|
||||
this.client.send({
|
||||
data: {
|
||||
text: `Found ${tweets.length} tweets for '${content}':
|
||||
${tweets.slice(0, 5).map((t: Tweet) => `- ${t.text.substring(0, 100)}...`).join('\n')}`,
|
||||
},
|
||||
type: 'input:text',
|
||||
})
|
||||
return true
|
||||
}
|
||||
else {
|
||||
throw new Error('Search query is empty. Please provide a query to search.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize the browser context to refresh session state
|
||||
* This is needed when credentials are updated from the UI
|
||||
*/
|
||||
private async reinitializeBrowserContext(): Promise<void> {
|
||||
try {
|
||||
// Clear the session file to force re-authentication with new credentials
|
||||
const sessionFile = await useSessionFileAsync()
|
||||
|
||||
// Clear the session file to force re-authentication
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
JSON.stringify({ cookies: [], origins: [] }, null, 2),
|
||||
)
|
||||
|
||||
logger.main.log('Session file cleared, re-initializing browser context')
|
||||
|
||||
// Use the updated configuration with new credentials
|
||||
const config = {
|
||||
...getDefaultConfig(),
|
||||
credentials: {
|
||||
...getDefaultConfig().credentials,
|
||||
...this.config.credentials,
|
||||
},
|
||||
}
|
||||
|
||||
await initBrowser(config)
|
||||
|
||||
// Update the context reference
|
||||
this.ctx = useContext()
|
||||
|
||||
// Reinitialize services with the new context
|
||||
this.twitterServices = {
|
||||
timeline: useTwitterTimelineServices(this.ctx),
|
||||
tweet: useTwitterTweetServices(this.ctx),
|
||||
user: useTwitterUserServices(this.ctx),
|
||||
}
|
||||
|
||||
logger.main.log('Browser context reinitialized successfully with new credentials')
|
||||
}
|
||||
catch (error) {
|
||||
logger.main.errorWithError('Failed to reinitialize browser context:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// Handle configuration from UI
|
||||
this.client.onEvent('ui:configure', async (event) => {
|
||||
@@ -129,234 +357,6 @@ export class AiriAdapter {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async handlePostTweet(content: string): Promise<void> {
|
||||
if (content) {
|
||||
await this.twitterServices.tweet.postTweet(content)
|
||||
logger.main.log('Posted tweet:', content)
|
||||
}
|
||||
else {
|
||||
throw new Error('Tweet text is empty. Please provide text to post.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSearchTweets(content: string): Promise<boolean> {
|
||||
if (content) {
|
||||
const tweets = await this.twitterServices.tweet.searchTweets(content)
|
||||
logger.main.log(`Found ${tweets.length} tweets for query: ${content}`)
|
||||
// Return results to the user
|
||||
this.client.send({
|
||||
type: 'input:text',
|
||||
data: {
|
||||
text: `Found ${tweets.length} tweets for '${content}':
|
||||
${tweets.slice(0, 5).map((t: Tweet) => `- ${t.text.substring(0, 100)}...`).join('\n')}`,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
else {
|
||||
throw new Error('Search query is empty. Please provide a query to search.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLikeTweet(content: string): Promise<void> {
|
||||
if (content) {
|
||||
await this.twitterServices.tweet.likeTweet(content)
|
||||
logger.main.log(`Liked tweet: ${content}`)
|
||||
}
|
||||
else {
|
||||
throw new Error('Tweet ID is empty. Please provide a tweet ID to like.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRetweet(content: string): Promise<void> {
|
||||
if (content) {
|
||||
await this.twitterServices.tweet.retweet(content)
|
||||
logger.main.log(`Retweeted: ${content}`)
|
||||
}
|
||||
else {
|
||||
throw new Error('Tweet ID is empty. Please provide a tweet ID to retweet.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetUser(content: string): Promise<boolean> {
|
||||
if (content) {
|
||||
const userProfile = await this.twitterServices.user.getUserProfile(content)
|
||||
logger.main.log(`Retrieved profile for user: @${content}`)
|
||||
// Return user info to the user
|
||||
this.client.send({
|
||||
type: 'input:text',
|
||||
data: {
|
||||
text: `User Profile for @${userProfile.username}:
|
||||
Display Name: ${userProfile.displayName}
|
||||
Bio: ${userProfile.bio || 'N/A'}
|
||||
Followers: ${userProfile.followersCount || 0}
|
||||
Following: ${userProfile.followingCount || 0}`,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
else {
|
||||
throw new Error('Username is empty. Please provide a username to retrieve.')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetTimeline(count: number): Promise<boolean> {
|
||||
const timelineOptions = { count }
|
||||
const tweets = await this.twitterServices.timeline.getTimeline(timelineOptions)
|
||||
logger.main.log(`Retrieved ${tweets.length} tweets from timeline`)
|
||||
// Return timeline to the user
|
||||
this.client.send({
|
||||
type: 'input:text',
|
||||
data: {
|
||||
text: `Latest ${tweets.length} tweets from your timeline:
|
||||
${tweets.map((t: Tweet) => `- ${t.author.displayName}: ${t.text.substring(0, 80)}...`).join('\n')}`,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async handleInput(input: string): Promise<void> {
|
||||
let responseSent = false
|
||||
try {
|
||||
// Parse and handle X commands
|
||||
logger.main.log('Processing X command:', input)
|
||||
|
||||
// Parse the command using the dedicated parsing function
|
||||
const parsedCommand = parseTwitterCommand(input)
|
||||
|
||||
if (!parsedCommand) {
|
||||
throw new Error(`Unknown X command: ${input}. Supported commands: "post tweet: <text>", "search tweets: <query>", "like tweet: <tweetId>", "retweet: <tweetId>", "get user: <username>", "get timeline [count: N]"`)
|
||||
}
|
||||
|
||||
// Execute the appropriate command handler based on the parsed command
|
||||
switch (parsedCommand.command) {
|
||||
case 'post tweet':
|
||||
await this.handlePostTweet(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'search tweets':
|
||||
responseSent = await this.handleSearchTweets(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'like tweet':
|
||||
await this.handleLikeTweet(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'retweet':
|
||||
await this.handleRetweet(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'get user':
|
||||
responseSent = await this.handleGetUser(parsedCommand.content)
|
||||
break
|
||||
|
||||
case 'get timeline':
|
||||
responseSent = await this.handleGetTimeline(parsedCommand.count || 10)
|
||||
break
|
||||
|
||||
default:
|
||||
// This should not happen if parseTwitterCommand is working correctly
|
||||
throw new Error(`Unknown X command: ${input}`)
|
||||
}
|
||||
|
||||
// Only send the original processing response if we haven't already sent a specific response
|
||||
if (!responseSent) {
|
||||
this.client.send({
|
||||
type: 'input:text',
|
||||
data: {
|
||||
text: `Processed X command: ${input}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
const errorMessage = errorToMessage(error)
|
||||
logger.main.errorWithError('Error handling input:', error)
|
||||
this.client.send({
|
||||
type: 'input:text',
|
||||
data: {
|
||||
text: `Error processing X command: ${errorMessage}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the AiriAdapter and connect to the AIRI server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
logger.main.log('Starting Airi adapter for X...')
|
||||
try {
|
||||
await this.client.connect()
|
||||
logger.main.log('Airi adapter for X started successfully')
|
||||
}
|
||||
catch (error) {
|
||||
logger.main.errorWithError('Failed to start Airi adapter for X:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the AiriAdapter and disconnect from the AIRI server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
logger.main.log('Stopping Airi adapter for X...')
|
||||
try {
|
||||
this.client.close()
|
||||
logger.main.log('Airi adapter for X stopped')
|
||||
}
|
||||
catch (error) {
|
||||
logger.main.errorWithError('Error stopping Airi adapter for X:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize the browser context to refresh session state
|
||||
* This is needed when credentials are updated from the UI
|
||||
*/
|
||||
private async reinitializeBrowserContext(): Promise<void> {
|
||||
try {
|
||||
// Clear the session file to force re-authentication with new credentials
|
||||
const sessionFile = await useSessionFileAsync()
|
||||
|
||||
// Clear the session file to force re-authentication
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
JSON.stringify({ cookies: [], origins: [] }, null, 2),
|
||||
)
|
||||
|
||||
logger.main.log('Session file cleared, re-initializing browser context')
|
||||
|
||||
// Use the updated configuration with new credentials
|
||||
const config = {
|
||||
...getDefaultConfig(),
|
||||
credentials: {
|
||||
...getDefaultConfig().credentials,
|
||||
...this.config.credentials,
|
||||
},
|
||||
}
|
||||
|
||||
await initBrowser(config)
|
||||
|
||||
// Update the context reference
|
||||
this.ctx = useContext()
|
||||
|
||||
// Reinitialize services with the new context
|
||||
this.twitterServices = {
|
||||
timeline: useTwitterTimelineServices(this.ctx),
|
||||
tweet: useTwitterTweetServices(this.ctx),
|
||||
user: useTwitterUserServices(this.ctx),
|
||||
}
|
||||
|
||||
logger.main.log('Browser context reinitialized successfully with new credentials')
|
||||
}
|
||||
catch (error) {
|
||||
logger.main.errorWithError('Failed to reinitialize browser context:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isXConfig(config: unknown): config is XConfig {
|
||||
|
||||
@@ -23,13 +23,13 @@ import { logger } from '../utils/logger'
|
||||
* Implements HTTP server using H3.js
|
||||
*/
|
||||
export class MCPAdapter {
|
||||
private mcpServer: McpServer
|
||||
private app: H3
|
||||
private server: ReturnType<typeof createServer> | null = null
|
||||
private port: number
|
||||
private activeTransports: SSEServerTransport[] = []
|
||||
private extraResourceInfo: string[] = []
|
||||
private app: H3
|
||||
private ctx: Context
|
||||
private extraResourceInfo: string[] = []
|
||||
private mcpServer: McpServer
|
||||
private port: number
|
||||
private server: null | ReturnType<typeof createServer> = null
|
||||
|
||||
private twitterServices: TwitterServices
|
||||
|
||||
@@ -59,6 +59,79 @@ export class MCPAdapter {
|
||||
this.setupRoutes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start MCP server
|
||||
*/
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.server !== null) {
|
||||
logger.mcp.warn('MCP server is already running')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Create Node.js HTTP server
|
||||
this.server = createServer(toNodeHandler(this.app))
|
||||
|
||||
// Add error event handlers
|
||||
this.server.on('error', (error) => {
|
||||
logger.mcp.errorWithError('MCP server error:', error)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
// Log available resources for debugging
|
||||
logger.mcp.debug('Registered MCP resources:')
|
||||
logger.mcp.debug('- twitter://timeline/{count}: Get tweet timeline')
|
||||
logger.mcp.debug('- twitter://tweet/{id}: Get single tweet details')
|
||||
logger.mcp.debug('- twitter://user/{username}: Get user profile information')
|
||||
if (this.extraResourceInfo?.length) {
|
||||
this.extraResourceInfo.forEach((info) => {
|
||||
logger.mcp.debug(`- ${info}`)
|
||||
})
|
||||
}
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
const serverAddress = `http://localhost:${this.port}`
|
||||
logger.mcp.log(`MCP server started at: ${serverAddress}`)
|
||||
logger.mcp.log(`SSE endpoint: ${serverAddress}/sse`)
|
||||
logger.mcp.log(`Messages endpoint: ${serverAddress}/messages`)
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
logger.mcp.errorWithError('Error starting MCP server:', error)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop MCP server
|
||||
*/
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server === null) {
|
||||
logger.mcp.warn('MCP server is not running')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.server.close(() => {
|
||||
this.server = null
|
||||
logger.mcp.log('MCP server stopped')
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
logger.mcp.errorWithError('Error stopping MCP server:', error)
|
||||
this.server = null
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure MCP server resources and tools
|
||||
*/
|
||||
@@ -73,9 +146,9 @@ export class MCPAdapter {
|
||||
logger.mcp.debug('Listing available timeline resources')
|
||||
return {
|
||||
resources: [{
|
||||
description: 'Tweet timeline',
|
||||
name: 'timeline',
|
||||
uri: 'twitter://timeline/10', // Default number of tweets
|
||||
description: 'Tweet timeline',
|
||||
}],
|
||||
}
|
||||
},
|
||||
@@ -93,8 +166,8 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
contents: tweets.map((tweet: Tweet) => ({
|
||||
uri: `twitter://tweet/${tweet.id}`,
|
||||
text: `Tweet by @${tweet.author.username} (${tweet.author.displayName}):\n${tweet.text}`,
|
||||
uri: `twitter://tweet/${tweet.id}`,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -115,8 +188,8 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
text: `Tweet by @${tweet.author.username} (${tweet.author.displayName}):\n${tweet.text}`,
|
||||
uri: uri.href,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -137,8 +210,8 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
text: `Profile for @${profile.username} (${profile.displayName})\n${profile.bio || ''}`,
|
||||
uri: uri.href,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -159,16 +232,16 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: success
|
||||
? 'Successfully loaded login state from session file! If you logged in manually, auto-monitoring is set up to save your session.'
|
||||
: 'No valid session file found. Please log in manually in the browser, the system will automatically save your session.',
|
||||
type: 'text',
|
||||
}],
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to check login status: ${errorToMessage(error)}` }],
|
||||
content: [{ text: `Failed to check login status: ${errorToMessage(error)}`, type: 'text' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -180,10 +253,10 @@ export class MCPAdapter {
|
||||
'post-tweet',
|
||||
{
|
||||
content: z.string(),
|
||||
replyTo: z.string().optional(),
|
||||
media: z.array(z.string()).optional(),
|
||||
replyTo: z.string().optional(),
|
||||
},
|
||||
async ({ content, replyTo, media }) => {
|
||||
async ({ content, media, replyTo }) => {
|
||||
try {
|
||||
const tweetId = await this.twitterServices.tweet.postTweet(content, {
|
||||
inReplyTo: replyTo,
|
||||
@@ -192,14 +265,14 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Successfully posted tweet: ${tweetId}`,
|
||||
type: 'text',
|
||||
}],
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to post tweet: ${errorToMessage(error)}` }],
|
||||
content: [{ text: `Failed to post tweet: ${errorToMessage(error)}`, type: 'text' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -216,14 +289,14 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: success ? 'Successfully liked tweet' : 'Failed to like tweet',
|
||||
type: 'text',
|
||||
}],
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to like tweet: ${errorToMessage(error)}` }],
|
||||
content: [{ text: `Failed to like tweet: ${errorToMessage(error)}`, type: 'text' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -240,14 +313,14 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: success ? 'Successfully retweeted' : 'Failed to retweet',
|
||||
type: 'text',
|
||||
}],
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to retweet: ${errorToMessage(error)}` }],
|
||||
content: [{ text: `Failed to retweet: ${errorToMessage(error)}`, type: 'text' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -264,16 +337,16 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: success
|
||||
? 'Successfully saved browser session to file. This session will be loaded automatically next time.'
|
||||
: 'Failed to save browser session',
|
||||
type: 'text',
|
||||
}],
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to save session: ${errorToMessage(error)}` }],
|
||||
content: [{ text: `Failed to save session: ${errorToMessage(error)}`, type: 'text' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -284,25 +357,25 @@ export class MCPAdapter {
|
||||
this.mcpServer.tool(
|
||||
'search',
|
||||
{
|
||||
query: z.string(),
|
||||
count: z.number().optional(),
|
||||
filter: z.enum(['latest', 'photos', 'videos', 'top']).optional(),
|
||||
query: z.string(),
|
||||
},
|
||||
async ({ query, count, filter }) => {
|
||||
async ({ count, filter, query }) => {
|
||||
try {
|
||||
const results = await this.twitterServices.tweet.searchTweets(query, { count, filter })
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Search results: ${results.length} tweets`,
|
||||
type: 'text',
|
||||
}],
|
||||
resources: results.map((tweet: Tweet) => `twitter://tweet/${tweet.id}`),
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Search failed: ${errorToMessage(error)}` }],
|
||||
content: [{ text: `Search failed: ${errorToMessage(error)}`, type: 'text' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -327,15 +400,15 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Successfully refreshed timeline, retrieved ${tweets.length} tweets`,
|
||||
type: 'text',
|
||||
}],
|
||||
resources: tweets.map((tweet: Tweet) => `twitter://tweet/${tweet.id}`),
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to refresh timeline: ${errorToMessage(error)}` }],
|
||||
content: [{ text: `Failed to refresh timeline: ${errorToMessage(error)}`, type: 'text' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -362,8 +435,8 @@ export class MCPAdapter {
|
||||
if (!profileUsername) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Failed to get profile: Please provide a username or navigate to a profile page`,
|
||||
type: 'text',
|
||||
}],
|
||||
isError: true,
|
||||
}
|
||||
@@ -373,7 +446,6 @@ export class MCPAdapter {
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Profile Information:\n`
|
||||
+ `Username: @${profile.username}\n`
|
||||
+ `Display Name: ${profile.displayName}\n`
|
||||
@@ -382,13 +454,14 @@ export class MCPAdapter {
|
||||
+ `Following: ${profile.followingCount || 'N/A'}\n`
|
||||
+ `Tweets: ${profile.tweetCount || 'N/A'}\n`
|
||||
+ `Joined: ${profile.joinDate || 'N/A'}`,
|
||||
type: 'text',
|
||||
}],
|
||||
resources: [`twitter://user/${profile.username}`],
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Failed to get profile: ${errorToMessage(error)}` }],
|
||||
content: [{ text: `Failed to get profile: ${errorToMessage(error)}`, type: 'text' }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
@@ -406,7 +479,7 @@ export class MCPAdapter {
|
||||
const parsedUrl = new URL(url)
|
||||
if (parsedUrl.hostname === 'x.com') {
|
||||
const pathParts = parsedUrl.pathname.split('/').filter(Boolean)
|
||||
if (pathParts.length > 0 && !['search', 'explore', 'home', 'notifications', 'messages'].includes(pathParts[0])) {
|
||||
if (pathParts.length > 0 && !['explore', 'home', 'messages', 'notifications', 'search'].includes(pathParts[0])) {
|
||||
return pathParts[0]
|
||||
}
|
||||
}
|
||||
@@ -499,91 +572,18 @@ export class MCPAdapter {
|
||||
// Root path - provide service info
|
||||
router.get('/', defineEventHandler(() => {
|
||||
return {
|
||||
endpoints: {
|
||||
messages: '/messages',
|
||||
sse: '/sse',
|
||||
},
|
||||
name: 'Twitter MCP Service',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
sse: '/sse',
|
||||
messages: '/messages',
|
||||
},
|
||||
}
|
||||
}))
|
||||
|
||||
// Use router
|
||||
this.app.use(router)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start MCP server
|
||||
*/
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.server !== null) {
|
||||
logger.mcp.warn('MCP server is already running')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Create Node.js HTTP server
|
||||
this.server = createServer(toNodeHandler(this.app))
|
||||
|
||||
// Add error event handlers
|
||||
this.server.on('error', (error) => {
|
||||
logger.mcp.errorWithError('MCP server error:', error)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
// Log available resources for debugging
|
||||
logger.mcp.debug('Registered MCP resources:')
|
||||
logger.mcp.debug('- twitter://timeline/{count}: Get tweet timeline')
|
||||
logger.mcp.debug('- twitter://tweet/{id}: Get single tweet details')
|
||||
logger.mcp.debug('- twitter://user/{username}: Get user profile information')
|
||||
if (this.extraResourceInfo?.length) {
|
||||
this.extraResourceInfo.forEach((info) => {
|
||||
logger.mcp.debug(`- ${info}`)
|
||||
})
|
||||
}
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
const serverAddress = `http://localhost:${this.port}`
|
||||
logger.mcp.log(`MCP server started at: ${serverAddress}`)
|
||||
logger.mcp.log(`SSE endpoint: ${serverAddress}/sse`)
|
||||
logger.mcp.log(`Messages endpoint: ${serverAddress}/messages`)
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
logger.mcp.errorWithError('Error starting MCP server:', error)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop MCP server
|
||||
*/
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server === null) {
|
||||
logger.mcp.warn('MCP server is not running')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.server.close(() => {
|
||||
this.server = null
|
||||
logger.mcp.log('MCP server stopped')
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
logger.mcp.errorWithError('Error stopping MCP server:', error)
|
||||
this.server = null
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// h3 utility function: read body from event
|
||||
|
||||
@@ -30,6 +30,21 @@ export class ConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete configuration
|
||||
*/
|
||||
getConfig(): Config {
|
||||
return this.config
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(newConfig: Partial<Config>): void {
|
||||
// Use defu to merge new configuration
|
||||
this.config = merge(this.config, newConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from file
|
||||
*/
|
||||
@@ -48,21 +63,6 @@ export class ConfigManager {
|
||||
logger.config.errorWithError(`Failed to load configuration file: ${(error as Error).message}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete configuration
|
||||
*/
|
||||
getConfig(): Config {
|
||||
return this.config
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(newConfig: Partial<Config>): void {
|
||||
// Use defu to merge new configuration
|
||||
this.config = merge(this.config, newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -7,6 +7,19 @@ import process from 'node:process'
|
||||
* Complete configuration interface
|
||||
*/
|
||||
export interface Config {
|
||||
// Adapter configuration
|
||||
adapters: {
|
||||
airi?: {
|
||||
enabled: boolean
|
||||
token?: string
|
||||
url?: string
|
||||
}
|
||||
mcp?: {
|
||||
enabled: boolean
|
||||
port?: number
|
||||
}
|
||||
}
|
||||
|
||||
// Browser configuration
|
||||
browser: BrowserConfig & {
|
||||
apiKey: string // API Key for Stagehand
|
||||
@@ -15,39 +28,26 @@ export interface Config {
|
||||
|
||||
// Twitter API credentials
|
||||
credentials?: {
|
||||
apiKey?: string
|
||||
apiSecret?: string
|
||||
accessToken?: string
|
||||
accessTokenSecret?: string
|
||||
apiKey?: string
|
||||
apiSecret?: string
|
||||
}
|
||||
|
||||
// System configuration
|
||||
system: {
|
||||
concurrency: number
|
||||
logFormat?: 'json' | 'pretty'
|
||||
logLevel: 'debug' | 'error' | 'info' | 'verbose' | 'warn'
|
||||
}
|
||||
|
||||
// Twitter configuration
|
||||
twitter: {
|
||||
defaultOptions?: {
|
||||
timeline?: TimelineOptions
|
||||
search?: SearchOptions
|
||||
timeline?: TimelineOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter configuration
|
||||
adapters: {
|
||||
airi?: {
|
||||
url?: string
|
||||
token?: string
|
||||
enabled: boolean
|
||||
}
|
||||
mcp?: {
|
||||
port?: number
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// System configuration
|
||||
system: {
|
||||
logLevel: 'error' | 'warn' | 'info' | 'verbose' | 'debug'
|
||||
logFormat?: 'json' | 'pretty'
|
||||
concurrency: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,23 +58,39 @@ export function getDefaultConfig(): Config {
|
||||
// The auth service will load cookies from session file instead
|
||||
|
||||
return {
|
||||
adapters: {
|
||||
airi: {
|
||||
enabled: process.env.ENABLE_AIRI === 'true',
|
||||
token: process.env.AIRI_TOKEN || '',
|
||||
url: process.env.AIRI_URL || 'http://localhost:3000',
|
||||
},
|
||||
mcp: {
|
||||
enabled: process.env.ENABLE_MCP === 'true',
|
||||
port: Number(process.env.MCP_PORT || 8080),
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
apiKey: process.env.BROWSERBASE_API_KEY || '', // Move apiKey to browser config
|
||||
headless: process.env.BROWSER_HEADLESS === 'true',
|
||||
requestRetries: Number.parseInt(process.env.BROWSER_REQUEST_RETRIES || '2'),
|
||||
requestTimeout: Number.parseInt(process.env.BROWSER_REQUEST_TIMEOUT || '20000'),
|
||||
timeout: Number.parseInt(process.env.BROWSER_TIMEOUT || '30000'),
|
||||
userAgent: process.env.BROWSER_USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
viewport: {
|
||||
width: Number.parseInt(process.env.BROWSER_VIEWPORT_WIDTH || '1280'),
|
||||
height: Number.parseInt(process.env.BROWSER_VIEWPORT_HEIGHT || '800'),
|
||||
width: Number.parseInt(process.env.BROWSER_VIEWPORT_WIDTH || '1280'),
|
||||
},
|
||||
timeout: Number.parseInt(process.env.BROWSER_TIMEOUT || '30000'),
|
||||
requestTimeout: Number.parseInt(process.env.BROWSER_REQUEST_TIMEOUT || '20000'),
|
||||
requestRetries: Number.parseInt(process.env.BROWSER_REQUEST_RETRIES || '2'),
|
||||
},
|
||||
credentials: {
|
||||
apiKey: process.env.TWITTER_API_KEY,
|
||||
apiSecret: process.env.TWITTER_API_SECRET,
|
||||
accessToken: process.env.TWITTER_ACCESS_TOKEN,
|
||||
accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
|
||||
apiKey: process.env.TWITTER_API_KEY,
|
||||
apiSecret: process.env.TWITTER_API_SECRET,
|
||||
},
|
||||
system: {
|
||||
concurrency: Number(process.env.CONCURRENCY || 1),
|
||||
logFormat: 'pretty',
|
||||
logLevel: 'debug',
|
||||
},
|
||||
twitter: {
|
||||
defaultOptions: {
|
||||
@@ -85,21 +101,5 @@ export function getDefaultConfig(): Config {
|
||||
},
|
||||
},
|
||||
},
|
||||
adapters: {
|
||||
airi: {
|
||||
url: process.env.AIRI_URL || 'http://localhost:3000',
|
||||
token: process.env.AIRI_TOKEN || '',
|
||||
enabled: process.env.ENABLE_AIRI === 'true',
|
||||
},
|
||||
mcp: {
|
||||
port: Number(process.env.MCP_PORT || 8080),
|
||||
enabled: process.env.ENABLE_MCP === 'true',
|
||||
},
|
||||
},
|
||||
system: {
|
||||
logLevel: 'debug',
|
||||
logFormat: 'pretty',
|
||||
concurrency: Number(process.env.CONCURRENCY || 1),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ export async function initBrowser(config: Config) {
|
||||
})
|
||||
|
||||
const context = await browser.newContext({
|
||||
bypassCSP: true,
|
||||
storageState: await useSessionFileAsync(),
|
||||
userAgent: config.browser.userAgent,
|
||||
viewport: config.browser.viewport,
|
||||
bypassCSP: true,
|
||||
})
|
||||
|
||||
context.setDefaultTimeout(config.browser.timeout || 30000)
|
||||
@@ -36,6 +36,14 @@ export async function initBrowser(config: Config) {
|
||||
ctxInstance = { browser, context, page }
|
||||
}
|
||||
|
||||
export function useContext(): Context {
|
||||
if (!ctxInstance) {
|
||||
throw new Error('Context not initialized')
|
||||
}
|
||||
|
||||
return ctxInstance
|
||||
}
|
||||
|
||||
export async function useSessionFileAsync(): Promise<string> {
|
||||
const defaultSession = {
|
||||
cookies: [],
|
||||
@@ -60,11 +68,3 @@ export async function useSessionFileAsync(): Promise<string> {
|
||||
|
||||
return TWITTER_SESSION_FILE
|
||||
}
|
||||
|
||||
export function useContext(): Context {
|
||||
if (!ctxInstance) {
|
||||
throw new Error('Context not initialized')
|
||||
}
|
||||
|
||||
return ctxInstance
|
||||
}
|
||||
|
||||
@@ -8,21 +8,11 @@ import { errorToMessage } from '../../utils/error'
|
||||
import { scrollToLoadMoreTweets } from '../utils/scroll-helper'
|
||||
|
||||
/**
|
||||
* Tweet Interface
|
||||
* Post Options
|
||||
*/
|
||||
export interface Tweet {
|
||||
id: string
|
||||
text: string
|
||||
author: {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
timestamp: string
|
||||
likeCount?: number
|
||||
retweetCount?: number
|
||||
replyCount?: number
|
||||
mediaUrls?: string[]
|
||||
export interface PostOptions {
|
||||
inReplyTo?: string
|
||||
media?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,23 +20,33 @@ export interface Tweet {
|
||||
*/
|
||||
export interface SearchOptions {
|
||||
count?: number
|
||||
filter?: 'latest' | 'photos' | 'videos' | 'top'
|
||||
filter?: 'latest' | 'photos' | 'top' | 'videos'
|
||||
}
|
||||
|
||||
/**
|
||||
* Post Options
|
||||
* Tweet Interface
|
||||
*/
|
||||
export interface PostOptions {
|
||||
media?: string[]
|
||||
inReplyTo?: string
|
||||
export interface Tweet {
|
||||
author: {
|
||||
avatarUrl?: string
|
||||
displayName: string
|
||||
username: string
|
||||
}
|
||||
id: string
|
||||
likeCount?: number
|
||||
mediaUrls?: string[]
|
||||
replyCount?: number
|
||||
retweetCount?: number
|
||||
text: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tweet Detail
|
||||
*/
|
||||
export interface TweetDetail extends Tweet {
|
||||
replies?: Tweet[]
|
||||
quotedTweet?: Tweet
|
||||
replies?: Tweet[]
|
||||
}
|
||||
|
||||
export function useTwitterTweetServices(ctx: Context): TwitterService {
|
||||
@@ -72,15 +72,15 @@ export function useTwitterTweetServices(ctx: Context): TwitterService {
|
||||
case 'latest':
|
||||
await page.click(SELECTORS.SEARCH.LATEST_TAB)
|
||||
break
|
||||
case 'top':
|
||||
await page.click(SELECTORS.SEARCH.TOP_TAB)
|
||||
break
|
||||
case 'photos':
|
||||
case 'videos':
|
||||
// These require custom filter selection from the filter button
|
||||
await page.click(SELECTORS.SEARCH.SEARCH_FILTERS)
|
||||
await page.click(`[role="menuitem"]:has-text("${options.filter === 'photos' ? 'Photos' : 'Videos'}")`)
|
||||
break
|
||||
case 'top':
|
||||
await page.click(SELECTORS.SEARCH.TOP_TAB)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,8 +315,8 @@ export function useTwitterTweetServices(ctx: Context): TwitterService {
|
||||
// Construct the detailed tweet
|
||||
const tweetDetail: TweetDetail = {
|
||||
...mainTweet,
|
||||
replies: replies.length > 0 ? replies : undefined,
|
||||
quotedTweet,
|
||||
replies: replies.length > 0 ? replies : undefined,
|
||||
}
|
||||
|
||||
return tweetDetail
|
||||
@@ -328,10 +328,10 @@ export function useTwitterTweetServices(ctx: Context): TwitterService {
|
||||
}
|
||||
|
||||
return {
|
||||
searchTweets,
|
||||
likeTweet,
|
||||
retweet,
|
||||
postTweet,
|
||||
getTweetDetails,
|
||||
likeTweet,
|
||||
postTweet,
|
||||
retweet,
|
||||
searchTweets,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,38 +4,38 @@ import type { Context } from '../browser/context'
|
||||
import { TWITTER_BASE_URL } from '../../constants'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
/**
|
||||
* User Link
|
||||
*/
|
||||
export interface UserLink {
|
||||
title: string
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User Profile
|
||||
*/
|
||||
export interface UserProfile {
|
||||
username: string
|
||||
displayName: string
|
||||
bio?: string
|
||||
avatarUrl?: string
|
||||
bannerUrl?: string
|
||||
bio?: string
|
||||
displayName: string
|
||||
followersCount?: number
|
||||
followingCount?: number
|
||||
tweetCount?: number
|
||||
isVerified?: boolean
|
||||
joinDate?: string
|
||||
tweetCount?: number
|
||||
username: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User Stats
|
||||
*/
|
||||
export interface UserStats {
|
||||
tweets: number
|
||||
following: number
|
||||
followers: number
|
||||
}
|
||||
|
||||
/**
|
||||
* User Link
|
||||
*/
|
||||
export interface UserLink {
|
||||
type: string
|
||||
url: string
|
||||
title: string
|
||||
following: number
|
||||
tweets: number
|
||||
}
|
||||
|
||||
export function useTwitterUserServices(ctx: Context): TwitterService {
|
||||
@@ -76,12 +76,12 @@ export function useTwitterUserServices(ctx: Context): TwitterService {
|
||||
: undefined
|
||||
|
||||
return {
|
||||
username,
|
||||
displayName,
|
||||
bio: bio || undefined,
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
bio: bio || undefined,
|
||||
displayName,
|
||||
followersCount: followerCount || undefined,
|
||||
followingCount: followingCount || undefined,
|
||||
username,
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -3,6 +3,16 @@ import type { Page } from 'playwright'
|
||||
import { SELECTORS } from '../../parsers/selectors'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
/**
|
||||
* Count visible elements on the page
|
||||
* @param page Playwright page instance
|
||||
* @param selector Element selector
|
||||
*/
|
||||
export async function countVisibleElements(page: Page, selector: string): Promise<number> {
|
||||
const elements = await page.$$(selector)
|
||||
return elements.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the page to load more tweets
|
||||
* @param page Playwright page instance
|
||||
@@ -50,13 +60,3 @@ export async function scrollToLoadMoreTweets(
|
||||
logger.main.errorWithError('Error while scrolling to load more content:', (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count visible elements on the page
|
||||
* @param page Playwright page instance
|
||||
* @param selector Element selector
|
||||
*/
|
||||
export async function countVisibleElements(page: Page, selector: string): Promise<number> {
|
||||
const elements = await page.$$(selector)
|
||||
return elements.length
|
||||
}
|
||||
|
||||
@@ -11,6 +11,28 @@ import { initBrowser, useContext } from './core/browser/context'
|
||||
import { useTwitterAuthServices } from './core/services/auth'
|
||||
import { initLogger, logger } from './utils/logger'
|
||||
|
||||
/**
|
||||
* Bootstrap the application
|
||||
*/
|
||||
async function bootstrap() {
|
||||
initLogger()
|
||||
|
||||
try {
|
||||
const resources = await initializeApp()
|
||||
setupShutdownHooks(resources.adapters, resources.context)
|
||||
logger.main.log('Twitter service successfully started!')
|
||||
}
|
||||
catch (error) {
|
||||
logger.main.withError(error).error('Startup failed')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.main.withError(reason).error('Unhandled Promise rejection:')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up application resources
|
||||
*/
|
||||
@@ -30,6 +52,27 @@ async function cleanup(adapters: { airi?: AiriAdapter, mcp?: MCPAdapter }, brows
|
||||
logger.main.log('Twitter service stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize core application components
|
||||
*/
|
||||
async function initializeApp() {
|
||||
const config = useConfigManager().getConfig()
|
||||
logger.main.log('Starting Twitter service...')
|
||||
|
||||
await initBrowser(config)
|
||||
const ctx = useContext()
|
||||
const adapters = await useAdapter().initAdapters(config, ctx)
|
||||
|
||||
// Login to Twitter
|
||||
await useTwitterAuthServices(ctx).attemptLogin()
|
||||
|
||||
return {
|
||||
adapters,
|
||||
browser: ctx.browser,
|
||||
context: ctx.context,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up process shutdown hooks
|
||||
*/
|
||||
@@ -52,47 +95,4 @@ function setupShutdownHooks(adapters: { airi?: AiriAdapter, mcp?: MCPAdapter },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize core application components
|
||||
*/
|
||||
async function initializeApp() {
|
||||
const config = useConfigManager().getConfig()
|
||||
logger.main.log('Starting Twitter service...')
|
||||
|
||||
await initBrowser(config)
|
||||
const ctx = useContext()
|
||||
const adapters = await useAdapter().initAdapters(config, ctx)
|
||||
|
||||
// Login to Twitter
|
||||
await useTwitterAuthServices(ctx).attemptLogin()
|
||||
|
||||
return {
|
||||
adapters,
|
||||
context: ctx.context,
|
||||
browser: ctx.browser,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap the application
|
||||
*/
|
||||
async function bootstrap() {
|
||||
initLogger()
|
||||
|
||||
try {
|
||||
const resources = await initializeApp()
|
||||
setupShutdownHooks(resources.adapters, resources.context)
|
||||
logger.main.log('Twitter service successfully started!')
|
||||
}
|
||||
catch (error) {
|
||||
logger.main.withError(error).error('Startup failed')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.main.withError(reason).error('Unhandled Promise rejection:')
|
||||
})
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
// Define a union type for the command parsing result
|
||||
export type ParseResult
|
||||
= | { command: 'post tweet', content: string }
|
||||
| { command: 'search tweets', content: string }
|
||||
| { command: 'like tweet', content: string }
|
||||
| { command: 'retweet', content: string }
|
||||
= | { command: 'get timeline', content: string, count: number }
|
||||
| { command: 'get user', content: string }
|
||||
| { command: 'get timeline', content: string, count: number }
|
||||
| { command: 'like tweet', content: string }
|
||||
| { command: 'post tweet', content: string }
|
||||
| { command: 'retweet', content: string }
|
||||
| { command: 'search tweets', content: string }
|
||||
|
||||
type NonTimelineCommands = 'post tweet' | 'search tweets' | 'like tweet' | 'retweet' | 'get user'
|
||||
type NonTimelineCommands = 'get user' | 'like tweet' | 'post tweet' | 'retweet' | 'search tweets'
|
||||
|
||||
/**
|
||||
* Parses a Twitter command from the input string
|
||||
* @param input The input string containing the command
|
||||
* @returns Parsed command and content, or null if no valid command found
|
||||
*/
|
||||
export function parseTwitterCommand(input: string): ParseResult | null {
|
||||
export function parseTwitterCommand(input: string): null | ParseResult {
|
||||
// Handle commands based on explicit prefixes for better reliability
|
||||
const normalizedInput = input.trim().toLowerCase()
|
||||
|
||||
// Define command patterns
|
||||
const commandPatterns: Array<{ pattern: string, command: string }> = [
|
||||
{ pattern: 'post tweet:', command: 'post tweet' },
|
||||
{ pattern: 'search tweets:', command: 'search tweets' },
|
||||
{ pattern: 'like tweet:', command: 'like tweet' },
|
||||
{ pattern: 'retweet:', command: 'retweet' },
|
||||
{ pattern: 'get user:', command: 'get user' },
|
||||
{ pattern: 'get timeline', command: 'get timeline' },
|
||||
const commandPatterns: Array<{ command: string, pattern: string }> = [
|
||||
{ command: 'post tweet', pattern: 'post tweet:' },
|
||||
{ command: 'search tweets', pattern: 'search tweets:' },
|
||||
{ command: 'like tweet', pattern: 'like tweet:' },
|
||||
{ command: 'retweet', pattern: 'retweet:' },
|
||||
{ command: 'get user', pattern: 'get user:' },
|
||||
{ command: 'get timeline', pattern: 'get timeline' },
|
||||
]
|
||||
|
||||
// Find the matching command pattern
|
||||
for (const { pattern, command } of commandPatterns) {
|
||||
for (const { command, pattern } of commandPatterns) {
|
||||
if (normalizedInput.startsWith(pattern)) {
|
||||
// Extract the content after the prefix
|
||||
const content = input.substring(pattern.length)
|
||||
|
||||
@@ -54,8 +54,8 @@ export class ProfileParser {
|
||||
// const _links = await this.extractUserLinks(page)
|
||||
|
||||
const profile: UserProfile = {
|
||||
username,
|
||||
displayName,
|
||||
username,
|
||||
}
|
||||
|
||||
// Add optional fields if they exist
|
||||
@@ -86,76 +86,12 @@ export class ProfileParser {
|
||||
|
||||
// Return minimal profile to avoid breaking
|
||||
return {
|
||||
username: 'unknown',
|
||||
displayName: 'Unknown User',
|
||||
username: 'unknown',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract username from Twitter profile URL
|
||||
* @param url Twitter profile URL
|
||||
* @returns Username or null if not found
|
||||
*/
|
||||
private static extractUsernameFromUrl(url: string): string | null {
|
||||
try {
|
||||
const match = url.match(/twitter\.com\/([^/]+)/)
|
||||
if (match && match[1] && !['home', 'explore', 'notifications', 'messages'].includes(match[1])) {
|
||||
return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user statistics (followers, following, tweets)
|
||||
* @param page Playwright page instance
|
||||
* @returns Promise resolving to UserStats object
|
||||
*/
|
||||
private static async extractUserStats(page: Page): Promise<UserStats> {
|
||||
const stats: UserStats = {
|
||||
followers: 0,
|
||||
following: 0,
|
||||
tweets: 0,
|
||||
}
|
||||
|
||||
try {
|
||||
// Get stats container
|
||||
const statsContainer = await page.$(SELECTORS.PROFILE.STATS)
|
||||
if (!statsContainer)
|
||||
return stats
|
||||
|
||||
// Get all stat items
|
||||
const statItems = await statsContainer.$$('a')
|
||||
|
||||
for (const statItem of statItems) {
|
||||
const text = await statItem.textContent() || ''
|
||||
|
||||
if (text.includes('Following')) {
|
||||
const countText = text.replace(/Following.*/, '').trim()
|
||||
stats.following = this.parseStatNumber(countText)
|
||||
}
|
||||
else if (text.includes('Followers')) {
|
||||
const countText = text.replace(/Followers.*/, '').trim()
|
||||
stats.followers = this.parseStatNumber(countText)
|
||||
}
|
||||
else if (text.includes('posts') || text.includes('Posts')) {
|
||||
const countText = text.replace(/posts|Posts.*/, '').trim()
|
||||
stats.tweets = this.parseStatNumber(countText)
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
catch (error) {
|
||||
logger.parser.error('Error extracting user stats:', (error as Error).message)
|
||||
return stats
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract profile avatar URL
|
||||
* @param page Playwright page instance
|
||||
@@ -236,9 +172,9 @@ export class ProfileParser {
|
||||
|
||||
if (href && title) {
|
||||
links.push({
|
||||
title,
|
||||
type: 'url',
|
||||
url: href,
|
||||
title,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -249,9 +185,9 @@ export class ProfileParser {
|
||||
const locationText = await locationElement.textContent()
|
||||
if (locationText) {
|
||||
links.push({
|
||||
title: locationText.replace('Location', '').trim(),
|
||||
type: 'location',
|
||||
url: '',
|
||||
title: locationText.replace('Location', '').trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -264,6 +200,70 @@ export class ProfileParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract username from Twitter profile URL
|
||||
* @param url Twitter profile URL
|
||||
* @returns Username or null if not found
|
||||
*/
|
||||
private static extractUsernameFromUrl(url: string): null | string {
|
||||
try {
|
||||
const match = url.match(/twitter\.com\/([^/]+)/)
|
||||
if (match && match[1] && !['explore', 'home', 'messages', 'notifications'].includes(match[1])) {
|
||||
return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user statistics (followers, following, tweets)
|
||||
* @param page Playwright page instance
|
||||
* @returns Promise resolving to UserStats object
|
||||
*/
|
||||
private static async extractUserStats(page: Page): Promise<UserStats> {
|
||||
const stats: UserStats = {
|
||||
followers: 0,
|
||||
following: 0,
|
||||
tweets: 0,
|
||||
}
|
||||
|
||||
try {
|
||||
// Get stats container
|
||||
const statsContainer = await page.$(SELECTORS.PROFILE.STATS)
|
||||
if (!statsContainer)
|
||||
return stats
|
||||
|
||||
// Get all stat items
|
||||
const statItems = await statsContainer.$$('a')
|
||||
|
||||
for (const statItem of statItems) {
|
||||
const text = await statItem.textContent() || ''
|
||||
|
||||
if (text.includes('Following')) {
|
||||
const countText = text.replace(/Following.*/, '').trim()
|
||||
stats.following = this.parseStatNumber(countText)
|
||||
}
|
||||
else if (text.includes('Followers')) {
|
||||
const countText = text.replace(/Followers.*/, '').trim()
|
||||
stats.followers = this.parseStatNumber(countText)
|
||||
}
|
||||
else if (text.includes('posts') || text.includes('Posts')) {
|
||||
const countText = text.replace(/posts|Posts.*/, '').trim()
|
||||
stats.tweets = this.parseStatNumber(countText)
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
catch (error) {
|
||||
logger.parser.error('Error extracting user stats:', (error as Error).message)
|
||||
return stats
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse stat number with K, M suffixes
|
||||
* @param text Number text (e.g., "10.5K")
|
||||
|
||||
@@ -3,81 +3,81 @@
|
||||
* Used to locate elements on the page
|
||||
*/
|
||||
export const SELECTORS = {
|
||||
LOGIN: {
|
||||
USERNAME_INPUT: 'input[autocomplete="username"]',
|
||||
PASSWORD_INPUT: 'input[type="password"]',
|
||||
NEXT_BUTTON: 'div[role="button"]:has-text("Next")',
|
||||
LOGIN_BUTTON: 'div[role="button"]:has-text("Log in")',
|
||||
NEXT_BUTTON_ALT: '[data-testid="login-next-button"]',
|
||||
LOGIN_BUTTON_ALT: '[data-testid="login-submit-button"]',
|
||||
COMPOSE: {
|
||||
EMOJI_BUTTON: '[data-testid="emojiButton"]',
|
||||
MEDIA_BUTTON: '[data-testid="imageOrGifButton"]',
|
||||
POLL_BUTTON: '[data-testid="createPollButton"]',
|
||||
SCHEDULE_BUTTON: '[data-testid="scheduleButton"]',
|
||||
TWEET_BUTTON: '[data-testid="tweetButtonInline"]',
|
||||
TWEET_INPUT: '[data-testid="tweetTextarea_0"]',
|
||||
},
|
||||
HOME: {
|
||||
TIMELINE: '[data-testid="primaryColumn"]',
|
||||
TRENDING: '[data-testid="sidebarColumn"]',
|
||||
TWEET_COMPOSER: '[data-testid="tweetButtonInline"]',
|
||||
},
|
||||
TIMELINE: {
|
||||
TWEET: '[data-testid="tweet"]',
|
||||
TWEET_TEXT: '[data-testid="tweetText"]',
|
||||
TWEET_TIME: 'time',
|
||||
LIKE_BUTTON: '[data-testid="like"]',
|
||||
RETWEET_BUTTON: '[data-testid="retweet"]',
|
||||
REPLY_BUTTON: '[data-testid="reply"]',
|
||||
AUTHOR_LINK: '[data-testid="User-Name"] a',
|
||||
AUTHOR_NAME: '[data-testid="User-Name"] a div span',
|
||||
AUTHOR_USERNAME: '[data-testid="User-Name"] a div div span',
|
||||
TWEET_STATS: '[data-testid="tweet"] [role="group"]',
|
||||
REPLY_COUNT: '[data-testid="reply"] span span',
|
||||
RETWEET_COUNT: '[data-testid="retweet"] span span',
|
||||
LIKE_COUNT: '[data-testid="like"] span span',
|
||||
VIEW_COUNT: '[data-testid="tweet"] [data-testid="app-text-transition-container"] span span',
|
||||
MEDIA_CONTAINER: '[data-testid="tweetPhoto"]',
|
||||
VIDEO_CONTAINER: '[data-testid="videoPlayer"]',
|
||||
TWEET_LINK: 'a[aria-label*="posted"]',
|
||||
TWEET_ID_CONTAINER: 'a[href*="/status/"]',
|
||||
},
|
||||
PROFILE: {
|
||||
FOLLOW_BUTTON: '[data-testid="followButton"]',
|
||||
UNFOLLOW_BUTTON: '[data-testid="unfollowButton"]',
|
||||
DISPLAY_NAME: '[data-testid="UserName"] div span',
|
||||
USERNAME: '[data-testid="UserName"] div span:has-text("@")',
|
||||
BIO: '[data-testid="UserDescription"]',
|
||||
STATS: '[data-testid="UserProfileStats"]',
|
||||
FOLLOWING_STAT: '[href$="/following"]',
|
||||
FOLLOWERS_STAT: '[href$="/followers"]',
|
||||
AVATAR: '[data-testid="UserAvatar-Container"] img',
|
||||
BANNER: '[data-testid="UserProfileHeader_Items"] img',
|
||||
JOIN_DATE: '[data-testid="UserProfileHeader_Items"] span:has-text("Joined")',
|
||||
LOCATION: '[data-testid="UserProfileHeader_Items"] span:has-text("Location")',
|
||||
WEBSITE: '[data-testid="UserProfileHeader_Items"] a[href^="https"]',
|
||||
VERIFIED_BADGE: '[data-testid="icon-verified"]',
|
||||
},
|
||||
COMPOSE: {
|
||||
TWEET_INPUT: '[data-testid="tweetTextarea_0"]',
|
||||
TWEET_BUTTON: '[data-testid="tweetButtonInline"]',
|
||||
MEDIA_BUTTON: '[data-testid="imageOrGifButton"]',
|
||||
POLL_BUTTON: '[data-testid="createPollButton"]',
|
||||
EMOJI_BUTTON: '[data-testid="emojiButton"]',
|
||||
SCHEDULE_BUTTON: '[data-testid="scheduleButton"]',
|
||||
},
|
||||
SEARCH: {
|
||||
INPUT: '[data-testid="SearchBox_Search_Input"]',
|
||||
RESULT_TAB: '[role="tablist"] [role="presentation"]',
|
||||
PEOPLE_TAB: '[role="tab"]:has-text("People")',
|
||||
LATEST_TAB: '[role="tab"]:has-text("Latest")',
|
||||
TOP_TAB: '[role="tab"]:has-text("Top")',
|
||||
SEARCH_FILTERS: '[data-testid="searchFiltersButton"]',
|
||||
},
|
||||
NOTIFICATIONS: {
|
||||
ITEM: '[data-testid="cellInnerDiv"]',
|
||||
MENTION: '[data-testid="notification"]',
|
||||
FOLLOW: '[data-testid="notification"]',
|
||||
LIKE: '[data-testid="notification"]',
|
||||
RETWEET: '[data-testid="notification"]',
|
||||
LOGIN: {
|
||||
LOGIN_BUTTON: 'div[role="button"]:has-text("Log in")',
|
||||
LOGIN_BUTTON_ALT: '[data-testid="login-submit-button"]',
|
||||
NEXT_BUTTON: 'div[role="button"]:has-text("Next")',
|
||||
NEXT_BUTTON_ALT: '[data-testid="login-next-button"]',
|
||||
PASSWORD_INPUT: 'input[type="password"]',
|
||||
USERNAME_INPUT: 'input[autocomplete="username"]',
|
||||
},
|
||||
MESSAGES: {
|
||||
CONVERSATION: '[data-testid="conversation"]',
|
||||
MESSAGE_INPUT: '[data-testid="dmComposerTextInput"]',
|
||||
SEND_BUTTON: '[data-testid="dmComposerSendButton"]',
|
||||
},
|
||||
NOTIFICATIONS: {
|
||||
FOLLOW: '[data-testid="notification"]',
|
||||
ITEM: '[data-testid="cellInnerDiv"]',
|
||||
LIKE: '[data-testid="notification"]',
|
||||
MENTION: '[data-testid="notification"]',
|
||||
RETWEET: '[data-testid="notification"]',
|
||||
},
|
||||
PROFILE: {
|
||||
AVATAR: '[data-testid="UserAvatar-Container"] img',
|
||||
BANNER: '[data-testid="UserProfileHeader_Items"] img',
|
||||
BIO: '[data-testid="UserDescription"]',
|
||||
DISPLAY_NAME: '[data-testid="UserName"] div span',
|
||||
FOLLOW_BUTTON: '[data-testid="followButton"]',
|
||||
FOLLOWERS_STAT: '[href$="/followers"]',
|
||||
FOLLOWING_STAT: '[href$="/following"]',
|
||||
JOIN_DATE: '[data-testid="UserProfileHeader_Items"] span:has-text("Joined")',
|
||||
LOCATION: '[data-testid="UserProfileHeader_Items"] span:has-text("Location")',
|
||||
STATS: '[data-testid="UserProfileStats"]',
|
||||
UNFOLLOW_BUTTON: '[data-testid="unfollowButton"]',
|
||||
USERNAME: '[data-testid="UserName"] div span:has-text("@")',
|
||||
VERIFIED_BADGE: '[data-testid="icon-verified"]',
|
||||
WEBSITE: '[data-testid="UserProfileHeader_Items"] a[href^="https"]',
|
||||
},
|
||||
SEARCH: {
|
||||
INPUT: '[data-testid="SearchBox_Search_Input"]',
|
||||
LATEST_TAB: '[role="tab"]:has-text("Latest")',
|
||||
PEOPLE_TAB: '[role="tab"]:has-text("People")',
|
||||
RESULT_TAB: '[role="tablist"] [role="presentation"]',
|
||||
SEARCH_FILTERS: '[data-testid="searchFiltersButton"]',
|
||||
TOP_TAB: '[role="tab"]:has-text("Top")',
|
||||
},
|
||||
TIMELINE: {
|
||||
AUTHOR_LINK: '[data-testid="User-Name"] a',
|
||||
AUTHOR_NAME: '[data-testid="User-Name"] a div span',
|
||||
AUTHOR_USERNAME: '[data-testid="User-Name"] a div div span',
|
||||
LIKE_BUTTON: '[data-testid="like"]',
|
||||
LIKE_COUNT: '[data-testid="like"] span span',
|
||||
MEDIA_CONTAINER: '[data-testid="tweetPhoto"]',
|
||||
REPLY_BUTTON: '[data-testid="reply"]',
|
||||
REPLY_COUNT: '[data-testid="reply"] span span',
|
||||
RETWEET_BUTTON: '[data-testid="retweet"]',
|
||||
RETWEET_COUNT: '[data-testid="retweet"] span span',
|
||||
TWEET: '[data-testid="tweet"]',
|
||||
TWEET_ID_CONTAINER: 'a[href*="/status/"]',
|
||||
TWEET_LINK: 'a[aria-label*="posted"]',
|
||||
TWEET_STATS: '[data-testid="tweet"] [role="group"]',
|
||||
TWEET_TEXT: '[data-testid="tweetText"]',
|
||||
TWEET_TIME: 'time',
|
||||
VIDEO_CONTAINER: '[data-testid="videoPlayer"]',
|
||||
VIEW_COUNT: '[data-testid="tweet"] [data-testid="app-text-transition-container"] span span',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,54 @@ import { SELECTORS } from './selectors'
|
||||
* Extracts tweet information directly from the page DOM using Playwright
|
||||
*/
|
||||
export class TweetParser {
|
||||
/**
|
||||
* Extract tweet data from tweet element
|
||||
* @param page Playwright page instance
|
||||
* @param tweetElement Tweet element handle
|
||||
* @returns Promise resolving to Tweet object
|
||||
*/
|
||||
static async extractTweetData(page: Page, tweetElement: ElementHandle): Promise<null | Tweet> {
|
||||
try {
|
||||
// Extract tweet ID
|
||||
const id = await this.extractTweetId(tweetElement)
|
||||
|
||||
// Extract tweet text
|
||||
const textElement = await tweetElement.$(SELECTORS.TIMELINE.TWEET_TEXT)
|
||||
const text = textElement ? await textElement.textContent() : ''
|
||||
|
||||
// Extract author info
|
||||
const author = await this.extractAuthorInfo(tweetElement)
|
||||
|
||||
// Extract timestamp
|
||||
const timeElement = await tweetElement.$('time')
|
||||
const timestamp = timeElement ? await timeElement.getAttribute('datetime') : new Date().toISOString()
|
||||
|
||||
// Extract engagement stats
|
||||
const stats = await this.extractTweetStats(tweetElement)
|
||||
|
||||
// Extract media URLs
|
||||
const mediaUrls = await this.extractMediaUrls(tweetElement)
|
||||
|
||||
const tweet: Tweet = {
|
||||
author,
|
||||
id,
|
||||
text: text || '',
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
...stats,
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0) {
|
||||
tweet.mediaUrls = mediaUrls
|
||||
}
|
||||
|
||||
return tweet
|
||||
}
|
||||
catch (error) {
|
||||
logger.parser.error('Error extracting tweet data:', (error as Error).message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timeline tweets directly from the page
|
||||
* @param page Playwright page instance
|
||||
@@ -38,50 +86,71 @@ export class TweetParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tweet data from tweet element
|
||||
* @param page Playwright page instance
|
||||
* Extract author info from tweet element
|
||||
* @param tweetElement Tweet element handle
|
||||
* @returns Promise resolving to Tweet object
|
||||
* @returns Promise resolving to author object
|
||||
*/
|
||||
static async extractTweetData(page: Page, tweetElement: ElementHandle): Promise<Tweet | null> {
|
||||
private static async extractAuthorInfo(tweetElement: ElementHandle): Promise<Tweet['author']> {
|
||||
try {
|
||||
// Extract tweet ID
|
||||
const id = await this.extractTweetId(tweetElement)
|
||||
|
||||
// Extract tweet text
|
||||
const textElement = await tweetElement.$(SELECTORS.TIMELINE.TWEET_TEXT)
|
||||
const text = textElement ? await textElement.textContent() : ''
|
||||
|
||||
// Extract author info
|
||||
const author = await this.extractAuthorInfo(tweetElement)
|
||||
|
||||
// Extract timestamp
|
||||
const timeElement = await tweetElement.$('time')
|
||||
const timestamp = timeElement ? await timeElement.getAttribute('datetime') : new Date().toISOString()
|
||||
|
||||
// Extract engagement stats
|
||||
const stats = await this.extractTweetStats(tweetElement)
|
||||
|
||||
// Extract media URLs
|
||||
const mediaUrls = await this.extractMediaUrls(tweetElement)
|
||||
|
||||
const tweet: Tweet = {
|
||||
id,
|
||||
text: text || '',
|
||||
author,
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
...stats,
|
||||
// Find author element
|
||||
const authorElement = await tweetElement.$('[data-testid="User-Name"]')
|
||||
if (!authorElement) {
|
||||
return {
|
||||
displayName: 'Unknown User',
|
||||
username: 'unknown',
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaUrls.length > 0) {
|
||||
tweet.mediaUrls = mediaUrls
|
||||
}
|
||||
// Get display name
|
||||
const displayNameElement = await authorElement.$('span:first-child')
|
||||
const displayName = displayNameElement ? await displayNameElement.textContent() || 'Unknown User' : 'Unknown User'
|
||||
|
||||
return tweet
|
||||
// Get username
|
||||
const usernameElement = await authorElement.$('a[href^="/"]')
|
||||
let username = usernameElement ? await usernameElement.getAttribute('href') : 'unknown'
|
||||
username = username?.replace('/', '') || 'unknown'
|
||||
|
||||
// Get avatar URL
|
||||
const avatarElement = await tweetElement.$('img[src*="/profile_images/"]')
|
||||
const avatarUrl = avatarElement ? await avatarElement.getAttribute('src') : undefined
|
||||
|
||||
return {
|
||||
displayName,
|
||||
username,
|
||||
...(avatarUrl && { avatarUrl }),
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logger.parser.error('Error extracting tweet data:', (error as Error).message)
|
||||
return null
|
||||
logger.parser.error('Error extracting author info:', (error as Error).message)
|
||||
return {
|
||||
displayName: 'Unknown User',
|
||||
username: 'unknown',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract media URLs from tweet element
|
||||
* @param tweetElement Tweet element handle
|
||||
* @returns Promise resolving to array of media URLs
|
||||
*/
|
||||
private static async extractMediaUrls(tweetElement: ElementHandle): Promise<string[]> {
|
||||
try {
|
||||
const mediaElements = await tweetElement.$$('img[src*="pbs.twimg.com/media/"]')
|
||||
|
||||
const mediaUrls: string[] = []
|
||||
for (const mediaElement of mediaElements) {
|
||||
const src = await mediaElement.getAttribute('src')
|
||||
if (src) {
|
||||
mediaUrls.push(src)
|
||||
}
|
||||
}
|
||||
|
||||
return mediaUrls
|
||||
}
|
||||
catch (error) {
|
||||
logger.parser.error('Error extracting media URLs:', (error as Error).message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,50 +182,6 @@ export class TweetParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract author info from tweet element
|
||||
* @param tweetElement Tweet element handle
|
||||
* @returns Promise resolving to author object
|
||||
*/
|
||||
private static async extractAuthorInfo(tweetElement: ElementHandle): Promise<Tweet['author']> {
|
||||
try {
|
||||
// Find author element
|
||||
const authorElement = await tweetElement.$('[data-testid="User-Name"]')
|
||||
if (!authorElement) {
|
||||
return {
|
||||
username: 'unknown',
|
||||
displayName: 'Unknown User',
|
||||
}
|
||||
}
|
||||
|
||||
// Get display name
|
||||
const displayNameElement = await authorElement.$('span:first-child')
|
||||
const displayName = displayNameElement ? await displayNameElement.textContent() || 'Unknown User' : 'Unknown User'
|
||||
|
||||
// Get username
|
||||
const usernameElement = await authorElement.$('a[href^="/"]')
|
||||
let username = usernameElement ? await usernameElement.getAttribute('href') : 'unknown'
|
||||
username = username?.replace('/', '') || 'unknown'
|
||||
|
||||
// Get avatar URL
|
||||
const avatarElement = await tweetElement.$('img[src*="/profile_images/"]')
|
||||
const avatarUrl = avatarElement ? await avatarElement.getAttribute('src') : undefined
|
||||
|
||||
return {
|
||||
username,
|
||||
displayName,
|
||||
...(avatarUrl && { avatarUrl }),
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logger.parser.error('Error extracting author info:', (error as Error).message)
|
||||
return {
|
||||
username: 'unknown',
|
||||
displayName: 'Unknown User',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tweet stats (likes, retweets, replies)
|
||||
* @param tweetElement Tweet element handle
|
||||
@@ -164,13 +189,13 @@ export class TweetParser {
|
||||
*/
|
||||
private static async extractTweetStats(tweetElement: ElementHandle): Promise<{
|
||||
likeCount?: number
|
||||
retweetCount?: number
|
||||
replyCount?: number
|
||||
retweetCount?: number
|
||||
}> {
|
||||
const stats: {
|
||||
likeCount?: number
|
||||
retweetCount?: number
|
||||
replyCount?: number
|
||||
retweetCount?: number
|
||||
} = {}
|
||||
|
||||
try {
|
||||
@@ -206,37 +231,12 @@ export class TweetParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract media URLs from tweet element
|
||||
* @param tweetElement Tweet element handle
|
||||
* @returns Promise resolving to array of media URLs
|
||||
*/
|
||||
private static async extractMediaUrls(tweetElement: ElementHandle): Promise<string[]> {
|
||||
try {
|
||||
const mediaElements = await tweetElement.$$('img[src*="pbs.twimg.com/media/"]')
|
||||
|
||||
const mediaUrls: string[] = []
|
||||
for (const mediaElement of mediaElements) {
|
||||
const src = await mediaElement.getAttribute('src')
|
||||
if (src) {
|
||||
mediaUrls.push(src)
|
||||
}
|
||||
}
|
||||
|
||||
return mediaUrls
|
||||
}
|
||||
catch (error) {
|
||||
logger.parser.error('Error extracting media URLs:', (error as Error).message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse count text (handles K, M suffixes)
|
||||
* @param countText Count text from tweet
|
||||
* @returns Parsed number or undefined
|
||||
*/
|
||||
private static parseCount(countText: string | null): number | undefined {
|
||||
private static parseCount(countText: null | string): number | undefined {
|
||||
if (!countText)
|
||||
return undefined
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* Browser Configuration Types
|
||||
*/
|
||||
|
||||
export interface Viewport {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface BrowserConfig {
|
||||
headless: boolean
|
||||
requestRetries: number
|
||||
requestTimeout: number
|
||||
timeout: number
|
||||
userAgent: string
|
||||
viewport: Viewport
|
||||
timeout: number
|
||||
requestTimeout: number
|
||||
requestRetries: number
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ export interface SearchOptions {
|
||||
includeReplies?: boolean
|
||||
includeRetweets?: boolean
|
||||
lang?: string
|
||||
resultType?: 'recent' | 'popular' | 'mixed'
|
||||
maxId?: string
|
||||
resultType?: 'mixed' | 'popular' | 'recent'
|
||||
sinceId?: string
|
||||
}
|
||||
|
||||
export interface TimelineOptions {
|
||||
count?: number
|
||||
includeReplies?: boolean
|
||||
includeRetweets?: boolean
|
||||
excludeReplies?: boolean
|
||||
includePromoted?: boolean
|
||||
includeReplies?: boolean
|
||||
includeRetweets?: boolean
|
||||
}
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
/**
|
||||
* Create an error with detailed context information
|
||||
*
|
||||
* @param message - Error message
|
||||
* @param originalError - Original error object (optional)
|
||||
* @param context - Additional context information (optional)
|
||||
* @returns Enhanced error object
|
||||
*/
|
||||
export function createError(
|
||||
message: string,
|
||||
originalError?: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
): Error {
|
||||
let errorMessage = message
|
||||
|
||||
// Add original error information
|
||||
if (originalError) {
|
||||
errorMessage += `: ${errorToMessage(originalError)}`
|
||||
}
|
||||
|
||||
// Create new error object
|
||||
const error = new Error(errorMessage)
|
||||
|
||||
// Add context information
|
||||
if (context) {
|
||||
Object.assign(error, { context })
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract error message from any error type
|
||||
* Handles Error objects, strings, objects, and other types
|
||||
@@ -41,34 +72,3 @@ export function errorToMessage(error: unknown, fallbackMessage = 'Unknown error'
|
||||
// For other cases, try to force convert to string
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error with detailed context information
|
||||
*
|
||||
* @param message - Error message
|
||||
* @param originalError - Original error object (optional)
|
||||
* @param context - Additional context information (optional)
|
||||
* @returns Enhanced error object
|
||||
*/
|
||||
export function createError(
|
||||
message: string,
|
||||
originalError?: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
): Error {
|
||||
let errorMessage = message
|
||||
|
||||
// Add original error information
|
||||
if (originalError) {
|
||||
errorMessage += `: ${errorToMessage(originalError)}`
|
||||
}
|
||||
|
||||
// Create new error object
|
||||
const error = new Error(errorMessage)
|
||||
|
||||
// Add context information
|
||||
if (context) {
|
||||
Object.assign(error, { context })
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ export function initLogger(): void {
|
||||
const config = useConfigManager().getConfig()
|
||||
|
||||
const logLevelMap: Record<string, LogLevel> = {
|
||||
debug: LogLevel.Debug,
|
||||
error: LogLevel.Error,
|
||||
warn: LogLevel.Warning,
|
||||
info: LogLevel.Log,
|
||||
verbose: LogLevel.Verbose,
|
||||
debug: LogLevel.Debug,
|
||||
warn: LogLevel.Warning,
|
||||
}
|
||||
|
||||
setGlobalLogLevel(logLevelMap[config.system?.logLevel] || LogLevel.Debug)
|
||||
@@ -64,12 +64,12 @@ export function useLogger(name?: string): Logg {
|
||||
|
||||
// Create pre-configured loggers for various services
|
||||
export const logger = {
|
||||
auth: useLogger('auth-service'),
|
||||
timeline: useLogger('timeline-service'),
|
||||
browser: useLogger('browser-adapter'),
|
||||
airi: useLogger('airi-adapter'),
|
||||
auth: useLogger('auth-service'),
|
||||
browser: useLogger('browser-adapter'),
|
||||
config: useLogger('config'),
|
||||
main: useLogger('twitter-service'),
|
||||
mcp: useLogger('mcp-adapter'),
|
||||
parser: useLogger('parser'),
|
||||
main: useLogger('twitter-service'),
|
||||
config: useLogger('config'),
|
||||
timeline: useLogger('timeline-service'),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user