mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(chat): add the ability to download files from the deployed chat (#2280)
* added teams download and chat download file * Removed comments * removed comments * component structure and download all * removed comments * cleanup code * fix empty files case * small fix
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ArrowDown, Download, Loader2, Music } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { ChatFile } from '@/app/chat/components/message/message'
|
||||
|
||||
const logger = createLogger('ChatFileDownload')
|
||||
|
||||
interface ChatFileDownloadProps {
|
||||
file: ChatFile
|
||||
}
|
||||
|
||||
interface ChatFileDownloadAllProps {
|
||||
files: ChatFile[]
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function isAudioFile(mimeType: string, filename: string): boolean {
|
||||
const audioMimeTypes = [
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/mp3',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
]
|
||||
const audioExtensions = ['mp3', 'wav', 'ogg', 'webm', 'aac', 'flac', 'm4a']
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
|
||||
return (
|
||||
audioMimeTypes.some((t) => mimeType.includes(t)) ||
|
||||
(extension ? audioExtensions.includes(extension) : false)
|
||||
)
|
||||
}
|
||||
|
||||
function isImageFile(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
function getFileUrl(file: ChatFile): string {
|
||||
return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}`
|
||||
}
|
||||
|
||||
async function triggerDownload(url: string, filename: string): Promise<void> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
logger.info(`Downloaded: ${filename}`)
|
||||
}
|
||||
|
||||
export function ChatFileDownload({ file }: ChatFileDownloadProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
logger.info(`Initiating download for file: ${file.name}`)
|
||||
const url = getFileUrl(file)
|
||||
await triggerDownload(url, file.name)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
if (file.url) {
|
||||
window.open(file.url, '_blank')
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isAudioFile(file.type, file.name)) {
|
||||
return <Music className='h-4 w-4 text-purple-500' />
|
||||
}
|
||||
if (isImageFile(file.type)) {
|
||||
const ImageIcon = DefaultFileIcon
|
||||
return <ImageIcon className='h-5 w-5' />
|
||||
}
|
||||
const DocumentIcon = getDocumentIcon(file.type, file.name)
|
||||
return <DocumentIcon className='h-5 w-5' />
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleDownload}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
disabled={isDownloading}
|
||||
className='flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center'>{renderIcon()}</div>
|
||||
<div className='min-w-0 flex-1 text-left'>
|
||||
<div className='w-[100px] truncate text-xs'>{file.name}</div>
|
||||
<div className='text-[10px] text-[var(--text-muted)]'>{formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
{isDownloading ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<ArrowDown
|
||||
className={`h-3.5 w-3.5 transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
if (!files || files.length === 0) return null
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
if (isDownloading) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
logger.info(`Initiating download for ${files.length} files`)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
try {
|
||||
const url = getFileUrl(file)
|
||||
await triggerDownload(url, file.name)
|
||||
logger.info(`Downloaded file ${i + 1}/${files.length}: ${file.name}`)
|
||||
|
||||
if (i < files.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isDownloading}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:opacity-50'
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' strokeWidth={2} />
|
||||
) : (
|
||||
<Download className='h-3 w-3' strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
ChatFileDownload,
|
||||
ChatFileDownloadAll,
|
||||
} from '@/app/chat/components/message/components/file-download'
|
||||
import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer'
|
||||
|
||||
export interface ChatAttachment {
|
||||
@@ -13,6 +17,16 @@ export interface ChatAttachment {
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface ChatFile {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
key: string
|
||||
size: number
|
||||
type: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string | Record<string, unknown>
|
||||
@@ -21,6 +35,7 @@ export interface ChatMessage {
|
||||
isInitialMessage?: boolean
|
||||
isStreaming?: boolean
|
||||
attachments?: ChatAttachment[]
|
||||
files?: ChatFile[]
|
||||
}
|
||||
|
||||
function EnhancedMarkdownRenderer({ content }: { content: string }) {
|
||||
@@ -177,6 +192,13 @@ export const ClientChatMessage = memo(
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{message.files && message.files.length > 0 && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{message.files.map((file) => (
|
||||
<ChatFileDownload key={file.id} file={file} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
|
||||
<div className='flex items-center justify-start space-x-2'>
|
||||
{/* Copy Button - Only show when not streaming */}
|
||||
@@ -207,6 +229,10 @@ export const ClientChatMessage = memo(
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* Download All Button - Only show when there are files */}
|
||||
{!message.isStreaming && message.files && (
|
||||
<ChatFileDownloadAll files={message.files} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -221,7 +247,8 @@ export const ClientChatMessage = memo(
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.isStreaming === nextProps.message.isStreaming &&
|
||||
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage
|
||||
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage &&
|
||||
prevProps.message.files?.length === nextProps.message.files?.length
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { isUserFile } from '@/lib/core/utils/display-filters'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { ChatMessage } from '@/app/chat/components/message/message'
|
||||
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
|
||||
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
|
||||
|
||||
const logger = createLogger('UseChatStreaming')
|
||||
|
||||
function extractFilesFromData(
|
||||
data: any,
|
||||
files: ChatFile[] = [],
|
||||
seenIds = new Set<string>()
|
||||
): ChatFile[] {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return files
|
||||
}
|
||||
|
||||
if (isUserFile(data)) {
|
||||
if (!seenIds.has(data.id)) {
|
||||
seenIds.add(data.id)
|
||||
files.push({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
key: data.key,
|
||||
size: data.size,
|
||||
type: data.type,
|
||||
context: data.context,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
for (const item of data) {
|
||||
extractFilesFromData(item, files, seenIds)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
for (const value of Object.values(data)) {
|
||||
extractFilesFromData(value, files, seenIds)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
export interface VoiceSettings {
|
||||
isVoiceEnabled: boolean
|
||||
voiceId: string
|
||||
@@ -185,12 +225,21 @@ export function useChatStreaming() {
|
||||
|
||||
const outputConfigs = streamingOptions?.outputConfigs
|
||||
const formattedOutputs: string[] = []
|
||||
let extractedFiles: ChatFile[] = []
|
||||
|
||||
const formatValue = (value: any): string | null => {
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isUserFile(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
@@ -235,6 +284,26 @@ export function useChatStreaming() {
|
||||
if (!blockOutputs) continue
|
||||
|
||||
const value = getOutputValue(blockOutputs, config.path)
|
||||
|
||||
if (isUserFile(value)) {
|
||||
extractedFiles.push({
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
url: value.url,
|
||||
key: value.key,
|
||||
size: value.size,
|
||||
type: value.type,
|
||||
context: value.context,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const nestedFiles = extractFilesFromData(value)
|
||||
if (nestedFiles.length > 0) {
|
||||
extractedFiles = [...extractedFiles, ...nestedFiles]
|
||||
continue
|
||||
}
|
||||
|
||||
const formatted = formatValue(value)
|
||||
if (formatted) {
|
||||
formattedOutputs.push(formatted)
|
||||
@@ -267,7 +336,7 @@ export function useChatStreaming() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalContent) {
|
||||
if (!finalContent && extractedFiles.length === 0) {
|
||||
if (finalData.error) {
|
||||
if (typeof finalData.error === 'string') {
|
||||
finalContent = finalData.error
|
||||
@@ -291,6 +360,7 @@ export function useChatStreaming() {
|
||||
...msg,
|
||||
isStreaming: false,
|
||||
content: finalContent ?? msg.content,
|
||||
files: extractedFiles.length > 0 ? extractedFiles : undefined,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
|
||||
@@ -228,6 +228,12 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'includeAttachments',
|
||||
title: 'Include Attachments',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: ['read_chat', 'read_channel'] },
|
||||
},
|
||||
// File upload (basic mode)
|
||||
{
|
||||
id: 'attachmentFiles',
|
||||
@@ -320,6 +326,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
files,
|
||||
messageId,
|
||||
reactionType,
|
||||
includeAttachments,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
@@ -332,6 +339,10 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
credential,
|
||||
}
|
||||
|
||||
if ((operation === 'read_chat' || operation === 'read_channel') && includeAttachments) {
|
||||
baseParams.includeAttachments = true
|
||||
}
|
||||
|
||||
// Add files if provided
|
||||
const fileParam = attachmentFiles || files
|
||||
if (fileParam && (operation === 'write_chat' || operation === 'write_channel')) {
|
||||
@@ -437,6 +448,10 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
description: 'Message content. Mention users with <at>userName</at>',
|
||||
},
|
||||
reactionType: { type: 'string', description: 'Emoji reaction (e.g., ❤️, 👍, 😊)' },
|
||||
includeAttachments: {
|
||||
type: 'boolean',
|
||||
description: 'Download and include message attachments',
|
||||
},
|
||||
attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' },
|
||||
files: { type: 'array', description: 'Files to attach (UserFile array)' },
|
||||
},
|
||||
@@ -447,6 +462,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
|
||||
messages: { type: 'json', description: 'Array of message objects' },
|
||||
totalAttachments: { type: 'number', description: 'Total number of attachments' },
|
||||
attachmentTypes: { type: 'json', description: 'Array of attachment content types' },
|
||||
attachments: { type: 'array', description: 'Downloaded message attachments' },
|
||||
updatedContent: {
|
||||
type: 'boolean',
|
||||
description: 'Whether content was successfully updated/sent',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import {
|
||||
downloadAllReferenceAttachments,
|
||||
extractMessageAttachments,
|
||||
fetchHostedContentsForChannelMessage,
|
||||
} from '@/tools/microsoft_teams/utils'
|
||||
@@ -62,18 +63,15 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
throw new Error('Channel ID is required')
|
||||
}
|
||||
|
||||
// URL encode the IDs to handle special characters
|
||||
const encodedTeamId = encodeURIComponent(teamId)
|
||||
const encodedChannelId = encodeURIComponent(channelId)
|
||||
|
||||
// Fetch the most recent messages from the channel
|
||||
const url = `https://graph.microsoft.com/v1.0/teams/${encodedTeamId}/channels/${encodedChannelId}/messages`
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
// Validate access token
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
@@ -87,7 +85,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
// Microsoft Graph API returns messages in a 'value' array
|
||||
const messages = data.value || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
@@ -107,7 +104,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
}
|
||||
}
|
||||
|
||||
// Process messages with attachments
|
||||
const processedMessages = await Promise.all(
|
||||
messages.map(async (message: any, index: number) => {
|
||||
try {
|
||||
@@ -123,7 +119,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
sender = 'System'
|
||||
}
|
||||
|
||||
// Optionally fetch and upload hosted contents
|
||||
let uploaded: any[] = []
|
||||
if (
|
||||
params?.includeAttachments &&
|
||||
@@ -133,12 +128,19 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
messageId
|
||||
) {
|
||||
try {
|
||||
uploaded = await fetchHostedContentsForChannelMessage({
|
||||
const hostedContents = await fetchHostedContentsForChannelMessage({
|
||||
accessToken: params.accessToken,
|
||||
teamId: params.teamId,
|
||||
channelId: params.channelId,
|
||||
messageId,
|
||||
})
|
||||
uploaded.push(...hostedContents)
|
||||
|
||||
const referenceFiles = await downloadAllReferenceAttachments({
|
||||
accessToken: params.accessToken,
|
||||
attachments,
|
||||
})
|
||||
uploaded.push(...referenceFiles)
|
||||
} catch (_e) {
|
||||
uploaded = []
|
||||
}
|
||||
@@ -167,7 +169,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
})
|
||||
)
|
||||
|
||||
// Format the messages into a readable text (no attachment info in content)
|
||||
const formattedMessages = processedMessages
|
||||
.map((message: any) => {
|
||||
const sender = message.sender
|
||||
@@ -179,7 +180,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
// Calculate attachment statistics
|
||||
const allAttachments = processedMessages.flatMap((msg: any) => msg.attachments || [])
|
||||
const attachmentTypes: string[] = []
|
||||
const seenTypes = new Set<string>()
|
||||
@@ -195,7 +195,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
}
|
||||
})
|
||||
|
||||
// Create document metadata
|
||||
const metadata = {
|
||||
teamId: messages[0]?.channelIdentity?.teamId || params?.teamId || '',
|
||||
channelId: messages[0]?.channelIdentity?.channelId || params?.channelId || '',
|
||||
@@ -205,7 +204,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
|
||||
messages: processedMessages,
|
||||
}
|
||||
|
||||
// Flatten uploaded files across all messages for convenience
|
||||
const flattenedUploads = processedMessages.flatMap((m: any) => m.uploadedFiles || [])
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import {
|
||||
downloadAllReferenceAttachments,
|
||||
extractMessageAttachments,
|
||||
fetchHostedContentsForChatMessage,
|
||||
} from '@/tools/microsoft_teams/utils'
|
||||
@@ -43,17 +44,14 @@ export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsRe
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
// Ensure chatId is valid
|
||||
const chatId = params.chatId?.trim()
|
||||
if (!chatId) {
|
||||
throw new Error('Chat ID is required')
|
||||
}
|
||||
// Fetch the most recent messages from the chat
|
||||
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=50&$orderby=createdDateTime desc`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
// Validate access token
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
@@ -67,7 +65,6 @@ export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsRe
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
// Microsoft Graph API returns messages in a 'value' array
|
||||
const messages = data.value || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
@@ -86,24 +83,28 @@ export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsRe
|
||||
}
|
||||
}
|
||||
|
||||
// Process messages with attachments
|
||||
const processedMessages = await Promise.all(
|
||||
messages.map(async (message: any) => {
|
||||
const content = message.body?.content || 'No content'
|
||||
const messageId = message.id
|
||||
|
||||
// Extract attachments without any content processing
|
||||
const attachments = extractMessageAttachments(message)
|
||||
|
||||
// Optionally fetch and upload hosted contents
|
||||
let uploaded: any[] = []
|
||||
if (params?.includeAttachments && params.accessToken && params.chatId && messageId) {
|
||||
try {
|
||||
uploaded = await fetchHostedContentsForChatMessage({
|
||||
const hostedContents = await fetchHostedContentsForChatMessage({
|
||||
accessToken: params.accessToken,
|
||||
chatId: params.chatId,
|
||||
messageId,
|
||||
})
|
||||
uploaded.push(...hostedContents)
|
||||
|
||||
const referenceFiles = await downloadAllReferenceAttachments({
|
||||
accessToken: params.accessToken,
|
||||
attachments,
|
||||
})
|
||||
uploaded.push(...referenceFiles)
|
||||
} catch (_e) {
|
||||
uploaded = []
|
||||
}
|
||||
|
||||
@@ -132,6 +132,115 @@ export async function fetchHostedContentsForChannelMessage(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a reference-type attachment (SharePoint/OneDrive file) from Teams.
|
||||
* These are files shared in Teams that are stored in SharePoint/OneDrive.
|
||||
*
|
||||
*/
|
||||
export async function downloadReferenceAttachment(params: {
|
||||
accessToken: string
|
||||
attachment: MicrosoftTeamsAttachment
|
||||
}): Promise<ToolFileData | null> {
|
||||
const { accessToken, attachment } = params
|
||||
|
||||
if (attachment.contentType !== 'reference') {
|
||||
return null
|
||||
}
|
||||
|
||||
const contentUrl = attachment.contentUrl
|
||||
if (!contentUrl) {
|
||||
logger.warn('Reference attachment has no contentUrl', { attachmentId: attachment.id })
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedUrl = Buffer.from(contentUrl)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
const shareId = `u!${encodedUrl}`
|
||||
|
||||
const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareId}/driveItem`
|
||||
const metadataRes = await fetch(metadataUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!metadataRes.ok) {
|
||||
const errorData = await metadataRes.json().catch(() => ({}))
|
||||
logger.error('Failed to get driveItem metadata via shares API', {
|
||||
status: metadataRes.status,
|
||||
error: errorData,
|
||||
attachmentName: attachment.name,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const driveItem = await metadataRes.json()
|
||||
const mimeType = driveItem.file?.mimeType || 'application/octet-stream'
|
||||
const fileName = attachment.name || driveItem.name || 'attachment'
|
||||
|
||||
const downloadUrl = `https://graph.microsoft.com/v1.0/shares/${shareId}/driveItem/content`
|
||||
const downloadRes = await fetch(downloadUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!downloadRes.ok) {
|
||||
logger.error('Failed to download file content', {
|
||||
status: downloadRes.status,
|
||||
fileName,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const arrayBuffer = await downloadRes.arrayBuffer()
|
||||
const base64Data = Buffer.from(arrayBuffer).toString('base64')
|
||||
|
||||
logger.info('Successfully downloaded reference attachment', {
|
||||
fileName,
|
||||
size: arrayBuffer.byteLength,
|
||||
})
|
||||
|
||||
return {
|
||||
name: fileName,
|
||||
mimeType,
|
||||
data: base64Data,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error downloading reference attachment:', {
|
||||
error,
|
||||
attachmentId: attachment.id,
|
||||
attachmentName: attachment.name,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAllReferenceAttachments(params: {
|
||||
accessToken: string
|
||||
attachments: MicrosoftTeamsAttachment[]
|
||||
}): Promise<ToolFileData[]> {
|
||||
const { accessToken, attachments } = params
|
||||
const results: ToolFileData[] = []
|
||||
|
||||
const referenceAttachments = attachments.filter((att) => att.contentType === 'reference')
|
||||
|
||||
if (referenceAttachments.length === 0) {
|
||||
return results
|
||||
}
|
||||
|
||||
logger.info(`Downloading ${referenceAttachments.length} reference attachment(s)`)
|
||||
|
||||
for (const attachment of referenceAttachments) {
|
||||
const file = await downloadReferenceAttachment({ accessToken, attachment })
|
||||
if (file) {
|
||||
results.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function parseMentions(content: string): ParsedMention[] {
|
||||
const mentions: ParsedMention[] = []
|
||||
const mentionRegex = /<at>([^<]+)<\/at>/gi
|
||||
|
||||
Reference in New Issue
Block a user