mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(office-excel): support Office.js add-in embed and surface Graph errors (#4479)
* fix(office-excel): support Office.js add-in embed and surface Graph errors * fix(office-excel): delegate to parseGraphErrorFromData and handle array embed param
This commit is contained in:
@@ -7,7 +7,7 @@ import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/securi
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
|
||||
import { extractGraphError, GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -76,13 +76,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error?.message || 'Failed to fetch drive' },
|
||||
{ status: response.status }
|
||||
)
|
||||
const errorMessage = await extractGraphError(response)
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data: GraphDrive = await response.json()
|
||||
@@ -102,15 +97,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
const errorMessage = await extractGraphError(response)
|
||||
logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, {
|
||||
status: response.status,
|
||||
error: errorData.error?.message,
|
||||
error: errorMessage,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error?.message || 'Failed to fetch drives' },
|
||||
{ status: response.status }
|
||||
)
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getItemBasePath } from '@/tools/microsoft_excel/utils'
|
||||
import { extractGraphError, getItemBasePath } from '@/tools/microsoft_excel/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -73,18 +73,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
})
|
||||
|
||||
if (!worksheetsResponse.ok) {
|
||||
const errorData = await worksheetsResponse
|
||||
.text()
|
||||
.then((text) => JSON.parse(text))
|
||||
.catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
const errorMessage = await extractGraphError(worksheetsResponse)
|
||||
logger.error(`[${requestId}] Microsoft Graph API error`, {
|
||||
status: worksheetsResponse.status,
|
||||
error: errorData.error?.message || 'Failed to fetch worksheets',
|
||||
error: errorMessage,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error?.message || 'Failed to fetch worksheets' },
|
||||
{ status: worksheetsResponse.status }
|
||||
)
|
||||
return NextResponse.json({ error: errorMessage }, { status: worksheetsResponse.status })
|
||||
}
|
||||
|
||||
const data: WorksheetsResponse = await worksheetsResponse.json()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import Script from 'next/script'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Office?: {
|
||||
onReady: () => Promise<{ host: string | null; platform: string | null }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Office.js nullifies window.history.replaceState and pushState (a legacy
|
||||
* IE10 workaround inside the library) which breaks Next.js's client-side
|
||||
* router. Cache the originals at module load — before <Script> renders
|
||||
* Office.js into the DOM — so we can restore them after it loads.
|
||||
*
|
||||
* See https://learn.microsoft.com/en-us/answers/questions/1070090/using-office-javascript-api-in-next-js.
|
||||
*/
|
||||
const cachedHistory =
|
||||
typeof window !== 'undefined'
|
||||
? {
|
||||
replaceState: window.history.replaceState.bind(window.history),
|
||||
pushState: window.history.pushState.bind(window.history),
|
||||
}
|
||||
: null
|
||||
|
||||
/**
|
||||
* Loads Office.js and signals readiness so Office host applications
|
||||
* (Excel, Word, PowerPoint, Outlook) recognize this page as a valid add-in.
|
||||
*
|
||||
* Office.onReady() must be called once Office.js is loaded — see
|
||||
* https://learn.microsoft.com/en-us/javascript/api/office#office-office-onready-function(1).
|
||||
*/
|
||||
export function OfficeEmbedInit() {
|
||||
return (
|
||||
<Script
|
||||
src='https://appsforoffice.microsoft.com/lib/1/hosted/office.js'
|
||||
strategy='afterInteractive'
|
||||
onReady={() => {
|
||||
if (cachedHistory) {
|
||||
window.history.replaceState = cachedHistory.replaceState
|
||||
window.history.pushState = cachedHistory.pushState
|
||||
}
|
||||
void window.Office?.onReady()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { Metadata } from 'next'
|
||||
import ChatClient from '@/app/chat/[identifier]/chat'
|
||||
import { OfficeEmbedInit } from '@/app/chat/[identifier]/office-embed-init'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat',
|
||||
}
|
||||
|
||||
export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) {
|
||||
export default async function ChatPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ identifier: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const { identifier } = await params
|
||||
return <ChatClient key={identifier} identifier={identifier} />
|
||||
const { embed } = await searchParams
|
||||
const isOfficeEmbed = embed === 'office' || (Array.isArray(embed) && embed.includes('office'))
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOfficeEmbed && <OfficeEmbedInit />}
|
||||
<ChatClient key={identifier} identifier={identifier} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
buildTimeCSPDirectives,
|
||||
type CSPDirectives,
|
||||
generateRuntimeCSP,
|
||||
getChatEmbedCSPPolicy,
|
||||
getMainCSPPolicy,
|
||||
getWorkflowExecutionCSPPolicy,
|
||||
removeCSPSource,
|
||||
@@ -278,3 +279,21 @@ describe('buildTimeCSPDirectives', () => {
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('blob:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getChatEmbedCSPPolicy', () => {
|
||||
it('allows iframe embedding from any origin', () => {
|
||||
expect(getChatEmbedCSPPolicy()).toContain('frame-ancestors *')
|
||||
})
|
||||
|
||||
it('allows Office.js to load from Microsoft for Excel/Word add-in embedding', () => {
|
||||
const policy = getChatEmbedCSPPolicy()
|
||||
expect(policy).toMatch(/script-src[^;]*https:\/\/appsforoffice\.microsoft\.com/)
|
||||
expect(policy).toMatch(/connect-src[^;]*https:\/\/appsforoffice\.microsoft\.com/)
|
||||
})
|
||||
|
||||
it('does not regress object-src or base-uri restrictions', () => {
|
||||
const policy = getChatEmbedCSPPolicy()
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(policy).toContain("base-uri 'self'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -236,10 +236,21 @@ function getEmbedCSPPolicy(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* CSP for embeddable chat pages
|
||||
* CSP for embeddable chat pages.
|
||||
* Extends the shared embed policy with Microsoft Office.js sources so the
|
||||
* chat page can serve as an Office (Excel/Word/Outlook) add-in surface
|
||||
* when loaded with `?embed=office`.
|
||||
*/
|
||||
export function getChatEmbedCSPPolicy(): string {
|
||||
return getEmbedCSPPolicy()
|
||||
return buildCSPString({
|
||||
...buildTimeCSPDirectives,
|
||||
'script-src': [...STATIC_SCRIPT_SRC, 'https://appsforoffice.microsoft.com'],
|
||||
'connect-src': [
|
||||
...(buildTimeCSPDirectives['connect-src'] ?? []),
|
||||
'https://appsforoffice.microsoft.com',
|
||||
],
|
||||
'frame-ancestors': ['*'],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
* 2. Add the ID to ErrorExtractorId constant at the bottom of this file
|
||||
*/
|
||||
|
||||
import { parseGraphErrorFromData } from '@/tools/microsoft_excel/utils'
|
||||
|
||||
export interface ErrorInfo {
|
||||
status?: number
|
||||
statusText?: string
|
||||
@@ -184,6 +186,13 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
|
||||
examples: ['Microsoft OAuth', 'Google OAuth', 'OAuth2 providers'],
|
||||
extract: (errorInfo) => errorInfo?.data?.error_description,
|
||||
},
|
||||
{
|
||||
id: 'microsoft-graph-errors',
|
||||
description:
|
||||
'Microsoft Graph error format with nested innerError chain and details[] (Excel, OneDrive, SharePoint, Outlook). See https://learn.microsoft.com/en-us/graph/errors',
|
||||
examples: ['Microsoft Excel', 'Microsoft OneDrive', 'Microsoft SharePoint'],
|
||||
extract: (errorInfo) => parseGraphErrorFromData(errorInfo?.data),
|
||||
},
|
||||
{
|
||||
id: 'nested-error-object',
|
||||
description: 'Error field containing nested object or string',
|
||||
@@ -260,6 +269,7 @@ export function extractErrorMessage(errorInfo?: ErrorInfo, extractorId?: string)
|
||||
|
||||
export const ErrorExtractorId = {
|
||||
ATLASSIAN_ERRORS: 'atlassian-errors',
|
||||
MICROSOFT_GRAPH_ERRORS: 'microsoft-graph-errors',
|
||||
GRAPHQL_ERRORS: 'graphql-errors',
|
||||
TWITTER_ERRORS: 'twitter-errors',
|
||||
DETAILS_ARRAY: 'details-array',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
ExcelCellValue,
|
||||
MicrosoftExcelReadResponse,
|
||||
@@ -8,15 +9,25 @@ import type {
|
||||
import {
|
||||
getItemBasePath,
|
||||
getSpreadsheetWebUrl,
|
||||
parseGraphErrorMessage,
|
||||
trimTrailingEmptyRowsAndColumns,
|
||||
} from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const EXCEL_RETRY_CONFIG = {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 30000,
|
||||
retryIdempotentOnly: true,
|
||||
} as const
|
||||
|
||||
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',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
@@ -95,6 +106,7 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
retry: EXCEL_RETRY_CONFIG,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
|
||||
@@ -123,8 +135,10 @@ export const readTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelReadRe
|
||||
})
|
||||
|
||||
if (!rangeResp.ok) {
|
||||
const errorText = await rangeResp.text().catch(() => '')
|
||||
const detail = parseGraphErrorMessage(rangeResp.status, rangeResp.statusText, errorText)
|
||||
throw new Error(
|
||||
'Invalid range provided or worksheet not found. Provide a range like "Sheet1!A1:B2" or just the sheet name to read the whole sheet'
|
||||
`Failed to read worksheet "${firstSheetName}": ${detail}. Provide a range like "Sheet1!A1:B2" or just the sheet name to read the whole sheet.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,6 +223,7 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
|
||||
name: 'Read from Microsoft Excel V2',
|
||||
description: 'Read data from a specific sheet in a Microsoft Excel spreadsheet',
|
||||
version: '2.0.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
@@ -284,6 +299,7 @@ export const readV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV2
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
retry: EXCEL_RETRY_CONFIG,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelTableAddResponse,
|
||||
MicrosoftExcelTableToolParams,
|
||||
@@ -13,6 +14,7 @@ export const tableAddTool: ToolConfig<
|
||||
name: 'Add to Microsoft Excel Table',
|
||||
description: 'Add new rows to a Microsoft Excel table',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseGraphErrorMessage } from '@/tools/microsoft_excel/utils'
|
||||
|
||||
describe('parseGraphErrorMessage', () => {
|
||||
it('extracts top-level error.message', () => {
|
||||
const body = JSON.stringify({
|
||||
error: { code: 'badRequest', message: 'Uploaded fragment overlaps with existing data.' },
|
||||
})
|
||||
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
|
||||
'Uploaded fragment overlaps with existing data.'
|
||||
)
|
||||
})
|
||||
|
||||
it('combines top-level and innerError messages with em-dash separator', () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
code: 'invalidRequest',
|
||||
message: 'The request is invalid.',
|
||||
innerError: { code: 'invalidRange', message: 'Range A1:Z9999 is out of bounds.' },
|
||||
},
|
||||
})
|
||||
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
|
||||
'The request is invalid. — Range A1:Z9999 is out of bounds.'
|
||||
)
|
||||
})
|
||||
|
||||
it('walks nested innerError chain (lowercase spec form)', () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
message: 'Outer message.',
|
||||
innererror: {
|
||||
message: 'Middle message.',
|
||||
innererror: { message: 'Innermost message.' },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(parseGraphErrorMessage(500, 'Internal Server Error', body)).toBe(
|
||||
'Outer message. — Middle message. — Innermost message.'
|
||||
)
|
||||
})
|
||||
|
||||
it('appends details[].message entries', () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
message: 'Multiple problems.',
|
||||
details: [{ message: 'Cell A1 invalid.' }, { message: 'Cell B2 invalid.' }],
|
||||
},
|
||||
})
|
||||
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe(
|
||||
'Multiple problems. — Cell A1 invalid. — Cell B2 invalid.'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to error.code when no messages present', () => {
|
||||
const body = JSON.stringify({ error: { code: 'itemNotFound' } })
|
||||
expect(parseGraphErrorMessage(404, 'Not Found', body)).toBe('itemNotFound (404 Not Found)')
|
||||
})
|
||||
|
||||
it('returns raw text when body is not JSON', () => {
|
||||
expect(parseGraphErrorMessage(502, 'Bad Gateway', 'upstream timeout')).toBe('upstream timeout')
|
||||
})
|
||||
|
||||
it('falls back to status text when body is empty', () => {
|
||||
expect(parseGraphErrorMessage(503, 'Service Unavailable', '')).toBe('503 Service Unavailable')
|
||||
})
|
||||
|
||||
it('handles deeply nested chain without infinite loop', () => {
|
||||
let nested: Record<string, unknown> = { message: 'leaf' }
|
||||
for (let i = 0; i < 50; i++) {
|
||||
nested = { message: `level-${i}`, innerError: nested }
|
||||
}
|
||||
const body = JSON.stringify({ error: nested })
|
||||
const result = parseGraphErrorMessage(500, 'Internal Server Error', body)
|
||||
// Should include outer plus capped nested messages, not blow up.
|
||||
expect(result.startsWith('level-49')).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates identical inner messages', () => {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
message: 'Same message.',
|
||||
innerError: { message: 'Same message.' },
|
||||
},
|
||||
})
|
||||
expect(parseGraphErrorMessage(400, 'Bad Request', body)).toBe('Same message.')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,112 @@ import type { ExcelCellValue } from '@/tools/microsoft_excel/types'
|
||||
|
||||
const logger = createLogger('MicrosoftExcelUtils')
|
||||
|
||||
/**
|
||||
* Extract a developer-readable message from a parsed Microsoft Graph error body.
|
||||
* Graph errors follow the documented shape:
|
||||
* { error: { code, message, innerError: { code, message, ... }, details: [...] } }
|
||||
* See https://learn.microsoft.com/en-us/graph/errors
|
||||
*
|
||||
* Walks the nested innerError chain (capped at depth 5) and appends details[].message.
|
||||
* Returns undefined when no message-like field is present so callers can fall back.
|
||||
*/
|
||||
export function parseGraphErrorFromData(data: unknown): string | undefined {
|
||||
if (!data || typeof data !== 'object') return undefined
|
||||
|
||||
const root = (
|
||||
data as {
|
||||
error?: {
|
||||
code?: unknown
|
||||
message?: unknown
|
||||
innerError?: unknown
|
||||
innererror?: unknown
|
||||
details?: unknown
|
||||
}
|
||||
}
|
||||
).error
|
||||
if (root && typeof root === 'object') {
|
||||
const messages: string[] = []
|
||||
if (typeof root.message === 'string' && root.message.trim()) {
|
||||
messages.push(root.message.trim())
|
||||
}
|
||||
|
||||
// Walk the (possibly nested) innerError chain. Spec uses `innererror`
|
||||
// but Graph commonly returns `innerError` — accept both.
|
||||
let inner: any = (root as any).innererror ?? (root as any).innerError
|
||||
let depth = 0
|
||||
while (inner && depth < 5) {
|
||||
if (typeof inner.message === 'string' && inner.message.trim()) {
|
||||
const msg = inner.message.trim()
|
||||
if (!messages.includes(msg)) messages.push(msg)
|
||||
}
|
||||
inner = inner.innererror ?? inner.innerError
|
||||
depth++
|
||||
}
|
||||
|
||||
if (Array.isArray((root as any).details)) {
|
||||
for (const detail of (root as any).details) {
|
||||
if (detail && typeof detail.message === 'string' && detail.message.trim()) {
|
||||
const msg = detail.message.trim()
|
||||
if (!messages.includes(msg)) messages.push(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length > 0) return messages.join(' — ')
|
||||
|
||||
if (typeof root.code === 'string' && root.code.trim()) {
|
||||
return root.code.trim()
|
||||
}
|
||||
}
|
||||
|
||||
const topMessage = (data as { message?: unknown }).message
|
||||
if (typeof topMessage === 'string' && topMessage.trim()) {
|
||||
return topMessage.trim()
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Microsoft Graph error response body into a string message.
|
||||
* Used by API routes that have a Response object rather than parsed data.
|
||||
*/
|
||||
export function parseGraphErrorMessage(
|
||||
status: number,
|
||||
statusText: string,
|
||||
errorText: string
|
||||
): string {
|
||||
try {
|
||||
const data = JSON.parse(errorText)
|
||||
const message = parseGraphErrorFromData(data)
|
||||
if (message) {
|
||||
// If the only thing we found was the bare error code, append status for context.
|
||||
const root = data?.error
|
||||
if (
|
||||
root &&
|
||||
message === root.code?.trim?.() &&
|
||||
!(typeof root.message === 'string' && root.message.trim())
|
||||
) {
|
||||
return `${message} (${status} ${statusText})`
|
||||
}
|
||||
return message
|
||||
}
|
||||
} catch {
|
||||
if (errorText?.trim()) return errorText.trim()
|
||||
}
|
||||
|
||||
return statusText ? `${status} ${statusText}` : `Microsoft Graph request failed (${status})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an error response body and produce a developer-readable message.
|
||||
* Safely handles non-JSON bodies and read failures. Used by internal API routes.
|
||||
*/
|
||||
export async function extractGraphError(response: Response): Promise<string> {
|
||||
const errorText = await response.text().catch(() => '')
|
||||
return parseGraphErrorMessage(response.status, response.statusText, errorText)
|
||||
}
|
||||
|
||||
/** Pattern for Microsoft Graph item/drive IDs: alphanumeric, hyphens, underscores, and ! (for SharePoint b!<base64> format) */
|
||||
export const GRAPH_ID_PATTERN = /^[a-zA-Z0-9!_-]+$/
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelWorksheetAddResponse,
|
||||
MicrosoftExcelWorksheetToolParams,
|
||||
@@ -17,6 +18,7 @@ export const worksheetAddTool: ToolConfig<
|
||||
name: 'Add Worksheet to Microsoft Excel',
|
||||
description: 'Create a new worksheet (sheet) in a Microsoft Excel workbook',
|
||||
version: '1.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
@@ -99,19 +101,6 @@ export const worksheetAddTool: ToolConfig<
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelWorksheetToolParams) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage =
|
||||
errorData?.error?.message || `Failed to create worksheet: ${response.statusText}`
|
||||
|
||||
// Handle specific error cases
|
||||
if (response.status === 409) {
|
||||
throw new Error('A worksheet with this name already exists. Please choose a different name')
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const spreadsheetId = params?.spreadsheetId?.trim() || ''
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ErrorExtractorId } from '@/tools/error-extractors'
|
||||
import type {
|
||||
MicrosoftExcelToolParams,
|
||||
MicrosoftExcelV2ToolParams,
|
||||
@@ -7,11 +8,25 @@ import type {
|
||||
import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Range writes (PATCH /workbook/.../range) are semantically idempotent —
|
||||
* the same payload produces the same result — so we permit retries on
|
||||
* transient 429/5xx even though PATCH is not in the default idempotent set.
|
||||
*/
|
||||
const EXCEL_RETRY_CONFIG = {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 30000,
|
||||
retryIdempotentOnly: false,
|
||||
} as const
|
||||
|
||||
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',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
@@ -140,6 +155,7 @@ export const writeTool: ToolConfig<MicrosoftExcelToolParams, MicrosoftExcelWrite
|
||||
|
||||
return body
|
||||
},
|
||||
retry: EXCEL_RETRY_CONFIG,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelToolParams) => {
|
||||
@@ -190,6 +206,7 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
|
||||
name: 'Write to Microsoft Excel V2',
|
||||
description: 'Write data to a specific sheet in a Microsoft Excel spreadsheet',
|
||||
version: '2.0.0',
|
||||
errorExtractor: ErrorExtractorId.MICROSOFT_GRAPH_ERRORS,
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
@@ -326,6 +343,7 @@ export const writeV2Tool: ToolConfig<MicrosoftExcelV2ToolParams, MicrosoftExcelV
|
||||
|
||||
return body
|
||||
},
|
||||
retry: EXCEL_RETRY_CONFIG,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftExcelV2ToolParams) => {
|
||||
|
||||
Reference in New Issue
Block a user