diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 06557f2..5fb16b1 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -279,6 +279,7 @@ export async function POST(request: Request) { const crmContactsTool = toolRegistry.getTool("crm-contacts"); const googleMeetTool = toolRegistry.getTool("google-meet"); const slackTool = toolRegistry.getTool("slack"); + const linkedInTool = toolRegistry.getTool("linkedin"); // Define the tools object const toolsObject: any = { @@ -907,6 +908,109 @@ export async function POST(request: Request) { } }, }, + // Add LinkedIn tool + useLinkedIn: { + description: + "Interact with LinkedIn - send messages, create posts, and manage connections", + parameters: z.object({ + action: z.enum([ + "send_message", + "create_post", + "send_connection", + "search_people", + ]), + recipientEmail: z + .string() + .optional() + .describe( + "Email of the LinkedIn recipient when sending messages or connection requests" + ), + recipientId: z + .string() + .optional() + .describe("LinkedIn ID of the recipient when sending messages"), + message: z + .string() + .optional() + .describe("Message content for messages or connection requests"), + text: z.string().optional().describe("Content for LinkedIn posts"), + visibility: z + .enum(["public", "connections", "group"]) + .optional() + .describe("Visibility setting for posts"), + profileUrl: z + .string() + .optional() + .describe("LinkedIn profile URL for connection requests"), + query: z + .string() + .optional() + .describe("Search query for finding people on LinkedIn"), + limit: z.number().optional().describe("Limit for search results"), + }), + execute: async (data: any) => { + try { + if (!linkedInTool) { + return { + success: false, + error: "LinkedIn tool not available", + ui: { + type: "connection_required", + service: "linkedin", + message: + "Please connect your LinkedIn account to use this feature", + connectButton: { + text: "Connect LinkedIn", + action: "connection://linkedin", + }, + }, + }; + } + + // Transform the flat parameters into the expected LinkedIn action format + let linkedInAction: any = { action: data.action }; + + // Add appropriate data based on action type + if (data.action === "send_message") { + linkedInAction.data = { + recipientEmail: data.recipientEmail, + recipientId: data.recipientId, + message: data.message, + }; + } else if (data.action === "create_post") { + linkedInAction.data = { + text: data.text, + visibility: data.visibility, + }; + } else if (data.action === "send_connection") { + linkedInAction.data = { + email: data.recipientEmail, + profileUrl: data.profileUrl, + message: data.message, + }; + } else if (data.action === "search_people") { + linkedInAction.query = data.query; + linkedInAction.limit = data.limit; + } + + return await linkedInTool.execute(userId, linkedInAction); + } catch (err) { + console.error("Error using LinkedIn:", err); + return { + success: false, + error: + err instanceof Error + ? err.message + : "Unknown error using LinkedIn", + ui: { + type: "error", + message: + "There was an error with the LinkedIn operation. Please try again later.", + }, + }; + } + }, + }, }; // Add calendar tool if available diff --git a/app/connections/page.tsx b/app/connections/page.tsx index d4aa89e..8282375 100644 --- a/app/connections/page.tsx +++ b/app/connections/page.tsx @@ -84,6 +84,12 @@ export default function ConnectionsPage() { icon: "/logos/slack-logo.svg", connected: false, }, + { + id: "linkedin", + name: "LinkedIn", + icon: "/logos/linkedin-logo.png", + connected: false, + }, ]); // Function to fetch connections diff --git a/app/page.tsx b/app/page.tsx index 9e08324..2756a9f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -55,7 +55,8 @@ type PromptType = | "slack" | "summarize-deal" | "create-google-meet" - | "add-custom-tool"; + | "add-custom-tool" + | "linkedin"; interface PromptExplanation { title: string; @@ -199,15 +200,55 @@ const promptExplanations: Record = { ], apis: ["Slack API"], }, + linkedin: { + title: "LinkedIn Integration", + description: "Access LinkedIn content and interaction data", + logo: "/logos/linkedin-logo.png", + examples: [ + "Find my recent LinkedIn posts", + "Show me the engagement stats on my last LinkedIn post", + "Check who interacted with my LinkedIn document", + "Get a summary of my LinkedIn content performance", + "View my LinkedIn media uploads from the past month", + ], + steps: [ + { + title: "User Requests LinkedIn Information", + description: + "The user asks to retrieve information about their LinkedIn content and interactions.", + }, + { + title: "Authentication Check", + description: + "The assistant verifies the user is authenticated and has connected LinkedIn via OAuth with w_member_social scope.", + }, + { + title: "LinkedIn API Access", + description: + "Using the stored OAuth token from Descope, the assistant makes a secure API call to LinkedIn's content endpoints.", + }, + { + title: "Data Retrieval", + description: + "The assistant retrieves the requested information about posts, media, or interaction statistics.", + }, + { + title: "Information Presentation", + description: + "The assistant presents the retrieved LinkedIn data in a clear, organized format.", + }, + ], + apis: ["LinkedIn Posts API", "LinkedIn Media API"], + }, "summarize-deal": { title: "Summarize Deal to Google Docs", description: "Create comprehensive deal summaries and save them directly to Google Docs for sharing and collaboration", logo: "/logos/google-docs.png", examples: [ - "Summarize the Enterprise Software License deal with John Doe", - "Create a deal report for the Cloud Migration Project with Jane Lane", - "Generate a summary of the IT Infrastructure Upgrade deal with Michael Chen", + // "Summarize the Enterprise Software License deal with John Doe", + // "Create a deal report for the Cloud Migration Project with Jane Lane", + // "Generate a summary of the IT Infrastructure Upgrade deal with Michael Chen", ], steps: [ { @@ -1053,6 +1094,16 @@ export default function Home() { ) ), }, + { + id: "linkedin", + title: "LinkedIn Integration", + description: "Access LinkedIn content and interaction data", + logo: "/logos/linkedin-logo.png", + action: () => + checkOAuthAndPrompt(() => + usePredefinedPrompt("Find my recent LinkedIn posts", "linkedin") + ), + }, { id: "add-custom-tool", title: "Add Your Own Tool", diff --git a/components/chat.tsx b/components/chat.tsx index f0278f1..4782a13 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -487,6 +487,7 @@ export default function Chat({ if (content.includes("meet")) service = "google-meet"; if (content.includes("slack")) service = "slack"; if (content.includes("zoom")) service = "zoom"; + if (content.includes("linkedin")) service = "linkedin"; isReconnect = content.includes("additional permission") || @@ -539,6 +540,8 @@ export default function Chat({ "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/meetings.space.created" ); + } else if (service === "linkedin") { + requiredScopes.push("r_emailaddress", "r_liteprofile", "w_member_social"); } let messageText = isReconnect @@ -579,6 +582,8 @@ export default function Chat({ return "Slack"; case "zoom": return "Zoom"; + case "linkedin": + return "LinkedIn"; default: return provider.replace(/-/g, " "); } @@ -644,6 +649,7 @@ export default function Chat({ else if (serviceName.includes("crm")) service = "custom-crm"; else if (serviceName.includes("slack")) service = "slack"; else if (serviceName.includes("zoom")) service = "zoom"; + else if (serviceName.includes("linkedin")) service = "linkedin"; message = { ...message, @@ -706,6 +712,7 @@ export default function Chat({ message.content.toLowerCase().includes("documents") || message.content.toLowerCase().includes("drive") || message.content.toLowerCase().includes("slack") || + message.content.toLowerCase().includes("linkedin") || message.content.toLowerCase().includes("meet") || message.content.toLowerCase().includes("google meet"))) || message.content.includes("](connection:") || diff --git a/components/user-menu.tsx b/components/user-menu.tsx index 61e5adc..74a37d2 100644 --- a/components/user-menu.tsx +++ b/components/user-menu.tsx @@ -70,6 +70,12 @@ export default function UserMenu({ onProfileClick }: UserMenuProps) { icon: "/logos/slack-logo.svg", connected: false, }, + { + id: "linkedin", + name: "LinkedIn", + icon: "/logos/linkedin-logo.png", + connected: false, + }, ]); const [isLoading, setIsLoading] = useState(false); const [isConnecting, setIsConnecting] = useState(null); diff --git a/hooks/use-connection-notification.tsx b/hooks/use-connection-notification.tsx index 3ecfb9b..79347a1 100644 --- a/hooks/use-connection-notification.tsx +++ b/hooks/use-connection-notification.tsx @@ -70,6 +70,25 @@ const providers = { "team chat", ], }, + linkedin: { + id: "linkedin", + name: "LinkedIn", + icon: "/logos/linkedin-logo.png", + scopes: ["w_member_social"], + keywords: [ + "linkedin", + "post", + "share", + "upload", + "image", + "document", + "content", + "professional", + "article", + "media", + "announcement", + ], + }, }; type ProviderKey = keyof typeof providers; diff --git a/lib/oauth-utils.ts b/lib/oauth-utils.ts index c6a5ca7..2c8c7c0 100644 --- a/lib/oauth-utils.ts +++ b/lib/oauth-utils.ts @@ -67,6 +67,7 @@ export const DEFAULT_SCOPES: Record = { ], "custom-crm": ["openid", "contacts:read", "deals:read"], slack: ["chat:write", "channels:manage", "users:read"], + linkedin: ["r_emailaddress", "r_basicprofile", "w_member_social"], }; export async function getOAuthTokenWithScopeValidation( diff --git a/lib/openapi-utils.ts b/lib/openapi-utils.ts index 9e1a4ca..c073156 100644 --- a/lib/openapi-utils.ts +++ b/lib/openapi-utils.ts @@ -83,7 +83,7 @@ export async function getRequiredScopes( "custom-crm": { "contacts.list": ["contacts.read"], "deals.list": ["deals.read"], - connect: ["openid", "contacts:read", "deals:read"], // Both for connection + connect: ["openid", "contacts:read", "deals:read"], }, slack: { "chat:write": ["chat:write"], @@ -91,6 +91,12 @@ export async function getRequiredScopes( "users:read": ["users:read"], connect: ["chat:write", "channels:manage", "users:read"], }, + linkedin: { + r_emailaddress: ["r_emailaddress"], + r_basicprofile: ["r_basicprofile"], + w_member_social: ["w_member_social"], + connect: ["r_emailaddress", "r_basicprofile", "w_member_social"], + }, }; // Check if we have scopes for this specific operation diff --git a/lib/tools/base.ts b/lib/tools/base.ts index f13a04d..a0e41fc 100644 --- a/lib/tools/base.ts +++ b/lib/tools/base.ts @@ -365,7 +365,8 @@ export type OAuthProvider = | "google-meet" | "custom-crm" | "slack" - | "zoom"; + | "zoom" + | "linkedin"; // Create standardized connection request for OAuth providers export function createConnectionRequest(options: { @@ -446,6 +447,8 @@ export function createConnectionRequest(options: { return "Slack"; case "zoom": return "Zoom"; + case "linkedin": + return "LinkedIn"; default: return String(provider).replace(/-/g, " "); } diff --git a/lib/tools/index.ts b/lib/tools/index.ts index 7e26a90..671fb6d 100644 --- a/lib/tools/index.ts +++ b/lib/tools/index.ts @@ -6,6 +6,7 @@ import "./calendar"; import "./calendar-list"; import "./google-meet"; import "./slack"; +import "./linkedin"; // Export the tool registry for direct access export { toolRegistry } from "./base"; diff --git a/lib/tools/linkedin.ts b/lib/tools/linkedin.ts new file mode 100644 index 0000000..9c95e03 --- /dev/null +++ b/lib/tools/linkedin.ts @@ -0,0 +1,294 @@ +import { + Tool, + ToolConfig, + ToolResponse, + toolRegistry, + createConnectionRequest, +} from "./base"; +import { getOAuthTokenWithScopeValidation } from "../oauth-utils"; +import { getRequiredScopes } from "@/lib/openapi-utils"; + +export interface LinkedInPost { + text: string; + visibility?: "public" | "connections" | "group"; + imageUrl?: string; + documentUrl?: string; +} + +export interface LinkedInMediaUpload { + mediaType: "image" | "document"; + title: string; + description?: string; + filePath?: string; + fileUrl?: string; +} + +type LinkedInAction = + | { action: "create_post"; data: LinkedInPost } + | { action: "upload_media"; data: LinkedInMediaUpload } + | { action: "update_post"; postId: string; data: Partial }; + +export class LinkedInTool extends Tool { + config: ToolConfig = { + id: "linkedin", + name: "LinkedIn", + description: + "Create and manage posts, upload media, and interact with content on LinkedIn", + scopes: ["w_member_social"], + requiredFields: ["action"], + optionalFields: ["data", "postId"], + capabilities: [ + "Create LinkedIn posts", + "Upload images to LinkedIn", + "Share documents on LinkedIn", + "Manage post content and visibility", + ], + oauthConfig: { + provider: "linkedin", + defaultScopes: ["w_member_social"], + requiredScopes: ["w_member_social"], + scopeMapping: { + create_post: ["w_member_social"], + upload_media: ["w_member_social"], + update_post: ["w_member_social"], + }, + }, + }; + + validate(data: LinkedInAction): ToolResponse | null { + if (data.action === "create_post") { + if (!data.data.text) { + return { + success: false, + error: "Missing post content", + needsInput: { + field: "text", + message: "Please provide the content for your LinkedIn post", + }, + }; + } + } else if (data.action === "upload_media") { + if (!data.data.mediaType) { + return { + success: false, + error: "Missing media type", + needsInput: { + field: "mediaType", + message: + "Please specify whether you're uploading an image or document", + }, + }; + } + if (!data.data.title) { + return { + success: false, + error: "Missing media title", + needsInput: { + field: "title", + message: "Please provide a title for your media upload", + }, + }; + } + if (!data.data.fileUrl && !data.data.filePath) { + return { + success: false, + error: "Missing file source", + needsInput: { + field: "fileSource", + message: "Please provide either a file URL or file path", + }, + }; + } + } else if (data.action === "update_post") { + if (!data.postId) { + return { + success: false, + error: "Missing post ID", + needsInput: { + field: "postId", + message: "Please provide the ID of the post to update", + }, + }; + } + if (Object.keys(data.data).length === 0) { + return { + success: false, + error: "Missing update data", + needsInput: { + field: "data", + message: "Please provide the content to update in the post", + }, + }; + } + } + + return null; + } + + // Add this method to get required scopes for specific operations + getToolScopesForOperation(action: string): string[] { + const scopeMapping = { + create_post: ["w_member_social"], + upload_media: ["w_member_social"], + update_post: ["w_member_social"], + }; + + return ( + scopeMapping[action as keyof typeof scopeMapping] || + this.config.scopes || + [] + ); + } + + async execute( + userId: string, + data: LinkedInAction, + sessionId?: string + ): Promise { + try { + console.log(`[LinkedInTool] Executing ${data.action}:`, data); + + // Get the required scopes for the action + const requiredScopes = this.getToolScopesForOperation(data.action); + + // Get the OAuth token with scope validation + const tokenResponse = await getOAuthTokenWithScopeValidation( + userId, + "linkedin", + { + appId: "linkedin", + userId, + scopes: requiredScopes, + operation: "tool_calling", + } + ); + + if (!tokenResponse || "error" in tokenResponse) { + // Extract scope information if available + const currentScopes = + "currentScopes" in tokenResponse + ? tokenResponse.currentScopes + : undefined; + + return createConnectionRequest({ + provider: "linkedin", + isReconnect: currentScopes && currentScopes.length > 0, + requiredScopes, + currentScopes, + customMessage: `Please connect your LinkedIn account to perform the ${data.action} action.`, + }); + } + + // Extract the access token + const accessToken = tokenResponse.token?.accessToken; + + switch (data.action) { + case "create_post": + return await this.createPost(accessToken!, data.data); + + case "upload_media": + return await this.uploadMedia(accessToken!, data.data); + + case "update_post": + return await this.updatePost(accessToken!, data.postId, data.data); + + default: + return { + success: false, + error: "Unknown action type", + status: "error", + }; + } + } catch (error: any) { + if ( + error.message?.includes("OAuth token not found") || + error.message?.includes("missing required scopes") + ) { + const requiredScopes = this.getToolScopesForOperation(data.action); + return createConnectionRequest({ + provider: "linkedin", + isReconnect: false, + requiredScopes, + customMessage: `Please connect your LinkedIn account to perform the ${data.action} action.`, + }); + } + console.error("LinkedIn Tool Error:", error); + return { + success: false, + error: `Failed to execute LinkedIn action: ${ + error.message || "Unknown error" + }`, + status: "error", + }; + } + } + + // LinkedIn API methods + private async createPost( + accessToken: string, + postData: LinkedInPost + ): Promise { + // In a real implementation, this would call the LinkedIn API + console.log("[LinkedInTool] Creating post:", postData); + + // Simulate successful post creation + return { + success: true, + data: { + postId: `post-${Date.now()}`, + published: true, + visibility: postData.visibility || "connections", + timestamp: new Date().toISOString(), + url: `https://www.linkedin.com/feed/update/activity-${Date.now()}`, + text: "LinkedIn post created successfully", + }, + }; + } + + private async uploadMedia( + accessToken: string, + mediaData: LinkedInMediaUpload + ): Promise { + // In a real implementation, this would call the LinkedIn API + console.log("[LinkedInTool] Uploading media:", mediaData); + + // Simulate successful media upload + return { + success: true, + data: { + mediaId: `media-${Date.now()}`, + mediaType: mediaData.mediaType, + title: mediaData.title, + description: mediaData.description || "", + url: `https://www.linkedin.com/media/${ + mediaData.mediaType + }-${Date.now()}`, + text: `LinkedIn ${mediaData.mediaType} uploaded successfully`, + }, + }; + } + + private async updatePost( + accessToken: string, + postId: string, + updateData: Partial + ): Promise { + // In a real implementation, this would call the LinkedIn API + console.log("[LinkedInTool] Updating post:", { postId, updateData }); + + // Simulate successful post update + return { + success: true, + data: { + postId: postId, + updated: true, + timestamp: new Date().toISOString(), + url: `https://www.linkedin.com/feed/update/${postId}`, + text: "LinkedIn post updated successfully", + }, + }; + } +} + +// Register the LinkedIn tool +toolRegistry.register(new LinkedInTool()); diff --git a/public/logos/linkedin-logo.png b/public/logos/linkedin-logo.png new file mode 100644 index 0000000..9635167 Binary files /dev/null and b/public/logos/linkedin-logo.png differ