import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { UpworkClient } from "../../services/upwork-client.js"; // Schema for the polling tool export const PollNewConversationsArgsSchema = z.object({ minutes_back: z.number() .min(1) .max(1440) .default(5) .describe("How many minutes back to check for new messages (default: 5)"), include_details: z.boolean() .default(true) .describe("Include full message details in response (default: true)") }); export type PollNewConversationsArgs = z.infer; // Tool definition export const pollNewConversationsToolDefinition: Tool = { name: "upwork_poll_new_conversations", description: "Poll for new Upwork messages within a time window (stateless). Returns AI-friendly JSON for notifications. Perfect for cron jobs running every 5 minutes.", inputSchema: { type: "object", properties: { minutes_back: { type: "number", minimum: 1, maximum: 1440, default: 5, description: "How many minutes back to check for new messages (default: 5)" }, include_details: { type: "boolean", default: true, description: "Include full message details in response (default: true)" } } } }; // Types for the response interface MessageActivity { roomId: string; roomName: string; roomType: string; contractTopic: string | null; newMessageCount: number; latestMessage: { id: string; sender: string; timestamp: string; preview: string; isFromClient: boolean; }; urgency: 'high' | 'normal' | 'low'; roomUrl: string; } interface PollResult { success: boolean; checkTime: string; summary: { totalRooms: number; roomsWithActivity: number; totalNewMessages: number; }; newActivity: MessageActivity[]; metadata: { pollInterval: string; nextCheckTime: string; timeWindowUsed: string; }; errors?: string[]; } // Handler implementation export async function pollNewConversationsHandler( args: unknown, upworkClient: UpworkClient ): Promise<{ content: Array<{ type: string; text: string }> }> { const validatedArgs = PollNewConversationsArgsSchema.parse(args); const checkTime = new Date(); const cutoffTime = new Date(checkTime.getTime() - validatedArgs.minutes_back * 60 * 1000); try { // GraphQL query - using the CORRECT fields from actual Upwork API const roomListQuery = ` query GetRooms { roomList { totalCount edges { node { id roomName roomType topic numUnread lastVisitedDateTime lastReadDateTime contractId latestStory { id message createdDateTime user { id name } } } } } } `; const response = await upworkClient.graphqlQuery(roomListQuery, {}); if (!response.data?.roomList) { throw new Error('Failed to fetch rooms from Upwork'); } const rooms = response.data.roomList.edges.map((edge: any) => edge.node); const newActivity: MessageActivity[] = []; // Filter for messages within our time window for (const room of rooms) { if (!room.latestStory) continue; const messageTime = new Date(room.latestStory.createdDateTime); if (messageTime > cutoffTime) { // This message is within our time window! const minutesAgo = Math.floor((checkTime.getTime() - messageTime.getTime()) / 60000); const activity: MessageActivity = { roomId: room.id, roomName: room.roomName || 'Unknown Room', roomType: room.roomType || 'UNKNOWN', contractTopic: room.topic, newMessageCount: 1, // We only know about the latest latestMessage: { id: room.latestStory.id, sender: room.latestStory.user?.name || 'Unknown', timestamp: room.latestStory.createdDateTime, preview: truncateMessage(room.latestStory.message), isFromClient: isClientMessage(room) }, urgency: calculateUrgency(room, minutesAgo), roomUrl: `https://www.upwork.com/ab/messages/rooms/${room.id}` }; newActivity.push(activity); } } // Sort by urgency and timestamp newActivity.sort((a, b) => { const urgencyOrder = { high: 0, normal: 1, low: 2 }; if (a.urgency !== b.urgency) { return urgencyOrder[a.urgency] - urgencyOrder[b.urgency]; } return new Date(b.latestMessage.timestamp).getTime() - new Date(a.latestMessage.timestamp).getTime(); }); // Build the result const result: PollResult = { success: true, checkTime: checkTime.toISOString(), summary: { totalRooms: rooms.length, roomsWithActivity: newActivity.length, totalNewMessages: newActivity.reduce((sum, a) => sum + a.newMessageCount, 0) }, newActivity, metadata: { pollInterval: `${validatedArgs.minutes_back} minutes`, nextCheckTime: new Date(checkTime.getTime() + validatedArgs.minutes_back * 60 * 1000).toISOString(), timeWindowUsed: `${cutoffTime.toLocaleTimeString()} - ${checkTime.toLocaleTimeString()}` } }; // Format the response let textResponse = ''; if (newActivity.length === 0) { textResponse = `šŸ“­ **No New Messages**\n\n` + `Checked ${result.summary.totalRooms} rooms\n` + `Time window: Last ${validatedArgs.minutes_back} minutes\n` + `No messages found in this period`; } else { textResponse = `šŸ”” **New Upwork Messages**\n\n` + `šŸ“Š **Summary**: ${result.summary.roomsWithActivity} conversation(s), ` + `${result.summary.totalNewMessages} new message(s)\n\n`; // Group by urgency const highPriority = newActivity.filter(a => a.urgency === 'high'); const normalPriority = newActivity.filter(a => a.urgency === 'normal'); if (highPriority.length > 0) { textResponse += `šŸ”“ **HIGH PRIORITY**\n`; for (const activity of highPriority) { textResponse += formatActivity(activity); } } if (normalPriority.length > 0) { textResponse += `\n🟔 **NORMAL PRIORITY**\n`; for (const activity of normalPriority) { textResponse += formatActivity(activity); } } textResponse += `\nā° _Checked at: ${checkTime.toLocaleTimeString()}_\n`; textResponse += `šŸ“… _Next check: ${new Date(result.metadata.nextCheckTime).toLocaleTimeString()}_`; } // Include JSON if requested if (validatedArgs.include_details) { textResponse += `\n\n**šŸ“‹ Raw JSON for AI Processing:**\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``; } return { content: [{ type: "text", text: textResponse }] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorResult: PollResult = { success: false, checkTime: checkTime.toISOString(), summary: { totalRooms: 0, roomsWithActivity: 0, totalNewMessages: 0 }, newActivity: [], metadata: { pollInterval: `${validatedArgs.minutes_back} minutes`, nextCheckTime: new Date(checkTime.getTime() + validatedArgs.minutes_back * 60 * 1000).toISOString(), timeWindowUsed: `${cutoffTime.toLocaleTimeString()} - ${checkTime.toLocaleTimeString()}` }, errors: [errorMessage] }; return { content: [{ type: "text", text: `āŒ **Polling Failed**\n\n` + `Error: ${errorMessage}\n` + `Time: ${checkTime.toLocaleTimeString()}\n\n` + `**JSON Response:**\n\`\`\`json\n${JSON.stringify(errorResult, null, 2)}\n\`\`\`` }] }; } } // Helper functions function truncateMessage(message: string | null): string { if (!message) return '(No message preview available)'; const cleaned = message.replace(/\s+/g, ' ').trim(); return cleaned.length > 100 ? cleaned.substring(0, 97) + '...' : cleaned; } function isClientMessage(room: any): boolean { // Interview rooms are usually from clients if (room.roomType === 'INTERVIEW') return true; // Check for company suffixes in room name const hasCompanySuffix = /\b(LLC|Inc|Corp|Ltd|Company|Co\.|Agency)\b/i.test(room.roomName || ''); return hasCompanySuffix; } function calculateUrgency(room: any, minutesAgo: number): 'high' | 'normal' | 'low' { // Very recent messages from interviews are HIGH if (room.roomType === 'INTERVIEW' && minutesAgo <= 2) return 'high'; // Interview rooms are generally high priority if (room.roomType === 'INTERVIEW') return 'high'; // Contract discussions are high priority if (room.contractId) return 'high'; // Very recent messages are higher priority if (minutesAgo <= 2) return 'normal'; return 'normal'; } function formatActivity(activity: MessageActivity): string { const minutesAgo = Math.floor( (Date.now() - new Date(activity.latestMessage.timestamp).getTime()) / 60000 ); const timeAgo = minutesAgo >= 60 ? `${Math.floor(minutesAgo/60)}h ${minutesAgo%60}m ago` : `${minutesAgo}m ago`; let text = `\nšŸ’¼ **${activity.roomName}**\n`; if (activity.contractTopic) { text += `šŸ“‹ _${activity.contractTopic}_\n`; } text += `šŸ“¬ ${activity.newMessageCount} new message${activity.newMessageCount > 1 ? 's' : ''} (${timeAgo})\n`; text += `šŸ‘¤ From: ${activity.latestMessage.sender}${activity.latestMessage.isFromClient ? ' āœ“ Client' : ''}\n`; text += `šŸ’¬ _"${activity.latestMessage.preview}"_\n`; text += `šŸ”— [Open Room](${activity.roomUrl})\n`; return text; } // Export the tool export const pollNewConversationsTool = { definition: pollNewConversationsToolDefinition, handler: pollNewConversationsHandler, schema: PollNewConversationsArgsSchema };