feat(tools): added microsoft excel tools/block (#441)

* got the read excel tool working

* added read manual ID insertion

* write working

* tool finished

* removed old logic

* removed logic that was causing credential error

* cleaned up

* ran bun lint

* removed logger

* added back merging logic

* fix: removed comments

* fix: added greptile changes

* ran bun lint

* removed function

* added microsoft excel docs

* fix: icon format

* fix: docs

* ran lint

---------

Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
This commit is contained in:
Adam Gough
2025-05-30 19:11:45 -07:00
committed by GitHub
co-authored by Adam Gough
parent e17cdc9698
commit c5727ef018
20 changed files with 1911 additions and 6 deletions
+13 -1
View File
@@ -9,7 +9,19 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
type="linear"
color="#5E6AD2"
icon={true}
iconSvg={`<svg className="block-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 100 100"><path fill="#fff" d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"/></svg>`}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
fill='currentColor'
viewBox='0 0 100 100'
>
<path
fill='currentColor'
d='M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z'
/>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
+1
View File
@@ -25,6 +25,7 @@
"linkup",
"mem0",
"memory",
"microsoft_excel",
"microsoft_teams",
"mistral_parse",
"notion",
@@ -0,0 +1,197 @@
---
title: Microsoft Excel
description: Read, write, and update data
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="microsoft_excel"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon"
xmlns='http://www.w3.org/2000/svg'
version='1.1'
id='Livello_1'
x='0px'
y='0px'
viewBox='0 0 2289.75 2130'
enableBackground='new 0 0 2289.75 2130'
>
<path
fill='#185C37'
d='M1437.75,1011.75L532.5,852v1180.393c0,53.907,43.7,97.607,97.607,97.607l0,0h1562.036 c53.907,0,97.607-43.7,97.607-97.607l0,0V1597.5L1437.75,1011.75z'
/>
<path
fill='#21A366'
d='M1437.75,0H630.107C576.2,0,532.5,43.7,532.5,97.607c0,0,0,0,0,0V532.5l905.25,532.5L1917,1224.75 L2289.75,1065V532.5L1437.75,0z'
/>
<path fill='#107C41' d='M532.5,532.5h905.25V1065H532.5V532.5z' />
<path
opacity='0.1'
enableBackground='new'
d='M1180.393,426H532.5v1331.25h647.893c53.834-0.175,97.432-43.773,97.607-97.607 V523.607C1277.825,469.773,1234.227,426.175,1180.393,426z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1127.143,479.25H532.5V1810.5h594.643 c53.834-0.175,97.432-43.773,97.607-97.607V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1127.143,479.25H532.5V1704h594.643c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z'
/>
<linearGradient
id='SVGID_1_'
gradientUnits='userSpaceOnUse'
x1='203.5132'
y1='1729.0183'
x2='967.9868'
y2='404.9817'
gradientTransform='matrix(1 0 0 -1 0 2132)'
>
<stop offset='0' style={{ stopColor: '#18884F' }} />
<stop offset='0.5' style={{ stopColor: '#117E43' }} />
<stop offset='1' style={{ stopColor: '#0B6631' }} />
</linearGradient>
<path
fill='url(#SVGID_1_)'
d='M97.607,479.25h976.285c53.907,0,97.607,43.7,97.607,97.607v976.285 c0,53.907-43.7,97.607-97.607,97.607H97.607C43.7,1650.75,0,1607.05,0,1553.143V576.857C0,522.95,43.7,479.25,97.607,479.25z'
/>
<path
fill='#FFFFFF'
d='M302.3,1382.264l205.332-318.169L319.5,747.683h151.336l102.666,202.35 c9.479,19.223,15.975,33.494,19.49,42.919h1.331c6.745-15.336,13.845-30.228,21.3-44.677L725.371,747.79h138.929l-192.925,314.548 L869.2,1382.263H721.378L602.79,1160.158c-5.586-9.45-10.326-19.376-14.164-29.66h-1.757c-3.474,10.075-8.083,19.722-13.739,28.755 l-122.102,223.011H302.3z'
/>
<path
fill='#33C481'
d='M2192.143,0H1437.75v532.5h852V97.607C2289.75,43.7,2246.05,0,2192.143,0L2192.143,0z'
/>
<path fill='#107C41' d='M1437.75,1065h852v532.5h-852V1065z' />
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Microsoft Teams](https://www.microsoft.com/en-us/microsoft-365/excel) is a powerful spreadsheet application that enables data management, analysis, and visualization. Through the Microsoft Excel integration in Sim Studio, you can programmatically read, write, and manipulate spreadsheet data to support your workflow automation needs.
With Microsoft Excel integration, you can:
- **Read Spreadsheet Data**: Access data from specific ranges, sheets, and cells
- **Write and Update Data**: Add new data or modify existing spreadsheet content
- **Manage Tables**: Create and manipulate tabular data structures
- **Handle Multiple Sheets**: Work with multiple worksheets in a workbook
- **Process Data**: Import, export, and transform spreadsheet data
In Sim Studio, the Microsoft Excel integration provides seamless access to spreadsheet functionality through OAuth authentication. You can read data from specific ranges, write new information, update existing cells, and handle various data formats. The integration supports both reading and writing operations with flexible input and output options. This enables you to build workflows that can effectively manage spreadsheet data, whether you're extracting information for analysis, updating records automatically, or maintaining data consistency across your applications.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Microsoft Excel functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and manipulate table data using OAuth authentication. Supports various input and output formats for flexible data handling.
## Tools
### `microsoft_excel_read`
Read data from a Microsoft Excel spreadsheet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Excel API |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to read from |
| `range` | string | No | The range of cells to read from |
#### Output
| Parameter | Type |
| --------- | ---- |
| `data` | json |
### `microsoft_excel_write`
Write data to a Microsoft Excel spreadsheet
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Excel API |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet to write to |
| `range` | string | No | The range of cells to write to |
| `values` | array | Yes | The data to write to the spreadsheet |
| `valueInputOption` | string | No | The format of the data to write |
| `includeValuesInResponse` | boolean | No | Whether to include the written values in the response |
#### Output
| Parameter | Type |
| --------- | ---- |
| `updatedRange` | string |
| `updatedRows` | string |
| `updatedColumns` | string |
| `updatedCells` | string |
| `metadata` | string |
| `spreadsheetId` | string |
| `spreadsheetUrl` | string |
### `microsoft_excel_table_add`
Add new rows to a Microsoft Excel table
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `accessToken` | string | Yes | The access token for the Microsoft Excel API |
| `spreadsheetId` | string | Yes | The ID of the spreadsheet containing the table |
| `tableName` | string | Yes | The name of the table to add rows to |
| `values` | array | Yes | The data to add to the table \(array of arrays or array of objects\) |
#### Output
| Parameter | Type |
| --------- | ---- |
| `data` | json |
## Block Configuration
### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `operation` | string | Yes | Operation |
### Outputs
| Output | Type | Description |
| ------ | ---- | ----------- |
| `response` | object | Output from response |
| ↳ `data` | json | data of the response |
| ↳ `metadata` | json | metadata of the response |
| ↳ `updatedRange` | string | updatedRange of the response |
| ↳ `updatedRows` | number | updatedRows of the response |
| ↳ `updatedColumns` | number | updatedColumns of the response |
| ↳ `updatedCells` | number | updatedCells of the response |
| ↳ `index` | number | index of the response |
| ↳ `values` | json | values of the response |
## Notes
- Category: `tools`
- Type: `microsoft_excel`
@@ -0,0 +1,110 @@
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 { db } from '@/db'
import { account } from '@/db/schema'
import { refreshAccessTokenIfNeeded } from '../../utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftFileAPI')
/**
* Get a single file from Microsoft OneDrive
*/
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID and file ID from the query params
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 })
}
// Get the credential from the database
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]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
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,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: response.status,
error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive',
})
return NextResponse.json(
{
error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive',
},
{ status: response.status }
)
}
const file = await response.json()
// Transform the response to match expected format
const transformedFile = {
id: file.id,
name: file.name,
mimeType:
file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
downloadUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${file.id}/content`,
}
return NextResponse.json({ file: transformedFile }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching file from Microsoft OneDrive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
@@ -0,0 +1,138 @@
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 { db } from '@/db'
import { account } from '@/db/schema'
import { refreshAccessTokenIfNeeded } from '../../utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('MicrosoftFilesAPI')
/**
* Get Excel files from Microsoft OneDrive
*/
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8) // Generate a short request ID for correlation
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query') || ''
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
return NextResponse.json({ error: 'Credential 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 using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build search query for Excel files
let searchQuery = '.xlsx'
if (query) {
searchQuery = `${query} .xlsx`
}
// Build the query parameters for Microsoft Graph API
const searchParams_new = new URLSearchParams()
searchParams_new.append(
'$select',
'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy'
)
searchParams_new.append('$top', '50')
const response = await fetch(
`https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: response.status,
error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive',
})
return NextResponse.json(
{
error: errorData.error?.message || 'Failed to fetch Excel files from Microsoft OneDrive',
},
{ status: response.status }
)
}
const data = await response.json()
let files = data.value || []
// Transform Microsoft Graph response to match expected format and filter for Excel files
files = files
.filter(
(file: any) =>
file.name?.toLowerCase().endsWith('.xlsx') ||
file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
.map((file: any) => ({
id: file.id,
name: file.name,
mimeType:
file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
iconLink: file.thumbnails?.[0]?.small?.url,
webViewLink: file.webUrl,
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
createdTime: file.createdDateTime,
modifiedTime: file.lastModifiedDateTime,
size: file.size?.toString(),
owners: file.createdBy
? [
{
displayName: file.createdBy.user?.displayName || 'Unknown',
emailAddress: file.createdBy.user?.email || '',
},
]
: [],
}))
return NextResponse.json({ files }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching Excel files from Microsoft OneDrive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
@@ -0,0 +1,582 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react'
import { MicrosoftExcelIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { createLogger } from '@/lib/logs/console-logger'
import {
type Credential,
getProviderIdFromServiceId,
getServiceByProviderAndId,
getServiceIdFromScopes,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = createLogger('MicrosoftFileSelector')
export interface MicrosoftFileInfo {
id: string
name: string
mimeType: string
iconLink?: string
webViewLink?: string
thumbnailLink?: string
createdTime?: string
modifiedTime?: string
size?: string
owners?: { displayName: string; emailAddress: string }[]
}
interface MicrosoftFileSelectorProps {
value: string
onChange: (value: string, fileInfo?: MicrosoftFileInfo) => void
provider: OAuthProvider
requiredScopes?: string[]
label?: string
disabled?: boolean
serviceId?: string
showPreview?: boolean
onFileInfoChange?: (fileInfo: MicrosoftFileInfo | null) => void
}
export function MicrosoftFileSelector({
value,
onChange,
provider,
requiredScopes = [],
label = 'Select file',
disabled = false,
serviceId,
showPreview = true,
onFileInfoChange,
}: MicrosoftFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<MicrosoftFileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSelectedFile, setIsLoadingSelectedFile] = useState(false)
const [isLoadingFiles, setIsLoadingFiles] = useState(false)
const [availableFiles, setAvailableFiles] = useState<MicrosoftFileInfo[]>([])
const [searchQuery, setSearchQuery] = useState<string>('')
const [showOAuthModal, setShowOAuthModal] = useState(false)
const initialFetchRef = useRef(false)
// Determine the appropriate service ID based on provider and scopes
const getServiceId = (): string => {
if (serviceId) return serviceId
return getServiceIdFromScopes(provider, requiredScopes)
}
// Determine the appropriate provider ID based on service and scopes
const getProviderId = (): string => {
const effectiveServiceId = getServiceId()
return getProviderIdFromServiceId(effectiveServiceId)
}
// Fetch available credentials for this provider
const fetchCredentials = useCallback(async () => {
setIsLoading(true)
try {
const providerId = getProviderId()
const response = await fetch(`/api/auth/oauth/credentials?provider=${providerId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
// Auto-select logic for credentials
if (data.credentials.length > 0) {
// If we already have a selected credential ID, check if it's valid
if (
selectedCredentialId &&
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
) {
// Keep the current selection
} else {
// Otherwise, select the default or first credential
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) {
setSelectedCredentialId(defaultCred.id)
} else if (data.credentials.length === 1) {
setSelectedCredentialId(data.credentials[0].id)
}
}
}
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
}
}, [provider, getProviderId, selectedCredentialId])
// Fetch available Excel files for the selected credential
const fetchAvailableFiles = useCallback(async () => {
if (!selectedCredentialId) return
setIsLoadingFiles(true)
try {
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
})
// Add search query if provided
if (searchQuery.trim()) {
queryParams.append('query', searchQuery.trim())
}
const response = await fetch(`/api/auth/oauth/microsoft/files?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
setAvailableFiles(data.files || [])
} else {
logger.error('Error fetching available files:', {
error: await response.text(),
})
setAvailableFiles([])
}
} catch (error) {
logger.error('Error fetching available files:', { error })
setAvailableFiles([])
} finally {
setIsLoadingFiles(false)
}
}, [selectedCredentialId, searchQuery])
// Fetch a single file by ID when we have a selectedFileId but no metadata
const fetchFileById = useCallback(
async (fileId: string) => {
if (!selectedCredentialId || !fileId) return null
setIsLoadingSelectedFile(true)
try {
// Construct query parameters
const queryParams = new URLSearchParams({
credentialId: selectedCredentialId,
fileId: fileId,
})
const response = await fetch(`/api/auth/oauth/microsoft/file?${queryParams.toString()}`)
if (response.ok) {
const data = await response.json()
if (data.file) {
setSelectedFile(data.file)
onFileInfoChange?.(data.file)
return data.file
}
} else {
logger.error('Error fetching file by ID:', {
error: await response.text(),
})
}
return null
} catch (error) {
logger.error('Error fetching file by ID:', { error })
return null
} finally {
setIsLoadingSelectedFile(false)
}
},
[selectedCredentialId, onFileInfoChange]
)
// Fetch credentials on initial mount
useEffect(() => {
if (!initialFetchRef.current) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
// Fetch available files when credential changes
useEffect(() => {
if (selectedCredentialId) {
fetchAvailableFiles()
}
}, [selectedCredentialId, fetchAvailableFiles])
// Refetch files when search query changes
useEffect(() => {
if (selectedCredentialId && searchQuery !== undefined) {
const timeoutId = setTimeout(() => {
fetchAvailableFiles()
}, 300) // Debounce search
return () => clearTimeout(timeoutId)
}
}, [searchQuery, selectedCredentialId, fetchAvailableFiles])
// Fetch the selected file metadata once credentials are loaded or changed
useEffect(() => {
// If we have a file ID selected and credentials are ready but we still don't have the file info, fetch it
if (value && selectedCredentialId && !selectedFile) {
fetchFileById(value)
}
}, [value, selectedCredentialId, selectedFile, fetchFileById])
// Keep internal selectedFileId in sync with the value prop
useEffect(() => {
if (value !== selectedFileId) {
setSelectedFileId(value)
}
}, [value])
// Handle selecting a file from the available files
const handleFileSelect = (file: MicrosoftFileInfo) => {
setSelectedFileId(file.id)
setSelectedFile(file)
onChange(file.id, file)
onFileInfoChange?.(file)
setOpen(false)
setSearchQuery('') // Clear search when file is selected
}
// Handle adding a new credential
const handleAddCredential = () => {
const effectiveServiceId = getServiceId()
const providerId = getProviderId()
// Store information about the required connection
saveToStorage<string>('pending_service_id', effectiveServiceId)
saveToStorage<string[]>('pending_oauth_scopes', requiredScopes)
saveToStorage<string>('pending_oauth_return_url', window.location.href)
saveToStorage<string>('pending_oauth_provider_id', providerId)
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
setSearchQuery('') // Clear search when closing
}
// Clear selection
const handleClearSelection = () => {
setSelectedFileId('')
setSelectedFile(null)
onChange('', undefined)
onFileInfoChange?.(null)
}
// Get provider icon
const getProviderIcon = (providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
if (!baseProviderConfig) {
return <ExternalLink className='h-4 w-4' />
}
// For compound providers, find the specific service
if (providerName.includes('-')) {
for (const service of Object.values(baseProviderConfig.services)) {
if (service.providerId === providerName) {
return service.icon({ className: 'h-4 w-4' })
}
}
}
// Fallback to base provider icon
return baseProviderConfig.icon({ className: 'h-4 w-4' })
}
// Get provider name
const getProviderName = (providerName: OAuthProvider) => {
const effectiveServiceId = getServiceId()
try {
// First try to get the service by provider and service ID
const service = getServiceByProviderAndId(providerName, effectiveServiceId)
return service.name
} catch (_error) {
// If that fails, try to get the service by parsing the provider
try {
const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
// For compound providers like 'google-drive', try to find the specific service
if (providerName.includes('-')) {
const serviceKey = providerName.split('-')[1] || ''
for (const [key, service] of Object.entries(baseProviderConfig?.services || {})) {
if (key === serviceKey || key === providerName || service.providerId === providerName) {
return service.name
}
}
}
// Fallback to provider name if service not found
if (baseProviderConfig) {
return baseProviderConfig.name
}
} catch (_parseError) {
// Ignore parse error and continue to final fallback
}
// Final fallback: capitalize the provider name
return providerName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}
// Get file icon based on mime type
const getFileIcon = (file: MicrosoftFileInfo, size: 'sm' | 'md' = 'sm') => {
const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
if (file.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
return <MicrosoftExcelIcon className={`${iconSize} text-green-600`} />
}
// if (file.mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
// return <FileIcon className={`${iconSize} text-blue-600`} />
// }
// if (file.mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
// return <FileIcon className={`${iconSize} text-orange-600`} />
// }
// return <FileIcon className={`${iconSize} text-muted-foreground`} />
}
// Handle search input changes
const handleSearch = (query: string) => {
setSearchQuery(query)
}
return (
<>
<div className='space-y-2'>
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen)
if (!isOpen) {
setSearchQuery('') // Clear search when popover closes
}
}}
>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled}
>
{selectedFile ? (
<div className='flex items-center gap-2 overflow-hidden'>
{getFileIcon(selectedFile, 'sm')}
<span className='truncate font-normal'>{selectedFile.name}</span>
</div>
) : selectedFileId && (isLoadingSelectedFile || !selectedCredentialId) ? (
<div className='flex items-center gap-2'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground'>Loading document...</span>
</div>
) : (
<div className='flex items-center gap-2'>
{getProviderIcon(provider)}
<span className='text-muted-foreground'>{label}</span>
</div>
)}
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
{/* Current account indicator */}
{selectedCredentialId && credentials.length > 0 && (
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
{getProviderIcon(provider)}
<span className='text-muted-foreground text-xs'>
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
'Unknown'}
</span>
</div>
{credentials.length > 1 && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setOpen(true)}
>
Switch
</Button>
)}
</div>
)}
<Command>
<CommandInput placeholder='Search Excel files...' onValueChange={handleSearch} />
<CommandList>
<CommandEmpty>
{isLoading || isLoadingFiles ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading...</span>
</div>
) : credentials.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No accounts connected.</p>
<p className='text-muted-foreground text-xs'>
Connect a {getProviderName(provider)} account to continue.
</p>
</div>
) : availableFiles.length === 0 ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No Excel files found.</p>
<p className='text-muted-foreground text-xs'>
No .xlsx files were found in your OneDrive.
</p>
</div>
) : null}
</CommandEmpty>
{/* Account selection - only show if we have multiple accounts */}
{credentials.length > 1 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Switch Account
</div>
{credentials.map((cred) => (
<CommandItem
key={cred.id}
value={`account-${cred.id}`}
onSelect={() => setSelectedCredentialId(cred.id)}
>
<div className='flex items-center gap-2'>
{getProviderIcon(cred.provider)}
<span className='font-normal'>{cred.name}</span>
</div>
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Available Excel files - only show if we have credentials and files */}
{credentials.length > 0 && selectedCredentialId && availableFiles.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Excel Files
</div>
{availableFiles.map((file) => (
<CommandItem
key={file.id}
value={`file-${file.id}-${file.name}`}
onSelect={() => handleFileSelect(file)}
>
<div className='flex items-center gap-2 overflow-hidden'>
{getFileIcon(file, 'sm')}
<div className='min-w-0 flex-1'>
<span className='truncate font-normal'>{file.name}</span>
{file.modifiedTime && (
<div className='text-muted-foreground text-xs'>
Modified {new Date(file.modifiedTime).toLocaleDateString()}
</div>
)}
</div>
</div>
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
{/* Connect account option - only show if no credentials */}
{credentials.length === 0 && (
<CommandGroup>
<CommandItem onSelect={handleAddCredential}>
<div className='flex items-center gap-2 text-primary'>
{getProviderIcon(provider)}
<span>Connect {getProviderName(provider)} account</span>
</div>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* File preview */}
{showPreview && selectedFile && (
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
<div className='absolute top-2 right-2'>
<Button
variant='ghost'
size='icon'
className='h-5 w-5 hover:bg-muted'
onClick={handleClearSelection}
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex items-center gap-3 pr-4'>
<div className='flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-muted/20'>
{getFileIcon(selectedFile, 'sm')}
</div>
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='flex items-center gap-2'>
<h4 className='truncate font-medium text-xs'>{selectedFile.name}</h4>
{selectedFile.modifiedTime && (
<span className='whitespace-nowrap text-muted-foreground text-xs'>
{new Date(selectedFile.modifiedTime).toLocaleDateString()}
</span>
)}
</div>
{selectedFile.webViewLink ? (
<a
href={selectedFile.webViewLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-primary text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in OneDrive</span>
<ExternalLink className='h-3 w-3' />
</a>
) : (
<a
href={`https://graph.microsoft.com/v1.0/me/drive/items/${selectedFile.id}`}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-1 text-primary text-xs hover:underline'
onClick={(e) => e.stopPropagation()}
>
<span>Open in OneDrive</span>
<ExternalLink className='h-3 w-3' />
</a>
)}
</div>
</div>
</div>
)}
</div>
{showOAuthModal && (
<OAuthRequiredModal
isOpen={showOAuthModal}
onClose={() => setShowOAuthModal(false)}
provider={provider}
toolName={getProviderName(provider)}
requiredScopes={requiredScopes}
serviceId={getServiceId()}
/>
)}
</>
)
}
@@ -23,7 +23,7 @@ import {
import { saveToStorage } from '@/stores/workflows/persistence'
import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal'
const logger = new Logger('teams_message_selector')
const logger = new Logger('TeamsMessageSelector')
export interface TeamsMessageInfo {
id: string
@@ -14,6 +14,8 @@ import type { FileInfo } from './components/google-drive-picker'
import { GoogleDrivePicker } from './components/google-drive-picker'
import type { JiraIssueInfo } from './components/jira-issue-selector'
import { JiraIssueSelector } from './components/jira-issue-selector'
import type { MicrosoftFileInfo } from './components/microsoft-file-selector'
import { MicrosoftFileSelector } from './components/microsoft-file-selector'
import type { TeamsMessageInfo } from './components/teams-message-selector'
import { TeamsMessageSelector } from './components/teams-message-selector'
@@ -41,11 +43,9 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
const isJira = provider === 'jira'
const isDiscord = provider === 'discord'
const isMicrosoftTeams = provider === 'microsoft-teams'
const isMicrosoftExcel = provider === 'microsoft-excel'
// For Confluence and Jira, we need the domain and credentials
const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : ''
const _credentials =
isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : ''
// For Discord, we need the bot token and server ID
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : ''
@@ -186,6 +186,38 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS
)
}
if (isMicrosoftExcel) {
// Get credential using the same pattern as other tools
const credential = (getValue(blockId, 'credential') as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<MicrosoftFileSelector
value={selectedFileId}
onChange={handleFileChange}
provider='microsoft-excel'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Microsoft Excel file'}
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select Microsoft Excel credentials first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Handle Microsoft Teams selector
if (isMicrosoftTeams) {
// Get credential using the same pattern as other tools
+215
View File
@@ -0,0 +1,215 @@
import { MicrosoftExcelIcon } from '@/components/icons'
import type {
MicrosoftExcelReadResponse,
MicrosoftExcelTableAddResponse,
MicrosoftExcelWriteResponse,
} from '@/tools/microsoft_excel/types'
import type { BlockConfig } from '../types'
type MicrosoftExcelResponse =
| MicrosoftExcelReadResponse
| MicrosoftExcelWriteResponse
| MicrosoftExcelTableAddResponse
export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
type: 'microsoft_excel',
name: 'Microsoft Excel',
description: 'Read, write, and update data',
longDescription:
'Integrate Microsoft Excel functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and manipulate table data using OAuth authentication. Supports various input and output formats for flexible data handling.',
docsLink: 'https://docs.simstudio.ai/tools/microsoft_excel',
category: 'tools',
bgColor: '#E0E0E0',
icon: MicrosoftExcelIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Read Data', id: 'read' },
{ label: 'Write/Update Data', id: 'write' },
{ label: 'Add to Table', id: 'table_add' },
],
},
{
id: 'credential',
title: 'Microsoft Account',
type: 'oauth-input',
layout: 'full',
provider: 'microsoft-excel',
serviceId: 'microsoft-excel',
requiredScopes: [],
placeholder: 'Select Microsoft account',
},
{
id: 'spreadsheetId',
title: 'Select Sheet',
type: 'file-selector',
layout: 'full',
provider: 'microsoft-excel',
serviceId: 'microsoft-excel',
requiredScopes: [],
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
placeholder: 'Select a spreadsheet',
},
{
id: 'manualSpreadsheetId',
title: 'Or Enter Spreadsheet ID Manually',
type: 'short-input',
layout: 'full',
placeholder: 'ID of the spreadsheet (from URL)',
condition: { field: 'spreadsheetId', value: '' },
},
{
id: 'range',
title: 'Range',
type: 'short-input',
layout: 'full',
placeholder: 'Sheet name and cell range (e.g., Sheet1!A1:D10)',
condition: { field: 'operation', value: ['read', 'write', 'update'] },
},
{
id: 'tableName',
title: 'Table Name',
type: 'short-input',
layout: 'full',
placeholder: 'Name of the Excel table',
condition: { field: 'operation', value: ['table_add'] },
},
{
id: 'values',
title: 'Values',
type: 'long-input',
layout: 'full',
placeholder:
'Enter values as JSON array of arrays (e.g., [["A1", "B1"], ["A2", "B2"]]) or an array of objects (e.g., [{"name":"John", "age":30}, {"name":"Jane", "age":25}])',
condition: { field: 'operation', value: 'write' },
},
{
id: 'valueInputOption',
title: 'Value Input Option',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' },
{ label: "Raw (Don't parse formulas)", id: 'RAW' },
],
condition: { field: 'operation', value: 'write' },
},
{
id: 'values',
title: 'Values',
type: 'long-input',
layout: 'full',
placeholder:
'Enter values as JSON array of arrays (e.g., [["A1", "B1"], ["A2", "B2"]]) or an array of objects (e.g., [{"name":"John", "age":30}, {"name":"Jane", "age":25}])',
condition: { field: 'operation', value: 'update' },
},
{
id: 'valueInputOption',
title: 'Value Input Option',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' },
{ label: "Raw (Don't parse formulas)", id: 'RAW' },
],
condition: { field: 'operation', value: 'update' },
},
{
id: 'values',
title: 'Values',
type: 'long-input',
layout: 'full',
placeholder:
'Enter values as JSON array of arrays (e.g., [["A1", "B1"], ["A2", "B2"]]) or an array of objects (e.g., [{"name":"John", "age":30}, {"name":"Jane", "age":25}])',
condition: { field: 'operation', value: 'table_add' },
},
],
tools: {
access: ['microsoft_excel_read', 'microsoft_excel_write', 'microsoft_excel_table_add'],
config: {
tool: (params) => {
switch (params.operation) {
case 'read':
return 'microsoft_excel_read'
case 'write':
return 'microsoft_excel_write'
case 'table_add':
return 'microsoft_excel_table_add'
default:
throw new Error(`Invalid Microsoft Excel operation: ${params.operation}`)
}
},
params: (params) => {
const { credential, values, spreadsheetId, manualSpreadsheetId, tableName, ...rest } =
params
// Parse values from JSON string to array if it exists
let parsedValues
try {
parsedValues = values ? JSON.parse(values as string) : undefined
} catch (error) {
throw new Error('Invalid JSON format for values')
}
// Use the selected spreadsheet ID or the manually entered one
const effectiveSpreadsheetId = (spreadsheetId || manualSpreadsheetId || '').trim()
if (!effectiveSpreadsheetId) {
throw new Error(
'Spreadsheet ID is required. Please select a spreadsheet or enter an ID manually.'
)
}
// For table operations, ensure tableName is provided
if (params.operation === 'table_add' && !tableName) {
throw new Error('Table name is required for table operations.')
}
const baseParams = {
...rest,
spreadsheetId: effectiveSpreadsheetId,
values: parsedValues,
credential,
}
// Add table-specific parameters
if (params.operation === 'table_add') {
return {
...baseParams,
tableName,
}
}
return baseParams
},
},
},
inputs: {
operation: { type: 'string', required: true },
credential: { type: 'string', required: true },
spreadsheetId: { type: 'string', required: false },
manualSpreadsheetId: { type: 'string', required: false },
range: { type: 'string', required: false },
tableName: { type: 'string', required: false },
values: { type: 'string', required: false },
valueInputOption: { type: 'string', required: false },
},
outputs: {
response: {
type: {
data: 'json',
metadata: 'json',
updatedRange: 'string',
updatedRows: 'number',
updatedColumns: 'number',
updatedCells: 'number',
index: 'number',
values: 'json',
},
},
},
}
+2
View File
@@ -33,6 +33,7 @@ import { LinkupBlock } from './blocks/linkup'
import { Mem0Block } from './blocks/mem0'
// import { GuestyBlock } from './blocks/guesty'
import { MemoryBlock } from './blocks/memory'
import { MicrosoftExcelBlock } from './blocks/microsoft_excel'
import { MicrosoftTeamsBlock } from './blocks/microsoft_teams'
import { MistralParseBlock } from './blocks/mistral_parse'
import { NotionBlock } from './blocks/notion'
@@ -84,6 +85,7 @@ export const registry: Record<string, BlockConfig> = {
google_drive: GoogleDriveBlock,
google_search: GoogleSearchBlock,
google_sheets: GoogleSheetsBlock,
microsoft_excel: MicrosoftExcelBlock,
microsoft_teams: MicrosoftTeamsBlock,
// guesty: GuestyBlock,
image_generator: ImageGeneratorBlock,
+71
View File
@@ -2689,3 +2689,74 @@ export function OutlookIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function MicrosoftExcelIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
version='1.1'
id='Livello_1'
x='0px'
y='0px'
viewBox='0 0 2289.75 2130'
enableBackground='new 0 0 2289.75 2130'
>
<path
fill='#185C37'
d='M1437.75,1011.75L532.5,852v1180.393c0,53.907,43.7,97.607,97.607,97.607l0,0h1562.036 c53.907,0,97.607-43.7,97.607-97.607l0,0V1597.5L1437.75,1011.75z'
/>
<path
fill='#21A366'
d='M1437.75,0H630.107C576.2,0,532.5,43.7,532.5,97.607c0,0,0,0,0,0V532.5l905.25,532.5L1917,1224.75 L2289.75,1065V532.5L1437.75,0z'
/>
<path fill='#107C41' d='M532.5,532.5h905.25V1065H532.5V532.5z' />
<path
opacity='0.1'
enableBackground='new'
d='M1180.393,426H532.5v1331.25h647.893c53.834-0.175,97.432-43.773,97.607-97.607 V523.607C1277.825,469.773,1234.227,426.175,1180.393,426z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1127.143,479.25H532.5V1810.5h594.643 c53.834-0.175,97.432-43.773,97.607-97.607V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1127.143,479.25H532.5V1704h594.643c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1224.575,523.023,1180.977,479.425,1127.143,479.25z'
/>
<path
opacity='0.2'
enableBackground='new'
d='M1073.893,479.25H532.5V1704h541.393c53.834-0.175,97.432-43.773,97.607-97.607 V576.857C1171.325,523.023,1127.727,479.425,1073.893,479.25z'
/>
<linearGradient
id='SVGID_1_'
gradientUnits='userSpaceOnUse'
x1='203.5132'
y1='1729.0183'
x2='967.9868'
y2='404.9817'
gradientTransform='matrix(1 0 0 -1 0 2132)'
>
<stop offset='0' style={{ stopColor: '#18884F' }} />
<stop offset='0.5' style={{ stopColor: '#117E43' }} />
<stop offset='1' style={{ stopColor: '#0B6631' }} />
</linearGradient>
<path
fill='url(#SVGID_1_)'
d='M97.607,479.25h976.285c53.907,0,97.607,43.7,97.607,97.607v976.285 c0,53.907-43.7,97.607-97.607,97.607H97.607C43.7,1650.75,0,1607.05,0,1553.143V576.857C0,522.95,43.7,479.25,97.607,479.25z'
/>
<path
fill='#FFFFFF'
d='M302.3,1382.264l205.332-318.169L319.5,747.683h151.336l102.666,202.35 c9.479,19.223,15.975,33.494,19.49,42.919h1.331c6.745-15.336,13.845-30.228,21.3-44.677L725.371,747.79h138.929l-192.925,314.548 L869.2,1382.263H721.378L602.79,1160.158c-5.586-9.45-10.326-19.376-14.164-29.66h-1.757c-3.474,10.075-8.083,19.722-13.739,28.755 l-122.102,223.011H302.3z'
/>
<path
fill='#33C481'
d='M2192.143,0H1437.75v532.5h852V97.607C2289.75,43.7,2246.05,0,2192.143,0L2192.143,0z'
/>
<path fill='#107C41' d='M1437.75,1065h852v532.5h-852V1065z' />
</svg>
)
}
+16
View File
@@ -396,6 +396,22 @@ export const auth = betterAuth({
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/microsoft-teams`,
},
{
providerId: 'microsoft-excel',
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/microsoft-excel`,
},
{
providerId: 'outlook',
clientId: env.MICROSOFT_CLIENT_ID as string,
+11
View File
@@ -12,6 +12,7 @@ import {
GoogleSheetsIcon,
JiraIcon,
LinearIcon,
MicrosoftExcelIcon,
MicrosoftIcon,
MicrosoftTeamsIcon,
NotionIcon,
@@ -53,6 +54,7 @@ export type OAuthService =
| 'notion'
| 'jira'
| 'discord'
| 'microsoft-excel'
| 'microsoft-teams'
| 'outlook'
| 'linear'
@@ -144,6 +146,15 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
name: 'Microsoft',
icon: (props) => MicrosoftIcon(props),
services: {
'microsoft-excel': {
id: 'microsoft-excel',
name: 'Microsoft Excel',
description: 'Connect to Microsoft Excel and manage spreadsheets.',
providerId: 'microsoft-excel',
icon: (props) => MicrosoftExcelIcon(props),
baseProviderIcon: (props) => MicrosoftIcon(props),
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
},
'microsoft-teams': {
id: 'microsoft-teams',
name: 'Microsoft Teams',
+7 -1
View File
@@ -65,6 +65,12 @@ export function mergeSubblockState(
{} as Record<string, SubBlockState>
)
// Return the full block state with updated subBlocks
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
// Add any values that exist in the store but aren't in the block structure
// This handles cases where block config has been updated but values still exist
Object.entries(blockValues).forEach(([subBlockId, value]) => {
@@ -78,7 +84,7 @@ export function mergeSubblockState(
}
})
// Return the full block state with updated subBlocks
// Update the block with the final merged subBlocks (including orphaned values)
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
+7
View File
@@ -0,0 +1,7 @@
import { readTool } from './read'
import { tableAddTool } from './table_add'
import { writeTool } from './write'
export const microsoftExcelReadTool = readTool
export const microsoftExcelTableAddTool = tableAddTool
export const microsoftExcelWriteTool = writeTool
+127
View File
@@ -0,0 +1,127 @@
import type { ToolConfig } from '../types'
import type { MicrosoftExcelReadResponse, MicrosoftExcelToolParams } from './types'
export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadResponse> = {
id: 'microsoft_excel_read',
name: 'Read from Microsoft Excel',
description: 'Read data from a Microsoft Excel spreadsheet',
version: '1.0',
oauth: {
required: true,
provider: 'microsoft-excel',
additionalScopes: [],
},
params: {
accessToken: {
type: 'string',
required: true,
description: 'The access token for the Microsoft Excel API',
},
spreadsheetId: {
type: 'string',
required: true,
description: 'The ID of the spreadsheet to read from',
},
range: { type: 'string', required: false, description: 'The range of cells to read from' },
},
request: {
url: (params) => {
const spreadsheetId = params.spreadsheetId?.trim()
if (!spreadsheetId) {
throw new Error('Spreadsheet ID is required')
}
if (!params.range) {
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('Sheet1')/range(address='A1:Z1000')`
}
const rangeInput = params.range.trim()
const match = rangeInput.match(/^([^!]+)!(.+)$/)
if (!match) {
throw new Error(`Invalid range format: "${params.range}". Use the format "Sheet1!A1:B2"`)
}
const sheetName = encodeURIComponent(match[1])
const address = encodeURIComponent(match[2])
return `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}/workbook/worksheets('${sheetName}')/range(address='${address}')`
},
method: 'GET',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
}
},
},
transformResponse: async (response: Response) => {
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 Excel data: ${errorText}`)
}
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const metadata = {
spreadsheetId,
properties: {},
spreadsheetUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`,
}
const result: MicrosoftExcelReadResponse = {
success: true,
output: {
data: {
range: data.range || '',
values: data.values || [],
},
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
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 Excel API error: ${JSON.stringify(error)}`
} catch (_e) {
return 'Microsoft Excel API error: Unable to parse error details'
}
}
return 'An error occurred while reading from Microsoft Excel'
},
}
+139
View File
@@ -0,0 +1,139 @@
import type { ToolConfig } from '../types'
import type { MicrosoftExcelTableAddResponse, MicrosoftExcelTableToolParams } from './types'
export const tableAddTool: ToolConfig<
MicrosoftExcelTableToolParams,
MicrosoftExcelTableAddResponse
> = {
id: 'microsoft_excel_table_add',
name: 'Add to Microsoft Excel Table',
description: 'Add new rows to a Microsoft Excel table',
version: '1.0',
oauth: {
required: true,
provider: 'microsoft-excel',
additionalScopes: [],
},
params: {
accessToken: {
type: 'string',
required: true,
description: 'The access token for the Microsoft Excel API',
},
spreadsheetId: {
type: 'string',
required: true,
description: 'The ID of the spreadsheet containing the table',
},
tableName: {
type: 'string',
required: true,
description: 'The name of the table to add rows to',
},
values: {
type: 'array',
required: true,
description: 'The data to add to the table (array of arrays or array of objects)',
},
},
request: {
url: (params) => {
const tableName = encodeURIComponent(params.tableName)
return `https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/workbook/tables('${tableName}')/rows/add`
},
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
let processedValues: any = params.values || []
if (
Array.isArray(processedValues) &&
processedValues.length > 0 &&
typeof processedValues[0] === 'object' &&
!Array.isArray(processedValues[0])
) {
const allKeys = new Set<string>()
processedValues.forEach((obj: any) => {
if (obj && typeof obj === 'object') {
Object.keys(obj).forEach((key) => allKeys.add(key))
}
})
const headers = Array.from(allKeys)
processedValues = processedValues.map((obj: any) => {
if (!obj || typeof obj !== 'object') {
return Array(headers.length).fill('')
}
return headers.map((key) => {
const value = obj[key]
if (value !== null && typeof value === 'object') {
return JSON.stringify(value)
}
return value === undefined ? '' : value
})
})
}
if (!Array.isArray(processedValues) || processedValues.length === 0) {
throw new Error('Values must be a non-empty array')
}
if (!Array.isArray(processedValues[0])) {
processedValues = [processedValues]
}
return {
values: processedValues,
}
},
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to add rows to Microsoft Excel table: ${errorText}`)
}
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const metadata = {
spreadsheetId,
spreadsheetUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`,
}
const result = {
success: true,
output: {
index: data.index || 0,
values: data.values || [],
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
return result
},
transformError: (error) => {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'object' && error !== null) {
if (error.error) {
return typeof error.error === 'string' ? error.error : JSON.stringify(error.error)
}
if (error.message) {
return error.message
}
}
return 'An error occurred while adding rows to Microsoft Excel table'
},
}
+69
View File
@@ -0,0 +1,69 @@
import type { ToolResponse } from '../types'
// Type for Excel cell values - covers all valid data types that Excel supports
export type ExcelCellValue = string | number | boolean | null
export interface MicrosoftExcelRange {
sheetId?: number
sheetName?: string
range: string
values: ExcelCellValue[][]
}
export interface MicrosoftExcelMetadata {
spreadsheetId: string
spreadsheetUrl?: string
title?: string
sheets?: {
sheetId: number
title: string
index: number
rowCount?: number
columnCount?: number
}[]
}
export interface MicrosoftExcelReadResponse extends ToolResponse {
output: {
data: MicrosoftExcelRange
metadata: MicrosoftExcelMetadata
}
}
export interface MicrosoftExcelWriteResponse extends ToolResponse {
output: {
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
metadata: MicrosoftExcelMetadata
}
}
export interface MicrosoftExcelTableAddResponse extends ToolResponse {
output: {
index: number
values: ExcelCellValue[][]
metadata: MicrosoftExcelMetadata
}
}
export interface MicrosoftExcelToolParams {
accessToken: string
spreadsheetId: string
range?: string
values?: ExcelCellValue[][]
valueInputOption?: 'RAW' | 'USER_ENTERED'
insertDataOption?: 'OVERWRITE' | 'INSERT_ROWS'
includeValuesInResponse?: boolean
responseValueRenderOption?: 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA'
majorDimension?: 'ROWS' | 'COLUMNS'
}
export interface MicrosoftExcelTableToolParams {
accessToken: string
spreadsheetId: string
tableName: string
values: ExcelCellValue[][]
rowIndex?: number
}
+162
View File
@@ -0,0 +1,162 @@
import type { ToolConfig } from '../types'
import type { MicrosoftExcelToolParams, MicrosoftExcelWriteResponse } from './types'
export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWriteResponse> = {
id: 'microsoft_excel_write',
name: 'Write to Microsoft Excel',
description: 'Write data to a Microsoft Excel spreadsheet',
version: '1.0',
oauth: {
required: true,
provider: 'microsoft-excel',
additionalScopes: [],
},
params: {
accessToken: {
type: 'string',
required: true,
description: 'The access token for the Microsoft Excel API',
},
spreadsheetId: {
type: 'string',
required: true,
description: 'The ID of the spreadsheet to write to',
},
range: { type: 'string', required: false, description: 'The range of cells to write to' },
values: { type: 'array', required: true, description: 'The data to write to the spreadsheet' },
valueInputOption: {
type: 'string',
required: false,
description: 'The format of the data to write',
},
includeValuesInResponse: {
type: 'boolean',
required: false,
description: 'Whether to include the written values in the response',
},
},
request: {
url: (params) => {
const rangeInput = params.range?.trim()
const match = rangeInput?.match(/^([^!]+)!(.+)$/)
if (!match) {
throw new Error(`Invalid range format: "${params.range}". Use the format "Sheet1!A1:B2"`)
}
const sheetName = encodeURIComponent(match[1])
const address = encodeURIComponent(match[2])
const url = new URL(
`https://graph.microsoft.com/v1.0/me/drive/items/${params.spreadsheetId}/workbook/worksheets('${sheetName}')/range(address='${address}')`
)
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
url.searchParams.append('valueInputOption', valueInputOption)
if (params.includeValuesInResponse) {
url.searchParams.append('includeValuesInResponse', 'true')
}
return url.toString()
},
method: 'PATCH',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
let processedValues: any = params.values || []
if (
Array.isArray(processedValues) &&
processedValues.length > 0 &&
typeof processedValues[0] === 'object' &&
!Array.isArray(processedValues[0])
) {
const allKeys = new Set<string>()
processedValues.forEach((obj: any) => {
if (obj && typeof obj === 'object') {
Object.keys(obj).forEach((key) => allKeys.add(key))
}
})
const headers = Array.from(allKeys)
const rows = processedValues.map((obj: any) => {
if (!obj || typeof obj !== 'object') {
return Array(headers.length).fill('')
}
return headers.map((key) => {
const value = obj[key]
if (value !== null && typeof value === 'object') {
return JSON.stringify(value)
}
return value === undefined ? '' : value
})
})
processedValues = [headers, ...rows]
}
const body: Record<string, any> = {
majorDimension: params.majorDimension || 'ROWS',
values: processedValues,
}
if (params.range) {
body.range = params.range
}
return body
},
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to write data to Microsoft Excel: ${errorText}`)
}
const data = await response.json()
const urlParts = response.url.split('/drive/items/')
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
const metadata = {
spreadsheetId,
properties: {},
spreadsheetUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${spreadsheetId}`,
}
const result = {
success: true,
output: {
updatedRange: data.updatedRange,
updatedRows: data.updatedRows,
updatedColumns: data.updatedColumns,
updatedCells: data.updatedCells,
metadata: {
spreadsheetId: metadata.spreadsheetId,
spreadsheetUrl: metadata.spreadsheetUrl,
},
},
}
return result
},
transformError: (error) => {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'object' && error !== null) {
if (error.error) {
return typeof error.error === 'string' ? error.error : JSON.stringify(error.error)
}
if (error.message) {
return error.message
}
}
return 'An error occurred while writing to Microsoft Excel'
},
}
+8
View File
@@ -49,6 +49,11 @@ import { linearCreateIssueTool, linearReadIssuesTool } from './linear'
import { linkupSearchTool } from './linkup'
import { mem0AddMemoriesTool, mem0GetMemoriesTool, mem0SearchMemoriesTool } from './mem0'
import { memoryAddTool, memoryDeleteTool, memoryGetAllTool, memoryGetTool } from './memory'
import {
microsoftExcelReadTool,
microsoftExcelTableAddTool,
microsoftExcelWriteTool,
} from './microsoft_excel'
import {
microsoftTeamsReadChannelTool,
microsoftTeamsReadChatTool,
@@ -190,4 +195,7 @@ export const tools: Record<string, ToolConfig> = {
outlook_draft: outlookDraftTool,
linear_read_issues: linearReadIssuesTool,
linear_create_issue: linearCreateIssueTool,
microsoft_excel_read: microsoftExcelReadTool,
microsoft_excel_write: microsoftExcelWriteTool,
microsoft_excel_table_add: microsoftExcelTableAddTool,
}