chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
MicrosoftTeamsDeleteMessageParams,
|
||||
MicrosoftTeamsDeleteResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const deleteChannelMessageTool: ToolConfig<
|
||||
MicrosoftTeamsDeleteMessageParams,
|
||||
MicrosoftTeamsDeleteResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_delete_channel_message',
|
||||
name: 'Delete Microsoft Teams Channel Message',
|
||||
description: 'Soft delete a message in a Microsoft Teams channel',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings or channel info)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel containing the message (e.g., "19:abc123def456@thread.tacv2" - from channel listings)',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the message to delete (e.g., "1234567890123" - a numeric string from message responses)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the deletion was successful' },
|
||||
deleted: { type: 'boolean', description: 'Confirmation of deletion' },
|
||||
messageId: { type: 'string', description: 'ID of the deleted message' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const teamId = params.teamId?.trim()
|
||||
const channelId = params.channelId?.trim()
|
||||
const messageId = params.messageId?.trim()
|
||||
if (!teamId || !channelId || !messageId) {
|
||||
throw new Error('Team ID, Channel ID, and Message ID are required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/softDelete`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (_response: Response, params?: MicrosoftTeamsDeleteMessageParams) => {
|
||||
// Soft delete returns 204 No Content on success
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
deleted: true,
|
||||
messageId: params?.messageId || '',
|
||||
metadata: {
|
||||
messageId: params?.messageId || '',
|
||||
teamId: params?.teamId || '',
|
||||
channelId: params?.channelId || '',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
MicrosoftTeamsDeleteMessageParams,
|
||||
MicrosoftTeamsDeleteResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const deleteChatMessageTool: ToolConfig<
|
||||
MicrosoftTeamsDeleteMessageParams,
|
||||
MicrosoftTeamsDeleteResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_delete_chat_message',
|
||||
name: 'Delete Microsoft Teams Chat Message',
|
||||
description: 'Soft delete a message in a Microsoft Teams chat',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
chatId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the chat containing the message (e.g., "19:abc123def456@thread.v2" - from chat listings)',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the message to delete (e.g., "1234567890123" - a numeric string from message responses)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the deletion was successful' },
|
||||
deleted: { type: 'boolean', description: 'Confirmation of deletion' },
|
||||
messageId: { type: 'string', description: 'ID of the deleted message' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const chatId = params.chatId?.trim()
|
||||
const messageId = params.messageId?.trim()
|
||||
if (!chatId || !messageId) {
|
||||
throw new Error('Chat ID and Message ID are required')
|
||||
}
|
||||
return '/api/tools/microsoft_teams/delete_chat_message'
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
return {
|
||||
accessToken: params.accessToken,
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (_response: Response, params?: MicrosoftTeamsDeleteMessageParams) => {
|
||||
// Soft delete returns 204 No Content on success
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
deleted: true,
|
||||
messageId: params?.messageId || '',
|
||||
metadata: {
|
||||
messageId: params?.messageId || '',
|
||||
chatId: params?.chatId || '',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
MicrosoftTeamsGetMessageParams,
|
||||
MicrosoftTeamsReadResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getMessageTool: ToolConfig<
|
||||
MicrosoftTeamsGetMessageParams,
|
||||
MicrosoftTeamsReadResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_get_message',
|
||||
name: 'Get Microsoft Teams Message',
|
||||
description: 'Get a specific message from a Microsoft Teams chat or channel',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team for channel messages (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel for channel messages (e.g., "19:abc123def456@thread.tacv2")',
|
||||
},
|
||||
chatId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the chat for chat messages (e.g., "19:abc123def456@thread.v2")',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the message to retrieve (e.g., "1234567890123" - a numeric string from message responses)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the retrieval was successful' },
|
||||
content: { type: 'string', description: 'The message content' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'Message metadata including sender, timestamp, etc.',
|
||||
properties: {
|
||||
messageId: { type: 'string', description: 'Message ID' },
|
||||
content: { type: 'string', description: 'Message content' },
|
||||
createdTime: { type: 'string', description: 'Message creation timestamp' },
|
||||
url: { type: 'string', description: 'Web URL to the message' },
|
||||
teamId: { type: 'string', description: 'Team ID' },
|
||||
channelId: { type: 'string', description: 'Channel ID' },
|
||||
chatId: { type: 'string', description: 'Chat ID' },
|
||||
messages: { type: 'array', description: 'Array of message details' },
|
||||
messageCount: { type: 'number', description: 'Number of messages' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const messageId = params.messageId?.trim()
|
||||
if (!messageId) {
|
||||
throw new Error('Message ID is required')
|
||||
}
|
||||
|
||||
// Check if it's a channel or chat message
|
||||
if (params.teamId && params.channelId) {
|
||||
const teamId = params.teamId.trim()
|
||||
const channelId = params.channelId.trim()
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`
|
||||
}
|
||||
if (params.chatId) {
|
||||
const chatId = params.chatId.trim()
|
||||
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}`
|
||||
}
|
||||
|
||||
throw new Error('Either (teamId and channelId) or chatId is required')
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsGetMessageParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const metadata = {
|
||||
messageId: data.id || params?.messageId || '',
|
||||
content: data.body?.content || '',
|
||||
createdTime: data.createdDateTime || '',
|
||||
url: data.webUrl || '',
|
||||
teamId: params?.teamId,
|
||||
channelId: params?.channelId,
|
||||
chatId: params?.chatId,
|
||||
messages: [
|
||||
{
|
||||
id: data.id || '',
|
||||
content: data.body?.content || '',
|
||||
sender: data.from?.user?.displayName || 'Unknown',
|
||||
timestamp: data.createdDateTime || '',
|
||||
messageType: data.messageType || 'message',
|
||||
attachments: data.attachments || [],
|
||||
},
|
||||
],
|
||||
messageCount: 1,
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: data.body?.content || '',
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { deleteChannelMessageTool } from '@/tools/microsoft_teams/delete_channel_message'
|
||||
import { deleteChatMessageTool } from '@/tools/microsoft_teams/delete_chat_message'
|
||||
import { getMessageTool } from '@/tools/microsoft_teams/get_message'
|
||||
import { listChannelMembersTool } from '@/tools/microsoft_teams/list_channel_members'
|
||||
import { listChannelsTool } from '@/tools/microsoft_teams/list_channels'
|
||||
import { listChatMembersTool } from '@/tools/microsoft_teams/list_chat_members'
|
||||
import { listChatsTool } from '@/tools/microsoft_teams/list_chats'
|
||||
import { listTeamMembersTool } from '@/tools/microsoft_teams/list_team_members'
|
||||
import { listTeamsTool } from '@/tools/microsoft_teams/list_teams'
|
||||
import { readChannelTool } from '@/tools/microsoft_teams/read_channel'
|
||||
import { readChatTool } from '@/tools/microsoft_teams/read_chat'
|
||||
import { replyToMessageTool } from '@/tools/microsoft_teams/reply_to_message'
|
||||
import { setReactionTool } from '@/tools/microsoft_teams/set_reaction'
|
||||
import { unsetReactionTool } from '@/tools/microsoft_teams/unset_reaction'
|
||||
import { updateChannelMessageTool } from '@/tools/microsoft_teams/update_channel_message'
|
||||
import { updateChatMessageTool } from '@/tools/microsoft_teams/update_chat_message'
|
||||
import { writeChannelTool } from '@/tools/microsoft_teams/write_channel'
|
||||
import { writeChatTool } from '@/tools/microsoft_teams/write_chat'
|
||||
|
||||
export const microsoftTeamsReadChannelTool = readChannelTool
|
||||
export const microsoftTeamsReadChatTool = readChatTool
|
||||
export const microsoftTeamsGetMessageTool = getMessageTool
|
||||
export const microsoftTeamsWriteChannelTool = writeChannelTool
|
||||
export const microsoftTeamsWriteChatTool = writeChatTool
|
||||
export const microsoftTeamsUpdateChatMessageTool = updateChatMessageTool
|
||||
export const microsoftTeamsUpdateChannelMessageTool = updateChannelMessageTool
|
||||
export const microsoftTeamsDeleteChatMessageTool = deleteChatMessageTool
|
||||
export const microsoftTeamsDeleteChannelMessageTool = deleteChannelMessageTool
|
||||
export const microsoftTeamsReplyToMessageTool = replyToMessageTool
|
||||
export const microsoftTeamsSetReactionTool = setReactionTool
|
||||
export const microsoftTeamsUnsetReactionTool = unsetReactionTool
|
||||
export const microsoftTeamsListTeamMembersTool = listTeamMembersTool
|
||||
export const microsoftTeamsListChannelMembersTool = listChannelMembersTool
|
||||
export const microsoftTeamsListChatMembersTool = listChatMembersTool
|
||||
export const microsoftTeamsListTeamsTool = listTeamsTool
|
||||
export const microsoftTeamsListChatsTool = listChatsTool
|
||||
export const microsoftTeamsListChannelsTool = listChannelsTool
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
MicrosoftTeamsListMembersResponse,
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listChannelMembersTool: ToolConfig<
|
||||
MicrosoftTeamsToolParams,
|
||||
MicrosoftTeamsListMembersResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_list_channel_members',
|
||||
name: 'List Microsoft Teams Channel Members',
|
||||
description: 'List all members of a Microsoft Teams channel',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel (e.g., "19:abc123def456@thread.tacv2" - from channel listings)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the listing was successful' },
|
||||
members: { type: 'array', description: 'Array of channel members' },
|
||||
memberCount: { type: 'number', description: 'Total number of members' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const teamId = params.teamId?.trim()
|
||||
const channelId = params.channelId?.trim()
|
||||
if (!teamId || !channelId) {
|
||||
throw new Error('Team ID and Channel ID are required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/members`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const members = (data.value || []).map((member: any) => ({
|
||||
id: member.id || '',
|
||||
displayName: member.displayName || '',
|
||||
email: member.email || '',
|
||||
userId: member.userId || '',
|
||||
roles: member.roles || [],
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
members,
|
||||
memberCount: members.length,
|
||||
metadata: {
|
||||
teamId: params?.teamId || '',
|
||||
channelId: params?.channelId || '',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
MicrosoftTeamsListChannelsResponse,
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listChannelsTool: ToolConfig<
|
||||
MicrosoftTeamsToolParams,
|
||||
MicrosoftTeamsListChannelsResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_list_channels',
|
||||
name: 'List Microsoft Teams Channels',
|
||||
description: 'List all channels in a Microsoft Teams team',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the listing was successful' },
|
||||
channels: { type: 'array', description: 'Array of channels in the team' },
|
||||
channelCount: { type: 'number', description: 'Total number of channels' },
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Graph indicated additional pages beyond this response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const teamId = params.teamId?.trim()
|
||||
if (!teamId) {
|
||||
throw new Error('Team ID is required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const channels = (data.value || []).map((channel: any) => ({
|
||||
id: channel.id || '',
|
||||
displayName: channel.displayName || '',
|
||||
description: channel.description ?? '',
|
||||
membershipType: channel.membershipType || 'standard',
|
||||
webUrl: channel.webUrl || '',
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
channels,
|
||||
channelCount: channels.length,
|
||||
hasMore: Boolean(data['@odata.nextLink']),
|
||||
metadata: {
|
||||
teamId: params?.teamId || '',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
MicrosoftTeamsListMembersResponse,
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listChatMembersTool: ToolConfig<
|
||||
MicrosoftTeamsToolParams,
|
||||
MicrosoftTeamsListMembersResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_list_chat_members',
|
||||
name: 'List Microsoft Teams Chat Members',
|
||||
description: 'List all members of a Microsoft Teams chat',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
chatId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the chat (e.g., "19:abc123def456@thread.v2" - from chat listings)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the listing was successful' },
|
||||
members: { type: 'array', description: 'Array of chat members' },
|
||||
memberCount: { type: 'number', description: 'Total number of members' },
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Graph indicated additional pages beyond this response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const chatId = params.chatId?.trim()
|
||||
if (!chatId) {
|
||||
throw new Error('Chat ID is required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const members = (data.value || []).map((member: any) => ({
|
||||
id: member.id || '',
|
||||
displayName: member.displayName || '',
|
||||
email: member.email || '',
|
||||
userId: member.userId || '',
|
||||
roles: member.roles || [],
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
members,
|
||||
memberCount: members.length,
|
||||
hasMore: Boolean(data['@odata.nextLink']),
|
||||
metadata: {
|
||||
chatId: params?.chatId || '',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
MicrosoftTeamsListChatsResponse,
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listChatsTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsListChatsResponse> =
|
||||
{
|
||||
id: 'microsoft_teams_list_chats',
|
||||
name: 'List Microsoft Teams Chats',
|
||||
description: 'List the Microsoft Teams chats the current user is part of',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the listing was successful' },
|
||||
chats: { type: 'array', description: 'Array of chats the user is part of' },
|
||||
chatCount: { type: 'number', description: 'Total number of chats' },
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Graph indicated additional pages beyond this response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
// $top=50 is the maximum page size Graph allows for this endpoint.
|
||||
url: () => 'https://graph.microsoft.com/v1.0/me/chats?$top=50',
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
const chats = (data.value || []).map((chat: any) => ({
|
||||
id: chat.id || '',
|
||||
topic: chat.topic ?? null,
|
||||
chatType: chat.chatType || '',
|
||||
webUrl: chat.webUrl || '',
|
||||
createdDateTime: chat.createdDateTime || '',
|
||||
lastUpdatedDateTime: chat.lastUpdatedDateTime || '',
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
chats,
|
||||
chatCount: chats.length,
|
||||
hasMore: Boolean(data['@odata.nextLink']),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
MicrosoftTeamsListMembersResponse,
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listTeamMembersTool: ToolConfig<
|
||||
MicrosoftTeamsToolParams,
|
||||
MicrosoftTeamsListMembersResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_list_team_members',
|
||||
name: 'List Microsoft Teams Team Members',
|
||||
description: 'List all members of a Microsoft Teams team',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the listing was successful' },
|
||||
members: { type: 'array', description: 'Array of team members' },
|
||||
memberCount: { type: 'number', description: 'Total number of members' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const teamId = params.teamId?.trim()
|
||||
if (!teamId) {
|
||||
throw new Error('Team ID is required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/members`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const members = (data.value || []).map((member: any) => ({
|
||||
id: member.id || '',
|
||||
displayName: member.displayName || '',
|
||||
email: member.email || '',
|
||||
userId: member.userId || '',
|
||||
roles: member.roles || [],
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
members,
|
||||
memberCount: members.length,
|
||||
metadata: {
|
||||
teamId: params?.teamId || '',
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
MicrosoftTeamsListTeamsResponse,
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listTeamsTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsListTeamsResponse> =
|
||||
{
|
||||
id: 'microsoft_teams_list_teams',
|
||||
name: 'List Microsoft Teams',
|
||||
description: 'List the Microsoft Teams the current user is a direct member of',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the listing was successful' },
|
||||
teams: { type: 'array', description: 'Array of teams the user is a member of' },
|
||||
teamCount: { type: 'number', description: 'Total number of teams' },
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Graph indicated additional pages beyond this response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
// Note: GET /me/joinedTeams does not support OData query parameters ($top, etc.) per Graph docs:
|
||||
// https://learn.microsoft.com/en-us/graph/api/user-list-joinedteams
|
||||
url: () => 'https://graph.microsoft.com/v1.0/me/joinedTeams',
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
const teams = (data.value || []).map((team: any) => ({
|
||||
id: team.id || '',
|
||||
displayName: team.displayName || '',
|
||||
description: team.description || '',
|
||||
isArchived: Boolean(team.isArchived),
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
teams,
|
||||
teamCount: teams.length,
|
||||
hasMore: Boolean(data['@odata.nextLink']),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type {
|
||||
MicrosoftTeamsReadResponse,
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import {
|
||||
downloadAllReferenceAttachments,
|
||||
extractMessageAttachments,
|
||||
fetchHostedContentsForChannelMessage,
|
||||
} from '@/tools/microsoft_teams/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('MicrosoftTeamsReadChannel')
|
||||
|
||||
export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsReadResponse> = {
|
||||
id: 'microsoft_teams_read_channel',
|
||||
name: 'Read Microsoft Teams Channel',
|
||||
description: 'Read content from a Microsoft Teams channel',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team to read from (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel to read from (e.g., "19:abc123def456@thread.tacv2" - from channel listings)',
|
||||
},
|
||||
includeAttachments: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Download and include message attachments (hosted contents) into storage',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const teamId = params.teamId?.trim()
|
||||
if (!teamId) {
|
||||
throw new Error('Team ID is required')
|
||||
}
|
||||
|
||||
const channelId = params.channelId?.trim()
|
||||
if (!channelId) {
|
||||
throw new Error('Channel ID is required')
|
||||
}
|
||||
|
||||
const encodedTeamId = encodeURIComponent(teamId)
|
||||
const encodedChannelId = encodeURIComponent(channelId)
|
||||
|
||||
// Graph API's default page size for channel messages is 20; request the max of 50
|
||||
// so a single call surfaces more history without requiring pagination.
|
||||
const url = `https://graph.microsoft.com/v1.0/teams/${encodedTeamId}/channels/${encodedChannelId}/messages?$top=50`
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const messages = data.value || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: 'No messages found in this channel.',
|
||||
metadata: {
|
||||
teamId: '',
|
||||
channelId: '',
|
||||
messageCount: 0,
|
||||
messages: [],
|
||||
totalAttachments: 0,
|
||||
attachmentTypes: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const processedMessages = await Promise.all(
|
||||
messages.map(async (message: any, index: number) => {
|
||||
try {
|
||||
const content = message.body?.content || 'No content'
|
||||
const messageId = message.id
|
||||
|
||||
const attachments = extractMessageAttachments(message)
|
||||
|
||||
let sender = 'Unknown'
|
||||
if (message.from?.user?.displayName) {
|
||||
sender = message.from.user.displayName
|
||||
} else if (message.messageType === 'systemEventMessage') {
|
||||
sender = 'System'
|
||||
}
|
||||
|
||||
let uploaded: any[] = []
|
||||
if (
|
||||
params?.includeAttachments &&
|
||||
params.accessToken &&
|
||||
params.teamId &&
|
||||
params.channelId &&
|
||||
messageId
|
||||
) {
|
||||
try {
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: messageId,
|
||||
content: content,
|
||||
sender,
|
||||
timestamp: message.createdDateTime,
|
||||
messageType: message.messageType || 'message',
|
||||
attachments,
|
||||
uploadedFiles: uploaded,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing message at index ${index}:`, error)
|
||||
return {
|
||||
id: message.id || `unknown-${index}`,
|
||||
content: 'Error processing message',
|
||||
sender: 'Unknown',
|
||||
timestamp: message.createdDateTime || new Date().toISOString(),
|
||||
messageType: 'error',
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const formattedMessages = processedMessages
|
||||
.map((message: any) => {
|
||||
const sender = message.sender
|
||||
const timestamp = message.timestamp
|
||||
? new Date(message.timestamp).toLocaleString()
|
||||
: 'Unknown time'
|
||||
|
||||
return `[${timestamp}] ${sender}: ${message.content}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const allAttachments = processedMessages.flatMap((msg: any) => msg.attachments || [])
|
||||
const attachmentTypes: string[] = []
|
||||
const seenTypes = new Set<string>()
|
||||
|
||||
allAttachments.forEach((att: any) => {
|
||||
if (
|
||||
att.contentType &&
|
||||
typeof att.contentType === 'string' &&
|
||||
!seenTypes.has(att.contentType)
|
||||
) {
|
||||
attachmentTypes.push(att.contentType)
|
||||
seenTypes.add(att.contentType)
|
||||
}
|
||||
})
|
||||
|
||||
const metadata = {
|
||||
teamId: messages[0]?.channelIdentity?.teamId || params?.teamId || '',
|
||||
channelId: messages[0]?.channelIdentity?.channelId || params?.channelId || '',
|
||||
messageCount: messages.length,
|
||||
totalAttachments: allAttachments.length,
|
||||
attachmentTypes,
|
||||
messages: processedMessages,
|
||||
}
|
||||
|
||||
const flattenedUploads = processedMessages.flatMap((m: any) => m.uploadedFiles || [])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: formattedMessages,
|
||||
metadata,
|
||||
attachments: flattenedUploads,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Teams channel read operation success status' },
|
||||
messageCount: { type: 'number', description: 'Number of messages retrieved from channel' },
|
||||
teamId: { type: 'string', description: 'ID of the team that was read from' },
|
||||
channelId: { type: 'string', description: 'ID of the channel that was read from' },
|
||||
messages: { type: 'array', description: 'Array of channel message objects' },
|
||||
attachmentCount: { type: 'number', description: 'Total number of attachments found' },
|
||||
attachmentTypes: { type: 'array', description: 'Types of attachments found' },
|
||||
content: { type: 'string', description: 'Formatted content of channel messages' },
|
||||
attachments: {
|
||||
type: 'file[]',
|
||||
description: 'Uploaded attachments for convenience (flattened)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
MicrosoftTeamsReadResponse,
|
||||
MicrosoftTeamsToolParams,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import {
|
||||
downloadAllReferenceAttachments,
|
||||
extractMessageAttachments,
|
||||
fetchHostedContentsForChatMessage,
|
||||
} from '@/tools/microsoft_teams/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsReadResponse> = {
|
||||
id: 'microsoft_teams_read_chat',
|
||||
name: 'Read Microsoft Teams Chat',
|
||||
description: 'Read content from a Microsoft Teams chat',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
chatId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the chat to read from (e.g., "19:abc123def456@thread.v2" - from chat listings)',
|
||||
},
|
||||
includeAttachments: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Download and include message attachments (hosted contents) into storage',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const chatId = params.chatId?.trim()
|
||||
if (!chatId) {
|
||||
throw new Error('Chat ID is required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=50&$orderby=createdDateTime desc`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const messages = data.value || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: 'No messages found in this chat.',
|
||||
metadata: {
|
||||
chatId: '',
|
||||
messageCount: 0,
|
||||
messages: [],
|
||||
totalAttachments: 0,
|
||||
attachmentTypes: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const processedMessages = await Promise.all(
|
||||
messages.map(async (message: any) => {
|
||||
const content = message.body?.content || 'No content'
|
||||
const messageId = message.id
|
||||
|
||||
const attachments = extractMessageAttachments(message)
|
||||
|
||||
let uploaded: any[] = []
|
||||
if (params?.includeAttachments && params.accessToken && params.chatId && messageId) {
|
||||
try {
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: messageId,
|
||||
content: content, // Keep original content without modification
|
||||
sender: message.from?.user?.displayName || 'Unknown',
|
||||
timestamp: message.createdDateTime,
|
||||
messageType: message.messageType || 'message',
|
||||
attachments, // Raw attachment metadata
|
||||
uploadedFiles: uploaded, // Uploaded file infos (paths/keys)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Format the messages into a readable text (no attachment info in content)
|
||||
const formattedMessages = processedMessages
|
||||
.map((message: any) => {
|
||||
const sender = message.sender
|
||||
const timestamp = message.timestamp
|
||||
? new Date(message.timestamp).toLocaleString()
|
||||
: 'Unknown time'
|
||||
|
||||
return `[${timestamp}] ${sender}: ${message.content}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
// Calculate attachment statistics
|
||||
const allAttachments = processedMessages.flatMap((msg: any) => msg.attachments || [])
|
||||
const attachmentTypes: string[] = []
|
||||
const seenTypes = new Set<string>()
|
||||
|
||||
allAttachments.forEach((att: any) => {
|
||||
if (
|
||||
att.contentType &&
|
||||
typeof att.contentType === 'string' &&
|
||||
!seenTypes.has(att.contentType)
|
||||
) {
|
||||
attachmentTypes.push(att.contentType)
|
||||
seenTypes.add(att.contentType)
|
||||
}
|
||||
})
|
||||
|
||||
// Create document metadata
|
||||
const metadata = {
|
||||
chatId: messages[0]?.chatId || params?.chatId || '',
|
||||
messageCount: messages.length,
|
||||
totalAttachments: allAttachments.length,
|
||||
attachmentTypes,
|
||||
messages: processedMessages,
|
||||
}
|
||||
|
||||
// Flatten uploaded files across all messages for convenience
|
||||
const flattenedUploads = processedMessages.flatMap((m: any) => m.uploadedFiles || [])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: formattedMessages,
|
||||
metadata,
|
||||
attachments: flattenedUploads,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Teams chat read operation success status' },
|
||||
messageCount: { type: 'number', description: 'Number of messages retrieved from chat' },
|
||||
chatId: { type: 'string', description: 'ID of the chat that was read from' },
|
||||
messages: { type: 'array', description: 'Array of chat message objects' },
|
||||
attachmentCount: { type: 'number', description: 'Total number of attachments found' },
|
||||
attachmentTypes: { type: 'array', description: 'Types of attachments found' },
|
||||
content: { type: 'string', description: 'Formatted content of chat messages' },
|
||||
attachments: {
|
||||
type: 'file[]',
|
||||
description: 'Uploaded attachments for convenience (flattened)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
MicrosoftTeamsReplyParams,
|
||||
MicrosoftTeamsWriteResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const replyToMessageTool: ToolConfig<
|
||||
MicrosoftTeamsReplyParams,
|
||||
MicrosoftTeamsWriteResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_reply_to_message',
|
||||
name: 'Reply to Microsoft Teams Channel Message',
|
||||
description: 'Reply to an existing message in a Microsoft Teams channel',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings or channel info)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel (e.g., "19:abc123def456@thread.tacv2" - from channel listings)',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the message to reply to (e.g., "1234567890123" - a numeric string from message responses)',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The reply content (plain text or HTML formatted message)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the reply was successful' },
|
||||
messageId: { type: 'string', description: 'ID of the reply message' },
|
||||
updatedContent: { type: 'boolean', description: 'Whether content was successfully sent' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const teamId = params.teamId?.trim()
|
||||
const channelId = params.channelId?.trim()
|
||||
const messageId = params.messageId?.trim()
|
||||
if (!teamId || !channelId || !messageId) {
|
||||
throw new Error('Team ID, Channel ID, and Message ID are required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
if (!params.content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
return {
|
||||
body: {
|
||||
contentType: 'text',
|
||||
content: params.content,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsReplyParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const metadata = {
|
||||
messageId: data.id || '',
|
||||
teamId: params?.teamId || '',
|
||||
channelId: params?.channelId || '',
|
||||
content: data.body?.content || params?.content || '',
|
||||
createdTime: data.createdDateTime || '',
|
||||
url: data.webUrl || '',
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updatedContent: true,
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Server-side utilities for Microsoft Teams integration.
|
||||
* This file contains functions that require server-side dependencies and should
|
||||
* only be imported by API routes, NOT by tool definitions (to avoid circular imports).
|
||||
*/
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { FileAccessDeniedError, verifyFileAccess } from '@/app/api/files/authorization'
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import type { GraphApiErrorResponse, GraphDriveItem } from '@/tools/microsoft_teams/types'
|
||||
|
||||
/** Maximum file size for Teams direct upload (4MB) */
|
||||
const MAX_TEAMS_FILE_SIZE = 4 * 1024 * 1024
|
||||
|
||||
/** Output format for uploaded files */
|
||||
interface TeamsFileOutput {
|
||||
name: string
|
||||
mimeType: string
|
||||
data: string
|
||||
size: number
|
||||
}
|
||||
|
||||
/** Attachment reference for Teams message */
|
||||
interface TeamsAttachmentRef {
|
||||
id: string
|
||||
contentType: 'reference'
|
||||
contentUrl: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Result from processing and uploading files for Teams */
|
||||
export interface TeamsFileUploadResult {
|
||||
attachments: TeamsAttachmentRef[]
|
||||
filesOutput: TeamsFileOutput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and upload files to OneDrive for Teams message attachments.
|
||||
* Handles size validation, downloading from storage, uploading to OneDrive,
|
||||
* and creating attachment references.
|
||||
*/
|
||||
export async function uploadFilesForTeamsMessage(params: {
|
||||
rawFiles: RawFileInput[]
|
||||
accessToken: string
|
||||
requestId: string
|
||||
logger: Logger
|
||||
userId: string
|
||||
}): Promise<TeamsFileUploadResult> {
|
||||
const { rawFiles, accessToken, requestId, logger: log, userId } = params
|
||||
const attachments: TeamsAttachmentRef[] = []
|
||||
const filesOutput: TeamsFileOutput[] = []
|
||||
|
||||
if (!rawFiles || rawFiles.length === 0) {
|
||||
return { attachments, filesOutput }
|
||||
}
|
||||
|
||||
log.info(`[${requestId}] Processing ${rawFiles.length} file(s) for upload to OneDrive`)
|
||||
|
||||
const userFiles = processFilesToUserFiles(rawFiles, requestId, log) as UserFile[]
|
||||
|
||||
for (const file of userFiles) {
|
||||
// Check size limit
|
||||
if (file.size > MAX_TEAMS_FILE_SIZE) {
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(2)
|
||||
log.error(
|
||||
`[${requestId}] File ${file.name} is ${sizeMB}MB, exceeds 4MB limit for direct upload`
|
||||
)
|
||||
throw new Error(
|
||||
`File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.`
|
||||
)
|
||||
}
|
||||
|
||||
log.info(`[${requestId}] Uploading file to Teams: ${file.name} (${file.size} bytes)`)
|
||||
|
||||
const hasAccess = await verifyFileAccess(file.key, userId)
|
||||
if (!hasAccess) {
|
||||
throw new FileAccessDeniedError()
|
||||
}
|
||||
|
||||
// Download file from storage
|
||||
const { buffer, contentType } = await downloadServableFileFromStorage(file, requestId, log)
|
||||
|
||||
if (buffer.length > MAX_TEAMS_FILE_SIZE) {
|
||||
const sizeMB = (buffer.length / (1024 * 1024)).toFixed(2)
|
||||
throw new Error(
|
||||
`File "${file.name}" (${sizeMB}MB) exceeds the 4MB limit for Teams attachments. Use smaller files or upload to SharePoint/OneDrive first.`
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedMimeType = contentType || file.type || 'application/octet-stream'
|
||||
filesOutput.push({
|
||||
name: file.name,
|
||||
mimeType: resolvedMimeType,
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
})
|
||||
|
||||
// Upload to OneDrive
|
||||
const uploadUrl =
|
||||
'https://graph.microsoft.com/v1.0/me/drive/root:/TeamsAttachments/' +
|
||||
encodeURIComponent(file.name) +
|
||||
':/content'
|
||||
|
||||
log.info(`[${requestId}] Uploading to OneDrive: ${uploadUrl}`)
|
||||
|
||||
const uploadResponse = await secureFetchWithValidation(
|
||||
uploadUrl,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': resolvedMimeType,
|
||||
},
|
||||
body: buffer,
|
||||
},
|
||||
'uploadUrl'
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorData = (await uploadResponse.json().catch(() => ({}))) as GraphApiErrorResponse
|
||||
log.error(`[${requestId}] Teams upload failed:`, errorData)
|
||||
throw new Error(
|
||||
`Failed to upload file to Teams: ${errorData.error?.message || 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
|
||||
const uploadedFile = (await uploadResponse.json()) as GraphDriveItem
|
||||
log.info(`[${requestId}] File uploaded to OneDrive successfully`, {
|
||||
id: uploadedFile.id,
|
||||
webUrl: uploadedFile.webUrl,
|
||||
})
|
||||
|
||||
// Get file details for attachment reference
|
||||
// Note: webDavUrl requires 'select' without the '$' prefix to be reliably returned
|
||||
const fileDetailsUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${uploadedFile.id}?select=id,name,webDavUrl,eTag,size`
|
||||
|
||||
const fileDetailsResponse = await secureFetchWithValidation(
|
||||
fileDetailsUrl,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
'fileDetailsUrl'
|
||||
)
|
||||
|
||||
if (!fileDetailsResponse.ok) {
|
||||
const errorData = (await fileDetailsResponse
|
||||
.json()
|
||||
.catch(() => ({}))) as GraphApiErrorResponse
|
||||
log.error(`[${requestId}] Failed to get file details:`, errorData)
|
||||
throw new Error(`Failed to get file details: ${errorData.error?.message || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
const fileDetails = (await fileDetailsResponse.json()) as GraphDriveItem
|
||||
log.info(`[${requestId}] Got file details`, {
|
||||
webDavUrl: fileDetails.webDavUrl,
|
||||
eTag: fileDetails.eTag,
|
||||
})
|
||||
|
||||
// Validate webDavUrl is present (required for Teams attachment references)
|
||||
if (!fileDetails.webDavUrl) {
|
||||
log.error(`[${requestId}] webDavUrl missing from file details`, { fileId: uploadedFile.id })
|
||||
throw new Error(
|
||||
`Failed to get file URL for attachment "${file.name}". The file was uploaded but Teams attachment reference could not be created.`
|
||||
)
|
||||
}
|
||||
|
||||
// Create attachment reference
|
||||
const attachmentId = fileDetails.eTag?.match(/\{([a-f0-9-]+)\}/i)?.[1] || fileDetails.id
|
||||
|
||||
attachments.push({
|
||||
id: attachmentId,
|
||||
contentType: 'reference',
|
||||
contentUrl: fileDetails.webDavUrl,
|
||||
name: file.name,
|
||||
})
|
||||
|
||||
log.info(`[${requestId}] Created attachment reference for ${file.name}`)
|
||||
}
|
||||
|
||||
log.info(
|
||||
`[${requestId}] All ${attachments.length} file(s) uploaded and attachment references created`
|
||||
)
|
||||
|
||||
return { attachments, filesOutput }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
MicrosoftTeamsReactionParams,
|
||||
MicrosoftTeamsReactionResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const setReactionTool: ToolConfig<
|
||||
MicrosoftTeamsReactionParams,
|
||||
MicrosoftTeamsReactionResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_set_reaction',
|
||||
name: 'Add Reaction to Microsoft Teams Message',
|
||||
description: 'Add an emoji reaction to a message in Microsoft Teams',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team for channel messages (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel for channel messages (e.g., "19:abc123def456@thread.tacv2")',
|
||||
},
|
||||
chatId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the chat for chat messages (e.g., "19:abc123def456@thread.v2")',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the message to react to (e.g., "1234567890123" - a numeric string from message responses)',
|
||||
},
|
||||
reactionType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The emoji reaction (e.g., ❤️, 👍, 😊)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the reaction was added successfully' },
|
||||
reactionType: { type: 'string', description: 'The emoji that was added' },
|
||||
messageId: { type: 'string', description: 'ID of the message' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const messageId = params.messageId?.trim()
|
||||
if (!messageId) {
|
||||
throw new Error('Message ID is required')
|
||||
}
|
||||
|
||||
// Check if it's a channel or chat message
|
||||
if (params.teamId && params.channelId) {
|
||||
const teamId = params.teamId.trim()
|
||||
const channelId = params.channelId.trim()
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/setReaction`
|
||||
}
|
||||
if (params.chatId) {
|
||||
const chatId = params.chatId.trim()
|
||||
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/setReaction`
|
||||
}
|
||||
|
||||
throw new Error('Either (teamId and channelId) or chatId is required')
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
if (!params.reactionType) {
|
||||
throw new Error('Reaction type is required')
|
||||
}
|
||||
return {
|
||||
reactionType: params.reactionType,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (_response: Response, params?: MicrosoftTeamsReactionParams) => {
|
||||
// setReaction returns 204 No Content on success
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
reactionType: params?.reactionType || '',
|
||||
messageId: params?.messageId || '',
|
||||
metadata: {
|
||||
messageId: params?.messageId || '',
|
||||
teamId: params?.teamId,
|
||||
channelId: params?.channelId,
|
||||
chatId: params?.chatId,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import type { ToolFileData, ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface GraphApiErrorResponse {
|
||||
error?: {
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface GraphDriveItem {
|
||||
id: string
|
||||
webUrl?: string
|
||||
webDavUrl?: string
|
||||
eTag?: string
|
||||
name?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface GraphChatMessage {
|
||||
id?: string
|
||||
chatId?: string
|
||||
channelIdentity?: { teamId?: string; channelId?: string }
|
||||
body?: { content?: string }
|
||||
createdDateTime?: string
|
||||
webUrl?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsAttachment {
|
||||
id: string
|
||||
contentType: string
|
||||
contentUrl?: string
|
||||
content?: string
|
||||
name?: string
|
||||
thumbnailUrl?: string
|
||||
size?: number
|
||||
sourceUrl?: string
|
||||
providerType?: string
|
||||
item?: any
|
||||
}
|
||||
|
||||
interface MicrosoftTeamsMetadata {
|
||||
messageId?: string
|
||||
channelId?: string
|
||||
teamId?: string
|
||||
chatId?: string
|
||||
content?: string
|
||||
createdTime?: string
|
||||
url?: string
|
||||
messageCount?: number
|
||||
messages?: Array<{
|
||||
id: string
|
||||
content: string
|
||||
sender: string
|
||||
timestamp: string
|
||||
messageType: string
|
||||
attachments?: MicrosoftTeamsAttachment[]
|
||||
uploadedFiles?: {
|
||||
path: string
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
}[]
|
||||
}>
|
||||
// Global attachments summary
|
||||
totalAttachments?: number
|
||||
attachmentTypes?: string[]
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsReadResponse extends ToolResponse {
|
||||
output: {
|
||||
content: string
|
||||
metadata: MicrosoftTeamsMetadata
|
||||
attachments?: Array<{
|
||||
path: string
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsWriteResponse extends ToolResponse {
|
||||
output: {
|
||||
updatedContent: boolean
|
||||
metadata: MicrosoftTeamsMetadata
|
||||
files?: ToolFileData[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsToolParams {
|
||||
accessToken: string
|
||||
messageId?: string
|
||||
chatId?: string
|
||||
channelId?: string
|
||||
teamId?: string
|
||||
content?: string
|
||||
includeAttachments?: boolean
|
||||
files?: UserFile[]
|
||||
reactionType?: string // For reaction operations
|
||||
}
|
||||
|
||||
// Update message params
|
||||
export interface MicrosoftTeamsUpdateMessageParams extends MicrosoftTeamsToolParams {
|
||||
messageId: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// Delete message params
|
||||
export interface MicrosoftTeamsDeleteMessageParams extends MicrosoftTeamsToolParams {
|
||||
messageId: string
|
||||
}
|
||||
|
||||
// Reply to message params
|
||||
export interface MicrosoftTeamsReplyParams extends MicrosoftTeamsToolParams {
|
||||
messageId: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// Reaction params
|
||||
export interface MicrosoftTeamsReactionParams extends MicrosoftTeamsToolParams {
|
||||
messageId: string
|
||||
reactionType: string
|
||||
}
|
||||
|
||||
// Get message params
|
||||
export interface MicrosoftTeamsGetMessageParams extends MicrosoftTeamsToolParams {
|
||||
messageId: string
|
||||
}
|
||||
|
||||
// Member list response
|
||||
interface MicrosoftTeamsMember {
|
||||
id: string
|
||||
displayName: string
|
||||
email?: string
|
||||
userId?: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsListMembersResponse extends ToolResponse {
|
||||
output: {
|
||||
members: MicrosoftTeamsMember[]
|
||||
memberCount: number
|
||||
hasMore?: boolean
|
||||
metadata: {
|
||||
teamId?: string
|
||||
channelId?: string
|
||||
chatId?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Joined team summary (from GET /me/joinedTeams)
|
||||
interface MicrosoftTeamsTeamSummary {
|
||||
id: string
|
||||
displayName: string
|
||||
description?: string
|
||||
isArchived?: boolean
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsListTeamsResponse extends ToolResponse {
|
||||
output: {
|
||||
teams: MicrosoftTeamsTeamSummary[]
|
||||
teamCount: number
|
||||
hasMore: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// Chat summary (from GET /me/chats)
|
||||
interface MicrosoftTeamsChatSummary {
|
||||
id: string
|
||||
topic: string | null
|
||||
chatType: string
|
||||
webUrl?: string
|
||||
createdDateTime?: string
|
||||
lastUpdatedDateTime?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsListChatsResponse extends ToolResponse {
|
||||
output: {
|
||||
chats: MicrosoftTeamsChatSummary[]
|
||||
chatCount: number
|
||||
hasMore: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// Channel summary (from GET /teams/{team-id}/channels)
|
||||
interface MicrosoftTeamsChannelSummary {
|
||||
id: string
|
||||
displayName: string
|
||||
description?: string | null
|
||||
membershipType?: string
|
||||
webUrl?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftTeamsListChannelsResponse extends ToolResponse {
|
||||
output: {
|
||||
channels: MicrosoftTeamsChannelSummary[]
|
||||
channelCount: number
|
||||
hasMore: boolean
|
||||
metadata: {
|
||||
teamId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete response
|
||||
export interface MicrosoftTeamsDeleteResponse extends ToolResponse {
|
||||
output: {
|
||||
deleted: boolean
|
||||
messageId: string
|
||||
metadata: MicrosoftTeamsMetadata
|
||||
}
|
||||
}
|
||||
|
||||
// Reaction response
|
||||
export interface MicrosoftTeamsReactionResponse extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
reactionType: string
|
||||
messageId: string
|
||||
metadata: MicrosoftTeamsMetadata
|
||||
}
|
||||
}
|
||||
|
||||
export type MicrosoftTeamsResponse =
|
||||
| MicrosoftTeamsReadResponse
|
||||
| MicrosoftTeamsWriteResponse
|
||||
| MicrosoftTeamsDeleteResponse
|
||||
| MicrosoftTeamsListMembersResponse
|
||||
| MicrosoftTeamsReactionResponse
|
||||
| MicrosoftTeamsListTeamsResponse
|
||||
| MicrosoftTeamsListChatsResponse
|
||||
| MicrosoftTeamsListChannelsResponse
|
||||
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
MicrosoftTeamsReactionParams,
|
||||
MicrosoftTeamsReactionResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const unsetReactionTool: ToolConfig<
|
||||
MicrosoftTeamsReactionParams,
|
||||
MicrosoftTeamsReactionResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_unset_reaction',
|
||||
name: 'Remove Reaction from Microsoft Teams Message',
|
||||
description: 'Remove an emoji reaction from a message in Microsoft Teams',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team for channel messages (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel for channel messages (e.g., "19:abc123def456@thread.tacv2")',
|
||||
},
|
||||
chatId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The ID of the chat for chat messages (e.g., "19:abc123def456@thread.v2")',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the message (e.g., "1234567890123" - a numeric string from message responses)',
|
||||
},
|
||||
reactionType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The emoji reaction to remove (e.g., ❤️, 👍, 😊)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the reaction was removed successfully' },
|
||||
reactionType: { type: 'string', description: 'The emoji that was removed' },
|
||||
messageId: { type: 'string', description: 'ID of the message' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const messageId = params.messageId?.trim()
|
||||
if (!messageId) {
|
||||
throw new Error('Message ID is required')
|
||||
}
|
||||
|
||||
// Check if it's a channel or chat message
|
||||
if (params.teamId && params.channelId) {
|
||||
const teamId = params.teamId.trim()
|
||||
const channelId = params.channelId.trim()
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/unsetReaction`
|
||||
}
|
||||
if (params.chatId) {
|
||||
const chatId = params.chatId.trim()
|
||||
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/unsetReaction`
|
||||
}
|
||||
|
||||
throw new Error('Either (teamId and channelId) or chatId is required')
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
if (!params.reactionType) {
|
||||
throw new Error('Reaction type is required')
|
||||
}
|
||||
return {
|
||||
reactionType: params.reactionType,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (_response: Response, params?: MicrosoftTeamsReactionParams) => {
|
||||
// unsetReaction returns 204 No Content on success
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
reactionType: params?.reactionType || '',
|
||||
messageId: params?.messageId || '',
|
||||
metadata: {
|
||||
messageId: params?.messageId || '',
|
||||
teamId: params?.teamId,
|
||||
channelId: params?.channelId,
|
||||
chatId: params?.chatId,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
MicrosoftTeamsUpdateMessageParams,
|
||||
MicrosoftTeamsWriteResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const updateChannelMessageTool: ToolConfig<
|
||||
MicrosoftTeamsUpdateMessageParams,
|
||||
MicrosoftTeamsWriteResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_update_channel_message',
|
||||
name: 'Update Microsoft Teams Channel Message',
|
||||
description: 'Update an existing message in a Microsoft Teams channel',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings or channel info)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel containing the message (e.g., "19:abc123def456@thread.tacv2" - from channel listings)',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the message to update (e.g., "1234567890123" - a numeric string from message responses)',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The new content for the message (plain text or HTML formatted)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the update was successful' },
|
||||
messageId: { type: 'string', description: 'ID of the updated message' },
|
||||
updatedContent: { type: 'boolean', description: 'Whether content was successfully updated' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const teamId = params.teamId?.trim()
|
||||
const channelId = params.channelId?.trim()
|
||||
const messageId = params.messageId?.trim()
|
||||
if (!teamId || !channelId || !messageId) {
|
||||
throw new Error('Team ID, Channel ID, and Message ID are required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
if (!params.content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
return {
|
||||
body: {
|
||||
contentType: 'text',
|
||||
content: params.content,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsUpdateMessageParams) => {
|
||||
let data: any = {}
|
||||
if (response.status !== 204 && response.headers.get('content-length') !== '0') {
|
||||
const text = await response.text()
|
||||
if (text) {
|
||||
data = JSON.parse(text)
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
messageId: data.id || params?.messageId || '',
|
||||
teamId: params?.teamId || '',
|
||||
channelId: params?.channelId || '',
|
||||
content: data.body?.content || params?.content || '',
|
||||
createdTime: data.createdDateTime || '',
|
||||
url: data.webUrl || '',
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updatedContent: true,
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
MicrosoftTeamsUpdateMessageParams,
|
||||
MicrosoftTeamsWriteResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const updateChatMessageTool: ToolConfig<
|
||||
MicrosoftTeamsUpdateMessageParams,
|
||||
MicrosoftTeamsWriteResponse
|
||||
> = {
|
||||
id: 'microsoft_teams_update_chat_message',
|
||||
name: 'Update Microsoft Teams Chat Message',
|
||||
description: 'Update an existing message in a Microsoft Teams chat',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
chatId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the chat containing the message (e.g., "19:abc123def456@thread.v2" - from chat listings)',
|
||||
},
|
||||
messageId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the message to update (e.g., "1234567890123" - a numeric string from message responses)',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The new content for the message (plain text or HTML formatted)',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the update was successful' },
|
||||
messageId: { type: 'string', description: 'ID of the updated message' },
|
||||
updatedContent: { type: 'boolean', description: 'Whether content was successfully updated' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const chatId = params.chatId?.trim()
|
||||
const messageId = params.messageId?.trim()
|
||||
if (!chatId || !messageId) {
|
||||
throw new Error('Chat ID and Message ID are required')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
if (!params.content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
return {
|
||||
body: {
|
||||
contentType: 'text',
|
||||
content: params.content,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsUpdateMessageParams) => {
|
||||
let data: any = {}
|
||||
if (response.status !== 204 && response.headers.get('content-length') !== '0') {
|
||||
const text = await response.text()
|
||||
if (text) {
|
||||
data = JSON.parse(text)
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
messageId: data.id || params?.messageId || '',
|
||||
chatId: params?.chatId || '',
|
||||
content: data.body?.content || params?.content || '',
|
||||
createdTime: data.createdDateTime || '',
|
||||
url: data.webUrl || '',
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updatedContent: true,
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { MicrosoftTeamsAttachment } from '@/tools/microsoft_teams/types'
|
||||
import type { ToolFileData } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('MicrosoftTeamsUtils')
|
||||
|
||||
interface ParsedMention {
|
||||
name: string
|
||||
fullTag: string
|
||||
mentionId: number
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
id: string
|
||||
displayName: string
|
||||
userIdentityType?: string
|
||||
}
|
||||
|
||||
export interface TeamsMention {
|
||||
id: number
|
||||
mentionText: string
|
||||
mentioned:
|
||||
| {
|
||||
user: {
|
||||
id: string
|
||||
displayName: string
|
||||
userIdentityType?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
application: {
|
||||
displayName: string
|
||||
id: string
|
||||
applicationIdentityType: 'bot'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform raw attachment data from Microsoft Graph API
|
||||
*/
|
||||
function transformAttachment(rawAttachment: any): MicrosoftTeamsAttachment {
|
||||
return {
|
||||
id: rawAttachment.id,
|
||||
contentType: rawAttachment.contentType,
|
||||
contentUrl: rawAttachment.contentUrl,
|
||||
content: rawAttachment.content,
|
||||
name: rawAttachment.name,
|
||||
thumbnailUrl: rawAttachment.thumbnailUrl,
|
||||
size: rawAttachment.size,
|
||||
sourceUrl: rawAttachment.sourceUrl,
|
||||
providerType: rawAttachment.providerType,
|
||||
item: rawAttachment.item,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attachments from message data
|
||||
* Returns all attachments without any content processing
|
||||
*/
|
||||
export function extractMessageAttachments(message: any): MicrosoftTeamsAttachment[] {
|
||||
const attachments = (message.attachments || []).map(transformAttachment)
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch hostedContents for a chat message, upload each item to storage, and return uploaded file infos.
|
||||
* Hosted contents expose base64 contentBytes via Microsoft Graph.
|
||||
*/
|
||||
export async function fetchHostedContentsForChatMessage(params: {
|
||||
accessToken: string
|
||||
chatId: string
|
||||
messageId: string
|
||||
}): Promise<ToolFileData[]> {
|
||||
const { accessToken, chatId, messageId } = params
|
||||
try {
|
||||
const url = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/hostedContents`
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } })
|
||||
if (!res.ok) {
|
||||
return []
|
||||
}
|
||||
const data = await res.json()
|
||||
const items = Array.isArray(data.value) ? data.value : []
|
||||
const results: ToolFileData[] = []
|
||||
for (const item of items) {
|
||||
const base64: string | undefined = item.contentBytes
|
||||
if (!base64) continue
|
||||
const contentType: string =
|
||||
typeof item.contentType === 'string' ? item.contentType : 'application/octet-stream'
|
||||
const name: string = item.id ? `teams-hosted-${item.id}` : 'teams-hosted-content'
|
||||
results.push({ name, mimeType: contentType, data: base64 })
|
||||
}
|
||||
return results
|
||||
} catch (error) {
|
||||
logger.error('Error fetching/uploading hostedContents for chat message:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch hostedContents for a channel message, upload each item to storage, and return uploaded file infos.
|
||||
*/
|
||||
export async function fetchHostedContentsForChannelMessage(params: {
|
||||
accessToken: string
|
||||
teamId: string
|
||||
channelId: string
|
||||
messageId: string
|
||||
}): Promise<ToolFileData[]> {
|
||||
const { accessToken, teamId, channelId, messageId } = params
|
||||
try {
|
||||
const url = `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/hostedContents`
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } })
|
||||
if (!res.ok) {
|
||||
return []
|
||||
}
|
||||
const data = await res.json()
|
||||
const items = Array.isArray(data.value) ? data.value : []
|
||||
const results: ToolFileData[] = []
|
||||
for (const item of items) {
|
||||
const base64: string | undefined = item.contentBytes
|
||||
if (!base64) continue
|
||||
const contentType: string =
|
||||
typeof item.contentType === 'string' ? item.contentType : 'application/octet-stream'
|
||||
const name: string = item.id ? `teams-hosted-${item.id}` : 'teams-hosted-content'
|
||||
results.push({ name, mimeType: contentType, data: base64 })
|
||||
}
|
||||
return results
|
||||
} catch (error) {
|
||||
logger.error('Error fetching/uploading hostedContents for channel message:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a reference-type attachment (SharePoint/OneDrive file) from Teams.
|
||||
* These are files shared in Teams that are stored in SharePoint/OneDrive.
|
||||
*
|
||||
*/
|
||||
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
|
||||
let match: RegExpExecArray | null
|
||||
let mentionId = 0
|
||||
|
||||
while ((match = mentionRegex.exec(content)) !== null) {
|
||||
const name = match[1].trim()
|
||||
if (name) {
|
||||
mentions.push({
|
||||
name,
|
||||
fullTag: match[0],
|
||||
mentionId: mentionId++,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
||||
async function fetchChatMembers(chatId: string, accessToken: string): Promise<TeamMember[]> {
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return (data.value || []).map((member: TeamMember) => ({
|
||||
id: member.id,
|
||||
displayName: member.displayName || '',
|
||||
userIdentityType: member.userIdentityType,
|
||||
}))
|
||||
}
|
||||
|
||||
async function fetchChannelMembers(
|
||||
teamId: string,
|
||||
channelId: string,
|
||||
accessToken: string
|
||||
): Promise<TeamMember[]> {
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/members`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return (data.value || []).map((member: TeamMember) => ({
|
||||
id: member.id,
|
||||
displayName: member.displayName || '',
|
||||
userIdentityType: member.userIdentityType,
|
||||
}))
|
||||
}
|
||||
|
||||
function findMemberByName(members: TeamMember[], name: string): TeamMember | undefined {
|
||||
const normalizedName = name.trim().toLowerCase()
|
||||
return members.find((member) => member.displayName.toLowerCase() === normalizedName)
|
||||
}
|
||||
|
||||
export async function resolveMentionsForChat(
|
||||
content: string,
|
||||
chatId: string,
|
||||
accessToken: string
|
||||
): Promise<{ mentions: TeamsMention[]; hasMentions: boolean; updatedContent: string }> {
|
||||
const parsedMentions = parseMentions(content)
|
||||
|
||||
if (parsedMentions.length === 0) {
|
||||
return { mentions: [], hasMentions: false, updatedContent: content }
|
||||
}
|
||||
|
||||
const members = await fetchChatMembers(chatId, accessToken)
|
||||
const mentions: TeamsMention[] = []
|
||||
const resolvedTags = new Set<string>()
|
||||
let updatedContent = content
|
||||
|
||||
for (const mention of parsedMentions) {
|
||||
if (resolvedTags.has(mention.fullTag)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const member = findMemberByName(members, mention.name)
|
||||
|
||||
if (member) {
|
||||
const isBot = member.userIdentityType === 'bot'
|
||||
|
||||
if (isBot) {
|
||||
mentions.push({
|
||||
id: mention.mentionId,
|
||||
mentionText: mention.name,
|
||||
mentioned: {
|
||||
application: {
|
||||
displayName: member.displayName,
|
||||
id: member.id,
|
||||
applicationIdentityType: 'bot',
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
mentions.push({
|
||||
id: mention.mentionId,
|
||||
mentionText: mention.name,
|
||||
mentioned: {
|
||||
user: {
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
userIdentityType: member.userIdentityType || 'aadUser',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
resolvedTags.add(mention.fullTag)
|
||||
updatedContent = updatedContent.replaceAll(
|
||||
mention.fullTag,
|
||||
`<at id="${mention.mentionId}">${mention.name}</at>`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mentions,
|
||||
hasMentions: mentions.length > 0,
|
||||
updatedContent,
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveMentionsForChannel(
|
||||
content: string,
|
||||
teamId: string,
|
||||
channelId: string,
|
||||
accessToken: string
|
||||
): Promise<{ mentions: TeamsMention[]; hasMentions: boolean; updatedContent: string }> {
|
||||
const parsedMentions = parseMentions(content)
|
||||
|
||||
if (parsedMentions.length === 0) {
|
||||
return { mentions: [], hasMentions: false, updatedContent: content }
|
||||
}
|
||||
|
||||
const members = await fetchChannelMembers(teamId, channelId, accessToken)
|
||||
const mentions: TeamsMention[] = []
|
||||
const resolvedTags = new Set<string>()
|
||||
let updatedContent = content
|
||||
|
||||
for (const mention of parsedMentions) {
|
||||
if (resolvedTags.has(mention.fullTag)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const member = findMemberByName(members, mention.name)
|
||||
|
||||
if (member) {
|
||||
const isBot = member.userIdentityType === 'bot'
|
||||
|
||||
if (isBot) {
|
||||
mentions.push({
|
||||
id: mention.mentionId,
|
||||
mentionText: mention.name,
|
||||
mentioned: {
|
||||
application: {
|
||||
displayName: member.displayName,
|
||||
id: member.id,
|
||||
applicationIdentityType: 'bot',
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
mentions.push({
|
||||
id: mention.mentionId,
|
||||
mentionText: mention.name,
|
||||
mentioned: {
|
||||
user: {
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
userIdentityType: member.userIdentityType || 'aadUser',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
resolvedTags.add(mention.fullTag)
|
||||
updatedContent = updatedContent.replaceAll(
|
||||
mention.fullTag,
|
||||
`<at id="${mention.mentionId}">${mention.name}</at>`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mentions,
|
||||
hasMentions: mentions.length > 0,
|
||||
updatedContent,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
MicrosoftTeamsToolParams,
|
||||
MicrosoftTeamsWriteResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const writeChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsWriteResponse> = {
|
||||
id: 'microsoft_teams_write_channel',
|
||||
name: 'Write to Microsoft Teams Channel',
|
||||
description: 'Write or send a message to a Microsoft Teams channel',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
teamId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the team to write to (e.g., "12345678-abcd-1234-efgh-123456789012" - a GUID from team listings)',
|
||||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the channel to write to (e.g., "19:abc123def456@thread.tacv2" - from channel listings)',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The content to write to the channel (plain text or HTML formatted, supports @mentions)',
|
||||
},
|
||||
files: {
|
||||
type: 'file[]',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Files to attach to the message',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Teams channel message send success status' },
|
||||
messageId: { type: 'string', description: 'Unique identifier for the sent message' },
|
||||
teamId: { type: 'string', description: 'ID of the team where message was sent' },
|
||||
channelId: { type: 'string', description: 'ID of the channel where message was sent' },
|
||||
createdTime: { type: 'string', description: 'Timestamp when message was created' },
|
||||
url: { type: 'string', description: 'Web URL to the message' },
|
||||
updatedContent: { type: 'boolean', description: 'Whether content was successfully updated' },
|
||||
files: { type: 'file[]', description: 'Files attached to the message' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const teamId = params.teamId?.trim()
|
||||
if (!teamId) {
|
||||
throw new Error('Team ID is required')
|
||||
}
|
||||
|
||||
const channelId = params.channelId?.trim()
|
||||
if (!channelId) {
|
||||
throw new Error('Channel ID is required')
|
||||
}
|
||||
|
||||
// If files are provided, use custom API route for attachment handling
|
||||
if (params.files && params.files.length > 0) {
|
||||
return '/api/tools/microsoft_teams/write_channel'
|
||||
}
|
||||
|
||||
// If content contains mentions, use custom API route for mention resolution
|
||||
const hasMentions = /<at>[^<]+<\/at>/i.test(params.content || '')
|
||||
if (hasMentions) {
|
||||
return '/api/tools/microsoft_teams/write_channel'
|
||||
}
|
||||
|
||||
const encodedTeamId = encodeURIComponent(teamId)
|
||||
const encodedChannelId = encodeURIComponent(channelId)
|
||||
|
||||
const url = `https://graph.microsoft.com/v1.0/teams/${encodedTeamId}/channels/${encodedChannelId}/messages`
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
// Validate access token
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
// Validate content
|
||||
if (!params.content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
|
||||
// If using custom API route (with files or mentions), pass all params
|
||||
const hasMentions = /<at>[^<]+<\/at>/i.test(params.content || '')
|
||||
if (params.files && params.files.length > 0) {
|
||||
return {
|
||||
accessToken: params.accessToken,
|
||||
teamId: params.teamId,
|
||||
channelId: params.channelId,
|
||||
content: params.content,
|
||||
files: params.files,
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMentions) {
|
||||
return {
|
||||
accessToken: params.accessToken,
|
||||
teamId: params.teamId,
|
||||
channelId: params.channelId,
|
||||
content: params.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Microsoft Teams API expects this specific format for channel messages
|
||||
const requestBody = {
|
||||
body: {
|
||||
contentType: 'text',
|
||||
content: params.content,
|
||||
},
|
||||
}
|
||||
|
||||
return requestBody
|
||||
},
|
||||
},
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
// Handle custom API route response format
|
||||
if (data.success !== undefined && data.output) {
|
||||
return data
|
||||
}
|
||||
|
||||
// Handle direct Graph API response format
|
||||
const metadata = {
|
||||
messageId: data.id || '',
|
||||
teamId: data.channelIdentity?.teamId || '',
|
||||
channelId: data.channelIdentity?.channelId || '',
|
||||
content: data.body?.content || params?.content || '',
|
||||
createdTime: data.createdDateTime || new Date().toISOString(),
|
||||
url: data.webUrl || '',
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updatedContent: true,
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type {
|
||||
MicrosoftTeamsToolParams,
|
||||
MicrosoftTeamsWriteResponse,
|
||||
} from '@/tools/microsoft_teams/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const writeChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsWriteResponse> = {
|
||||
id: 'microsoft_teams_write_chat',
|
||||
name: 'Write to Microsoft Teams Chat',
|
||||
description: 'Write or update content in a Microsoft Teams chat',
|
||||
version: '1.0',
|
||||
errorExtractor: 'nested-error-object',
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'microsoft-teams',
|
||||
},
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'The access token for the Microsoft Teams API',
|
||||
},
|
||||
chatId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The ID of the chat to write to (e.g., "19:abc123def456@thread.v2" - from chat listings)',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The content to write to the message (plain text or HTML formatted, supports @mentions)',
|
||||
},
|
||||
files: {
|
||||
type: 'file[]',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Files to attach to the message',
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Teams chat message send success status' },
|
||||
messageId: { type: 'string', description: 'Unique identifier for the sent message' },
|
||||
chatId: { type: 'string', description: 'ID of the chat where message was sent' },
|
||||
createdTime: { type: 'string', description: 'Timestamp when message was created' },
|
||||
url: { type: 'string', description: 'Web URL to the message' },
|
||||
updatedContent: { type: 'boolean', description: 'Whether content was successfully updated' },
|
||||
files: { type: 'file[]', description: 'Files attached to the message' },
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
// Ensure chatId is valid
|
||||
const chatId = params.chatId?.trim()
|
||||
if (!chatId) {
|
||||
throw new Error('Chat ID is required')
|
||||
}
|
||||
|
||||
// If files are provided, use custom API route for attachment handling
|
||||
if (params.files && params.files.length > 0) {
|
||||
return '/api/tools/microsoft_teams/write_chat'
|
||||
}
|
||||
|
||||
// If content contains mentions, use custom API route for mention resolution
|
||||
const hasMentions = /<at>[^<]+<\/at>/i.test(params.content || '')
|
||||
if (hasMentions) {
|
||||
return '/api/tools/microsoft_teams/write_chat'
|
||||
}
|
||||
|
||||
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
// Validate access token
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Access token is required')
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
// Validate content
|
||||
if (!params.content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
|
||||
// If using custom API route (with files or mentions), pass all params
|
||||
const hasMentions = /<at>[^<]+<\/at>/i.test(params.content || '')
|
||||
if (params.files && params.files.length > 0) {
|
||||
return {
|
||||
accessToken: params.accessToken,
|
||||
chatId: params.chatId,
|
||||
content: params.content,
|
||||
files: params.files,
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMentions) {
|
||||
return {
|
||||
accessToken: params.accessToken,
|
||||
chatId: params.chatId,
|
||||
content: params.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Microsoft Teams API expects this specific format
|
||||
const requestBody = {
|
||||
body: {
|
||||
contentType: 'text',
|
||||
content: params.content,
|
||||
},
|
||||
}
|
||||
|
||||
return requestBody
|
||||
},
|
||||
},
|
||||
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
// Handle custom API route response format
|
||||
if (data.success !== undefined && data.output) {
|
||||
return data
|
||||
}
|
||||
|
||||
// Handle direct Graph API response format
|
||||
const metadata = {
|
||||
messageId: data.id || '',
|
||||
chatId: data.chatId || '',
|
||||
content: data.body?.content || params?.content || '',
|
||||
createdTime: data.createdDateTime || new Date().toISOString(),
|
||||
url: data.webUrl || '',
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updatedContent: true,
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user