diff --git a/api/server/controllers/FeishuController.js b/api/server/controllers/FeishuController.js new file mode 100644 index 00000000000..abcf14458e9 --- /dev/null +++ b/api/server/controllers/FeishuController.js @@ -0,0 +1,68 @@ +const { importFeishuDocs } = require('@librechat/api'); +const { parseFeishuAllowedHosts } = require('librechat-data-provider'); +const { logger } = require('@librechat/data-schemas'); +const { createFile } = require('~/models'); +const { exchangeOboToken } = require('~/server/services/OboTokenService'); +const { getRetentionExpiry } = require('~/server/services/Files/retention'); + +const isEnabled = (value) => value === true || String(value).toLowerCase() === 'true'; + +function parseMaxBytes(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function normalizeUser(user) { + return { + ...user, + id: user?.id ?? user?._id?.toString?.(), + }; +} + +async function importFeishuDocsController(req, res) { + const urls = req.body?.urls; + if (!Array.isArray(urls) || urls.length === 0 || urls.some((url) => typeof url !== 'string')) { + return res.status(400).json({ + code: 'FEISHU_INVALID_REQUEST', + message: 'A non-empty urls array is required', + }); + } + + try { + const result = await importFeishuDocs({ + urls, + user: normalizeUser(req.user), + config: { + enabled: isEnabled(process.env.FEISHU_DOC_IMPORT_ENABLED), + oboScope: process.env.FEISHU_DOC_OBO_SCOPE ?? '', + apiBaseUrl: process.env.FEISHU_DOC_API_BASE_URL ?? '', + allowedHostSuffixes: parseFeishuAllowedHosts(process.env.FEISHU_DOC_ALLOWED_HOSTS), + maxBytes: parseMaxBytes(process.env.FEISHU_DOC_IMPORT_MAX_BYTES), + }, + deps: { + createFile, + exchangeOboToken, + getRetentionExpiry: () => getRetentionExpiry(req), + }, + }); + + return res.status(200).json(result); + } catch (error) { + const code = error?.code ?? 'FEISHU_IMPORT_FAILED'; + const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 500; + const message = error?.message ?? 'Failed to import Feishu document'; + + if (statusCode >= 500) { + logger.error('[importFeishuDocsController] Failed to import Feishu document:', error); + } + + return res.status(statusCode).json({ + code, + message, + }); + } +} + +module.exports = { + importFeishuDocsController, +}; diff --git a/api/server/experimental.js b/api/server/experimental.js index c20f6813cac..db6480ee522 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -391,6 +391,7 @@ if (cluster.isMaster) { app.use('/api/models', routes.models); app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config); app.use('/api/assistants', routes.assistants); + app.use('/api/feishu', routes.feishu); app.use('/api/files', await routes.files.initialize()); app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute); app.use('/api/share', routes.share); diff --git a/api/server/index.js b/api/server/index.js index 20fa0f9bf12..5819150a7ca 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -234,6 +234,7 @@ const startServer = async () => { app.use('/api/models', routes.models); app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config); app.use('/api/assistants', routes.assistants); + app.use('/api/feishu', routes.feishu); app.use('/api/files', await routes.files.initialize()); app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute); app.use('/api/share', preAuthTenantMiddleware, routes.share); diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 606b4ae8a1a..ee85086e8aa 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -159,6 +159,7 @@ describe('GET /api/config', () => { expect(response.body).not.toHaveProperty('bundlerURL'); expect(response.body).not.toHaveProperty('staticBundlerURL'); expect(response.body).not.toHaveProperty('sharePointFilePickerEnabled'); + expect(response.body).not.toHaveProperty('feishuDocImportEnabled'); expect(response.body).not.toHaveProperty('sharePointBaseUrl'); expect(response.body).not.toHaveProperty('sharePointPickerGraphScope'); expect(response.body).not.toHaveProperty('sharePointPickerSharePointScope'); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 79f36552cab..f5a721a6d5d 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -26,6 +26,7 @@ const publicSharedLinksEnabled = sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC); const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); +const feishuDocImportEnabled = isEnabled(process.env.FEISHU_DOC_IMPORT_ENABLED); const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); /** @@ -269,6 +270,7 @@ router.get('/', async function (req, res) { bundlerURL: process.env.SANDPACK_BUNDLER_URL, staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL, sharePointFilePickerEnabled, + feishuDocImportEnabled, sharePointBaseUrl: process.env.SHAREPOINT_BASE_URL, sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE, sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE, diff --git a/api/server/routes/feishu.js b/api/server/routes/feishu.js new file mode 100644 index 00000000000..e0cfbe724bd --- /dev/null +++ b/api/server/routes/feishu.js @@ -0,0 +1,16 @@ +const express = require('express'); +const middleware = require('~/server/middleware'); +const { importFeishuDocsController } = require('~/server/controllers/FeishuController'); + +const router = express.Router(); + +router.post( + '/docs/import', + middleware.requireJwtAuth, + middleware.configMiddleware, + middleware.checkBan, + middleware.uaParser, + importFeishuDocsController, +); + +module.exports = router; diff --git a/api/server/routes/feishu.spec.js b/api/server/routes/feishu.spec.js new file mode 100644 index 00000000000..f678762b307 --- /dev/null +++ b/api/server/routes/feishu.spec.js @@ -0,0 +1,147 @@ +const express = require('express'); +const request = require('supertest'); + +const mockImportFeishuDocs = jest.fn(); +const mockCreateFile = jest.fn(); +const mockExchangeOboToken = jest.fn(); +const mockGetRetentionExpiry = jest.fn(); + +jest.mock('@librechat/api', () => ({ + importFeishuDocs: (...args) => mockImportFeishuDocs(...args), +})); + +jest.mock('~/models', () => ({ + createFile: (...args) => mockCreateFile(...args), +})); + +jest.mock('~/server/services/OboTokenService', () => ({ + exchangeOboToken: (...args) => mockExchangeOboToken(...args), +})); + +jest.mock('~/server/services/Files/retention', () => ({ + getRetentionExpiry: (...args) => mockGetRetentionExpiry(...args), +})); + +jest.mock('~/server/middleware', () => { + const pass = (req, res, next) => next(); + return { + configMiddleware: pass, + checkBan: pass, + uaParser: pass, + requireJwtAuth: (req, res, next) => { + if (req.headers.authorization !== 'Bearer ok') { + return res.status(401).json({ message: 'Unauthorized' }); + } + + req.user = { + id: 'user-1', + provider: 'openid', + openidId: 'oidc-subject', + tenantId: 'tenant-1', + federatedTokens: { + access_token: 'archegate-token', + }, + }; + return next(); + }, + }; +}); + +describe('Feishu routes', () => { + let app; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + FEISHU_DOC_IMPORT_ENABLED: 'true', + FEISHU_DOC_OBO_SCOPE: 'Feishu.Docs.Read', + FEISHU_DOC_API_BASE_URL: 'https://open.feishu.cn', + FEISHU_DOC_ALLOWED_HOSTS: 'feishu.cn,larksuite.com', + FEISHU_DOC_IMPORT_MAX_BYTES: '1048576', + }; + mockGetRetentionExpiry.mockResolvedValue({ expiredAt: new Date('2026-06-10T00:00:00.000Z') }); + mockImportFeishuDocs.mockResolvedValue({ files: [], skipped: [] }); + + const feishuRouter = require('./feishu'); + app = express(); + app.use(express.json()); + app.use('/api/feishu', feishuRouter); + }); + + afterEach(() => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('requires authentication', async () => { + await request(app) + .post('/api/feishu/docs/import') + .send({ urls: ['https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG'] }) + .expect(401); + + expect(mockImportFeishuDocs).not.toHaveBeenCalled(); + }); + + test('passes import config and dependencies to the Feishu import service', async () => { + const response = await request(app) + .post('/api/feishu/docs/import') + .set('Authorization', 'Bearer ok') + .send({ urls: ['https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG'] }) + .expect(200); + + expect(response.body).toEqual({ files: [], skipped: [] }); + expect(mockImportFeishuDocs).toHaveBeenCalledWith( + expect.objectContaining({ + urls: ['https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG'], + user: expect.objectContaining({ id: 'user-1', provider: 'openid' }), + config: { + enabled: true, + oboScope: 'Feishu.Docs.Read', + apiBaseUrl: 'https://open.feishu.cn', + allowedHostSuffixes: ['feishu.cn', 'larksuite.com'], + maxBytes: 1048576, + }, + deps: expect.objectContaining({ + createFile: expect.any(Function), + exchangeOboToken: expect.any(Function), + getRetentionExpiry: expect.any(Function), + }), + }), + ); + }); + + test('maps Feishu import errors to stable HTTP responses', async () => { + mockImportFeishuDocs.mockRejectedValue({ + code: 'FEISHU_PERMISSION_DENIED', + statusCode: 403, + message: 'permission denied', + }); + + const response = await request(app) + .post('/api/feishu/docs/import') + .set('Authorization', 'Bearer ok') + .send({ urls: ['https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG'] }) + .expect(403); + + expect(response.body).toEqual({ + code: 'FEISHU_PERMISSION_DENIED', + message: 'permission denied', + }); + }); + + test('rejects missing url arrays before calling the import service', async () => { + const response = await request(app) + .post('/api/feishu/docs/import') + .set('Authorization', 'Bearer ok') + .send({ urls: [] }) + .expect(400); + + expect(response.body).toEqual({ + code: 'FEISHU_INVALID_REQUEST', + message: 'A non-empty urls array is required', + }); + expect(mockImportFeishuDocs).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 3322dd57e35..7c36ebf4e36 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -8,6 +8,7 @@ const adminGroups = require('./admin/groups'); const adminRoles = require('./admin/roles'); const adminUsers = require('./admin/users'); const endpoints = require('./endpoints'); +const feishu = require('./feishu'); const staticRoute = require('./static'); const messages = require('./messages'); const memories = require('./memories'); @@ -68,6 +69,7 @@ module.exports = { messages, memories, endpoints, + feishu, assistants, categories, staticRoute, diff --git a/client/src/data-provider/Feishu/index.ts b/client/src/data-provider/Feishu/index.ts new file mode 100644 index 00000000000..d5bc710cf49 --- /dev/null +++ b/client/src/data-provider/Feishu/index.ts @@ -0,0 +1 @@ +export * from './mutations'; diff --git a/client/src/data-provider/Feishu/mutations.ts b/client/src/data-provider/Feishu/mutations.ts new file mode 100644 index 00000000000..edd24770ae0 --- /dev/null +++ b/client/src/data-provider/Feishu/mutations.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; +import { MutationKeys, dataService } from 'librechat-data-provider'; +import type * as t from 'librechat-data-provider'; +import type { UseMutationResult } from '@tanstack/react-query'; + +export const useImportFeishuDocsMutation = (): UseMutationResult< + t.FeishuDocImportResponse, + unknown, + t.FeishuDocImportRequest, + unknown +> => { + return useMutation([MutationKeys.feishuDocImport], { + mutationFn: (body: t.FeishuDocImportRequest) => dataService.importFeishuDocs(body), + }); +}; diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index e4be6aee0bc..73a15766923 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -3,6 +3,7 @@ export * from './Agents'; export * from './Endpoints'; export * from './Skills'; export * from './Files'; +export * from './Feishu'; /* Memories */ export * from './Memories'; export * from './Messages'; diff --git a/client/src/hooks/Messages/useFeishuDocImport.spec.tsx b/client/src/hooks/Messages/useFeishuDocImport.spec.tsx new file mode 100644 index 00000000000..084f1e4d2f3 --- /dev/null +++ b/client/src/hooks/Messages/useFeishuDocImport.spec.tsx @@ -0,0 +1,111 @@ +import { act, renderHook } from '@testing-library/react'; +import useFeishuDocImport from './useFeishuDocImport'; + +const mockMutateAsync = jest.fn(); +const mockShowToast = jest.fn(); +let mockStartupConfig: { feishuDocImportEnabled?: boolean } | undefined; + +jest.mock('@librechat/client', () => ({ + useToastContext: () => ({ + showToast: mockShowToast, + }), +})); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: () => ({ data: mockStartupConfig }), + useImportFeishuDocsMutation: () => ({ + mutateAsync: mockMutateAsync, + isLoading: false, + }), +})); + +jest.mock('~/hooks/useLocalize', () => ({ + __esModule: true, + default: () => (key: string) => key, +})); + +describe('useFeishuDocImport', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockStartupConfig = { feishuDocImportEnabled: true }; + mockMutateAsync.mockResolvedValue({ + files: [], + skipped: [], + }); + }); + + test('does nothing when Feishu document import is disabled', async () => { + mockStartupConfig = { feishuDocImportEnabled: false }; + const { result } = renderHook(() => useFeishuDocImport()); + + await expect( + result.current.importFeishuDocsForMessage( + 'https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG', + ), + ).resolves.toEqual([]); + + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); + + test('does nothing when the message has no supported Feishu URLs', async () => { + const { result } = renderHook(() => useFeishuDocImport()); + + await expect(result.current.importFeishuDocsForMessage('hello')).resolves.toEqual([]); + + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); + + test('imports supported Feishu URLs and returns message file refs', async () => { + mockMutateAsync.mockResolvedValue({ + files: [ + { + file_id: 'file-1', + filepath: 'feishu://wiki/QcXRwepEGiApKykeshKc0T5KnGG', + type: 'text/markdown', + height: 10, + width: 20, + }, + ], + skipped: [], + }); + const { result } = renderHook(() => useFeishuDocImport()); + + let files; + await act(async () => { + files = await result.current.importFeishuDocsForMessage( + 'read https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG', + ); + }); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + urls: ['https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG'], + }); + expect(files).toEqual([ + { + file_id: 'file-1', + filepath: 'feishu://wiki/QcXRwepEGiApKykeshKc0T5KnGG', + type: 'text/markdown', + height: 10, + width: 20, + }, + ]); + }); + + test('surfaces import failures and does not swallow the error', async () => { + const error = new Error('permission denied'); + mockMutateAsync.mockRejectedValue(error); + const { result } = renderHook(() => useFeishuDocImport()); + + await expect( + result.current.importFeishuDocsForMessage( + 'https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG', + ), + ).rejects.toThrow('permission denied'); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + }), + ); + }); +}); diff --git a/client/src/hooks/Messages/useFeishuDocImport.ts b/client/src/hooks/Messages/useFeishuDocImport.ts new file mode 100644 index 00000000000..45220a704d0 --- /dev/null +++ b/client/src/hooks/Messages/useFeishuDocImport.ts @@ -0,0 +1,65 @@ +import { useCallback } from 'react'; +import { useToastContext } from '@librechat/client'; +import { extractFeishuDocUrls } from 'librechat-data-provider'; +import type * as t from 'librechat-data-provider'; +import { useGetStartupConfig, useImportFeishuDocsMutation } from '~/data-provider'; +import { useLocalize } from '~/hooks'; + +type ImportedMessageFile = NonNullable[number]; + +export default function useFeishuDocImport(): { + importFeishuDocsForMessage: (text: string) => Promise; + isImporting: boolean; +} { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { data: startupConfig } = useGetStartupConfig(); + const importMutation = useImportFeishuDocsMutation(); + + const importFeishuDocsForMessage = useCallback( + async (text: string): Promise => { + const links = extractFeishuDocUrls(text); + if (!startupConfig?.feishuDocImportEnabled || links.length === 0) { + return []; + } + + showToast({ + message: localize('com_ui_feishu_doc_importing'), + status: 'info', + }); + + try { + const response = await importMutation.mutateAsync({ + urls: links.map((link) => link.url), + }); + + if (response.files.length > 0) { + showToast({ + message: localize('com_ui_feishu_doc_import_success'), + status: 'success', + }); + } + + return response.files.map((file) => ({ + file_id: file.file_id, + filepath: file.filepath, + type: file.type ?? '', + height: file.height, + width: file.width, + })); + } catch (error) { + showToast({ + message: localize('com_ui_feishu_doc_import_failed'), + status: 'error', + }); + throw error; + } + }, + [importMutation, localize, showToast, startupConfig?.feishuDocImportEnabled], + ); + + return { + importFeishuDocsForMessage, + isImporting: importMutation.isLoading, + }; +} diff --git a/client/src/hooks/Messages/useSubmitMessage.spec.tsx b/client/src/hooks/Messages/useSubmitMessage.spec.tsx new file mode 100644 index 00000000000..9ee4dd74631 --- /dev/null +++ b/client/src/hooks/Messages/useSubmitMessage.spec.tsx @@ -0,0 +1,145 @@ +import { act, renderHook } from '@testing-library/react'; +import useSubmitMessage from './useSubmitMessage'; + +const mockAsk = jest.fn(); +const mockSetMessages = jest.fn(); +const mockGetMessages = jest.fn(); +const mockReset = jest.fn(); +const mockGetValues = jest.fn(); +const mockSetActivePrompt = jest.fn(); +const mockImportFeishuDocsForMessage = jest.fn(); + +let mockAutoSendPrompts = false; +let mockLatestMessage: { messageId: string } | undefined; +let mockRootMessages: Array<{ messageId: string }> | undefined; +let mockAddedConvo: { conversationId: string } | undefined; + +jest.mock('recoil', () => ({ + useRecoilValue: () => mockAutoSendPrompts, + useSetRecoilState: () => mockSetActivePrompt, +})); + +jest.mock('librechat-data-provider', () => ({ + replaceSpecialVars: jest.fn(({ text }: { text: string }) => text), +})); + +jest.mock('~/Providers', () => ({ + useAddedChatContext: () => ({ + conversation: mockAddedConvo, + }), + useChatContext: () => ({ + ask: mockAsk, + index: 0, + getMessages: mockGetMessages, + setMessages: mockSetMessages, + }), + useChatFormContext: () => ({ + reset: mockReset, + getValues: mockGetValues, + }), +})); + +jest.mock('~/hooks/AuthContext', () => ({ + useAuthContext: () => ({ + user: { id: 'user-1' }, + }), +})); + +jest.mock('~/hooks/Messages/useLatestMessage', () => ({ + useLatestMessage: () => mockLatestMessage, +})); + +jest.mock('~/common', () => ({ + mainTextareaId: 'main-textarea', +})); + +jest.mock('~/store', () => ({ + __esModule: true, + default: { + autoSendPrompts: 'autoSendPrompts', + activePromptByIndex: jest.fn(() => 'activePromptByIndex'), + }, +})); + +jest.mock('./useFeishuDocImport', () => ({ + __esModule: true, + default: () => ({ + importFeishuDocsForMessage: mockImportFeishuDocsForMessage, + isImporting: false, + }), +})); + +describe('useSubmitMessage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAutoSendPrompts = false; + mockLatestMessage = { messageId: 'latest-message' }; + mockRootMessages = [{ messageId: 'latest-message' }]; + mockAddedConvo = undefined; + mockGetMessages.mockImplementation(() => mockRootMessages); + mockGetValues.mockReturnValue(''); + mockImportFeishuDocsForMessage.mockResolvedValue([]); + }); + + test('submits normally when no Feishu documents are imported', async () => { + const { result } = renderHook(() => useSubmitMessage()); + + await act(async () => { + await result.current.submitMessage({ text: 'hello' }); + }); + + expect(mockImportFeishuDocsForMessage).toHaveBeenCalledWith('hello'); + expect(mockAsk).toHaveBeenCalledWith( + { text: 'hello' }, + { + addedConvo: undefined, + }, + ); + expect(mockReset).toHaveBeenCalledTimes(1); + }); + + test('passes imported Feishu documents as override files', async () => { + const importedFiles = [ + { + file_id: 'file-1', + filepath: 'feishu://wiki/QcXRwepEGiApKykeshKc0T5KnGG', + type: 'text/markdown', + }, + ]; + mockImportFeishuDocsForMessage.mockResolvedValue(importedFiles); + const { result } = renderHook(() => useSubmitMessage()); + + await act(async () => { + await result.current.submitMessage({ + text: 'read https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG', + }); + }); + + expect(mockAsk).toHaveBeenCalledWith( + { + text: 'read https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG', + }, + { + addedConvo: undefined, + overrideFiles: importedFiles, + }, + ); + expect(mockReset).toHaveBeenCalledTimes(1); + }); + + test('does not send or reset when Feishu import fails', async () => { + mockImportFeishuDocsForMessage.mockRejectedValue(new Error('import failed')); + const { result } = renderHook(() => useSubmitMessage()); + + await expect( + act(async () => { + await result.current.submitMessage({ + text: 'https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG', + }); + }), + ).rejects.toThrow('import failed'); + + expect(mockAsk).not.toHaveBeenCalled(); + expect(mockReset).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/Messages/useSubmitMessage.ts b/client/src/hooks/Messages/useSubmitMessage.ts index ebdb85f32d8..fdb015fe1b3 100644 --- a/client/src/hooks/Messages/useSubmitMessage.ts +++ b/client/src/hooks/Messages/useSubmitMessage.ts @@ -3,6 +3,7 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { replaceSpecialVars } from 'librechat-data-provider'; import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers'; import { useAuthContext } from '~/hooks/AuthContext'; +import useFeishuDocImport from '~/hooks/Messages/useFeishuDocImport'; import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { mainTextareaId } from '~/common'; import store from '~/store'; @@ -13,15 +14,18 @@ export default function useSubmitMessage() { const { conversation: addedConvo } = useAddedChatContext(); const { ask, index, getMessages, setMessages } = useChatContext(); const latestMessage = useLatestMessage(index); + const { importFeishuDocsForMessage } = useFeishuDocImport(); const autoSendPrompts = useRecoilValue(store.autoSendPrompts); const setActivePrompt = useSetRecoilState(store.activePromptByIndex(index)); const submitMessage = useCallback( - (data?: { text: string }) => { + async (data?: { text: string }) => { if (!data) { return console.warn('No data provided to submitMessage'); } + + const importedFiles = await importFeishuDocsForMessage(data.text); const rootMessages = getMessages(); const isLatestInRootMessages = rootMessages?.some( (message) => message.messageId === latestMessage?.messageId, @@ -36,11 +40,20 @@ export default function useSubmitMessage() { }, { addedConvo: addedConvo ?? undefined, + ...(importedFiles.length > 0 ? { overrideFiles: importedFiles } : {}), }, ); methods.reset(); }, - [ask, methods, addedConvo, setMessages, getMessages, latestMessage], + [ + ask, + methods, + addedConvo, + setMessages, + getMessages, + latestMessage, + importFeishuDocsForMessage, + ], ); const submitPrompt = useCallback( diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 80d92f9f863..7ad8e375209 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1050,6 +1050,9 @@ "com_ui_feedback_tag_not_matched": "Didn't match my request", "com_ui_feedback_tag_other": "Other issue", "com_ui_feedback_tag_unjustified_refusal": "Refused without reason", + "com_ui_feishu_doc_import_failed": "Could not import the Feishu document", + "com_ui_feishu_doc_import_success": "Feishu document imported", + "com_ui_feishu_doc_importing": "Importing Feishu document", "com_ui_field_max_length": "{{field}} must be less than {{length}} characters", "com_ui_field_required": "This field is required", "com_ui_file": "File", diff --git a/docs/superpowers/plans/2026-06-09-feishu-doc-url-import.md b/docs/superpowers/plans/2026-06-09-feishu-doc-url-import.md new file mode 100644 index 00000000000..ac74bab5e89 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-feishu-doc-url-import.md @@ -0,0 +1,467 @@ +# Feishu Doc URL Import Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Users paste a Feishu `docx` or `wiki` URL into a chat message and LibreChat imports the document text before sending the message. + +**Architecture:** Share URL parsing in `librechat-data-provider`, keep Feishu API and import logic in `packages/api`, and expose a thin authenticated `/api/feishu/docs/import` route from the legacy `api` server. The frontend detects supported URLs in `useSubmitMessage`, calls the import endpoint, and passes returned file references via `ask(..., { overrideFiles })`. + +**Tech Stack:** TypeScript, React hooks, React Query mutations, Express, Jest, existing OpenID OBO token exchange, Feishu OpenAPI-compatible HTTP calls. + +--- + +## File Map + +- Create `packages/data-provider/src/feishu.ts`: shared URL parser and allowed-host helpers. +- Test `packages/data-provider/src/feishu.spec.ts`: parser coverage for docx, wiki, unsupported types, host filtering, duplicates. +- Modify `packages/data-provider/src/index.ts`: export shared Feishu helpers. +- Modify `packages/data-provider/src/types/queries.ts`: add Feishu import request/response types. +- Modify `packages/data-provider/src/api-endpoints.ts`: add `feishuDocImport()`. +- Modify `packages/data-provider/src/data-service.ts`: add `importFeishuDocs()`. +- Create `packages/api/src/files/feishu/errors.ts`: stable import error codes and HTTP status mapping inputs. +- Create `packages/api/src/files/feishu/client.ts`: Feishu API client for wiki node resolution and docx raw content fetch. +- Create `packages/api/src/files/feishu/import.ts`: OBO validation, content normalization, max-byte check, file record creation. +- Create `packages/api/src/files/feishu/index.ts`: exports. +- Test `packages/api/src/files/feishu/client.spec.ts` and `packages/api/src/files/feishu/import.spec.ts`. +- Modify `packages/api/src/files/index.ts`: export Feishu import service. +- Create `api/server/controllers/FeishuController.js`: thin controller wiring request deps into `@librechat/api`. +- Create `api/server/routes/feishu.js`: authenticated import route. +- Test `api/server/routes/feishu.spec.js`: route auth/config/error mapping. +- Modify `api/server/routes/index.js`, `api/server/index.js`, and `api/server/experimental.js`: register route. +- Modify `api/server/routes/config.js`: expose `feishuDocImportEnabled`. +- Modify `packages/data-provider/src/config.ts`: add startup config field. +- Create `client/src/data-provider/Feishu/mutations.ts` and `client/src/data-provider/Feishu/index.ts`: React Query mutation wrapper. +- Modify `client/src/data-provider/index.ts`: export Feishu hooks. +- Create `client/src/hooks/Messages/useFeishuDocImport.ts`: pre-send import hook. +- Test `client/src/hooks/Messages/useFeishuDocImport.spec.tsx`: disabled/no URL/import success/import failure behavior. +- Modify `client/src/hooks/Messages/useSubmitMessage.ts`: call pre-import before `ask`. +- Test `client/src/hooks/Messages/useSubmitMessage.spec.tsx`: import results passed as `overrideFiles`, failed import blocks send. +- Modify `client/src/locales/en/translation.json`: add Feishu import error/toast strings. + +## Task 1: Shared Feishu URL Parser + +**Files:** +- Create: `packages/data-provider/src/feishu.ts` +- Create: `packages/data-provider/src/feishu.spec.ts` +- Modify: `packages/data-provider/src/index.ts` + +- [ ] **Step 1: Write failing parser tests** + +Cover these exact behaviors: + +```ts +import { + extractFeishuDocUrls, + isSupportedFeishuDocUrl, + parseFeishuAllowedHosts, +} from './feishu'; + +describe('Feishu document URL parsing', () => { + test('extracts the ArcheBase wiki URL shape', () => { + expect( + extractFeishuDocUrls( + 'read https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG please', + ), + ).toEqual([ + { + url: 'https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG', + host: 'archebase.feishu.cn', + type: 'wiki', + token: 'QcXRwepEGiApKykeshKc0T5KnGG', + }, + ]); + }); + + test('deduplicates equivalent document links', () => { + const text = + 'https://archebase.feishu.cn/docx/AbCd123?from=chat https://archebase.feishu.cn/docx/AbCd123'; + expect(extractFeishuDocUrls(text)).toHaveLength(1); + }); + + test('rejects unsupported Feishu resource paths', () => { + expect(isSupportedFeishuDocUrl('https://archebase.feishu.cn/sheets/abc')).toBe(false); + }); + + test('honors configured host suffixes', () => { + expect( + extractFeishuDocUrls('https://docs.example.com/wiki/Wiki123', { + allowedHostSuffixes: ['example.com'], + }), + ).toHaveLength(1); + }); + + test('normalizes comma-separated allowed host config', () => { + expect(parseFeishuAllowedHosts(' feishu.cn, larksuite.com ,,')).toEqual([ + 'feishu.cn', + 'larksuite.com', + ]); + }); +}); +``` + +Run: `cd packages/data-provider && npx jest src/feishu.spec.ts --runInBand` +Expected: FAIL because `./feishu` does not exist. + +- [ ] **Step 2: Implement parser** + +Implement: + +```ts +export type FeishuDocType = 'docx' | 'wiki'; + +export type FeishuDocUrl = { + url: string; + host: string; + type: FeishuDocType; + token: string; +}; + +export function parseFeishuAllowedHosts(value?: string): string[]; +export function extractFeishuDocUrls(text: string, options?: { allowedHostSuffixes?: string[] }): FeishuDocUrl[]; +export function isSupportedFeishuDocUrl(url: string, options?: { allowedHostSuffixes?: string[] }): boolean; +``` + +Use `new URL()` for parsing, require `https`, accept default host suffixes `feishu.cn` and `larksuite.com`, accept only `/docx/` and `/wiki/`, trim trailing punctuation from URL matches, and dedupe by `${type}:${token}`. + +- [ ] **Step 3: Export and verify** + +Add `export * from './feishu';` to `packages/data-provider/src/index.ts`. + +Run: `cd packages/data-provider && npx jest src/feishu.spec.ts --runInBand` +Expected: PASS. + +Commit: `git add packages/data-provider/src/feishu.ts packages/data-provider/src/feishu.spec.ts packages/data-provider/src/index.ts && git commit -m "feat: parse feishu document urls"` + +## Task 2: Data Provider API Contract + +**Files:** +- Modify: `packages/data-provider/src/types/queries.ts` +- Modify: `packages/data-provider/src/api-endpoints.ts` +- Modify: `packages/data-provider/src/data-service.ts` +- Modify: `packages/data-provider/src/keys.ts` + +- [ ] **Step 1: Add Feishu import types** + +Add these types near the existing token query types: + +```ts +export type FeishuDocImportRequest = { + urls: string[]; +}; + +export type FeishuDocImportSkipped = { + url: string; + reason: string; +}; + +export type FeishuDocImportResponse = { + files: t.TFile[]; + skipped: FeishuDocImportSkipped[]; +}; +``` + +- [ ] **Step 2: Add endpoint and service wrapper** + +Add endpoint: + +```ts +export const feishuDocImport = () => `${BASE_URL}/api/feishu/docs/import`; +``` + +Add data service: + +```ts +export function importFeishuDocs( + data: q.FeishuDocImportRequest, +): Promise { + return request.post(endpoints.feishuDocImport(), data); +} +``` + +Add mutation key: + +```ts +feishuDocImport = 'feishuDocImport', +``` + +- [ ] **Step 3: Build data-provider** + +Run: `npm run build:data-provider` +Expected: build completes. + +Commit: `git add packages/data-provider/src/types/queries.ts packages/data-provider/src/api-endpoints.ts packages/data-provider/src/data-service.ts packages/data-provider/src/keys.ts && git commit -m "feat: add feishu import data contract"` + +## Task 3: Backend Feishu Client And Import Service + +**Files:** +- Create: `packages/api/src/files/feishu/errors.ts` +- Create: `packages/api/src/files/feishu/client.ts` +- Create: `packages/api/src/files/feishu/import.ts` +- Create: `packages/api/src/files/feishu/index.ts` +- Create: `packages/api/src/files/feishu/client.spec.ts` +- Create: `packages/api/src/files/feishu/import.spec.ts` +- Modify: `packages/api/src/files/index.ts` + +- [ ] **Step 1: Write failing client tests** + +Cover: + +- `fetchDocxRawContent` calls `/open-apis/docx/v1/documents//raw_content`. +- `resolveWikiNode` calls `/open-apis/wiki/v2/spaces/get_node?token=`. +- non-zero Feishu `code` throws a permission/fetch error. +- wiki node whose `obj_type` is not `docx` throws unsupported resource. + +Run: `cd packages/api && npx jest src/files/feishu/client.spec.ts --runInBand` +Expected: FAIL because files do not exist. + +- [ ] **Step 2: Implement client and errors** + +Create stable error codes: + +```ts +export type FeishuImportErrorCode = + | 'FEISHU_IMPORT_DISABLED' + | 'FEISHU_AUTH_UNAVAILABLE' + | 'FEISHU_TOKEN_UNAVAILABLE' + | 'FEISHU_OBO_FAILED' + | 'FEISHU_PERMISSION_DENIED' + | 'FEISHU_UNSUPPORTED_RESOURCE' + | 'FEISHU_CONTENT_TOO_LARGE' + | 'FEISHU_FETCH_FAILED'; +``` + +Implement `FeishuImportError extends Error`. + +Implement client with injected `fetchImpl` and base URL normalization. Use `Authorization: Bearer ${accessToken}` and never fetch the pasted document URL directly. + +- [ ] **Step 3: Write failing import service tests** + +Cover: + +- rejects when feature disabled. +- rejects non-OpenID user. +- rejects missing federated access token. +- calls `exchangeOboToken(user, accessToken, scope)`. +- creates `FileSources.text` and `FileContext.message_attachment` records. +- rejects text larger than configured max bytes. +- imports the ArcheBase wiki URL by resolving wiki token to docx token. + +Run: `cd packages/api && npx jest src/files/feishu/import.spec.ts --runInBand` +Expected: FAIL because service does not exist. + +- [ ] **Step 4: Implement import service** + +Create: + +```ts +export async function importFeishuDocs(params: FeishuImportParams): Promise; +``` + +Dependencies: + +```ts +type FeishuImportDeps = { + createFile: (file: FeishuCreateFileInput, save?: boolean) => Promise; + exchangeOboToken: (user: FeishuUser, accessToken: string, scope: string) => Promise<{ access_token: string }>; + getRetentionExpiry?: () => Promise<{ expiresAt?: Date; expiredAt?: Date }>; + fetchImpl?: typeof fetch; +}; +``` + +Use `extractFeishuDocUrls(urls.join('\n'), { allowedHostSuffixes })`, normalize content to markdown as: + +```md +# + +<content> +``` + +Create file IDs with `uuid.v4()`, filename as sanitized title plus `.md`, filepath as `feishu://<type>/<token>`, type `text/markdown`, textFormat `text`, source `FileSources.text`, embedded `false`, usage `0`, and tenant/user from request. + +- [ ] **Step 5: Export and verify** + +Export from `packages/api/src/files/feishu/index.ts` and `packages/api/src/files/index.ts`. + +Run: + +```bash +cd packages/api && npx jest src/files/feishu/client.spec.ts src/files/feishu/import.spec.ts --runInBand +``` + +Expected: PASS. + +Commit: `git add packages/api/src/files/feishu packages/api/src/files/index.ts && git commit -m "feat: import feishu docs as text files"` + +## Task 4: Legacy API Route And Startup Config + +**Files:** +- Create: `api/server/controllers/FeishuController.js` +- Create: `api/server/routes/feishu.js` +- Create: `api/server/routes/feishu.spec.js` +- Modify: `api/server/routes/index.js` +- Modify: `api/server/index.js` +- Modify: `api/server/experimental.js` +- Modify: `api/server/routes/config.js` +- Modify: `packages/data-provider/src/config.ts` + +- [ ] **Step 1: Write failing route tests** + +Cover: + +- `POST /docs/import` requires JWT middleware user. +- disabled config returns 403. +- successful request returns `{ files, skipped }`. +- service error code maps to HTTP status and JSON `code`. + +Run: `cd api && npx jest server/routes/feishu.spec.js --runInBand` +Expected: FAIL because route does not exist. + +- [ ] **Step 2: Implement thin controller and route** + +Controller calls `@librechat/api.importFeishuDocs` with: + +- `req.body.urls` +- `req.user` +- `process.env.FEISHU_DOC_IMPORT_ENABLED` +- `process.env.FEISHU_DOC_OBO_SCOPE` +- `process.env.FEISHU_DOC_API_BASE_URL` +- `process.env.FEISHU_DOC_ALLOWED_HOSTS` +- `process.env.FEISHU_DOC_IMPORT_MAX_BYTES` +- `db.createFile` +- `exchangeOboToken` +- `getRetentionExpiry(req)` + +Route applies `requireJwtAuth`, `configMiddleware`, `checkBan`, and `uaParser`, then `POST /docs/import`. + +- [ ] **Step 3: Register route and config** + +Add `feishu` to `api/server/routes/index.js`, mount before `/api` 404: + +```js +app.use('/api/feishu', routes.feishu); +``` + +Expose `feishuDocImportEnabled` in startup config and type it in `TStartupConfig`. + +- [ ] **Step 4: Verify route tests** + +Run: `cd api && npx jest server/routes/feishu.spec.js server/routes/__tests__/config.spec.js --runInBand` +Expected: PASS. + +Commit: `git add api/server/controllers/FeishuController.js api/server/routes/feishu.js api/server/routes/feishu.spec.js api/server/routes/index.js api/server/index.js api/server/experimental.js api/server/routes/config.js packages/data-provider/src/config.ts && git commit -m "feat: expose feishu doc import api"` + +## Task 5: Frontend Pre-Send Import + +**Files:** +- Create: `client/src/data-provider/Feishu/mutations.ts` +- Create: `client/src/data-provider/Feishu/index.ts` +- Create: `client/src/hooks/Messages/useFeishuDocImport.ts` +- Create: `client/src/hooks/Messages/useFeishuDocImport.spec.tsx` +- Create: `client/src/hooks/Messages/useSubmitMessage.spec.tsx` +- Modify: `client/src/data-provider/index.ts` +- Modify: `client/src/hooks/Messages/useSubmitMessage.ts` +- Modify: `client/src/locales/en/translation.json` + +- [ ] **Step 1: Write failing hook tests** + +Cover: + +- disabled startup config returns no files and does not call mutation. +- no supported URL returns no files and does not call mutation. +- supported ArcheBase wiki URL calls mutation with only the URL list. +- returned files become `TMessage['files']` refs. +- mutation error throws a user-visible error. + +Run: `cd client && npx jest src/hooks/Messages/useFeishuDocImport.spec.tsx --runInBand` +Expected: FAIL because hook does not exist. + +- [ ] **Step 2: Implement data-provider mutation and hook** + +Use `useMutation([MutationKeys.feishuDocImport], ...)` and `dataService.importFeishuDocs`. + +Hook behavior: + +```ts +const links = extractFeishuDocUrls(text); +if (!startupConfig?.feishuDocImportEnabled || links.length === 0) return []; +const response = await importMutation.mutateAsync({ urls: links.map((link) => link.url) }); +return response.files.map((file) => ({ + file_id: file.file_id, + filepath: file.filepath, + type: file.type ?? '', + height: file.height, + width: file.width, +})); +``` + +Show info/success/error toast strings from English locale. + +- [ ] **Step 3: Write failing submit tests** + +Cover: + +- submit without Feishu URLs calls `ask({ text })` normally. +- submit with imported files calls `ask({ text }, { overrideFiles })`. +- failed import does not call `ask` or `methods.reset`. + +Run: `cd client && npx jest src/hooks/Messages/useSubmitMessage.spec.tsx --runInBand` +Expected: FAIL before `useSubmitMessage` integration. + +- [ ] **Step 4: Integrate in `useSubmitMessage`** + +Make `submitMessage` async, call `importFeishuDocsForMessage(data.text)` before `ask`, pass `overrideFiles` only when imported files exist, and reset the form after successful `ask`. + +- [ ] **Step 5: Verify frontend tests** + +Run: + +```bash +cd client && npx jest src/hooks/Messages/useFeishuDocImport.spec.tsx src/hooks/Messages/useSubmitMessage.spec.tsx --runInBand +``` + +Expected: PASS. + +Commit: `git add client/src/data-provider/Feishu client/src/data-provider/index.ts client/src/hooks/Messages/useFeishuDocImport.ts client/src/hooks/Messages/useFeishuDocImport.spec.tsx client/src/hooks/Messages/useSubmitMessage.ts client/src/hooks/Messages/useSubmitMessage.spec.tsx client/src/locales/en/translation.json && git commit -m "feat: import feishu links before send"` + +## Task 6: Build And Final Verification + +**Files:** +- Verify all changed files. + +- [ ] **Step 1: Build shared packages** + +Run: + +```bash +npm run build:data-provider +npm run build:api +``` + +Expected: both commands exit 0. Existing repository diagnostics may print, but no new Feishu-specific TypeScript failures should appear. + +- [ ] **Step 2: Run targeted tests** + +Run: + +```bash +cd packages/data-provider && npx jest src/feishu.spec.ts --runInBand +cd ../api && npx jest src/files/feishu/client.spec.ts src/files/feishu/import.spec.ts --runInBand +cd ../../api && npx jest server/routes/feishu.spec.js --runInBand +cd ../client && npx jest src/hooks/Messages/useFeishuDocImport.spec.tsx src/hooks/Messages/useSubmitMessage.spec.tsx --runInBand +``` + +Expected: all targeted tests pass. + +- [ ] **Step 3: Inspect git state** + +Run: `git status --short` + +Expected: Feishu-related files are clean after commits; pre-existing unrelated dirty files may remain: + +- `api/server/services/Files/Code/process.spec.js` +- `packages/api/src/agents/handlers.spec.ts` +- old untracked `docs/superpowers/plans/2026-06-05-custom-endpoint-memory-bridge.md` + +- [ ] **Step 4: Push and create PR to fork** + +Push branch `archebase` to remote `archebase`, then create a PR targeting the fork's `archebase` branch. Do not include unrelated dirty files in commits. diff --git a/docs/superpowers/specs/2026-06-09-feishu-doc-url-import-design.md b/docs/superpowers/specs/2026-06-09-feishu-doc-url-import-design.md new file mode 100644 index 00000000000..b6a9637a285 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-feishu-doc-url-import-design.md @@ -0,0 +1,178 @@ +# Feishu Doc URL Import Design + +Date: 2026-06-09 + +## Goal + +Users can paste a Feishu cloud document URL directly into a chat message. LibreChat detects supported Feishu document links, imports the document content through the user's ArcheGate-authenticated session, and sends the model the document text as normal file context. + +The first version intentionally has no popup, picker, search UI, or separate Feishu login. The user experience is "paste the document link and send." + +## Scope + +In scope: + +- Detect Feishu `docx` and `wiki` URLs in message text before the message is sent. +- Import matching documents through a backend API using the current user's OpenID/ArcheGate session. +- Exchange the user's federated OpenID access token through the existing OBO flow. +- Read the Feishu document as user-authorized content. +- Save imported content as an existing LibreChat text file record with `FileSources.text`. +- Attach the imported file to the outgoing message so existing `extractFileContext` behavior injects the content. +- Surface clear user-facing errors for unsupported URL types, missing auth, permission denial, token exchange failure, and oversized content. + +Out of scope for the first version: + +- Feishu document picker/search UI. +- Manual import popup. +- Direct browser-side Feishu API access. +- Bot-token or `lark-cli` based runtime access. +- Editing Feishu documents. +- Reading sheets, bitables, slides, comments, media, or embedded child resources. +- Model tool calls that read Feishu documents on demand after a message has started. + +## Authentication + +Feishu document access must go through ArcheGate/OBO. Runtime code must not rely on `lark-cli`, app secrets, a separate Feishu OAuth flow, or a token copied into the browser. + +The backend accepts imports only when: + +- The user is authenticated via OpenID. +- `OPENID_REUSE_TOKENS=true`. +- `req.user.federatedTokens.access_token` exists. +- Feishu document import is enabled by server config. + +The backend uses the existing `OboTokenService.exchangeOboToken()` pattern: + +1. Use the user's federated OpenID access token as the assertion. +2. Request the configured Feishu document scope/audience from ArcheGate. +3. Cache the exchanged token using the existing OBO cache behavior. +4. Use the exchanged token to call the configured Feishu document API base URL. + +Config values: + +- `FEISHU_DOC_IMPORT_ENABLED`: enables URL import. +- `FEISHU_DOC_OBO_SCOPE`: scope/audience requested from ArcheGate for Feishu document access. +- `FEISHU_DOC_API_BASE_URL`: Feishu OpenAPI or ArcheGate proxy base URL. +- `FEISHU_DOC_ALLOWED_HOSTS`: comma-separated document URL host suffixes, defaulting to `feishu.cn,larksuite.com`. +- `FEISHU_DOC_IMPORT_MAX_BYTES`: maximum extracted text bytes, default aligned with the existing 15 MB text storage limit. + +## Architecture + +### Frontend + +Add a lightweight "URL pre-import" step to the chat send flow: + +- Scan the outgoing message text for supported Feishu document URLs. +- If no supported URL exists, keep the current send path unchanged. +- If supported URLs exist, call a backend import endpoint before sending. +- Add returned file records to the same attachment collection used by normal uploads. +- Continue sending the message with those file IDs. + +The attachment UI can show the imported document as a normal file chip. This keeps user feedback visible without adding a modal flow. + +### Shared Data Provider + +Add typed endpoint and data-service methods for Feishu imports: + +- Request: explicit URL list plus the upload metadata needed to create message attachments. +- Response: imported LibreChat file records and any skipped URLs with reasons. + +Endpoint constants and data-service wrappers belong in `packages/data-provider` so frontend and backend stay aligned. + +### Backend + +Keep new backend logic in `packages/api` where possible, with a thin `/api` route wrapper: + +- URL parser: extracts Feishu `docx` tokens and `wiki` node tokens from message text. +- Token service wrapper: validates OpenID reuse requirements and calls `exchangeOboToken`. +- Feishu document client: reads document title/body through the configured API base URL. +- Import service: normalizes content to markdown/plain text and creates a `FileSources.text` file record. +- Route handler: validates input, enforces auth, calls the import service, and returns created files. + +The legacy `api` layer should only register the route, pass request context, and send HTTP responses. + +## URL Handling + +Supported: + +- `https://*.feishu.cn/docx/<token>` +- `https://*.larksuite.com/docx/<token>` +- `https://*.feishu.cn/wiki/<node_token>` +- `https://*.larksuite.com/wiki/<node_token>` +- Equivalent host suffixes listed in `FEISHU_DOC_ALLOWED_HOSTS`. + +Example supported URL: + +- `https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG` + +Unsupported URLs are ignored unless the message contains only unsupported Feishu links, in which case the frontend should show a clear unsupported-resource error. + +Wiki links are resolved to their backing document object before fetching content. If a wiki node points to a non-docx resource, the import fails for that URL with an unsupported-resource message. + +## Data Flow + +1. User pastes a Feishu document URL into the message box and sends. +2. Frontend scans message text and finds supported Feishu URLs. +3. Frontend calls `POST /api/feishu/docs/import` with the URLs and upload metadata. +4. Backend checks feature flags and OpenID/OBO prerequisites. +5. Backend exchanges the user's ArcheGate token for the configured Feishu document token. +6. Backend reads the Feishu document title and textual content. +7. Backend creates a LibreChat file record: + - `source: FileSources.text` + - `context: FileContext.message_attachment` + - `filename: <document title>.md` + - `type: text/markdown` + - `text: <normalized document content>` + - `bytes: Buffer.byteLength(text, 'utf8')` + - `user` and `tenantId` from the request user +8. Frontend adds the returned file to the outgoing message attachment set. +9. Existing message processing calls `extractFileContext`, which injects the imported document text into the model context. + +## Error Handling + +Backend errors should be specific and map to stable client messages: + +- Feature disabled: Feishu document import is not enabled. +- Auth unavailable: OpenID token reuse is not enabled or the user is not an OpenID user. +- Token unavailable: the current session has no reusable ArcheGate access token. +- OBO failure: ArcheGate token exchange failed. +- Permission denied: the user cannot access the Feishu document. +- Unsupported resource: the URL is not a supported Feishu docx/wiki document. +- Content too large: extracted text exceeds the configured storage limit. +- Fetch failure: Feishu API returned an unexpected response or temporary error. + +The send flow should fail before the model request starts if a detected supported Feishu URL cannot be imported. This avoids silently answering without the document. + +## Security And Privacy + +- Feishu access tokens stay server-side. +- The imported file is owned by the current LibreChat user and tenant. +- Document content is stored only as the existing text attachment format already used for OCR/document parsing. +- Logs must not include access tokens or full document content. +- URL parsing must avoid SSRF-style arbitrary fetches by calling only configured Feishu API hosts, never the pasted URL directly. + +## Testing + +Backend tests: + +- Parses supported `docx` and `wiki` URLs from mixed message text. +- Ignores duplicate URLs in a single message. +- Rejects unsupported Feishu resource types. +- Requires OpenID user, `OPENID_REUSE_TOKENS`, and federated access token. +- Calls `exchangeOboToken` with the configured Feishu scope. +- Maps permission, token, unsupported resource, and oversized-content errors. +- Creates `FileSources.text` records with expected owner, tenant, filename, type, context, text, and byte count. + +Frontend tests: + +- Message without Feishu URLs sends unchanged. +- Message with supported Feishu URLs calls import before send. +- Imported files are added to the outgoing attachment set. +- Import failure blocks send and shows a clear error. +- Feature flag disabled keeps the send flow unchanged. + +## Rollout + +The feature is off by default. Enable it only after ArcheGate can mint the configured Feishu document token and the deployment has the correct Feishu API base URL. + +This design keeps the first PR small enough to merge into the ArcheBase fork quickly while leaving room for later picker/search support if users need it. diff --git a/packages/api/src/files/feishu/client.spec.ts b/packages/api/src/files/feishu/client.spec.ts new file mode 100644 index 00000000000..4b50ecddd00 --- /dev/null +++ b/packages/api/src/files/feishu/client.spec.ts @@ -0,0 +1,109 @@ +import { FeishuImportError } from './errors'; +import { createFeishuClient } from './client'; + +const jsonResponse = (body: object, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + +describe('Feishu document client', () => { + test('fetches docx raw content with a user access token', async () => { + const fetchImpl = jest.fn(async () => + jsonResponse({ code: 0, data: { content: 'Document body', title: 'Doc title' } }), + ); + const client = createFeishuClient({ + apiBaseUrl: 'https://open.feishu.cn', + accessToken: 'user-token', + fetchImpl, + }); + + await expect(client.fetchDocxRawContent('docToken')).resolves.toEqual({ + content: 'Document body', + title: 'Doc title', + }); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://open.feishu.cn/open-apis/docx/v1/documents/docToken/raw_content', + { + headers: { + Authorization: 'Bearer user-token', + Accept: 'application/json', + }, + }, + ); + }); + + test('resolves wiki node metadata by token', async () => { + const fetchImpl = jest.fn(async () => + jsonResponse({ + code: 0, + data: { + node: { + obj_token: 'docxToken', + obj_type: 'docx', + title: 'Wiki title', + }, + }, + }), + ); + const client = createFeishuClient({ + apiBaseUrl: 'https://open.feishu.cn/', + accessToken: 'user-token', + fetchImpl, + }); + + await expect(client.resolveWikiNode('wikiToken')).resolves.toEqual({ + objToken: 'docxToken', + objType: 'docx', + title: 'Wiki title', + }); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node?token=wikiToken', + { + headers: { + Authorization: 'Bearer user-token', + Accept: 'application/json', + }, + }, + ); + }); + + test('maps Feishu permission errors to a stable import error', async () => { + const fetchImpl = jest.fn(async () => + jsonResponse({ code: 99991663, msg: 'permission denied' }, 403), + ); + const client = createFeishuClient({ + apiBaseUrl: 'https://open.feishu.cn', + accessToken: 'user-token', + fetchImpl, + }); + + await expect(client.fetchDocxRawContent('docToken')).rejects.toMatchObject({ + code: 'FEISHU_PERMISSION_DENIED', + } satisfies Partial<FeishuImportError>); + }); + + test('rejects wiki nodes that do not point to docx documents', async () => { + const fetchImpl = jest.fn(async () => + jsonResponse({ + code: 0, + data: { + node: { + obj_token: 'sheetToken', + obj_type: 'sheet', + title: 'Sheet title', + }, + }, + }), + ); + const client = createFeishuClient({ + apiBaseUrl: 'https://open.feishu.cn', + accessToken: 'user-token', + fetchImpl, + }); + + await expect(client.resolveWikiNode('wikiToken')).rejects.toMatchObject({ + code: 'FEISHU_UNSUPPORTED_RESOURCE', + } satisfies Partial<FeishuImportError>); + }); +}); diff --git a/packages/api/src/files/feishu/client.ts b/packages/api/src/files/feishu/client.ts new file mode 100644 index 00000000000..d4414f5b35b --- /dev/null +++ b/packages/api/src/files/feishu/client.ts @@ -0,0 +1,146 @@ +import { FeishuImportError } from './errors'; + +export type FeishuFetch = (url: string, init?: RequestInit) => Promise<Response>; + +export type FeishuClientOptions = { + apiBaseUrl: string; + accessToken: string; + fetchImpl?: FeishuFetch; +}; + +export type FeishuDocxRawContent = { + content: string; + title?: string; +}; + +export type FeishuWikiNode = { + objToken: string; + objType: string; + title?: string; +}; + +type FeishuNodePayload = { + obj_token?: string; + obj_type?: string; + title?: string; +}; + +type FeishuApiData = { + content?: string; + title?: string; + node?: FeishuNodePayload; +}; + +type FeishuApiEnvelope = { + code?: number; + msg?: string; + data?: FeishuApiData; +}; + +function normalizeBaseUrl(apiBaseUrl: string): string { + return apiBaseUrl.replace(/\/+$/, ''); +} + +function apiUrl(baseUrl: string, path: string): string { + return `${normalizeBaseUrl(baseUrl)}${path}`; +} + +function mapFetchFailure(status: number, message: string): FeishuImportError { + if (status === 401 || status === 403) { + return new FeishuImportError('FEISHU_PERMISSION_DENIED', message, 403); + } + + return new FeishuImportError('FEISHU_FETCH_FAILED', message, 502); +} + +async function parseFeishuResponse(response: Response): Promise<FeishuApiEnvelope> { + let body: FeishuApiEnvelope; + try { + body = (await response.json()) as FeishuApiEnvelope; + } catch { + throw new FeishuImportError('FEISHU_FETCH_FAILED', 'Feishu returned a non-JSON response', 502); + } + + if (!response.ok) { + throw mapFetchFailure(response.status, body.msg ?? `Feishu request failed: ${response.status}`); + } + + if (body.code != null && body.code !== 0) { + throw mapFetchFailure(response.status, body.msg ?? `Feishu request failed: ${body.code}`); + } + + return body; +} + +export function createFeishuClient({ + apiBaseUrl, + accessToken, + fetchImpl = fetch, +}: FeishuClientOptions) { + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }; + + const fetchDocxRawContent = async (documentToken: string): Promise<FeishuDocxRawContent> => { + const response = await fetchImpl( + apiUrl( + apiBaseUrl, + `/open-apis/docx/v1/documents/${encodeURIComponent(documentToken)}/raw_content`, + ), + { headers }, + ); + const body = await parseFeishuResponse(response); + const content = body.data?.content; + if (typeof content !== 'string') { + throw new FeishuImportError( + 'FEISHU_FETCH_FAILED', + 'Feishu raw content response did not include text content', + 502, + ); + } + + return { + content, + title: body.data?.title, + }; + }; + + const resolveWikiNode = async (nodeToken: string): Promise<FeishuWikiNode> => { + const response = await fetchImpl( + apiUrl( + apiBaseUrl, + `/open-apis/wiki/v2/spaces/get_node?token=${encodeURIComponent(nodeToken)}`, + ), + { headers }, + ); + const body = await parseFeishuResponse(response); + const node = body.data?.node; + if (!node?.obj_token || !node.obj_type) { + throw new FeishuImportError( + 'FEISHU_FETCH_FAILED', + 'Feishu wiki node response did not include object metadata', + 502, + ); + } + + if (node.obj_type !== 'docx') { + throw new FeishuImportError( + 'FEISHU_UNSUPPORTED_RESOURCE', + `Feishu wiki node points to unsupported resource type: ${node.obj_type}`, + 400, + ); + } + + return { + objToken: node.obj_token, + objType: node.obj_type, + title: node.title, + }; + }; + + return { + fetchDocxRawContent, + resolveWikiNode, + }; +} diff --git a/packages/api/src/files/feishu/errors.ts b/packages/api/src/files/feishu/errors.ts new file mode 100644 index 00000000000..bf3c9047d1b --- /dev/null +++ b/packages/api/src/files/feishu/errors.ts @@ -0,0 +1,22 @@ +export type FeishuImportErrorCode = + | 'FEISHU_IMPORT_DISABLED' + | 'FEISHU_AUTH_UNAVAILABLE' + | 'FEISHU_TOKEN_UNAVAILABLE' + | 'FEISHU_OBO_FAILED' + | 'FEISHU_PERMISSION_DENIED' + | 'FEISHU_UNSUPPORTED_RESOURCE' + | 'FEISHU_CONTENT_TOO_LARGE' + | 'FEISHU_FETCH_FAILED'; + +export class FeishuImportError extends Error { + code: FeishuImportErrorCode; + + statusCode: number; + + constructor(code: FeishuImportErrorCode, message: string, statusCode = 500) { + super(message); + this.name = 'FeishuImportError'; + this.code = code; + this.statusCode = statusCode; + } +} diff --git a/packages/api/src/files/feishu/import.spec.ts b/packages/api/src/files/feishu/import.spec.ts new file mode 100644 index 00000000000..8f830411f15 --- /dev/null +++ b/packages/api/src/files/feishu/import.spec.ts @@ -0,0 +1,179 @@ +import { FileContext, FileSources, megabyte } from 'librechat-data-provider'; +import type { TFile } from 'librechat-data-provider'; +import { FeishuImportError } from './errors'; +import { importFeishuDocs } from './import'; + +const openIdUser = { + id: 'user-1', + provider: 'openid', + openidId: 'oidc-subject', + tenantId: 'tenant-1', + federatedTokens: { + access_token: 'archegate-token', + }, +}; + +const createResponse = (body: object): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +function createBaseParams() { + const createFile = jest.fn(async (file: Partial<TFile>) => ({ + object: 'file', + usage: 0, + embedded: false, + ...file, + })) as jest.MockedFunction<(file: Partial<TFile>, save?: boolean) => Promise<TFile>>; + const exchangeOboToken = jest.fn(async () => ({ + access_token: 'feishu-user-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'Feishu.Docs.Read', + })); + const fetchImpl = jest.fn(async (url: string) => { + if (url.includes('/wiki/v2/spaces/get_node')) { + return createResponse({ + code: 0, + data: { + node: { + obj_token: 'docxToken', + obj_type: 'docx', + title: 'Wiki title', + }, + }, + }); + } + + return createResponse({ + code: 0, + data: { + content: 'Document body', + }, + }); + }); + + return { + params: { + urls: ['https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG'], + user: openIdUser, + config: { + enabled: true, + oboScope: 'Feishu.Docs.Read', + apiBaseUrl: 'https://open.feishu.cn', + allowedHostSuffixes: ['feishu.cn'], + maxBytes: 15 * megabyte, + }, + deps: { + createFile, + exchangeOboToken, + fetchImpl, + getRetentionExpiry: async () => ({ expiredAt: new Date('2026-06-10T00:00:00.000Z') }), + }, + }, + createFile, + exchangeOboToken, + fetchImpl, + }; +} + +describe('importFeishuDocs', () => { + test('rejects when Feishu document import is disabled', async () => { + const { params } = createBaseParams(); + + await expect( + importFeishuDocs({ + ...params, + config: { + ...params.config, + enabled: false, + }, + }), + ).rejects.toMatchObject({ + code: 'FEISHU_IMPORT_DISABLED', + } satisfies Partial<FeishuImportError>); + }); + + test('requires an OpenID user with reusable ArcheGate token', async () => { + const { params } = createBaseParams(); + + await expect( + importFeishuDocs({ + ...params, + user: { + id: 'user-1', + provider: 'local', + }, + }), + ).rejects.toMatchObject({ + code: 'FEISHU_AUTH_UNAVAILABLE', + } satisfies Partial<FeishuImportError>); + + await expect( + importFeishuDocs({ + ...params, + user: { + id: 'user-1', + provider: 'openid', + openidId: 'oidc-subject', + }, + }), + ).rejects.toMatchObject({ + code: 'FEISHU_TOKEN_UNAVAILABLE', + } satisfies Partial<FeishuImportError>); + }); + + test('exchanges the ArcheGate token before reading Feishu content', async () => { + const { params, exchangeOboToken } = createBaseParams(); + + await importFeishuDocs(params); + + expect(exchangeOboToken).toHaveBeenCalledWith( + openIdUser, + 'archegate-token', + 'Feishu.Docs.Read', + ); + }); + + test('imports the ArcheBase wiki URL as a LibreChat text attachment', async () => { + const { params, createFile } = createBaseParams(); + + const result = await importFeishuDocs(params); + + expect(result.skipped).toEqual([]); + expect(result.files).toHaveLength(1); + expect(createFile).toHaveBeenCalledWith( + expect.objectContaining({ + user: 'user-1', + tenantId: 'tenant-1', + source: FileSources.text, + context: FileContext.message_attachment, + filename: 'Wiki title.md', + filepath: 'feishu://wiki/QcXRwepEGiApKykeshKc0T5KnGG', + type: 'text/markdown', + text: '# Wiki title\n\nDocument body', + textFormat: 'text', + bytes: Buffer.byteLength('# Wiki title\n\nDocument body', 'utf8'), + expiredAt: new Date('2026-06-10T00:00:00.000Z'), + }), + true, + ); + }); + + test('rejects content that exceeds the configured storage limit', async () => { + const { params } = createBaseParams(); + + await expect( + importFeishuDocs({ + ...params, + config: { + ...params.config, + maxBytes: 5, + }, + }), + ).rejects.toMatchObject({ + code: 'FEISHU_CONTENT_TOO_LARGE', + } satisfies Partial<FeishuImportError>); + }); +}); diff --git a/packages/api/src/files/feishu/import.ts b/packages/api/src/files/feishu/import.ts new file mode 100644 index 00000000000..9ebc78a0d7f --- /dev/null +++ b/packages/api/src/files/feishu/import.ts @@ -0,0 +1,206 @@ +import { v4 } from 'uuid'; +import { + FileContext, + FileSources, + extractFeishuDocUrls, + megabyte, +} from 'librechat-data-provider'; +import type { FeishuDocImportResponse, TFile } from 'librechat-data-provider'; +import { createFeishuClient } from './client'; +import type { FeishuFetch } from './client'; +import { FeishuImportError } from './errors'; + +export type FeishuImportUser = { + id: string; + provider?: string; + openidId?: string; + tenantId?: string; + federatedTokens?: { + access_token?: string; + }; +}; + +export type FeishuImportConfig = { + enabled: boolean; + oboScope: string; + apiBaseUrl: string; + allowedHostSuffixes?: string[]; + maxBytes?: number; +}; + +export type FeishuCreateFileInput = Omit<TFile, '_id' | '__v' | 'createdAt' | 'updatedAt'> & { + expiredAt?: Date; +}; + +export type FeishuImportDeps = { + createFile: (file: FeishuCreateFileInput, save?: boolean) => Promise<TFile>; + exchangeOboToken: ( + user: FeishuImportUser, + accessToken: string, + scope: string, + ) => Promise<{ access_token?: string }>; + getRetentionExpiry?: () => Promise<{ expiresAt?: Date; expiredAt?: Date }>; + fetchImpl?: FeishuFetch; +}; + +export type FeishuImportParams = { + urls: string[]; + user: FeishuImportUser; + config: FeishuImportConfig; + deps: FeishuImportDeps; +}; + +const DEFAULT_MAX_BYTES = 15 * megabyte; + +function sanitizeTitle(value: string): string { + const sanitized = value + .replace(/[\\/:*?"<>|]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + return sanitized || 'Feishu document'; +} + +function buildMarkdown(title: string, content: string): string { + return `# ${title}\n\n${content.trim()}`; +} + +async function exchangeFeishuToken({ + user, + accessToken, + scope, + deps, +}: { + user: FeishuImportUser; + accessToken: string; + scope: string; + deps: FeishuImportDeps; +}): Promise<string> { + try { + const tokenResponse = await deps.exchangeOboToken(user, accessToken, scope); + if (!tokenResponse.access_token) { + throw new FeishuImportError( + 'FEISHU_OBO_FAILED', + 'ArcheGate OBO exchange returned no Feishu access token', + 502, + ); + } + return tokenResponse.access_token; + } catch (error) { + if (error instanceof FeishuImportError) { + throw error; + } + const message = error instanceof Error ? error.message : 'ArcheGate OBO exchange failed'; + throw new FeishuImportError('FEISHU_OBO_FAILED', message, 502); + } +} + +function validateImportParams({ user, config }: Pick<FeishuImportParams, 'user' | 'config'>) { + if (!config.enabled) { + throw new FeishuImportError('FEISHU_IMPORT_DISABLED', 'Feishu document import is disabled', 403); + } + + if (user.provider !== 'openid' || !user.openidId) { + throw new FeishuImportError( + 'FEISHU_AUTH_UNAVAILABLE', + 'Feishu document import requires OpenID authentication', + 403, + ); + } + + if (!user.federatedTokens?.access_token) { + throw new FeishuImportError( + 'FEISHU_TOKEN_UNAVAILABLE', + 'No reusable ArcheGate access token is available for Feishu import', + 401, + ); + } + + if (!config.oboScope || !config.apiBaseUrl) { + throw new FeishuImportError( + 'FEISHU_FETCH_FAILED', + 'Feishu document import is missing required server configuration', + 500, + ); + } +} + +export async function importFeishuDocs({ + urls, + user, + config, + deps, +}: FeishuImportParams): Promise<FeishuDocImportResponse> { + validateImportParams({ user, config }); + + const links = extractFeishuDocUrls(urls.join('\n'), { + allowedHostSuffixes: config.allowedHostSuffixes, + }); + if (links.length === 0) { + return { + files: [], + skipped: urls.map((url) => ({ url, reason: 'unsupported_resource' })), + }; + } + + const feishuToken = await exchangeFeishuToken({ + user, + accessToken: user.federatedTokens?.access_token ?? '', + scope: config.oboScope, + deps, + }); + const client = createFeishuClient({ + apiBaseUrl: config.apiBaseUrl, + accessToken: feishuToken, + fetchImpl: deps.fetchImpl, + }); + + const files: TFile[] = []; + const maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES; + const retentionExpiry = (await deps.getRetentionExpiry?.()) ?? {}; + + for (const link of links) { + const wikiNode = link.type === 'wiki' ? await client.resolveWikiNode(link.token) : undefined; + const documentToken = wikiNode?.objToken ?? link.token; + const rawContent = await client.fetchDocxRawContent(documentToken); + const title = sanitizeTitle(wikiNode?.title ?? rawContent.title ?? `Feishu document ${documentToken}`); + const text = buildMarkdown(title, rawContent.content); + const bytes = Buffer.byteLength(text, 'utf8'); + + if (bytes > maxBytes) { + throw new FeishuImportError( + 'FEISHU_CONTENT_TOO_LARGE', + `Imported Feishu document exceeds the ${Math.ceil(maxBytes / megabyte)}MB storage limit`, + 413, + ); + } + + const file = await deps.createFile( + { + user: user.id, + file_id: v4(), + bytes, + filepath: `feishu://${link.type}/${link.token}`, + filename: `${title}.md`, + source: FileSources.text, + type: 'text/markdown', + context: FileContext.message_attachment, + tenantId: user.tenantId, + text, + textFormat: 'text', + embedded: false, + object: 'file', + usage: 0, + ...retentionExpiry, + }, + true, + ); + + files.push(file); + } + + return { + files, + skipped: [], + }; +} diff --git a/packages/api/src/files/feishu/index.ts b/packages/api/src/files/feishu/index.ts new file mode 100644 index 00000000000..56115ee252d --- /dev/null +++ b/packages/api/src/files/feishu/index.ts @@ -0,0 +1,3 @@ +export * from './client'; +export * from './errors'; +export * from './import'; diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index 0e9a23ff59d..51f9a29e486 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -5,6 +5,7 @@ export * from './context'; export * from './documents/crud'; export * from './encode'; export * from './filter'; +export * from './feishu'; export * from './mistral/crud'; export * from './ocr'; export * from './parse'; diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 6cec5b0b518..e6bf8a7a3e5 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -503,3 +503,6 @@ export const getAllEffectivePermissions = (resourceType: ResourceType) => // SharePoint Graph API Token export const graphToken = (scopes: string) => `${BASE_URL}/api/auth/graph-token?scopes=${encodeURIComponent(scopes)}`; + +// Feishu Document Import +export const feishuDocImport = () => `${BASE_URL}/api/feishu/docs/import`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 5d3404e3482..9357afb22e2 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1165,6 +1165,7 @@ export type TStartupConfig = { bundlerURL?: string; staticBundlerURL?: string; sharePointFilePickerEnabled?: boolean; + feishuDocImportEnabled?: boolean; sharePointBaseUrl?: string; sharePointPickerGraphScope?: string; sharePointPickerSharePointScope?: string; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 98e7a684537..7208c6c741f 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -1301,6 +1301,13 @@ export function getGraphApiToken(params: q.GraphTokenParams): Promise<q.GraphTok return request.get(endpoints.graphToken(params.scopes)); } +// Feishu Document Import +export function importFeishuDocs( + data: q.FeishuDocImportRequest, +): Promise<q.FeishuDocImportResponse> { + return request.post(endpoints.feishuDocImport(), data); +} + export function getDomainServerBaseUrl(): string { return `${endpoints.apiBaseUrl()}/api`; } diff --git a/packages/data-provider/src/feishu.spec.ts b/packages/data-provider/src/feishu.spec.ts new file mode 100644 index 00000000000..ac4e8caeaec --- /dev/null +++ b/packages/data-provider/src/feishu.spec.ts @@ -0,0 +1,48 @@ +import { + extractFeishuDocUrls, + isSupportedFeishuDocUrl, + parseFeishuAllowedHosts, +} from './feishu'; + +describe('Feishu document URL parsing', () => { + test('extracts the ArcheBase wiki URL shape', () => { + expect( + extractFeishuDocUrls( + 'read https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG please', + ), + ).toEqual([ + { + url: 'https://archebase.feishu.cn/wiki/QcXRwepEGiApKykeshKc0T5KnGG', + host: 'archebase.feishu.cn', + type: 'wiki', + token: 'QcXRwepEGiApKykeshKc0T5KnGG', + }, + ]); + }); + + test('deduplicates equivalent document links', () => { + const text = + 'https://archebase.feishu.cn/docx/AbCd123?from=chat https://archebase.feishu.cn/docx/AbCd123'; + + expect(extractFeishuDocUrls(text)).toHaveLength(1); + }); + + test('rejects unsupported Feishu resource paths', () => { + expect(isSupportedFeishuDocUrl('https://archebase.feishu.cn/sheets/abc')).toBe(false); + }); + + test('honors configured host suffixes', () => { + expect( + extractFeishuDocUrls('https://docs.example.com/wiki/Wiki123', { + allowedHostSuffixes: ['example.com'], + }), + ).toHaveLength(1); + }); + + test('normalizes comma-separated allowed host config', () => { + expect(parseFeishuAllowedHosts(' feishu.cn, larksuite.com ,,')).toEqual([ + 'feishu.cn', + 'larksuite.com', + ]); + }); +}); diff --git a/packages/data-provider/src/feishu.ts b/packages/data-provider/src/feishu.ts new file mode 100644 index 00000000000..46801ce7916 --- /dev/null +++ b/packages/data-provider/src/feishu.ts @@ -0,0 +1,105 @@ +export type FeishuDocType = 'docx' | 'wiki'; + +export type FeishuDocUrl = { + url: string; + host: string; + type: FeishuDocType; + token: string; +}; + +export type FeishuDocUrlOptions = { + allowedHostSuffixes?: string[]; +}; + +const DEFAULT_ALLOWED_HOST_SUFFIXES = ['feishu.cn', 'larksuite.com']; +const URL_PATTERN = /https:\/\/[^\s<>"']+/g; +const TRAILING_PUNCTUATION = /[),.;!?,。;!?)]+$/; + +export function parseFeishuAllowedHosts(value?: string): string[] { + if (!value) { + return [...DEFAULT_ALLOWED_HOST_SUFFIXES]; + } + + return value + .split(',') + .map((host) => host.trim().toLowerCase()) + .filter((host) => host.length > 0); +} + +function normalizeAllowedHosts(options?: FeishuDocUrlOptions): string[] { + const hosts = options?.allowedHostSuffixes?.length + ? options.allowedHostSuffixes + : DEFAULT_ALLOWED_HOST_SUFFIXES; + + return hosts.map((host) => host.trim().toLowerCase()).filter((host) => host.length > 0); +} + +function hostMatches(host: string, allowedHostSuffixes: string[]): boolean { + return allowedHostSuffixes.some( + (suffix) => host === suffix || host.endsWith(`.${suffix}`), + ); +} + +function trimUrlCandidate(candidate: string): string { + return candidate.replace(TRAILING_PUNCTUATION, ''); +} + +function parseFeishuDocUrl(url: string, options?: FeishuDocUrlOptions): FeishuDocUrl | null { + let parsed: URL; + try { + parsed = new URL(trimUrlCandidate(url)); + } catch { + return null; + } + + if (parsed.protocol !== 'https:') { + return null; + } + + const host = parsed.hostname.toLowerCase(); + if (!hostMatches(host, normalizeAllowedHosts(options))) { + return null; + } + + const [, type, token] = parsed.pathname.split('/'); + if ((type !== 'docx' && type !== 'wiki') || !token) { + return null; + } + + return { + url: parsed.href, + host, + type, + token, + }; +} + +export function extractFeishuDocUrls( + text: string, + options?: FeishuDocUrlOptions, +): FeishuDocUrl[] { + const matches = text.match(URL_PATTERN) ?? []; + const seen = new Set<string>(); + const links: FeishuDocUrl[] = []; + + for (const match of matches) { + const parsed = parseFeishuDocUrl(match, options); + if (!parsed) { + continue; + } + + const key = `${parsed.type}:${parsed.token}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + links.push(parsed); + } + + return links; +} + +export function isSupportedFeishuDocUrl(url: string, options?: FeishuDocUrlOptions): boolean { + return parseFeishuDocUrl(url, options) != null; +} diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index bb166d975ae..287742dbb27 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -48,6 +48,7 @@ export { default as createPayload } from './createPayload'; // export * from './react-query/react-query-service'; /* feedback */ export * from './feedback'; +export * from './feishu'; export * from './parameterSettings'; /* code-execution sandbox */ export * from './codeEnvRef'; diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 7a66cfd80d3..98049d07a88 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -95,6 +95,7 @@ export enum MutationKeys { deleteAgentApiKey = 'deleteAgentApiKey', fileUpload = 'fileUpload', fileDelete = 'fileDelete', + feishuDocImport = 'feishuDocImport', updatePreset = 'updatePreset', deletePreset = 'deletePreset', loginUser = 'loginUser', diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index adbddcd41c5..8472efaf5e7 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -1,6 +1,7 @@ import type { InfiniteData } from '@tanstack/react-query'; import type * as p from '../accessPermissions'; import type * as a from '../types/agents'; +import type * as f from '../types/files'; import type * as s from '../schemas'; import type * as t from '../types'; @@ -241,3 +242,18 @@ export type GraphTokenResponse = { expires_in: number; scope: string; }; + +/* Feishu Document Import */ +export type FeishuDocImportRequest = { + urls: string[]; +}; + +export type FeishuDocImportSkipped = { + url: string; + reason: string; +}; + +export type FeishuDocImportResponse = { + files: f.TFile[]; + skipped: FeishuDocImportSkipped[]; +};