From a3a5bf1d76e5c07b1e6f589c3ddd3a32a6e1d4f5 Mon Sep 17 00:00:00 2001 From: Adam Gough <77861281+aadamgough@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:27:21 -0700 Subject: [PATCH] feat(microsoft-tools): added planner, onedrive, and sharepoint (#840) * first push * feat: finished onedrive tool * added refresh * added sharepoint with create page * finished sharepoint and onedrive * planner working * fixed create task tool * made read task better * cleaned up read task * bun run lint * cleaned up #840 * greptile changes and clean up * bun run lint * fix #840 * added docs #840 * bun run lint #840 * removed unnecessary logic #840 * removed page token #840 * fixed docs and descriptions, added advanced mode #840 * remove unused types, cleaned up a lot, fixed docs * readded file upload and changed docs * bun run lint * added folder name --------- Co-authored-by: Adam Gough Co-authored-by: Adam Gough Co-authored-by: waleedlatif1 --- apps/docs/content/docs/tools/meta.json | 3 + .../content/docs/tools/microsoft_planner.mdx | 178 ++++++++++ apps/docs/content/docs/tools/onedrive.mdx | 127 +++++++ apps/docs/content/docs/tools/sharepoint.mdx | 135 ++++++++ .../[documentId]/chunks/[chunkId]/route.ts | 13 +- .../app/api/knowledge/[id]/documents/route.ts | 12 +- .../tools/microsoft_planner/tasks/route.ts | 110 ++++++ .../app/api/tools/onedrive/folder/route.ts | 83 +++++ .../app/api/tools/onedrive/folders/route.ts | 89 +++++ .../app/api/tools/sharepoint/site/route.ts | 105 ++++++ .../app/api/tools/sharepoint/sites/route.ts | 85 +++++ .../components/microsoft-file-selector.tsx | 318 +++++++++++++++-- .../file-selector/file-selector-input.tsx | 144 ++++++++ apps/sim/blocks/blocks/microsoft_planner.ts | 238 +++++++++++++ apps/sim/blocks/blocks/onedrive.ts | 235 +++++++++++++ apps/sim/blocks/blocks/sharepoint.ts | 158 +++++++++ apps/sim/blocks/registry.ts | 6 + apps/sim/components/icons.tsx | 163 +++++++++ apps/sim/lib/auth.ts | 62 ++++ apps/sim/lib/oauth/oauth.ts | 91 ++++- .../tools/microsoft_planner/create_task.ts | 203 +++++++++++ apps/sim/tools/microsoft_planner/index.ts | 5 + apps/sim/tools/microsoft_planner/read_task.ts | 149 ++++++++ apps/sim/tools/microsoft_planner/types.ts | 117 +++++++ apps/sim/tools/onedrive/create_folder.ts | 88 +++++ apps/sim/tools/onedrive/index.ts | 7 + apps/sim/tools/onedrive/list.ts | 120 +++++++ apps/sim/tools/onedrive/types.ts | 64 ++++ apps/sim/tools/onedrive/upload.ts | 132 +++++++ apps/sim/tools/registry.ts | 18 + apps/sim/tools/sharepoint/create_page.ts | 157 +++++++++ apps/sim/tools/sharepoint/index.ts | 7 + apps/sim/tools/sharepoint/list_sites.ts | 117 +++++++ apps/sim/tools/sharepoint/read_page.ts | 325 ++++++++++++++++++ apps/sim/tools/sharepoint/types.ts | 213 ++++++++++++ apps/sim/tools/sharepoint/utils.ts | 87 +++++ 36 files changed, 4113 insertions(+), 51 deletions(-) create mode 100644 apps/docs/content/docs/tools/microsoft_planner.mdx create mode 100644 apps/docs/content/docs/tools/onedrive.mdx create mode 100644 apps/docs/content/docs/tools/sharepoint.mdx create mode 100644 apps/sim/app/api/tools/microsoft_planner/tasks/route.ts create mode 100644 apps/sim/app/api/tools/onedrive/folder/route.ts create mode 100644 apps/sim/app/api/tools/onedrive/folders/route.ts create mode 100644 apps/sim/app/api/tools/sharepoint/site/route.ts create mode 100644 apps/sim/app/api/tools/sharepoint/sites/route.ts create mode 100644 apps/sim/blocks/blocks/microsoft_planner.ts create mode 100644 apps/sim/blocks/blocks/onedrive.ts create mode 100644 apps/sim/blocks/blocks/sharepoint.ts create mode 100644 apps/sim/tools/microsoft_planner/create_task.ts create mode 100644 apps/sim/tools/microsoft_planner/index.ts create mode 100644 apps/sim/tools/microsoft_planner/read_task.ts create mode 100644 apps/sim/tools/microsoft_planner/types.ts create mode 100644 apps/sim/tools/onedrive/create_folder.ts create mode 100644 apps/sim/tools/onedrive/index.ts create mode 100644 apps/sim/tools/onedrive/list.ts create mode 100644 apps/sim/tools/onedrive/types.ts create mode 100644 apps/sim/tools/onedrive/upload.ts create mode 100644 apps/sim/tools/sharepoint/create_page.ts create mode 100644 apps/sim/tools/sharepoint/index.ts create mode 100644 apps/sim/tools/sharepoint/list_sites.ts create mode 100644 apps/sim/tools/sharepoint/read_page.ts create mode 100644 apps/sim/tools/sharepoint/types.ts create mode 100644 apps/sim/tools/sharepoint/utils.ts diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index db0fc6c07..b4ba8f927 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -29,9 +29,11 @@ "mem0", "memory", "microsoft_excel", + "microsoft_planner", "microsoft_teams", "mistral_parse", "notion", + "onedrive", "openai", "outlook", "perplexity", @@ -41,6 +43,7 @@ "s3", "schedule", "serper", + "sharepoint", "slack", "stagehand", "stagehand_agent", diff --git a/apps/docs/content/docs/tools/microsoft_planner.mdx b/apps/docs/content/docs/tools/microsoft_planner.mdx new file mode 100644 index 000000000..5ae82298f --- /dev/null +++ b/apps/docs/content/docs/tools/microsoft_planner.mdx @@ -0,0 +1,178 @@ +--- +title: Microsoft Planner +description: Read and create tasks in Microsoft Planner +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[Microsoft Planner](https://www.microsoft.com/en-us/microsoft-365/planner) is a task management tool that helps teams organize work visually using boards, tasks, and buckets. Integrated with Microsoft 365, it offers a simple, intuitive way to manage team projects, assign responsibilities, and track progress. + +With Microsoft Planner, you can: + +- **Create and manage tasks**: Add new tasks with due dates, priorities, and assigned users +- **Organize with buckets**: Group tasks by phase, status, or category to reflect your team’s workflow +- **Visualize project status**: Use boards, charts, and filters to monitor workload and track progress +- **Stay integrated with Microsoft 365**: Seamlessly connect tasks with Teams, Outlook, and other Microsoft tools + +In Sim, the Microsoft Planner integration allows your agents to programmatically create, read, and manage tasks as part of their workflows. Agents can generate new tasks based on incoming requests, retrieve task details to drive decisions, and track status across projects — all without human intervention. Whether you're building workflows for client onboarding, internal project tracking, or follow-up task generation, integrating Microsoft Planner with Sim gives your agents a structured way to coordinate work, automate task creation, and keep teams aligned. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Microsoft Planner functionality to manage tasks. Read all user tasks, tasks from specific plans, individual tasks, or create new tasks with various properties like title, description, due date, and assignees using OAuth authentication. + + + +## Tools + +### `microsoft_planner_read_task` + +Read tasks from Microsoft Planner - get all user tasks or all tasks from a specific plan + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the Microsoft Planner API | +| `planId` | string | No | The ID of the plan to get tasks from \(if not provided, gets all user tasks\) | +| `taskId` | string | No | The ID of the task to get | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | json | The Microsoft Planner task object, including details such as id, title, description, status, due date, and assignees. | +| `metadata` | json | Additional metadata about the operation, such as timestamps, request status, or other relevant information. | + +### `microsoft_planner_create_task` + +Create a new task in Microsoft Planner + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the Microsoft Planner API | +| `planId` | string | Yes | The ID of the plan where the task will be created | +| `title` | string | Yes | The title of the task | +| `description` | string | No | The description of the task | +| `dueDateTime` | string | No | The due date and time for the task \(ISO 8601 format\) | +| `assigneeUserId` | string | No | The user ID to assign the task to | +| `bucketId` | string | No | The bucket ID to place the task in | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `task` | json | The Microsoft Planner task object, including details such as id, title, description, status, due date, and assignees. | +| `metadata` | json | Additional metadata about the operation, such as timestamps, request status, or other relevant information. | + + + +## Notes + +- Category: `tools` +- Type: `microsoft_planner` diff --git a/apps/docs/content/docs/tools/onedrive.mdx b/apps/docs/content/docs/tools/onedrive.mdx new file mode 100644 index 000000000..7a389f238 --- /dev/null +++ b/apps/docs/content/docs/tools/onedrive.mdx @@ -0,0 +1,127 @@ +--- +title: OneDrive +description: Create, upload, and list files +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + + + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[OneDrive](https://onedrive.live.com) is Microsoft’s cloud storage and file synchronization service that allows users to securely store, access, and share files across devices. Integrated deeply into the Microsoft 365 ecosystem, OneDrive supports seamless collaboration, version control, and real-time access to content across teams and organizations. + +Learn how to integrate the OneDrive tool in Sim to automatically pull, manage, and organize your cloud files within your workflows. This tutorial walks you through connecting OneDrive, setting up file access, and using stored content to power automation. Ideal for syncing essential documents and media with your agents in real time. + +With OneDrive, you can: + +- **Store files securely in the cloud**: Upload and access documents, images, and other files from any device +- **Organize your content**: Create structured folders and manage file versions with ease +- **Collaborate in real time**: Share files, edit them simultaneously with others, and track changes +- **Access across devices**: Use OneDrive from desktop, mobile, and web platforms +- **Integrate with Microsoft 365**: Work seamlessly with Word, Excel, PowerPoint, and Teams +- **Control permissions**: Share files and folders with custom access settings and expiration controls + +In Sim, the OneDrive integration enables your agents to directly interact with your cloud storage. Agents can upload new files to specific folders, retrieve and read existing files, and list folder contents to dynamically organize and access information. This integration allows your agents to incorporate file operations into intelligent workflows — automating document intake, content analysis, and structured storage management. By connecting Sim with OneDrive, you empower your agents to manage and use cloud documents programmatically, eliminating manual steps and enhancing automation with secure, real-time file access. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate OneDrive functionality to manage files and folders. Upload new files, create new folders, and list contents of folders using OAuth authentication. Supports file operations with custom MIME types and folder organization. + + + +## Tools + +### `onedrive_upload` + +Upload a file to OneDrive + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the OneDrive API | +| `fileName` | string | Yes | The name of the file to upload | +| `content` | string | Yes | The content of the file to upload | +| `folderSelector` | string | No | Select the folder to upload the file to | +| `folderId` | string | No | The ID of the folder to upload the file to \(internal use\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | json | The OneDrive file object, including details such as id, name, size, and more. | +| `files` | json | An array of OneDrive file objects, each containing details such as id, name, size, and more. | + +### `onedrive_create_folder` + +Create a new folder in OneDrive + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the OneDrive API | +| `folderName` | string | Yes | Name of the folder to create | +| `folderSelector` | string | No | Select the parent folder to create the folder in | +| `folderId` | string | No | ID of the parent folder \(internal use\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | json | The OneDrive file object, including details such as id, name, size, and more. | +| `files` | json | An array of OneDrive file objects, each containing details such as id, name, size, and more. | + +### `onedrive_list` + +List files and folders in OneDrive + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the OneDrive API | +| `folderSelector` | string | No | Select the folder to list files from | +| `folderId` | string | No | The ID of the folder to list files from \(internal use\) | +| `query` | string | No | A query to filter the files | +| `pageSize` | number | No | The number of files to return | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | json | The OneDrive file object, including details such as id, name, size, and more. | +| `files` | json | An array of OneDrive file objects, each containing details such as id, name, size, and more. | + + + +## Notes + +- Category: `tools` +- Type: `onedrive` diff --git a/apps/docs/content/docs/tools/sharepoint.mdx b/apps/docs/content/docs/tools/sharepoint.mdx new file mode 100644 index 000000000..3c44e3510 --- /dev/null +++ b/apps/docs/content/docs/tools/sharepoint.mdx @@ -0,0 +1,135 @@ +--- +title: Sharepoint +description: Read and create pages +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + + + + + + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[SharePoint](https://www.microsoft.com/en-us/microsoft-365/sharepoint/collaboration) is a collaborative platform from Microsoft that enables users to build and manage internal websites, share documents, and organize team resources. It provides a powerful, flexible solution for creating digital workspaces and streamlining content management across organizations. + +With SharePoint, you can: + +- **Create team and communication sites**: Set up pages and portals to support collaboration, announcements, and content distribution +- **Organize and share content**: Store documents, manage files, and enable version control with secure sharing capabilities +- **Customize pages**: Add text parts to tailor each site to your team's needs +- **Improve discoverability**: Use metadata, search, and navigation tools to help users quickly find what they need +- **Collaborate securely**: Control access with robust permission settings and Microsoft 365 integration + +In Sim, the SharePoint integration empowers your agents to create and access SharePoint sites and pages as part of their workflows. This enables automated document management, knowledge sharing, and workspace creation without manual effort. Agents can generate new project pages, upload or retrieve files, and organize resources dynamically, based on workflow inputs. By connecting Sim with SharePoint, you bring structured collaboration and content management into your automation flows — giving your agents the ability to coordinate team activities, surface key information, and maintain a single source of truth across your organization. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Sharepoint functionality to manage pages. Read and create pages, and list sites using OAuth authentication. Supports page operations with custom MIME types and folder organization. + + + +## Tools + +### `sharepoint_create_page` + +Create a new page in a SharePoint site + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the SharePoint API | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `siteSelector` | string | No | Select the SharePoint site | +| `pageName` | string | Yes | The name of the page to create | +| `pageTitle` | string | No | The title of the page \(defaults to page name if not provided\) | +| `pageContent` | string | No | The content of the page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sites` | json | An array of SharePoint site objects, each containing details such as id, name, and more. | + +### `sharepoint_read_page` + +Read a specific page from a SharePoint site + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the SharePoint API | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `pageId` | string | No | The ID of the page to read | +| `pageName` | string | No | The name of the page to read \(alternative to pageId\) | +| `maxPages` | number | No | Maximum number of pages to return when listing all pages \(default: 10, max: 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sites` | json | An array of SharePoint site objects, each containing details such as id, name, and more. | + +### `sharepoint_list_sites` + +List details of all SharePoint sites + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | The access token for the SharePoint API | +| `siteSelector` | string | No | Select the SharePoint site | +| `groupId` | string | No | The group ID for accessing a group team site | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sites` | json | An array of SharePoint site objects, each containing details such as id, name, and more. | + + + +## Notes + +- Category: `tools` +- Type: `sharepoint` diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts index f453790eb..b2eeb8035 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts @@ -1,4 +1,4 @@ -import crypto from 'node:crypto' +import { createHash, randomUUID } from 'crypto' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -22,7 +22,7 @@ export async function GET( req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } ) { - const requestId = crypto.randomUUID().slice(0, 8) + const requestId = randomUUID().slice(0, 8) const { id: knowledgeBaseId, documentId, chunkId } = await params try { @@ -70,7 +70,7 @@ export async function PUT( req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } ) { - const requestId = crypto.randomUUID().slice(0, 8) + const requestId = randomUUID().slice(0, 8) const { id: knowledgeBaseId, documentId, chunkId } = await params try { @@ -119,10 +119,7 @@ export async function PUT( updateData.contentLength = validatedData.content.length // Update token count estimation (rough approximation: 4 chars per token) updateData.tokenCount = Math.ceil(validatedData.content.length / 4) - updateData.chunkHash = crypto - .createHash('sha256') - .update(validatedData.content) - .digest('hex') + updateData.chunkHash = createHash('sha256').update(validatedData.content).digest('hex') } if (validatedData.enabled !== undefined) updateData.enabled = validatedData.enabled @@ -166,7 +163,7 @@ export async function DELETE( req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } ) { - const requestId = crypto.randomUUID().slice(0, 8) + const requestId = randomUUID().slice(0, 8) const { id: knowledgeBaseId, documentId, chunkId } = await params try { diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index b7492151f..c3b14ac4a 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -1,4 +1,4 @@ -import crypto from 'node:crypto' +import { randomUUID } from 'crypto' import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -114,7 +114,7 @@ async function processDocumentTags( // Create new tag definition if we have a slot if (targetSlot) { const newDefinition = { - id: crypto.randomUUID(), + id: randomUUID(), knowledgeBaseId, tagSlot: targetSlot as any, displayName: tagName, @@ -312,7 +312,7 @@ const BulkUpdateDocumentsSchema = z.object({ }) export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) + const requestId = randomUUID().slice(0, 8) const { id: knowledgeBaseId } = await params try { @@ -423,7 +423,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: } export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) + const requestId = randomUUID().slice(0, 8) const { id: knowledgeBaseId } = await params try { @@ -470,7 +470,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const createdDocuments = await db.transaction(async (tx) => { const documentPromises = validatedData.documents.map(async (docData) => { - const documentId = crypto.randomUUID() + const documentId = randomUUID() const now = new Date() // Process documentTagsData if provided (for knowledge base block) @@ -578,7 +578,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const validatedData = CreateDocumentSchema.parse(body) - const documentId = crypto.randomUUID() + const documentId = randomUUID() const now = new Date() // Process structured tag data if provided diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts new file mode 100644 index 000000000..f25802e8c --- /dev/null +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'crypto' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { db } from '@/db' +import { account } from '@/db/schema' +import type { PlannerTask } from '@/tools/microsoft_planner/types' + +const logger = createLogger('MicrosoftPlannerTasksAPI') + +export async function GET(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + + try { + const session = await getSession() + + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthenticated request rejected`) + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const credentialId = searchParams.get('credentialId') + const planId = searchParams.get('planId') + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId parameter`) + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + } + + if (!planId) { + logger.error(`[${requestId}] Missing planId parameter`) + return NextResponse.json({ error: 'Plan ID is required' }, { status: 400 }) + } + + // Get the credential from the database + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + + if (!credentials.length) { + logger.warn(`[${requestId}] Credential not found`, { credentialId }) + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const credential = credentials[0] + + // Check if the credential belongs to the user + if (credential.userId !== session.user.id) { + logger.warn(`[${requestId}] Unauthorized credential access attempt`, { + credentialUserId: credential.userId, + requestUserId: session.user.id, + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + // Refresh access token if needed + const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + + if (!accessToken) { + logger.error(`[${requestId}] Failed to obtain valid access token`) + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + // Fetch tasks directly from Microsoft Graph API + const response = await fetch(`https://graph.microsoft.com/v1.0/planner/plans/${planId}/tasks`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) + return NextResponse.json( + { error: 'Failed to fetch tasks from Microsoft Graph' }, + { status: response.status } + ) + } + + const data = await response.json() + const tasks = data.value || [] + + // Filter tasks to only include useful fields (matching our read_task tool) + const filteredTasks = tasks.map((task: PlannerTask) => ({ + id: task.id, + title: task.title, + planId: task.planId, + bucketId: task.bucketId, + percentComplete: task.percentComplete, + priority: task.priority, + dueDateTime: task.dueDateTime, + createdDateTime: task.createdDateTime, + completedDateTime: task.completedDateTime, + hasDescription: task.hasDescription, + assignments: task.assignments ? Object.keys(task.assignments) : [], + })) + + return NextResponse.json({ + tasks: filteredTasks, + metadata: { + planId, + planUrl: `https://graph.microsoft.com/v1.0/planner/plans/${planId}`, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching Microsoft Planner tasks:`, error) + return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts new file mode 100644 index 000000000..d29ad7e57 --- /dev/null +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'crypto' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { db } from '@/db' +import { account } from '@/db/schema' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OneDriveFolderAPI') + +/** + * Get a single folder from Microsoft OneDrive + */ +export async function GET(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const credentialId = searchParams.get('credentialId') + const fileId = searchParams.get('fileId') + + if (!credentialId || !fileId) { + return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 }) + } + + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const credential = credentials[0] + if (credential.userId !== session.user.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + const response = await fetch( + `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}?$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch folder from OneDrive' }, + { status: response.status } + ) + } + + const folder = await response.json() + + // Transform the response to match expected format + const transformedFolder = { + id: folder.id, + name: folder.name, + mimeType: 'application/vnd.microsoft.graph.folder', + webViewLink: folder.webUrl, + createdTime: folder.createdDateTime, + modifiedTime: folder.lastModifiedDateTime, + } + + return NextResponse.json({ file: transformedFolder }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching folder from OneDrive`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts new file mode 100644 index 000000000..4194addfb --- /dev/null +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -0,0 +1,89 @@ +import { randomUUID } from 'crypto' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { db } from '@/db' +import { account } from '@/db/schema' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OneDriveFoldersAPI') + +import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' + +/** + * Get folders from Microsoft OneDrive + */ +export async function GET(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const credentialId = searchParams.get('credentialId') + const query = searchParams.get('query') || '' + + if (!credentialId) { + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + } + + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const credential = credentials[0] + if (credential.userId !== session.user.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + // Build URL for OneDrive folders + let url = `https://graph.microsoft.com/v1.0/me/drive/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=50` + + if (query) { + url += `&$search="${encodeURIComponent(query)}"` + } + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch folders from OneDrive' }, + { status: response.status } + ) + } + + const data = await response.json() + const folders = (data.value || []) + .filter((item: MicrosoftGraphDriveItem) => item.folder) // Only folders + .map((folder: MicrosoftGraphDriveItem) => ({ + id: folder.id, + name: folder.name, + mimeType: 'application/vnd.microsoft.graph.folder', + webViewLink: folder.webUrl, + createdTime: folder.createdDateTime, + modifiedTime: folder.lastModifiedDateTime, + })) + + return NextResponse.json({ files: folders }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching folders from OneDrive`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts new file mode 100644 index 000000000..225bd748e --- /dev/null +++ b/apps/sim/app/api/tools/sharepoint/site/route.ts @@ -0,0 +1,105 @@ +import { randomUUID } from 'crypto' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { db } from '@/db' +import { account } from '@/db/schema' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SharePointSiteAPI') + +/** + * Get a single SharePoint site from Microsoft Graph API + */ +export async function GET(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const credentialId = searchParams.get('credentialId') + const siteId = searchParams.get('siteId') + + if (!credentialId || !siteId) { + return NextResponse.json({ error: 'Credential ID and Site ID are required' }, { status: 400 }) + } + + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const credential = credentials[0] + if (credential.userId !== session.user.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + // Handle different ways to access SharePoint sites: + // 1. Site ID: sites/{site-id} + // 2. Root site: sites/root + // 3. Hostname: sites/{hostname} + // 4. Server-relative URL: sites/{hostname}:/{server-relative-path} + // 5. Group team site: groups/{group-id}/sites/root + + let endpoint: string + if (siteId === 'root') { + endpoint = 'sites/root' + } else if (siteId.includes(':')) { + // Server-relative URL format + endpoint = `sites/${siteId}` + } else if (siteId.includes('groups/')) { + // Group team site format + endpoint = siteId + } else { + // Standard site ID or hostname + endpoint = `sites/${siteId}` + } + + const response = await fetch( + `https://graph.microsoft.com/v1.0/${endpoint}?$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch site from SharePoint' }, + { status: response.status } + ) + } + + const site = await response.json() + + // Transform the response to match expected format + const transformedSite = { + id: site.id, + name: site.displayName || site.name, + mimeType: 'application/vnd.microsoft.graph.site', + webViewLink: site.webUrl, + createdTime: site.createdDateTime, + modifiedTime: site.lastModifiedDateTime, + } + + logger.info(`[${requestId}] Successfully fetched SharePoint site: ${transformedSite.name}`) + return NextResponse.json({ site: transformedSite }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching site from SharePoint`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts new file mode 100644 index 000000000..93bc5bd09 --- /dev/null +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -0,0 +1,85 @@ +import { randomUUID } from 'crypto' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { db } from '@/db' +import { account } from '@/db/schema' +import type { SharepointSite } from '@/tools/sharepoint/types' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SharePointSitesAPI') + +/** + * Get SharePoint sites from Microsoft Graph API + */ +export async function GET(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const credentialId = searchParams.get('credentialId') + const query = searchParams.get('query') || '' + + if (!credentialId) { + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + } + + const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (!credentials.length) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + + const credential = credentials[0] + if (credential.userId !== session.user.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId) + if (!accessToken) { + return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) + } + + // Build URL for SharePoint sites + // Use search=* to get all sites the user has access to, or search for specific query + const searchQuery = query || '*' + const url = `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=50` + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) + return NextResponse.json( + { error: errorData.error?.message || 'Failed to fetch sites from SharePoint' }, + { status: response.status } + ) + } + + const data = await response.json() + const sites = (data.value || []).map((site: SharepointSite) => ({ + id: site.id, + name: site.displayName || site.name, + mimeType: 'application/vnd.microsoft.graph.site', + webViewLink: site.webUrl, + createdTime: site.createdDateTime, + modifiedTime: site.lastModifiedDateTime, + })) + + logger.info(`[${requestId}] Successfully fetched ${sites.length} SharePoint sites`) + return NextResponse.json({ files: sites }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching sites from SharePoint`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx index 9549f305e..d11c3e547 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx @@ -24,6 +24,7 @@ import { parseProvider, } from '@/lib/oauth' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' +import type { PlannerTask } from '@/tools/microsoft_planner/types' const logger = createLogger('MicrosoftFileSelector') @@ -40,6 +41,9 @@ export interface MicrosoftFileInfo { owners?: { displayName: string; emailAddress: string }[] } +// Union type for items that can be displayed in the file selector +type SelectableItem = MicrosoftFileInfo | PlannerTask + interface MicrosoftFileSelectorProps { value: string onChange: (value: string, fileInfo?: MicrosoftFileInfo) => void @@ -50,6 +54,7 @@ interface MicrosoftFileSelectorProps { serviceId?: string showPreview?: boolean onFileInfoChange?: (fileInfo: MicrosoftFileInfo | null) => void + planId?: string } export function MicrosoftFileSelector({ @@ -62,6 +67,7 @@ export function MicrosoftFileSelector({ serviceId, showPreview = true, onFileInfoChange, + planId, }: MicrosoftFileSelectorProps) { const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) @@ -77,6 +83,11 @@ export function MicrosoftFileSelector({ const [credentialsLoaded, setCredentialsLoaded] = useState(false) const initialFetchRef = useRef(false) + // Handle Microsoft Planner task selection + const [plannerTasks, setPlannerTasks] = useState([]) + const [isLoadingTasks, setIsLoadingTasks] = useState(false) + const [selectedTask, setSelectedTask] = useState(null) + // Determine the appropriate service ID based on provider and scopes const getServiceId = (): string => { if (serviceId) return serviceId @@ -128,7 +139,7 @@ export function MicrosoftFileSelector({ } }, [provider, getProviderId, selectedCredentialId]) - // Fetch available Excel files for the selected credential + // Fetch available files for the selected credential const fetchAvailableFiles = useCallback(async () => { if (!selectedCredentialId) return @@ -143,7 +154,17 @@ export function MicrosoftFileSelector({ queryParams.append('query', searchQuery.trim()) } - const response = await fetch(`/api/auth/oauth/microsoft/files?${queryParams.toString()}`) + // Route to correct endpoint based on service + let endpoint: string + if (serviceId === 'onedrive') { + endpoint = `/api/tools/onedrive/folders?${queryParams.toString()}` + } else if (serviceId === 'sharepoint') { + endpoint = `/api/tools/sharepoint/sites?${queryParams.toString()}` + } else { + endpoint = `/api/auth/oauth/microsoft/files?${queryParams.toString()}` + } + + const response = await fetch(endpoint) if (response.ok) { const data = await response.json() @@ -160,7 +181,7 @@ export function MicrosoftFileSelector({ } finally { setIsLoadingFiles(false) } - }, [selectedCredentialId, searchQuery]) + }, [selectedCredentialId, searchQuery, serviceId]) // Fetch a single file by ID when we have a selectedFileId but no metadata const fetchFileById = useCallback( @@ -175,7 +196,22 @@ export function MicrosoftFileSelector({ fileId: fileId, }) - const response = await fetch(`/api/auth/oauth/microsoft/file?${queryParams.toString()}`) + // Route to correct endpoint based on service + let endpoint: string + if (serviceId === 'onedrive') { + endpoint = `/api/tools/onedrive/folder?${queryParams.toString()}` + } else if (serviceId === 'sharepoint') { + // Change from fileId to siteId for SharePoint + const sharepointParams = new URLSearchParams({ + credentialId: selectedCredentialId, + siteId: fileId, // Use siteId instead of fileId + }) + endpoint = `/api/tools/sharepoint/site?${sharepointParams.toString()}` + } else { + endpoint = `/api/auth/oauth/microsoft/file?${queryParams.toString()}` + } + + const response = await fetch(endpoint) if (response.ok) { const data = await response.json() @@ -204,9 +240,77 @@ export function MicrosoftFileSelector({ setIsLoadingSelectedFile(false) } }, - [selectedCredentialId, onFileInfoChange] + [selectedCredentialId, onFileInfoChange, serviceId] ) + // Fetch Microsoft Planner tasks when planId and credentials are available + const fetchPlannerTasks = useCallback(async () => { + if (!selectedCredentialId || !planId || serviceId !== 'microsoft-planner') { + logger.info('Skipping task fetch - missing requirements:', { + selectedCredentialId: !!selectedCredentialId, + planId: !!planId, + serviceId, + }) + return + } + + logger.info('Fetching Planner tasks with:', { + credentialId: selectedCredentialId, + planId, + serviceId, + }) + + setIsLoadingTasks(true) + try { + const queryParams = new URLSearchParams({ + credentialId: selectedCredentialId, + planId: planId, + }) + + const url = `/api/tools/microsoft_planner/tasks?${queryParams.toString()}` + logger.info('Calling API endpoint:', url) + + const response = await fetch(url) + + if (response.ok) { + const data = await response.json() + logger.info('Received task data:', data) + const tasks = data.tasks || [] + + // Transform tasks to match file info format for consistency + const transformedTasks = tasks.map((task: PlannerTask) => ({ + id: task.id, + name: task.title, + mimeType: 'planner/task', + webViewLink: `https://tasks.office.com/planner/task/${task.id}`, + modifiedTime: task.createdDateTime, + createdTime: task.createdDateTime, + planId: task.planId, + bucketId: task.bucketId, + percentComplete: task.percentComplete, + priority: task.priority, + dueDateTime: task.dueDateTime, + })) + + logger.info('Transformed tasks:', transformedTasks) + setPlannerTasks(transformedTasks) + } else { + const errorText = await response.text() + logger.error('API response not ok:', { + status: response.status, + statusText: response.statusText, + errorText, + }) + setPlannerTasks([]) + } + } catch (error) { + logger.error('Network/fetch error:', error) + setPlannerTasks([]) + } finally { + setIsLoadingTasks(false) + } + }, [selectedCredentialId, planId, serviceId]) + // Fetch credentials on initial mount useEffect(() => { if (!initialFetchRef.current) { @@ -233,6 +337,35 @@ export function MicrosoftFileSelector({ } }, [searchQuery, selectedCredentialId, fetchAvailableFiles]) + // Fetch planner tasks when credentials and planId change + useEffect(() => { + if (serviceId === 'microsoft-planner' && selectedCredentialId && planId) { + fetchPlannerTasks() + } + }, [selectedCredentialId, planId, serviceId, fetchPlannerTasks]) + + // Handle task selection for planner + const handleTaskSelect = (task: PlannerTask) => { + const taskId = task.id || '' + // Convert PlannerTask to MicrosoftFileInfo format for compatibility + const taskAsFileInfo: MicrosoftFileInfo = { + id: taskId, + name: task.title, + mimeType: 'planner/task', + webViewLink: `https://tasks.office.com/planner/task/${taskId}`, + createdTime: task.createdDateTime, + modifiedTime: task.createdDateTime, + } + + setSelectedFileId(taskId) + setSelectedFile(taskAsFileInfo) + setSelectedTask(task) + onChange(taskId, taskAsFileInfo) + onFileInfoChange?.(taskAsFileInfo) + setOpen(false) + setSearchQuery('') + } + // Keep internal selectedFileId in sync with the value prop useEffect(() => { if (value !== selectedFileId) { @@ -276,7 +409,10 @@ export function MicrosoftFileSelector({ selectedCredentialId && credentialsLoaded && !selectedFile && - !isLoadingSelectedFile + !isLoadingSelectedFile && + serviceId !== 'microsoft-planner' && + serviceId !== 'sharepoint' && + serviceId !== 'onedrive' ) { fetchFileById(value) } @@ -287,6 +423,7 @@ export function MicrosoftFileSelector({ selectedFile, isLoadingSelectedFile, fetchFileById, + serviceId, ]) // Handle selecting a file from the available files @@ -324,6 +461,22 @@ export function MicrosoftFileSelector({ return } + // Handle OneDrive specifically by checking serviceId + if (baseProvider === 'microsoft' && serviceId === 'onedrive') { + const onedriveService = baseProviderConfig.services.onedrive + if (onedriveService) { + return onedriveService.icon({ className: 'h-4 w-4' }) + } + } + + // Handle SharePoint specifically by checking serviceId + if (baseProvider === 'microsoft' && serviceId === 'sharepoint') { + const sharepointService = baseProviderConfig.services.sharepoint + if (sharepointService) { + return sharepointService.icon({ className: 'h-4 w-4' }) + } + } + // For compound providers, find the specific service if (providerName.includes('-')) { for (const service of Object.values(baseProviderConfig.services)) { @@ -383,6 +536,9 @@ export function MicrosoftFileSelector({ if (file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') { return } + if (file.mimeType === 'planner/task') { + return getProviderIcon(provider) + } // if (file.mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { // return // } @@ -397,6 +553,55 @@ export function MicrosoftFileSelector({ setSearchQuery(query) } + const getFileTypeTitleCase = () => { + if (serviceId === 'onedrive') return 'Folders' + if (serviceId === 'sharepoint') return 'Sites' + if (serviceId === 'microsoft-planner') return 'Tasks' + return 'Excel Files' + } + + const getSearchPlaceholder = () => { + if (serviceId === 'onedrive') return 'Search OneDrive folders...' + if (serviceId === 'sharepoint') return 'Search SharePoint sites...' + if (serviceId === 'microsoft-planner') return 'Search tasks...' + return 'Search Excel files...' + } + + const getEmptyStateText = () => { + if (serviceId === 'onedrive') { + return { + title: 'No folders found.', + description: 'No folders were found in your OneDrive.', + } + } + if (serviceId === 'sharepoint') { + return { + title: 'No sites found.', + description: 'No SharePoint sites were found.', + } + } + if (serviceId === 'microsoft-planner') { + return { + title: 'No tasks found.', + description: 'No tasks were found in this plan.', + } + } + return { + title: 'No Excel files found.', + description: 'No .xlsx files were found in your OneDrive.', + } + } + + // Filter tasks based on search query for planner + const filteredTasks: SelectableItem[] = + serviceId === 'microsoft-planner' + ? plannerTasks.filter((task) => { + const title = task.title || '' + const query = searchQuery || '' + return title.toLowerCase().includes(query.toLowerCase()) + }) + : availableFiles + return ( <>
@@ -405,7 +610,7 @@ export function MicrosoftFileSelector({ onOpenChange={(isOpen) => { setOpen(isOpen) if (!isOpen) { - setSearchQuery('') // Clear search when popover closes + setSearchQuery('') } }} > @@ -415,7 +620,7 @@ export function MicrosoftFileSelector({ role='combobox' aria-expanded={open} className='h-10 w-full min-w-0 justify-between' - disabled={disabled} + disabled={disabled || (serviceId === 'microsoft-planner' && !planId)} >
{selectedFile ? ( @@ -463,10 +668,10 @@ export function MicrosoftFileSelector({ )} - + - {isLoading || isLoadingFiles ? ( + {isLoading || isLoadingFiles || isLoadingTasks ? (
Loading... @@ -478,11 +683,18 @@ export function MicrosoftFileSelector({ Connect a {getProviderName(provider)} account to continue.

- ) : availableFiles.length === 0 ? ( + ) : serviceId === 'microsoft-planner' && !planId ? (
-

No Excel files found.

+

Plan ID required.

- No .xlsx files were found in your OneDrive. + Please enter a Plan ID first to see tasks. +

+
+ ) : filteredTasks.length === 0 ? ( +
+

{getEmptyStateText().title}

+

+ {getEmptyStateText().description}

) : null} @@ -510,32 +722,58 @@ export function MicrosoftFileSelector({ )} - {/* Available Excel files - only show if we have credentials and files */} - {credentials.length > 0 && selectedCredentialId && availableFiles.length > 0 && ( + {/* Available files/tasks - only show if we have credentials and items */} + {credentials.length > 0 && selectedCredentialId && filteredTasks.length > 0 && (
- Excel Files + {getFileTypeTitleCase()}
- {availableFiles.map((file) => ( - handleFileSelect(file)} - > -
- {getFileIcon(file, 'sm')} -
- {file.name} - {file.modifiedTime && ( -
- Modified {new Date(file.modifiedTime).toLocaleDateString()} -
+ {filteredTasks.map((item) => { + const isPlanner = serviceId === 'microsoft-planner' + const isPlannerTask = isPlanner && 'title' in item + const plannerTask = item as PlannerTask + const fileInfo = item as MicrosoftFileInfo + + const displayName = isPlannerTask ? plannerTask.title : fileInfo.name + const dateField = isPlannerTask + ? plannerTask.createdDateTime + : fileInfo.createdTime + + return ( + + isPlannerTask + ? handleTaskSelect(plannerTask) + : handleFileSelect(fileInfo) + } + > +
+ {getFileIcon( + isPlannerTask + ? { + ...fileInfo, + id: plannerTask.id || '', + name: plannerTask.title, + mimeType: 'planner/task', + } + : fileInfo, + 'sm' )} +
+ {displayName} + {dateField && ( +
+ Modified {new Date(dateField).toLocaleDateString()} +
+ )} +
-
- {file.id === selectedFileId && } - - ))} + {item.id === selectedFileId && } + + ) + })} )} @@ -589,7 +827,13 @@ export function MicrosoftFileSelector({ className='flex items-center gap-1 text-primary text-xs hover:underline' onClick={(e) => e.stopPropagation()} > - Open in OneDrive + + {serviceId === 'microsoft-planner' + ? 'Open in Planner' + : serviceId === 'sharepoint' + ? 'Open in SharePoint' + : 'Open in OneDrive'} + ) : ( @@ -600,7 +844,9 @@ export function MicrosoftFileSelector({ className='flex items-center gap-1 text-primary text-xs hover:underline' onClick={(e) => e.stopPropagation()} > - Open in OneDrive + + {serviceId === 'sharepoint' ? 'Open in SharePoint' : 'Open in OneDrive'} + )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx index 73100096e..ab88099ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx @@ -68,8 +68,12 @@ export function FileSelectorInput({ const isDiscord = provider === 'discord' const isMicrosoftTeams = provider === 'microsoft-teams' const isMicrosoftExcel = provider === 'microsoft-excel' + const isMicrosoftWord = provider === 'microsoft-word' + const isMicrosoftOneDrive = provider === 'microsoft' && subBlock.serviceId === 'onedrive' const isGoogleCalendar = subBlock.provider === 'google-calendar' const isWealthbox = provider === 'wealthbox' + const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint' + const isMicrosoftPlanner = provider === 'microsoft-planner' // For Confluence and Jira, we need the domain and credentials const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : '' // For Discord, we need the bot token and server ID @@ -94,6 +98,8 @@ export function FileSelectorInput({ setSelectedCalendarId(value) } else if (isWealthbox) { setSelectedWealthboxItemId(value) + } else if (isMicrosoftSharePoint) { + setSelectedFileId(value) } else { setSelectedFileId(value) } @@ -111,6 +117,8 @@ export function FileSelectorInput({ setSelectedCalendarId(value) } else if (isWealthbox) { setSelectedWealthboxItemId(value) + } else if (isMicrosoftSharePoint) { + setSelectedFileId(value) } else { setSelectedFileId(value) } @@ -125,6 +133,7 @@ export function FileSelectorInput({ isMicrosoftTeams, isGoogleCalendar, isWealthbox, + isMicrosoftSharePoint, isPreview, previewValue, ]) @@ -325,6 +334,141 @@ export function FileSelectorInput({ ) } + // Handle Microsoft Word selector + if (isMicrosoftWord) { + // Get credential using the same pattern as other tools + const credential = (getValue(blockId, 'credential') as string) || '' + + return ( + + + +
+ void} + /> +
+
+ {!credential && ( + +

Please select Microsoft Word credentials first

+
+ )} +
+
+ ) + } + + // Handle Microsoft OneDrive selector + if (isMicrosoftOneDrive) { + const credential = (getValue(blockId, 'credential') as string) || '' + + return ( + + + +
+ void} + /> +
+
+ {!credential && ( + +

Please select Microsoft credentials first

+
+ )} +
+
+ ) + } + + // Handle Microsoft SharePoint selector + if (isMicrosoftSharePoint) { + const credential = (getValue(blockId, 'credential') as string) || '' + + return ( + + + +
+ void} + /> +
+
+ {!credential && ( + +

Please select SharePoint credentials first

+
+ )} +
+
+ ) + } + + // Handle Microsoft Planner task selector + if (isMicrosoftPlanner) { + const credential = (getValue(blockId, 'credential') as string) || '' + const planId = (getValue(blockId, 'planId') as string) || '' + + return ( + + + +
+ void} + planId={planId} + /> +
+
+ {!credential ? ( + +

Please select Microsoft Planner credentials first

+
+ ) : !planId ? ( + +

Please enter a Plan ID first

+
+ ) : null} +
+
+ ) + } + // Handle Microsoft Teams selector if (isMicrosoftTeams) { // Get credential using the same pattern as other tools diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts new file mode 100644 index 000000000..ce020652a --- /dev/null +++ b/apps/sim/blocks/blocks/microsoft_planner.ts @@ -0,0 +1,238 @@ +import { MicrosoftPlannerIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types' + +interface MicrosoftPlannerBlockParams { + credential: string + accessToken?: string + planId?: string + taskId?: string + title?: string + description?: string + dueDateTime?: string + assigneeUserId?: string + bucketId?: string + [key: string]: string | number | boolean | undefined +} + +export const MicrosoftPlannerBlock: BlockConfig = { + type: 'microsoft_planner', + name: 'Microsoft Planner', + description: 'Read and create tasks in Microsoft Planner', + longDescription: + 'Integrate Microsoft Planner functionality to manage tasks. Read all user tasks, tasks from specific plans, individual tasks, or create new tasks with various properties like title, description, due date, and assignees using OAuth authentication.', + docsLink: 'https://docs.sim.ai/tools/microsoft_planner', + category: 'tools', + bgColor: '#E0E0E0', + icon: MicrosoftPlannerIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Read Task', id: 'read_task' }, + { label: 'Create Task', id: 'create_task' }, + ], + }, + { + id: 'credential', + title: 'Microsoft Account', + type: 'oauth-input', + layout: 'full', + provider: 'microsoft-planner', + serviceId: 'microsoft-planner', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'Group.ReadWrite.All', + 'Group.Read.All', + 'Tasks.ReadWrite', + 'offline_access', + ], + placeholder: 'Select Microsoft account', + }, + { + id: 'planId', + title: 'Plan ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter the plan ID', + condition: { field: 'operation', value: ['create_task', 'read_task'] }, + }, + { + id: 'taskId', + title: 'Task ID', + type: 'file-selector', + layout: 'full', + placeholder: 'Select a task', + provider: 'microsoft-planner', + condition: { field: 'operation', value: ['read_task'] }, + mode: 'basic', + }, + + // Advanced mode + { + id: 'taskId', + title: 'Manual Task ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter the task ID', + condition: { field: 'operation', value: ['read_task'] }, + mode: 'advanced', + }, + + { + id: 'title', + title: 'Task Title', + type: 'short-input', + layout: 'full', + placeholder: 'Enter the task title', + condition: { field: 'operation', value: ['create_task'] }, + }, + { + id: 'description', + title: 'Description', + type: 'long-input', + layout: 'full', + placeholder: 'Enter task description (optional)', + condition: { field: 'operation', value: ['create_task'] }, + }, + { + id: 'dueDateTime', + title: 'Due Date', + type: 'short-input', + layout: 'full', + placeholder: 'Enter due date in ISO 8601 format (e.g., 2024-12-31T23:59:59Z)', + condition: { field: 'operation', value: ['create_task'] }, + }, + { + id: 'assigneeUserId', + title: 'Assignee User ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter the user ID to assign this task to (optional)', + condition: { field: 'operation', value: ['create_task'] }, + }, + { + id: 'bucketId', + title: 'Bucket ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter the bucket ID to organize the task (optional)', + condition: { field: 'operation', value: ['create_task'] }, + }, + ], + tools: { + access: ['microsoft_planner_read_task', 'microsoft_planner_create_task'], + config: { + tool: (params) => { + switch (params.operation) { + case 'read_task': + return 'microsoft_planner_read_task' + case 'create_task': + return 'microsoft_planner_create_task' + default: + throw new Error(`Invalid Microsoft Planner operation: ${params.operation}`) + } + }, + params: (params) => { + const { + credential, + operation, + planId, + taskId, + title, + description, + dueDateTime, + assigneeUserId, + bucketId, + ...rest + } = params + + const baseParams = { + ...rest, + credential, + } + + // For read operations + if (operation === 'read_task') { + const readParams: MicrosoftPlannerBlockParams = { ...baseParams } + + // If taskId is provided, add it (highest priority - get specific task) + if (taskId?.trim()) { + readParams.taskId = taskId.trim() + } + // If no taskId but planId is provided, add planId (get tasks from plan) + else if (planId?.trim()) { + readParams.planId = planId.trim() + } + // If neither, get all user tasks (baseParams only) + + return readParams + } + + // For create operation + if (operation === 'create_task') { + if (!planId?.trim()) { + throw new Error('Plan ID is required to create a task.') + } + if (!title?.trim()) { + throw new Error('Task title is required to create a task.') + } + + const createParams: MicrosoftPlannerBlockParams = { + ...baseParams, + planId: planId.trim(), + title: title.trim(), + } + + if (description?.trim()) { + createParams.description = description.trim() + } + + if (dueDateTime?.trim()) { + createParams.dueDateTime = dueDateTime.trim() + } + + if (assigneeUserId?.trim()) { + createParams.assigneeUserId = assigneeUserId.trim() + } + + if (bucketId?.trim()) { + createParams.bucketId = bucketId.trim() + } + + return createParams + } + + return baseParams + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + credential: { type: 'string', description: 'Microsoft account credential' }, + planId: { type: 'string', description: 'Plan ID' }, + taskId: { type: 'string', description: 'Task ID' }, + title: { type: 'string', description: 'Task title' }, + description: { type: 'string', description: 'Task description' }, + dueDateTime: { type: 'string', description: 'Due date' }, + assigneeUserId: { type: 'string', description: 'Assignee user ID' }, + bucketId: { type: 'string', description: 'Bucket ID' }, + }, + outputs: { + task: { + type: 'json', + description: + 'The Microsoft Planner task object, including details such as id, title, description, status, due date, and assignees.', + }, + metadata: { + type: 'json', + description: + 'Additional metadata about the operation, such as timestamps, request status, or other relevant information.', + }, + }, +} diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts new file mode 100644 index 000000000..dd99159da --- /dev/null +++ b/apps/sim/blocks/blocks/onedrive.ts @@ -0,0 +1,235 @@ +import { MicrosoftOneDriveIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import type { OneDriveResponse } from '@/tools/onedrive/types' + +export const OneDriveBlock: BlockConfig = { + type: 'onedrive', + name: 'OneDrive', + description: 'Create, upload, and list files', + longDescription: + 'Integrate OneDrive functionality to manage files and folders. Upload new files, create new folders, and list contents of folders using OAuth authentication. Supports file operations with custom MIME types and folder organization.', + docsLink: 'https://docs.sim.ai/tools/onedrive', + category: 'tools', + bgColor: '#E0E0E0', + icon: MicrosoftOneDriveIcon, + subBlocks: [ + // Operation selector + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Create Folder', id: 'create_folder' }, + { label: 'Upload File', id: 'upload' }, + { label: 'List Files', id: 'list' }, + ], + }, + // One Drive Credentials + { + id: 'credential', + title: 'Microsoft Account', + type: 'oauth-input', + layout: 'full', + provider: 'onedrive', + serviceId: 'onedrive', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'Files.Read', + 'Files.ReadWrite', + 'offline_access', + ], + placeholder: 'Select Microsoft account', + }, + // Upload Fields + { + id: 'fileName', + title: 'File Name', + type: 'short-input', + layout: 'full', + placeholder: 'Name of the file', + condition: { field: 'operation', value: 'upload' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input', + layout: 'full', + placeholder: 'Content to upload to the file', + condition: { field: 'operation', value: 'upload' }, + }, + + { + id: 'folderSelector', + title: 'Select Parent Folder', + type: 'file-selector', + layout: 'full', + provider: 'microsoft', + serviceId: 'onedrive', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'Files.Read', + 'Files.ReadWrite', + 'offline_access', + ], + mimeType: 'application/vnd.microsoft.graph.folder', + placeholder: 'Select a parent folder', + mode: 'basic', + condition: { field: 'operation', value: 'upload' }, + }, + { + id: 'manualFolderId', + title: 'Parent Folder ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter parent folder ID (leave empty for root folder)', + mode: 'advanced', + condition: { field: 'operation', value: 'upload' }, + }, + { + id: 'folderName', + title: 'Folder Name', + type: 'short-input', + layout: 'full', + placeholder: 'Name for the new folder', + condition: { field: 'operation', value: 'create_folder' }, + }, + { + id: 'folderSelector', + title: 'Select Parent Folder', + type: 'file-selector', + layout: 'full', + provider: 'microsoft', + serviceId: 'onedrive', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'Files.Read', + 'Files.ReadWrite', + 'offline_access', + ], + mimeType: 'application/vnd.microsoft.graph.folder', + placeholder: 'Select a parent folder', + mode: 'basic', + condition: { field: 'operation', value: 'create_folder' }, + }, + // Manual Folder ID input (advanced mode) + { + id: 'manualFolderId', + title: 'Parent Folder ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter parent folder ID (leave empty for root folder)', + mode: 'advanced', + condition: { field: 'operation', value: 'create_folder' }, + }, + // List Fields - Folder Selector (basic mode) + { + id: 'folderSelector', + title: 'Select Folder', + type: 'file-selector', + layout: 'full', + provider: 'microsoft', + serviceId: 'onedrive', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'Files.Read', + 'Files.ReadWrite', + 'offline_access', + ], + mimeType: 'application/vnd.microsoft.graph.folder', + placeholder: 'Select a folder to list files from', + mode: 'basic', + condition: { field: 'operation', value: 'list' }, + }, + // Manual Folder ID input (advanced mode) + { + id: 'manualFolderId', + title: 'Folder ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter folder ID (leave empty for root folder)', + mode: 'advanced', + condition: { field: 'operation', value: 'list' }, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + layout: 'full', + placeholder: 'Search for specific files (e.g., name contains "report")', + condition: { field: 'operation', value: 'list' }, + }, + { + id: 'pageSize', + title: 'Results Per Page', + type: 'short-input', + layout: 'full', + placeholder: 'Number of results (default: 100, max: 1000)', + condition: { field: 'operation', value: 'list' }, + }, + ], + tools: { + access: ['onedrive_upload', 'onedrive_create_folder', 'onedrive_list'], + config: { + tool: (params) => { + switch (params.operation) { + case 'upload': + return 'onedrive_upload' + case 'create_folder': + return 'onedrive_create_folder' + case 'list': + return 'onedrive_list' + default: + throw new Error(`Invalid OneDrive operation: ${params.operation}`) + } + }, + params: (params) => { + const { credential, folderSelector, manualFolderId, mimeType, ...rest } = params + + // Use folderSelector if provided, otherwise use manualFolderId + const effectiveFolderId = (folderSelector || manualFolderId || '').trim() + + return { + accessToken: credential, + folderId: effectiveFolderId, + pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined, + mimeType: mimeType, + ...rest, + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + credential: { type: 'string', description: 'Microsoft account credential' }, + // Upload and Create Folder operation inputs + fileName: { type: 'string', description: 'File name' }, + content: { type: 'string', description: 'File content' }, + // Get Content operation inputs + // fileId: { type: 'string', required: false }, + // List operation inputs + folderSelector: { type: 'string', description: 'Folder selector' }, + manualFolderId: { type: 'string', description: 'Manual folder ID' }, + query: { type: 'string', description: 'Search query' }, + pageSize: { type: 'number', description: 'Results per page' }, + }, + outputs: { + file: { + type: 'json', + description: 'The OneDrive file object, including details such as id, name, size, and more.', + }, + files: { + type: 'json', + description: + 'An array of OneDrive file objects, each containing details such as id, name, size, and more.', + }, + }, +} diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts new file mode 100644 index 000000000..ddceb96c6 --- /dev/null +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -0,0 +1,158 @@ +import { MicrosoftSharepointIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import type { SharepointResponse } from '@/tools/sharepoint/types' + +export const SharepointBlock: BlockConfig = { + type: 'sharepoint', + name: 'Sharepoint', + description: 'Read and create pages', + longDescription: + 'Integrate Sharepoint functionality to manage pages. Read and create pages, and list sites using OAuth authentication. Supports page operations with custom MIME types and folder organization.', + docsLink: 'https://docs.sim.ai/tools/sharepoint', + category: 'tools', + bgColor: '#E0E0E0', + icon: MicrosoftSharepointIcon, + subBlocks: [ + // Operation selector + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Create Page', id: 'create_page' }, + { label: 'Read Page', id: 'read_page' }, + { label: 'List Sites', id: 'list_sites' }, + ], + }, + // Sharepoint Credentials + { + id: 'credential', + title: 'Microsoft Account', + type: 'oauth-input', + layout: 'full', + provider: 'sharepoint', + serviceId: 'sharepoint', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'Files.Read', + 'Files.ReadWrite', + 'offline_access', + ], + placeholder: 'Select Microsoft account', + }, + + { + id: 'siteSelector', + title: 'Select Site', + type: 'file-selector', + layout: 'full', + provider: 'microsoft', + serviceId: 'sharepoint', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'Files.Read', + 'Files.ReadWrite', + 'offline_access', + ], + mimeType: 'application/vnd.microsoft.graph.folder', + placeholder: 'Select a site', + mode: 'basic', + condition: { field: 'operation', value: ['create_page', 'read_page', 'list_sites'] }, + }, + + { + id: 'pageName', + title: 'Page Name', + type: 'short-input', + layout: 'full', + placeholder: 'Name of the page', + condition: { field: 'operation', value: ['create_page', 'read_page'] }, + }, + + { + id: 'pageId', + title: 'Page ID', + type: 'short-input', + layout: 'full', + placeholder: 'Page ID (alternative to page name)', + condition: { field: 'operation', value: 'read_page' }, + mode: 'advanced', + }, + + { + id: 'pageContent', + title: 'Page Content', + type: 'long-input', + layout: 'full', + placeholder: 'Content of the page', + condition: { field: 'operation', value: 'create_page' }, + }, + + { + id: 'manualSiteId', + title: 'Site ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter site ID (leave empty for root site)', + mode: 'advanced', + condition: { field: 'operation', value: 'create_page' }, + }, + ], + tools: { + access: ['sharepoint_create_page', 'sharepoint_read_page', 'sharepoint_list_sites'], + config: { + tool: (params) => { + switch (params.operation) { + case 'create_page': + return 'sharepoint_create_page' + case 'read_page': + return 'sharepoint_read_page' + case 'list_sites': + return 'sharepoint_list_sites' + default: + throw new Error(`Invalid Sharepoint operation: ${params.operation}`) + } + }, + params: (params) => { + const { credential, siteSelector, manualSiteId, mimeType, ...rest } = params + + // Use siteSelector if provided, otherwise use manualSiteId + const effectiveSiteId = (siteSelector || manualSiteId || '').trim() + + return { + accessToken: credential, + siteId: effectiveSiteId, + pageSize: rest.pageSize ? Number.parseInt(rest.pageSize as string, 10) : undefined, + mimeType: mimeType, + ...rest, + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + credential: { type: 'string', description: 'Microsoft account credential' }, + // Create Page operation inputs + pageName: { type: 'string', description: 'Page name' }, + pageContent: { type: 'string', description: 'Page content' }, + pageTitle: { type: 'string', description: 'Page title' }, + // Read Page operation inputs + pageId: { type: 'string', description: 'Page ID' }, + // List operation inputs + siteSelector: { type: 'string', description: 'Site selector' }, + manualSiteId: { type: 'string', description: 'Manual site ID' }, + pageSize: { type: 'number', description: 'Results per page' }, + }, + outputs: { + sites: { + type: 'json', + description: + 'An array of SharePoint site objects, each containing details such as id, name, and more.', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index a307e84fe..9cde501eb 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -36,9 +36,11 @@ import { LinkupBlock } from '@/blocks/blocks/linkup' import { Mem0Block } from '@/blocks/blocks/mem0' import { MemoryBlock } from '@/blocks/blocks/memory' import { MicrosoftExcelBlock } from '@/blocks/blocks/microsoft_excel' +import { MicrosoftPlannerBlock } from '@/blocks/blocks/microsoft_planner' import { MicrosoftTeamsBlock } from '@/blocks/blocks/microsoft_teams' import { MistralParseBlock } from '@/blocks/blocks/mistral_parse' import { NotionBlock } from '@/blocks/blocks/notion' +import { OneDriveBlock } from '@/blocks/blocks/onedrive' import { OpenAIBlock } from '@/blocks/blocks/openai' import { OutlookBlock } from '@/blocks/blocks/outlook' import { PerplexityBlock } from '@/blocks/blocks/perplexity' @@ -50,6 +52,7 @@ import { RouterBlock } from '@/blocks/blocks/router' import { S3Block } from '@/blocks/blocks/s3' import { ScheduleBlock } from '@/blocks/blocks/schedule' import { SerperBlock } from '@/blocks/blocks/serper' +import { SharepointBlock } from '@/blocks/blocks/sharepoint' import { SlackBlock } from '@/blocks/blocks/slack' import { StagehandBlock } from '@/blocks/blocks/stagehand' import { StagehandAgentBlock } from '@/blocks/blocks/stagehand_agent' @@ -105,11 +108,13 @@ export const registry: Record = { linkup: LinkupBlock, mem0: Mem0Block, microsoft_excel: MicrosoftExcelBlock, + microsoft_planner: MicrosoftPlannerBlock, microsoft_teams: MicrosoftTeamsBlock, mistral_parse: MistralParseBlock, notion: NotionBlock, openai: OpenAIBlock, outlook: OutlookBlock, + onedrive: OneDriveBlock, perplexity: PerplexityBlock, pinecone: PineconeBlock, qdrant: QdrantBlock, @@ -120,6 +125,7 @@ export const registry: Record = { schedule: ScheduleBlock, s3: S3Block, serper: SerperBlock, + sharepoint: SharepointBlock, stagehand: StagehandBlock, stagehand_agent: StagehandAgentBlock, slack: SlackBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 42259f130..13471d594 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3181,3 +3181,166 @@ export function HunterIOIcon(props: SVGProps) { ) } + +export function MicrosoftOneDriveIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} + +export function MicrosoftSharepointIcon(props: SVGProps) { + return ( + + + + + + + + + + + + ) +} + +export function MicrosoftPlannerIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 14760e716..4d1b0127e 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -441,6 +441,29 @@ export const auth = betterAuth({ pkce: true, redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-excel`, }, + { + providerId: 'microsoft-planner', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: [ + 'openid', + 'profile', + 'email', + 'Group.ReadWrite.All', + 'Group.Read.All', + 'Tasks.ReadWrite', + 'offline_access', + ], + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + pkce: true, + redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-planner`, + }, { providerId: 'outlook', @@ -467,6 +490,45 @@ export const auth = betterAuth({ redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/outlook`, }, + { + providerId: 'onedrive', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + pkce: true, + redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/onedrive`, + }, + + { + providerId: 'sharepoint', + clientId: env.MICROSOFT_CLIENT_ID as string, + clientSecret: env.MICROSOFT_CLIENT_SECRET as string, + authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + userInfoUrl: 'https://graph.microsoft.com/v1.0/me', + scopes: [ + 'openid', + 'profile', + 'email', + 'Sites.Read.All', + 'Sites.ReadWrite.All', + 'offline_access', + ], + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + pkce: true, + redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/sharepoint`, + }, + { providerId: 'wealthbox', clientId: env.WEALTHBOX_CLIENT_ID as string, diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 8b538c7e8..03010ea27 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -14,6 +14,9 @@ import { LinearIcon, MicrosoftExcelIcon, MicrosoftIcon, + MicrosoftOneDriveIcon, + MicrosoftPlannerIcon, + MicrosoftSharepointIcon, MicrosoftTeamsIcon, NotionIcon, OutlookIcon, @@ -62,12 +65,14 @@ export type OAuthService = | 'discord' | 'microsoft-excel' | 'microsoft-teams' + | 'microsoft-planner' + | 'sharepoint' | 'outlook' | 'linear' | 'slack' | 'reddit' | 'wealthbox' - + | 'onedrive' export interface OAuthProviderConfig { id: OAuthProvider name: string @@ -159,6 +164,23 @@ export const OAUTH_PROVIDERS: Record = { baseProviderIcon: (props) => MicrosoftIcon(props), scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], }, + 'microsoft-planner': { + id: 'microsoft-planner', + name: 'Microsoft Planner', + description: 'Connect to Microsoft Planner and manage tasks.', + providerId: 'microsoft-planner', + icon: (props) => MicrosoftPlannerIcon(props), + baseProviderIcon: (props) => MicrosoftIcon(props), + scopes: [ + 'openid', + 'profile', + 'email', + 'Group.ReadWrite.All', + 'Group.Read.All', + 'Tasks.ReadWrite', + 'offline_access', + ], + }, 'microsoft-teams': { id: 'microsoft-teams', name: 'Microsoft Teams', @@ -201,6 +223,31 @@ export const OAUTH_PROVIDERS: Record = { 'offline_access', ], }, + onedrive: { + id: 'onedrive', + name: 'OneDrive', + description: 'Connect to OneDrive and manage files.', + providerId: 'onedrive', + icon: (props) => MicrosoftOneDriveIcon(props), + baseProviderIcon: (props) => MicrosoftIcon(props), + scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'], + }, + sharepoint: { + id: 'sharepoint', + name: 'SharePoint', + description: 'Connect to SharePoint and manage sites.', + providerId: 'sharepoint', + icon: (props) => MicrosoftSharepointIcon(props), + baseProviderIcon: (props) => MicrosoftIcon(props), + scopes: [ + 'openid', + 'profile', + 'email', + 'Sites.Read.All', + 'Sites.ReadWrite.All', + 'offline_access', + ], + }, }, defaultService: 'microsoft', }, @@ -472,6 +519,12 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[] return 'microsoft-teams' } else if (provider === 'outlook') { return 'outlook' + } else if (provider === 'sharepoint') { + return 'sharepoint' + } else if (provider === 'microsoft-planner') { + return 'microsoft-planner' + } else if (provider === 'onedrive') { + return 'onedrive' } else if (provider === 'github') { return 'github' } else if (provider === 'supabase') { @@ -543,6 +596,18 @@ export function parseProvider(provider: OAuthProvider): ProviderConfig { featureType: 'outlook', } } + if (provider === 'onedrive') { + return { + baseProvider: 'microsoft', + featureType: 'onedrive', + } + } + if (provider === 'sharepoint') { + return { + baseProvider: 'microsoft', + featureType: 'sharepoint', + } + } // Handle compound providers (e.g., 'google-email' -> { baseProvider: 'google', featureType: 'email' }) const [base, feature] = provider.split('-') @@ -712,6 +777,30 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { useBasicAuth: false, } } + case 'onedrive': { + const { clientId, clientSecret } = getCredentials( + env.MICROSOFT_CLIENT_ID, + env.MICROSOFT_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + clientId, + clientSecret, + useBasicAuth: false, + } + } + case 'sharepoint': { + const { clientId, clientSecret } = getCredentials( + env.MICROSOFT_CLIENT_ID, + env.MICROSOFT_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + clientId, + clientSecret, + useBasicAuth: false, + } + } case 'linear': { const { clientId, clientSecret } = getCredentials( env.LINEAR_CLIENT_ID, diff --git a/apps/sim/tools/microsoft_planner/create_task.ts b/apps/sim/tools/microsoft_planner/create_task.ts new file mode 100644 index 000000000..30401ac19 --- /dev/null +++ b/apps/sim/tools/microsoft_planner/create_task.ts @@ -0,0 +1,203 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + MicrosoftPlannerCreateResponse, + MicrosoftPlannerToolParams, + PlannerTask, +} from '@/tools/microsoft_planner/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('MicrosoftPlannerCreateTask') + +export const createTaskTool: ToolConfig< + MicrosoftPlannerToolParams, + MicrosoftPlannerCreateResponse +> = { + id: 'microsoft_planner_create_task', + name: 'Create Microsoft Planner Task', + description: 'Create a new task in Microsoft Planner', + version: '1.0', + oauth: { + required: true, + provider: 'microsoft-planner', + additionalScopes: [], + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Microsoft Planner API', + }, + planId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The ID of the plan where the task will be created', + }, + title: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The title of the task', + }, + description: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The description of the task', + }, + dueDateTime: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The due date and time for the task (ISO 8601 format)', + }, + assigneeUserId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The user ID to assign the task to', + }, + bucketId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The bucket ID to place the task in', + }, + }, + request: { + url: () => 'https://graph.microsoft.com/v1.0/planner/tasks', + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + if (!params.planId) { + throw new Error('Plan ID is required') + } + if (!params.title) { + throw new Error('Task title is required') + } + + const body: PlannerTask = { + planId: params.planId, + title: params.title, + } + + if (params.bucketId) { + body.bucketId = params.bucketId + } + + if (params.dueDateTime) { + body.dueDateTime = params.dueDateTime + } + + if (params.assigneeUserId) { + body.assignments = { + [params.assigneeUserId]: { + '@odata.type': 'microsoft.graph.plannerAssignment', + orderHint: ' !', + }, + } + } + + logger.info('Creating task with body:', body) + return body + }, + }, + transformResponse: async (response: Response, params) => { + if (!response.ok) { + const errorJson = await response.json().catch(() => ({ error: response.statusText })) + const errorText = + errorJson.error && typeof errorJson.error === 'object' + ? errorJson.error.message || JSON.stringify(errorJson.error) + : errorJson.error || response.statusText + throw new Error(`Failed to create Microsoft Planner task: ${errorText}`) + } + + const task = await response.json() + logger.info('Created task:', task) + + // If description was provided, update the task details + if (params?.description && task.id) { + try { + const detailsUrl = `https://graph.microsoft.com/v1.0/planner/tasks/${task.id}/details` + // Get task details to get the ETag + const getDetailsResponse = await fetch(detailsUrl, { + headers: { Authorization: `Bearer ${params.accessToken}` }, + }) + const etag = getDetailsResponse.headers.get('ETag') + + // Then update with correct ETag + const detailsResponse = await fetch(detailsUrl, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + 'If-Match': etag || '*', // Use actual ETag or '*' if not available + }, + body: JSON.stringify({ + description: params.description, + }), + }) + + if (detailsResponse.ok) { + const details = await detailsResponse.json() + task.details = details + } + } catch (error) { + logger.warn('Failed to update task description:', error) + } + } + + const result: MicrosoftPlannerCreateResponse = { + success: true, + output: { + task, + metadata: { + planId: task.planId, + taskId: task.id, + taskUrl: `https://graph.microsoft.com/v1.0/planner/tasks/${task.id}`, + }, + }, + } + + return result + }, + transformError: (error) => { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'object' && error !== null) { + if (error.error) { + if (typeof error.error === 'string') { + return error.error + } + if (typeof error.error === 'object' && error.error.message) { + return error.error.message + } + return JSON.stringify(error.error) + } + + if (error.message) { + return error.message + } + + try { + return `Microsoft Planner API error: ${JSON.stringify(error)}` + } catch (_e) { + return 'Microsoft Planner API error: Unable to parse error details' + } + } + + return 'An error occurred while creating the Microsoft Planner task' + }, +} diff --git a/apps/sim/tools/microsoft_planner/index.ts b/apps/sim/tools/microsoft_planner/index.ts new file mode 100644 index 000000000..145034f09 --- /dev/null +++ b/apps/sim/tools/microsoft_planner/index.ts @@ -0,0 +1,5 @@ +import { createTaskTool } from '@/tools/microsoft_planner/create_task' +import { readTaskTool } from '@/tools/microsoft_planner/read_task' + +export const microsoftPlannerCreateTaskTool = createTaskTool +export const microsoftPlannerReadTaskTool = readTaskTool diff --git a/apps/sim/tools/microsoft_planner/read_task.ts b/apps/sim/tools/microsoft_planner/read_task.ts new file mode 100644 index 000000000..891f7f7a2 --- /dev/null +++ b/apps/sim/tools/microsoft_planner/read_task.ts @@ -0,0 +1,149 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + MicrosoftPlannerReadResponse, + MicrosoftPlannerToolParams, +} from '@/tools/microsoft_planner/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('MicrosoftPlannerReadTask') + +export const readTaskTool: ToolConfig = { + id: 'microsoft_planner_read_task', + name: 'Read Microsoft Planner Tasks', + description: + 'Read tasks from Microsoft Planner - get all user tasks or all tasks from a specific plan', + version: '1.0', + oauth: { + required: true, + provider: 'microsoft-planner', + additionalScopes: [], + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Microsoft Planner API', + }, + planId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The ID of the plan to get tasks from (if not provided, gets all user tasks)', + }, + taskId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The ID of the task to get', + }, + }, + request: { + url: (params) => { + let finalUrl: string + + // If taskId is provided, get specific task + if (params.taskId) { + finalUrl = `https://graph.microsoft.com/v1.0/planner/tasks/${params.taskId}` + } + // Else if planId is provided, get tasks from plan + else if (params.planId) { + finalUrl = `https://graph.microsoft.com/v1.0/planner/plans/${params.planId}/tasks` + } + // Else get all user tasks + else { + finalUrl = 'https://graph.microsoft.com/v1.0/me/planner/tasks' + } + + logger.info('Microsoft Planner URL:', finalUrl) + return finalUrl + }, + method: 'GET', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + logger.info('Access token present:', !!params.accessToken) + return { + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + transformResponse: async (response: Response, params) => { + if (!response.ok) { + const errorJson = await response.json().catch(() => ({ error: response.statusText })) + const errorText = + errorJson.error && typeof errorJson.error === 'object' + ? errorJson.error.message || JSON.stringify(errorJson.error) + : errorJson.error || response.statusText + throw new Error(`Failed to read Microsoft Planner tasks: ${errorText}`) + } + + const data = await response.json() + logger.info('Raw response data:', data) + + // Handle single task vs multiple tasks response format + const rawTasks = params?.taskId ? [data] : data.value || [] + + // Filter tasks to only include useful fields + const tasks = rawTasks.map((task: any) => ({ + id: task.id, + title: task.title, + planId: task.planId, + bucketId: task.bucketId, + percentComplete: task.percentComplete, + priority: task.priority, + dueDateTime: task.dueDateTime, + createdDateTime: task.createdDateTime, + completedDateTime: task.completedDateTime, + hasDescription: task.hasDescription, + assignments: task.assignments ? Object.keys(task.assignments) : [], + })) + + const result: MicrosoftPlannerReadResponse = { + success: true, + output: { + tasks, + metadata: { + planId: params?.planId || '', + userId: params?.planId ? undefined : 'me', + planUrl: params?.planId + ? `https://graph.microsoft.com/v1.0/planner/plans/${params.planId}` + : undefined, + }, + }, + } + + return result + }, + transformError: (error) => { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'object' && error !== null) { + if (error.error) { + if (typeof error.error === 'string') { + return error.error + } + if (typeof error.error === 'object' && error.error.message) { + return error.error.message + } + return JSON.stringify(error.error) + } + + if (error.message) { + return error.message + } + + try { + return `Microsoft Planner API error: ${JSON.stringify(error)}` + } catch (_e) { + return 'Microsoft Planner API error: Unable to parse error details' + } + } + + return 'An error occurred while reading Microsoft Planner tasks' + }, +} diff --git a/apps/sim/tools/microsoft_planner/types.ts b/apps/sim/tools/microsoft_planner/types.ts new file mode 100644 index 000000000..01aea1632 --- /dev/null +++ b/apps/sim/tools/microsoft_planner/types.ts @@ -0,0 +1,117 @@ +import type { ToolResponse } from '@/tools/types' + +export interface PlannerIdentitySet { + user?: { + displayName?: string + id?: string + } + application?: { + displayName?: string + id?: string + } +} + +export interface PlannerAssignment { + '@odata.type': string + assignedDateTime?: string + orderHint?: string + assignedBy?: PlannerIdentitySet +} + +export interface PlannerReference { + alias?: string + lastModifiedBy?: PlannerIdentitySet + lastModifiedDateTime?: string + previewPriority?: string + type?: string +} + +export interface PlannerChecklistItem { + '@odata.type': string + isChecked?: boolean + title?: string + orderHint?: string + lastModifiedBy?: PlannerIdentitySet + lastModifiedDateTime?: string +} + +export interface PlannerContainer { + containerId?: string + type?: string + url?: string +} + +export interface PlannerTask { + id?: string + planId: string + title: string + orderHint?: string + assigneePriority?: string + percentComplete?: number + startDateTime?: string + createdDateTime?: string + dueDateTime?: string + hasDescription?: boolean + previewType?: string + completedDateTime?: string + completedBy?: PlannerIdentitySet + referenceCount?: number + checklistItemCount?: number + activeChecklistItemCount?: number + conversationThreadId?: string + priority?: number + assignments?: Record + bucketId?: string + details?: { + description?: string + references?: Record + checklist?: Record + } +} + +export interface PlannerPlan { + id: string + title: string + owner?: string + createdDateTime?: string + container?: PlannerContainer +} + +export interface MicrosoftPlannerMetadata { + planId?: string + taskId?: string + userId?: string + planUrl?: string + taskUrl?: string +} + +export interface MicrosoftPlannerReadResponse extends ToolResponse { + output: { + tasks?: PlannerTask[] + task?: PlannerTask + plan?: PlannerPlan + metadata: MicrosoftPlannerMetadata + } +} + +export interface MicrosoftPlannerCreateResponse extends ToolResponse { + output: { + task: PlannerTask + metadata: MicrosoftPlannerMetadata + } +} + +export interface MicrosoftPlannerToolParams { + accessToken: string + planId?: string + taskId?: string + title?: string + description?: string + dueDateTime?: string + assigneeUserId?: string + bucketId?: string + priority?: number + percentComplete?: number +} + +export type MicrosoftPlannerResponse = MicrosoftPlannerReadResponse | MicrosoftPlannerCreateResponse diff --git a/apps/sim/tools/onedrive/create_folder.ts b/apps/sim/tools/onedrive/create_folder.ts new file mode 100644 index 000000000..31d10c541 --- /dev/null +++ b/apps/sim/tools/onedrive/create_folder.ts @@ -0,0 +1,88 @@ +import type { OneDriveToolParams, OneDriveUploadResponse } from '@/tools/onedrive/types' +import type { ToolConfig } from '@/tools/types' + +export const createFolderTool: ToolConfig = { + id: 'onedrive_create_folder', + name: 'Create Folder in OneDrive', + description: 'Create a new folder in OneDrive', + version: '1.0', + oauth: { + required: true, + provider: 'onedrive', + additionalScopes: [], + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the OneDrive API', + }, + folderName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the folder to create', + }, + folderSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the parent folder to create the folder in', + }, + folderId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ID of the parent folder (internal use)', + }, + }, + request: { + url: (params) => { + // Use specific parent folder URL if parentId is provided + const parentFolderId = params.folderSelector || params.folderId + if (parentFolderId) { + return `https://graph.microsoft.com/v1.0/me/drive/items/${parentFolderId}/children` + } + return 'https://graph.microsoft.com/v1.0/me/drive/root/children' + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + return { + name: params.folderName, + folder: {}, // Required facet for folder creation in Microsoft Graph API + '@microsoft.graph.conflictBehavior': 'rename', // Handle name conflicts + } + }, + }, + transformResponse: async (response: Response) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error?.message || 'Failed to create folder in OneDrive') + } + const data = await response.json() + + return { + success: true, + output: { + file: { + id: data.id, + name: data.name, + mimeType: 'application/vnd.microsoft.graph.folder', + webViewLink: data.webUrl, + size: data.size, + createdTime: data.createdDateTime, + modifiedTime: data.lastModifiedDateTime, + parentReference: data.parentReference, + }, + }, + } + }, + transformError: (error) => { + return error.message || 'An error occurred while creating folder in OneDrive' + }, +} diff --git a/apps/sim/tools/onedrive/index.ts b/apps/sim/tools/onedrive/index.ts new file mode 100644 index 000000000..30298d9d7 --- /dev/null +++ b/apps/sim/tools/onedrive/index.ts @@ -0,0 +1,7 @@ +import { createFolderTool } from '@/tools/onedrive/create_folder' +import { listTool } from '@/tools/onedrive/list' +import { uploadTool } from '@/tools/onedrive/upload' + +export const onedriveCreateFolderTool = createFolderTool +export const onedriveListTool = listTool +export const onedriveUploadTool = uploadTool diff --git a/apps/sim/tools/onedrive/list.ts b/apps/sim/tools/onedrive/list.ts new file mode 100644 index 000000000..f8c8315b5 --- /dev/null +++ b/apps/sim/tools/onedrive/list.ts @@ -0,0 +1,120 @@ +import type { + MicrosoftGraphDriveItem, + OneDriveListResponse, + OneDriveToolParams, +} from '@/tools/onedrive/types' +import type { ToolConfig } from '@/tools/types' + +export const listTool: ToolConfig = { + id: 'onedrive_list', + name: 'List OneDrive Files', + description: 'List files and folders in OneDrive', + version: '1.0', + oauth: { + required: true, + provider: 'onedrive', + additionalScopes: [ + 'openid', + 'profile', + 'email', + 'Files.Read', + 'Files.ReadWrite', + 'offline_access', + ], + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the OneDrive API', + }, + folderSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the folder to list files from', + }, + folderId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the folder to list files from (internal use)', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'A query to filter the files', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'The number of files to return', + }, + }, + request: { + url: (params) => { + // Use specific folder if provided, otherwise use root + const folderId = params.folderId || params.folderSelector + const baseUrl = folderId + ? `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}/children` + : 'https://graph.microsoft.com/v1.0/me/drive/root/children' + + const url = new URL(baseUrl) + + // Use Microsoft Graph $select parameter + url.searchParams.append( + '$select', + 'id,name,file,folder,webUrl,size,createdDateTime,lastModifiedDateTime,parentReference' + ) + + // Add name filter if query provided + if (params.query) { + url.searchParams.append('$filter', `startswith(name,'${params.query}')`) + } + + // Add pagination + if (params.pageSize) { + url.searchParams.append('$top', params.pageSize.toString()) + } + + // Remove the $skip logic entirely. Instead, use the full nextLink URL if provided + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || 'Failed to list OneDrive files') + } + + return { + success: true, + output: { + files: data.value.map((item: MicrosoftGraphDriveItem) => ({ + id: item.id, + name: item.name, + mimeType: item.file?.mimeType || (item.folder ? 'application/folder' : 'unknown'), + webViewLink: item.webUrl, + webContentLink: item['@microsoft.graph.downloadUrl'], + size: item.size?.toString() || '0', + createdTime: item.createdDateTime, + modifiedTime: item.lastModifiedDateTime, + parents: item.parentReference ? [item.parentReference.id] : [], + })), + // Use the actual @odata.nextLink URL as the continuation token + nextPageToken: data['@odata.nextLink'] || undefined, + }, + } + }, + transformError: (error) => { + return error.message || 'An error occurred while listing OneDrive files' + }, +} diff --git a/apps/sim/tools/onedrive/types.ts b/apps/sim/tools/onedrive/types.ts new file mode 100644 index 000000000..20056ebdf --- /dev/null +++ b/apps/sim/tools/onedrive/types.ts @@ -0,0 +1,64 @@ +import type { ToolResponse } from '@/tools/types' + +export interface MicrosoftGraphDriveItem { + id: string + name: string + file?: { + mimeType: string + } + folder?: { + childCount: number + } + webUrl: string + createdDateTime: string + lastModifiedDateTime: string + size?: number + '@microsoft.graph.downloadUrl'?: string + parentReference?: { + id: string + driveId: string + path: string + } +} + +export interface OneDriveFile { + id: string + name: string + mimeType: string + webViewLink?: string + webContentLink?: string + size?: string + createdTime?: string + modifiedTime?: string + parents?: string[] +} + +export interface OneDriveListResponse extends ToolResponse { + output: { + files: OneDriveFile[] + nextPageToken?: string + } +} + +export interface OneDriveUploadResponse extends ToolResponse { + output: { + file: OneDriveFile + } +} + +export interface OneDriveToolParams { + accessToken: string + folderId?: string + folderSelector?: string + folderName?: string + fileId?: string + fileName?: string + content?: string + mimeType?: string + query?: string + pageSize?: number + pageToken?: string + exportMimeType?: string +} + +export type OneDriveResponse = OneDriveUploadResponse | OneDriveListResponse diff --git a/apps/sim/tools/onedrive/upload.ts b/apps/sim/tools/onedrive/upload.ts new file mode 100644 index 000000000..5783351bb --- /dev/null +++ b/apps/sim/tools/onedrive/upload.ts @@ -0,0 +1,132 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { OneDriveToolParams, OneDriveUploadResponse } from '@/tools/onedrive/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('OneDriveUploadTool') + +export const uploadTool: ToolConfig = { + id: 'onedrive_upload', + name: 'Upload to OneDrive', + description: 'Upload a file to OneDrive', + version: '1.0', + oauth: { + required: true, + provider: 'onedrive', + additionalScopes: [ + 'openid', + 'profile', + 'email', + 'Files.Read', + 'Files.ReadWrite', + 'offline_access', + ], + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the OneDrive API', + }, + fileName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the file to upload', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The content of the file to upload', + }, + folderSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the folder to upload the file to', + }, + folderId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the folder to upload the file to (internal use)', + }, + }, + request: { + url: (params) => { + let fileName = params.fileName || 'untitled' + + // Always create .txt files for text content + if (!fileName.endsWith('.txt')) { + // Remove any existing extensions and add .txt + fileName = `${fileName.replace(/\.[^.]*$/, '')}.txt` + } + + // Build the proper URL based on parent folder + const parentFolderId = params.folderSelector || params.folderId + if (parentFolderId && parentFolderId.trim() !== '') { + return `https://graph.microsoft.com/v1.0/me/drive/items/${parentFolderId}:/${fileName}:/content` + } + // Default to root folder + return `https://graph.microsoft.com/v1.0/me/drive/root:/${fileName}:/content` + }, + method: 'PUT', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'text/plain', + }), + body: (params) => (params.content || '') as unknown as Record, + }, + transformResponse: async (response: Response, params?: OneDriveToolParams) => { + try { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to upload file to OneDrive', { + status: response.status, + statusText: response.statusText, + errorData, + }) + throw new Error(errorData.error?.message || 'Failed to upload file to OneDrive') + } + + // Microsoft Graph API returns the file metadata directly + const fileData = await response.json() + + logger.info('Successfully uploaded file to OneDrive', { + fileId: fileData.id, + fileName: fileData.name, + }) + + return { + success: true, + output: { + file: { + id: fileData.id, + name: fileData.name, + mimeType: fileData.file?.mimeType || params?.mimeType || 'text/plain', + webViewLink: fileData.webUrl, + webContentLink: fileData['@microsoft.graph.downloadUrl'], + size: fileData.size, + createdTime: fileData.createdDateTime, + modifiedTime: fileData.lastModifiedDateTime, + parentReference: fileData.parentReference, + }, + }, + } + } catch (error: any) { + logger.error('Error in upload transformation', { + error: error.message, + stack: error.stack, + }) + throw error + } + }, + transformError: (error) => { + logger.error('Upload error', { + error: error.message, + stack: error.stack, + }) + return error.message || 'An error occurred while uploading to OneDrive' + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index fe5807030..84f33c7ec 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -79,6 +79,10 @@ import { microsoftExcelTableAddTool, microsoftExcelWriteTool, } from '@/tools/microsoft_excel' +import { + microsoftPlannerCreateTaskTool, + microsoftPlannerReadTaskTool, +} from '@/tools/microsoft_planner' import { microsoftTeamsReadChannelTool, microsoftTeamsReadChatTool, @@ -95,6 +99,7 @@ import { notionSearchTool, notionWriteTool, } from '@/tools/notion' +import { onedriveCreateFolderTool, onedriveListTool, onedriveUploadTool } from '@/tools/onedrive' import { imageTool, embeddingsTool as openAIEmbeddings } from '@/tools/openai' import { outlookDraftTool, outlookReadTool, outlookSendTool } from '@/tools/outlook' import { perplexityChatTool } from '@/tools/perplexity' @@ -109,6 +114,11 @@ import { qdrantFetchTool, qdrantSearchTool, qdrantUpsertTool } from '@/tools/qdr import { redditGetCommentsTool, redditGetPostsTool, redditHotPostsTool } from '@/tools/reddit' import { s3GetObjectTool } from '@/tools/s3' import { searchTool as serperSearch } from '@/tools/serper' +import { + sharepointCreatePageTool, + sharepointListSitesTool, + sharepointReadPageTool, +} from '@/tools/sharepoint' import { slackCanvasTool, slackMessageReaderTool, slackMessageTool } from '@/tools/slack' import { stagehandAgentTool, stagehandExtractTool } from '@/tools/stagehand' import { @@ -265,9 +275,14 @@ export const tools: Record = { outlook_draft: outlookDraftTool, linear_read_issues: linearReadIssuesTool, linear_create_issue: linearCreateIssueTool, + onedrive_create_folder: onedriveCreateFolderTool, + onedrive_list: onedriveListTool, + onedrive_upload: onedriveUploadTool, microsoft_excel_read: microsoftExcelReadTool, microsoft_excel_write: microsoftExcelWriteTool, microsoft_excel_table_add: microsoftExcelTableAddTool, + microsoft_planner_create_task: microsoftPlannerCreateTaskTool, + microsoft_planner_read_task: microsoftPlannerReadTaskTool, google_calendar_create: googleCalendarCreateTool, google_calendar_get: googleCalendarGetTool, google_calendar_list: googleCalendarListTool, @@ -293,4 +308,7 @@ export const tools: Record = { hunter_email_verifier: hunterEmailVerifierTool, hunter_companies_find: hunterCompaniesFindTool, hunter_email_count: hunterEmailCountTool, + sharepoint_create_page: sharepointCreatePageTool, + sharepoint_read_page: sharepointReadPageTool, + sharepoint_list_sites: sharepointListSitesTool, } diff --git a/apps/sim/tools/sharepoint/create_page.ts b/apps/sim/tools/sharepoint/create_page.ts new file mode 100644 index 000000000..235ecac6c --- /dev/null +++ b/apps/sim/tools/sharepoint/create_page.ts @@ -0,0 +1,157 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + SharepointCreatePageResponse, + SharepointPage, + SharepointToolParams, +} from '@/tools/sharepoint/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SharePointCreatePage') + +export const createPageTool: ToolConfig = { + id: 'sharepoint_create_page', + name: 'Create SharePoint Page', + description: 'Create a new page in a SharePoint site', + version: '1.0', + oauth: { + required: true, + provider: 'sharepoint', + additionalScopes: ['openid', 'profile', 'email', 'Sites.ReadWrite.All', 'offline_access'], + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + pageName: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The name of the page to create', + }, + pageTitle: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The title of the page (defaults to page name if not provided)', + }, + pageContent: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The content of the page', + }, + }, + request: { + url: (params) => { + // Use specific site if provided, otherwise use root site + const siteId = params.siteSelector || params.siteId || 'root' + return `https://graph.microsoft.com/v1.0/sites/${siteId}/pages` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + if (!params.pageName) { + throw new Error('Page name is required') + } + + const pageTitle = params.pageTitle || params.pageName + + // Basic page structure required by Microsoft Graph API + const pageData: SharepointPage = { + '@odata.type': '#microsoft.graph.sitePage', + name: params.pageName, + title: pageTitle, + publishingState: { + level: 'draft', + }, + pageLayout: 'article', + } + + // Add content if provided using the simple innerHtml approach from the documentation + if (params.pageContent) { + pageData.canvasLayout = { + horizontalSections: [ + { + layout: 'oneColumn', + id: '1', + emphasis: 'none', + columns: [ + { + id: '1', + width: 12, + webparts: [ + { + id: '6f9230af-2a98-4952-b205-9ede4f9ef548', + innerHtml: `

${params.pageContent.replace(/"/g, '"').replace(/'/g, ''')}

`, + }, + ], + }, + ], + }, + ], + } + } + + return pageData + }, + }, + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + logger.error('SharePoint page creation failed', { + status: response.status, + statusText: response.statusText, + error: data.error, + data, + }) + throw new Error( + data.error?.message || + `Failed to create SharePoint page: ${response.status} ${response.statusText}` + ) + } + + logger.info('SharePoint page created successfully', { + pageId: data.id, + pageName: data.name, + pageTitle: data.title, + }) + + return { + success: true, + output: { + page: { + id: data.id, + name: data.name, + title: data.title || data.name, + webUrl: data.webUrl, + pageLayout: data.pageLayout, + createdDateTime: data.createdDateTime, + lastModifiedDateTime: data.lastModifiedDateTime, + }, + }, + } + }, + transformError: (error) => { + return error.message || 'An error occurred while creating the SharePoint page' + }, +} diff --git a/apps/sim/tools/sharepoint/index.ts b/apps/sim/tools/sharepoint/index.ts new file mode 100644 index 000000000..702d29aec --- /dev/null +++ b/apps/sim/tools/sharepoint/index.ts @@ -0,0 +1,7 @@ +import { createPageTool } from '@/tools/sharepoint/create_page' +import { listSitesTool } from '@/tools/sharepoint/list_sites' +import { readPageTool } from '@/tools/sharepoint/read_page' + +export const sharepointCreatePageTool = createPageTool +export const sharepointListSitesTool = listSitesTool +export const sharepointReadPageTool = readPageTool diff --git a/apps/sim/tools/sharepoint/list_sites.ts b/apps/sim/tools/sharepoint/list_sites.ts new file mode 100644 index 000000000..d7876a47b --- /dev/null +++ b/apps/sim/tools/sharepoint/list_sites.ts @@ -0,0 +1,117 @@ +import type { + SharepointReadSiteResponse, + SharepointSite, + SharepointToolParams, +} from '@/tools/sharepoint/types' +import type { ToolConfig } from '@/tools/types' + +export const listSitesTool: ToolConfig = { + id: 'sharepoint_list_sites', + name: 'List SharePoint Sites', + description: 'List details of all SharePoint sites', + version: '1.0', + oauth: { + required: true, + provider: 'sharepoint', + additionalScopes: ['openid', 'profile', 'email', 'Sites.Read.All', 'offline_access'], + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + groupId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The group ID for accessing a group team site', + }, + }, + request: { + url: (params) => { + let baseUrl: string + + if (params.groupId) { + // Access group team site + baseUrl = `https://graph.microsoft.com/v1.0/groups/${params.groupId}/sites/root` + } else if (params.siteId || params.siteSelector) { + // Access specific site by ID + const siteId = params.siteId || params.siteSelector + baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}` + } else { + // get all sites + baseUrl = 'https://graph.microsoft.com/v1.0/sites?search=*' + } + + const url = new URL(baseUrl) + + // Use Microsoft Graph $select parameter to get site details + url.searchParams.append( + '$select', + 'id,name,displayName,webUrl,description,createdDateTime,lastModifiedDateTime,isPersonalSite,root,siteCollection' + ) + + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + transformResponse: async (response: Response, params) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || 'Failed to read SharePoint site(s)') + } + + // Check if this is a search result (multiple sites) or single site + if (data.value && Array.isArray(data.value)) { + // Multiple sites from search + return { + success: true, + output: { + sites: data.value.map((site: SharepointSite) => ({ + id: site.id, + name: site.name, + displayName: site.displayName, + webUrl: site.webUrl, + description: site.description, + createdDateTime: site.createdDateTime, + lastModifiedDateTime: site.lastModifiedDateTime, + })), + }, + } + } + // Single site response + return { + success: true, + output: { + site: { + id: data.id, + name: data.name, + displayName: data.displayName, + webUrl: data.webUrl, + description: data.description, + createdDateTime: data.createdDateTime, + lastModifiedDateTime: data.lastModifiedDateTime, + isPersonalSite: data.isPersonalSite, + root: data.root, + siteCollection: data.siteCollection, + }, + }, + } + }, + transformError: (error) => { + return error.message || 'An error occurred while reading the SharePoint site' + }, +} diff --git a/apps/sim/tools/sharepoint/read_page.ts b/apps/sim/tools/sharepoint/read_page.ts new file mode 100644 index 000000000..36a068599 --- /dev/null +++ b/apps/sim/tools/sharepoint/read_page.ts @@ -0,0 +1,325 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + GraphApiResponse, + SharepointPageContent, + SharepointReadPageResponse, + SharepointToolParams, +} from '@/tools/sharepoint/types' +import { cleanODataMetadata, extractTextFromCanvasLayout } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SharePointReadPage') + +export const readPageTool: ToolConfig = { + id: 'sharepoint_read_page', + name: 'Read SharePoint Page', + description: 'Read a specific page from a SharePoint site', + version: '1.0', + oauth: { + required: true, + provider: 'sharepoint', + additionalScopes: ['openid', 'profile', 'email', 'Sites.Read.All', 'offline_access'], + }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + pageId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The ID of the page to read', + }, + pageName: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The name of the page to read (alternative to pageId)', + }, + maxPages: { + type: 'number', + required: false, + visibility: 'user-only', + description: + 'Maximum number of pages to return when listing all pages (default: 10, max: 50)', + }, + }, + request: { + url: (params) => { + // Use specific site if provided, otherwise use root site + const siteId = params.siteId || params.siteSelector || 'root' + + let baseUrl: string + if (params.pageId) { + // Read specific page by ID + baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${params.pageId}` + } else { + // List all pages (with optional filtering by name) + baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages` + } + + const url = new URL(baseUrl) + + // Use Microsoft Graph $select parameter to get page details + // Only include valid properties for SharePoint pages + url.searchParams.append( + '$select', + 'id,name,title,webUrl,pageLayout,createdDateTime,lastModifiedDateTime' + ) + + // If searching by name, add filter + if (params.pageName && !params.pageId) { + // Try to handle both with and without .aspx extension + const pageName = params.pageName + const pageNameWithAspx = pageName.endsWith('.aspx') ? pageName : `${pageName}.aspx` + + // Search for exact match first, then with .aspx if needed + url.searchParams.append('$filter', `name eq '${pageName}' or name eq '${pageNameWithAspx}'`) + url.searchParams.append('$top', '10') // Get more results to find matches + } else if (!params.pageId && !params.pageName) { + // When listing all pages, apply maxPages limit + const maxPages = Math.min(params.maxPages || 10, 50) // Default 10, max 50 + url.searchParams.append('$top', maxPages.toString()) + } + + // Only expand content when getting a specific page by ID + if (params.pageId) { + url.searchParams.append('$expand', 'canvasLayout') + } + + const finalUrl = url.toString() + + logger.info('SharePoint API URL', { + finalUrl, + siteId, + pageId: params.pageId, + pageName: params.pageName, + searchParams: Object.fromEntries(url.searchParams), + }) + + return finalUrl + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + transformResponse: async (response: Response, params) => { + const data: GraphApiResponse = await response.json() + + if (!response.ok) { + logger.error('SharePoint API error', { + status: response.status, + statusText: response.statusText, + error: data.error, + data, + }) + throw new Error(data.error?.message || 'Failed to read SharePoint page') + } + + logger.info('SharePoint API response', { + pageId: params?.pageId, + pageName: params?.pageName, + resultsCount: data.value?.length || (data.id ? 1 : 0), + hasDirectPage: !!data.id, + hasSearchResults: !!data.value, + }) + + if (params?.pageId) { + // Direct page access - return single page + const pageData = data + const contentData = { + content: extractTextFromCanvasLayout(data.canvasLayout), + canvasLayout: data.canvasLayout as any, + } + + return { + success: true, + output: { + page: { + id: pageData.id!, + name: pageData.name!, + title: pageData.title || pageData.name!, + webUrl: pageData.webUrl!, + pageLayout: pageData.pageLayout, + createdDateTime: pageData.createdDateTime, + lastModifiedDateTime: pageData.lastModifiedDateTime, + }, + content: contentData, + }, + } + } + // Multiple pages or search by name + if (!data.value || data.value.length === 0) { + logger.error('No pages found', { + searchName: params?.pageName, + siteId: params?.siteId || params?.siteSelector || 'root', + totalResults: data.value?.length || 0, + }) + const errorMessage = params?.pageName + ? `Page with name '${params?.pageName}' not found. Make sure the page exists and you have access to it. Note: SharePoint page names typically include the .aspx extension.` + : 'No pages found on this SharePoint site.' + throw new Error(errorMessage) + } + + logger.info('Found pages', { + searchName: params?.pageName, + foundPages: data.value.map((p: any) => ({ id: p.id, name: p.name, title: p.title })), + totalCount: data.value.length, + }) + + if (params?.pageName) { + // Search by name - return single page (first match) + const pageData = data.value[0] + const siteId = params?.siteId || params?.siteSelector || 'root' + const contentUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageData.id}/microsoft.graph.sitePage?$expand=canvasLayout` + + logger.info('Making API call to get page content for searched page', { + pageId: pageData.id, + contentUrl, + siteId, + }) + + const contentResponse = await fetch(contentUrl, { + headers: { + Authorization: `Bearer ${params?.accessToken}`, + Accept: 'application/json', + }, + }) + + let contentData: SharepointPageContent = { content: '' } + if (contentResponse.ok) { + const contentResult = await contentResponse.json() + contentData = { + content: extractTextFromCanvasLayout(contentResult.canvasLayout), + canvasLayout: cleanODataMetadata(contentResult.canvasLayout), + } + } else { + logger.error('Failed to fetch page content', { + status: contentResponse.status, + statusText: contentResponse.statusText, + }) + } + + return { + success: true, + output: { + page: { + id: pageData.id, + name: pageData.name, + title: pageData.title || pageData.name, + webUrl: pageData.webUrl, + pageLayout: pageData.pageLayout, + createdDateTime: pageData.createdDateTime, + lastModifiedDateTime: pageData.lastModifiedDateTime, + }, + content: contentData, + }, + } + } + // List all pages - return multiple pages with content + const siteId = params?.siteId || params?.siteSelector || 'root' + const pagesWithContent = [] + + logger.info('Fetching content for all pages', { + totalPages: data.value.length, + siteId, + }) + + // Fetch content for each page + for (const pageInfo of data.value) { + const contentUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageInfo.id}/microsoft.graph.sitePage?$expand=canvasLayout` + + try { + const contentResponse = await fetch(contentUrl, { + headers: { + Authorization: `Bearer ${params?.accessToken}`, + Accept: 'application/json', + }, + }) + + let contentData = { content: '', canvasLayout: null } + if (contentResponse.ok) { + const contentResult = await contentResponse.json() + contentData = { + content: extractTextFromCanvasLayout(contentResult.canvasLayout), + canvasLayout: cleanODataMetadata(contentResult.canvasLayout), + } + } else { + logger.error('Failed to fetch content for page', { + pageId: pageInfo.id, + pageName: pageInfo.name, + status: contentResponse.status, + }) + } + + pagesWithContent.push({ + page: { + id: pageInfo.id, + name: pageInfo.name, + title: pageInfo.title || pageInfo.name, + webUrl: pageInfo.webUrl, + pageLayout: pageInfo.pageLayout, + createdDateTime: pageInfo.createdDateTime, + lastModifiedDateTime: pageInfo.lastModifiedDateTime, + }, + content: contentData, + }) + } catch (error) { + logger.error('Error fetching content for page', { + pageId: pageInfo.id, + pageName: pageInfo.name, + error: error instanceof Error ? error.message : String(error), + }) + + // Still add the page without content + pagesWithContent.push({ + page: { + id: pageInfo.id, + name: pageInfo.name, + title: pageInfo.title || pageInfo.name, + webUrl: pageInfo.webUrl, + pageLayout: pageInfo.pageLayout, + createdDateTime: pageInfo.createdDateTime, + lastModifiedDateTime: pageInfo.lastModifiedDateTime, + }, + content: { content: 'Failed to fetch content', canvasLayout: null }, + }) + } + } + + logger.info('Completed fetching content for all pages', { + totalPages: pagesWithContent.length, + successfulPages: pagesWithContent.filter( + (p) => p.content.content !== 'Failed to fetch content' + ).length, + }) + + return { + success: true, + output: { + pages: pagesWithContent, + totalPages: pagesWithContent.length, + }, + } + }, + transformError: (error) => { + return error.message || 'An error occurred while reading the SharePoint page' + }, +} diff --git a/apps/sim/tools/sharepoint/types.ts b/apps/sim/tools/sharepoint/types.ts new file mode 100644 index 000000000..6ecddf4ff --- /dev/null +++ b/apps/sim/tools/sharepoint/types.ts @@ -0,0 +1,213 @@ +import type { ToolResponse } from '@/tools/types' + +export interface SharepointSite { + id: string + name: string + displayName: string + webUrl: string + description?: string + createdDateTime?: string + lastModifiedDateTime?: string +} + +export interface SharepointPage { + '@odata.type'?: string + id?: string + name: string + title: string + webUrl?: string + pageLayout?: string + createdDateTime?: string + lastModifiedDateTime?: string + publishingState?: { + level: string + } + canvasLayout?: { + horizontalSections: Array<{ + layout: string + id: string + emphasis: string + columns?: Array<{ + id: string + width: number + webparts: Array<{ + id: string + innerHtml: string + }> + }> + webparts?: Array<{ + id: string + innerHtml: string + }> + }> + } +} + +export interface SharepointPageContent { + content: string + canvasLayout?: { + horizontalSections: Array<{ + layout: string + id: string + emphasis: string + webparts: Array<{ + id: string + innerHtml: string + }> + }> + } | null +} + +export interface SharepointListSitesResponse extends ToolResponse { + output: { + sites: SharepointSite[] + nextPageToken?: string + } +} + +export interface SharepointCreatePageResponse extends ToolResponse { + output: { + page: SharepointPage + } +} + +export interface SharepointPageWithContent { + page: SharepointPage + content: SharepointPageContent +} + +export interface SharepointReadPageResponse extends ToolResponse { + output: { + page?: SharepointPage + pages?: SharepointPageWithContent[] + content?: SharepointPageContent + totalPages?: number + } +} + +export interface SharepointReadSiteResponse extends ToolResponse { + output: { + site?: { + id: string + name: string + displayName: string + webUrl: string + description?: string + createdDateTime?: string + lastModifiedDateTime?: string + isPersonalSite?: boolean + root?: { + serverRelativeUrl: string + } + siteCollection?: { + hostname: string + } + } + sites?: Array<{ + id: string + name: string + displayName: string + webUrl: string + description?: string + createdDateTime?: string + lastModifiedDateTime?: string + }> + } +} + +export interface SharepointToolParams { + accessToken: string + siteId?: string + siteSelector?: string + pageId?: string + pageName?: string + pageContent?: string + pageTitle?: string + publishingState?: string + query?: string + pageSize?: number + pageToken?: string + hostname?: string + serverRelativePath?: string + groupId?: string + maxPages?: number +} + +export interface GraphApiResponse { + id?: string + name?: string + title?: string + webUrl?: string + pageLayout?: string + createdDateTime?: string + lastModifiedDateTime?: string + canvasLayout?: CanvasLayout + value?: GraphApiPageItem[] + error?: { + message: string + } +} + +export interface GraphApiPageItem { + id: string + name: string + title?: string + webUrl?: string + pageLayout?: string + createdDateTime?: string + lastModifiedDateTime?: string +} + +export interface CanvasLayout { + horizontalSections?: Array<{ + layout?: string + id?: string + emphasis?: string + columns?: Array<{ + webparts?: Array<{ + id?: string + innerHtml?: string + }> + }> + webparts?: Array<{ + id?: string + innerHtml?: string + }> + }> +} + +export interface SharepointReadSiteResponse extends ToolResponse { + output: { + site?: { + id: string + name: string + displayName: string + webUrl: string + description?: string + createdDateTime?: string + lastModifiedDateTime?: string + isPersonalSite?: boolean + root?: { + serverRelativeUrl: string + } + siteCollection?: { + hostname: string + } + } + sites?: Array<{ + id: string + name: string + displayName: string + webUrl: string + description?: string + createdDateTime?: string + lastModifiedDateTime?: string + }> + } +} + +export type SharepointResponse = + | SharepointListSitesResponse + | SharepointCreatePageResponse + | SharepointReadPageResponse + | SharepointReadSiteResponse diff --git a/apps/sim/tools/sharepoint/utils.ts b/apps/sim/tools/sharepoint/utils.ts new file mode 100644 index 000000000..7ed3169f2 --- /dev/null +++ b/apps/sim/tools/sharepoint/utils.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { CanvasLayout } from '@/tools/sharepoint/types' + +const logger = createLogger('SharepointUtils') + +// Extract readable text from SharePoint canvas layout +export function extractTextFromCanvasLayout(canvasLayout: CanvasLayout | null | undefined): string { + logger.info('Extracting text from canvas layout', { + hasCanvasLayout: !!canvasLayout, + hasHorizontalSections: !!canvasLayout?.horizontalSections, + sectionsCount: canvasLayout?.horizontalSections?.length || 0, + }) + + if (!canvasLayout?.horizontalSections) { + logger.info('No canvas layout or horizontal sections found') + return '' + } + + const textParts: string[] = [] + + for (const section of canvasLayout.horizontalSections) { + logger.info('Processing section', { + sectionId: section.id, + hasColumns: !!section.columns, + hasWebparts: !!section.webparts, + columnsCount: section.columns?.length || 0, + }) + + if (section.columns) { + for (const column of section.columns) { + if (column.webparts) { + for (const webpart of column.webparts) { + logger.info('Processing webpart', { + webpartId: webpart.id, + hasInnerHtml: !!webpart.innerHtml, + innerHtml: webpart.innerHtml, + }) + + if (webpart.innerHtml) { + // Extract text from HTML, removing tags + const text = webpart.innerHtml.replace(/<[^>]*>/g, '').trim() + if (text) { + textParts.push(text) + logger.info('Extracted text', { text }) + } + } + } + } + } + } else if (section.webparts) { + for (const webpart of section.webparts) { + if (webpart.innerHtml) { + const text = webpart.innerHtml.replace(/<[^>]*>/g, '').trim() + if (text) textParts.push(text) + } + } + } + } + + const finalContent = textParts.join('\n\n') + logger.info('Final extracted content', { + textPartsCount: textParts.length, + finalContentLength: finalContent.length, + finalContent, + }) + + return finalContent +} + +// Remove OData metadata from objects +export function cleanODataMetadata(obj: T): T { + if (!obj || typeof obj !== 'object') return obj + + if (Array.isArray(obj)) { + return obj.map((item) => cleanODataMetadata(item)) as T + } + + const cleaned: Record = {} + for (const [key, value] of Object.entries(obj as Record)) { + // Skip OData metadata keys + if (key.includes('@odata')) continue + + cleaned[key] = cleanODataMetadata(value) + } + + return cleaned as T +}