import * as z from "zod" import { defineAction } from "../../automation/actions" import { getTeamsGraphApi, listTeamsGraphCollection, } from "@automate.ax/integration-contracts/teams" import { GRAPH_CHANNEL_SCHEMA, GRAPH_CHAT_MESSAGE_SCHEMA, GRAPH_CHAT_SCHEMA, GRAPH_MEMBER_SCHEMA, GRAPH_TEAM_SCHEMA, TEAMS_CHANNEL_SCHEMA, TEAMS_CHAT_MESSAGE_SCHEMA, TEAMS_CHAT_SCHEMA, TEAMS_MEMBER_SCHEMA, TEAMS_TEAM_SCHEMA, } from "@automate.ax/integration-contracts/teams" import { TEAMS_CHANNEL_REFERENCE_MEMBER_REQUIREMENT, TEAMS_CHANNEL_REFERENCE_MESSAGE_READ_REQUIREMENT, TEAMS_CHANNEL_REFERENCE_MESSAGE_SEND_REQUIREMENT, TEAMS_CHAT_CREATE_REQUIREMENT, TEAMS_CHAT_MEMBER_READ_REQUIREMENT, TEAMS_CHAT_MESSAGE_READ_REQUIREMENT, TEAMS_CHAT_MESSAGE_SEND_REQUIREMENT, TEAMS_CHAT_READ_REQUIREMENT, TEAMS_TEAM_REFERENCE_MEMBER_REQUIREMENT, TEAMS_TEAM_REFERENCE_READ_REQUIREMENT, TEAMS_TEAM_READ_REQUIREMENT, } from "./lib/scopes" export * from "./extra-actions" const DEFAULT_LIMIT = 100 const MAX_LIMIT = 10_000 const GRAPH_PAGE_SIZE = 50 const LIMIT_SCHEMA = z.number().int().min(1).max(MAX_LIMIT).optional() const TEAM_INPUT_SCHEMA = z.object({ /** Exact Microsoft team display name or ID. */ team: z.string().trim().min(1), }) const CHANNEL_INPUT_SCHEMA = TEAM_INPUT_SCHEMA.extend({ /** Exact Microsoft channel display name or ID. */ channel: z.string().trim().min(1), }) const CHAT_ID_INPUT_SCHEMA = z.object({ /** Opaque Microsoft chat identifier. */ chatId: z.string().min(1), }) const CHAT_MESSAGE_CONTENT_SCHEMA = z.object({ /** Message content. HTML may include Teams-supported markup. */ body: z.string(), /** Whether the message is plain text or HTML. */ bodyType: z.enum(["text", "html"]).prefault("text"), }) /** * Lists teams the connected work or school account has joined. * * Microsoft Graph does not support this API for personal Microsoft accounts. */ export const listJoinedMicrosoftTeams = defineAction( "List joined Microsoft Teams", ) .describe("Lists teams joined by the connected Microsoft 365 user.") .account("microsoft", TEAMS_TEAM_READ_REQUIREMENT) .input(z.object({})) .output(TEAMS_TEAM_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account }) => { const { request } = getTeamsGraphApi(account.secret) return listTeamsGraphCollection( request, "/me/joinedTeams", GRAPH_TEAM_SCHEMA, MAX_LIMIT, ) }) /** Lists channels in one Microsoft Team. */ export const listMicrosoftTeamsChannels = defineAction( "List Microsoft Teams channels", ) .describe("Lists channels in one Microsoft Team.") .account("microsoft", TEAMS_TEAM_REFERENCE_READ_REQUIREMENT) .input( TEAM_INPUT_SCHEMA.extend({ /** Maximum channels returned across Graph pages. */ limit: LIMIT_SCHEMA, }), ) .output(TEAMS_CHANNEL_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { request } = getTeamsGraphApi(account.secret) const limit = input.limit ?? DEFAULT_LIMIT return listTeamsGraphCollection( request, `/teams/${encodeURIComponent(await resolveTeamId(account.secret, input.team))}/channels?$top=${Math.min(GRAPH_PAGE_SIZE, limit)}`, GRAPH_CHANNEL_SCHEMA, limit, ) }) /** Lists direct members of one Microsoft Team. */ export const listMicrosoftTeamsTeamMembers = defineAction( "List Microsoft Team members", ) .describe("Lists direct members and owners of one Microsoft Team.") .account("microsoft", TEAMS_TEAM_REFERENCE_MEMBER_REQUIREMENT) .input( TEAM_INPUT_SCHEMA.extend({ /** Maximum members returned across Graph pages. */ limit: LIMIT_SCHEMA, }), ) .output(TEAMS_MEMBER_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { return listMembers( account.secret, `/teams/${encodeURIComponent(await resolveTeamId(account.secret, input.team))}/members`, input.limit, ) }) /** Lists direct members of one Teams channel. */ export const listMicrosoftTeamsChannelMembers = defineAction( "List Teams channel members", ) .describe("Lists direct members and owners of one Teams channel.") .account("microsoft", TEAMS_CHANNEL_REFERENCE_MEMBER_REQUIREMENT) .input( CHANNEL_INPUT_SCHEMA.extend({ /** Maximum members returned across Graph pages. */ limit: LIMIT_SCHEMA, }), ) .output(TEAMS_MEMBER_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { channelId, teamId } = await resolveChannelReference( account.secret, input, ) return listMembers( account.secret, `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/members`, input.limit, ) }) /** Lists root messages in one Teams channel. */ export const listMicrosoftTeamsChannelMessages = defineAction( "List Teams channel messages", ) .describe("Lists root messages in one Microsoft Teams channel.") .account("microsoft", TEAMS_CHANNEL_REFERENCE_MESSAGE_READ_REQUIREMENT) .input( CHANNEL_INPUT_SCHEMA.extend({ /** Maximum messages returned across Graph pages. */ limit: LIMIT_SCHEMA, }), ) .output(TEAMS_CHAT_MESSAGE_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { channelId, teamId } = await resolveChannelReference( account.secret, input, ) return listMessages( account.secret, `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages`, input.limit, ) }) /** Gets one root or reply message from a Teams channel. */ export const getMicrosoftTeamsChannelMessage = defineAction( "Get Teams channel message", ) .describe("Gets one root or reply message from a Teams channel.") .account("microsoft", TEAMS_CHANNEL_REFERENCE_MESSAGE_READ_REQUIREMENT) .input( CHANNEL_INPUT_SCHEMA.extend({ /** Opaque Teams message identifier. */ messageId: z.string().min(1), /** Root message ID when retrieving a reply. */ replyToMessageId: z.string().min(1).optional(), }), ) .output(TEAMS_CHAT_MESSAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { return GRAPH_CHAT_MESSAGE_SCHEMA.parse( await getTeamsGraphApi(account.secret).request( channelMessagePath( { ...(await resolveChannelReference(account.secret, input)), messageId: input.messageId, }, input.replyToMessageId, ), ), ) }) /** Lists replies beneath one Teams channel message. */ export const listMicrosoftTeamsChannelReplies = defineAction( "List Teams channel replies", ) .describe("Lists replies beneath one Teams channel message.") .account("microsoft", TEAMS_CHANNEL_REFERENCE_MESSAGE_READ_REQUIREMENT) .input( CHANNEL_INPUT_SCHEMA.extend({ /** Maximum replies returned across Graph pages. */ limit: LIMIT_SCHEMA, /** Root Teams channel message identifier. */ messageId: z.string().min(1), }), ) .output(TEAMS_CHAT_MESSAGE_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { return listMessages( account.secret, `${channelMessagePath({ ...(await resolveChannelReference(account.secret, input)), messageId: input.messageId, })}/replies`, input.limit, ) }) /** Sends a new root message to a Teams channel. */ export const sendMicrosoftTeamsChannelMessage = defineAction( "Send Teams channel message", ) .describe("Sends a new root message to a Microsoft Teams channel.") .account("microsoft", TEAMS_CHANNEL_REFERENCE_MESSAGE_SEND_REQUIREMENT) .input(CHANNEL_INPUT_SCHEMA.extend(CHAT_MESSAGE_CONTENT_SCHEMA.shape)) .output(TEAMS_CHAT_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { channelId, teamId } = await resolveChannelReference( account.secret, input, ) return sendTeamsMessage( account.secret, `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages`, input, ) }) /** Sends a reply beneath one Teams channel message. */ export const replyToMicrosoftTeamsChannelMessage = defineAction( "Reply to Teams channel message", ) .describe("Sends a reply beneath one Microsoft Teams channel message.") .account("microsoft", TEAMS_CHANNEL_REFERENCE_MESSAGE_SEND_REQUIREMENT) .input( CHANNEL_INPUT_SCHEMA.extend({ ...CHAT_MESSAGE_CONTENT_SCHEMA.shape, /** Root Teams channel message identifier. */ messageId: z.string().min(1), }), ) .output(TEAMS_CHAT_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { return sendTeamsMessage( account.secret, `${channelMessagePath({ ...(await resolveChannelReference(account.secret, input)), messageId: input.messageId, })}/replies`, input, ) }) /** Lists chats the connected Microsoft 365 user participates in. */ export const listMicrosoftTeamsChats = defineAction( "List Microsoft Teams chats", ) .describe("Lists one-to-one, group, and meeting chats for the user.") .account("microsoft", TEAMS_CHAT_READ_REQUIREMENT) .input( z.object({ /** Maximum chats returned across Graph pages. */ limit: LIMIT_SCHEMA, }), ) .output(TEAMS_CHAT_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { request } = getTeamsGraphApi(account.secret) const limit = input.limit ?? DEFAULT_LIMIT return listTeamsGraphCollection( request, `/me/chats?$top=${Math.min(GRAPH_PAGE_SIZE, limit)}`, GRAPH_CHAT_SCHEMA, limit, ) }) /** * Creates a one-on-one or group chat and automatically includes the connected * Microsoft user as an owner. */ export const createMicrosoftTeamsChat = defineAction( "Create Microsoft Teams chat", ) .describe("Creates a one-on-one or group chat with Microsoft 365 users.") .account("microsoft", TEAMS_CHAT_CREATE_REQUIREMENT) .input( z .object({ /** Whether to create a direct or group chat. */ chatType: z.enum(["oneOnOne", "group"]), /** Other users to add, identified by Entra ID or user principal name. */ participants: z .object({ /** Membership role; use `guest` for in-tenant guest users. */ role: z.enum(["owner", "guest"]).prefault("owner"), /** Microsoft Entra user ID or user principal name. */ user: z.string().min(1), }) .array() .min(1), /** Group-chat title. Only valid for group chats. */ topic: z.string().min(1).optional(), }) .superRefine((input, context) => { if (input.chatType === "oneOnOne" && input.participants.length !== 1) { context.addIssue({ code: "custom", message: "A one-on-one chat requires exactly one other participant.", path: ["participants"], }) } if (input.chatType === "oneOnOne" && input.topic !== undefined) { context.addIssue({ code: "custom", message: "Only group chats may have a topic.", path: ["topic"], }) } if (input.chatType === "group" && input.participants.length < 2) { context.addIssue({ code: "custom", message: "A group chat requires at least two other participants.", path: ["participants"], }) } }), ) .output(TEAMS_CHAT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { request } = getTeamsGraphApi(account.secret) const { id: connectedUserId } = z .object({ id: z.string() }) .parse(await request("/me?$select=id")) return GRAPH_CHAT_SCHEMA.parse( await request("/chats", { body: { chatType: input.chatType, members: [ { "@odata.type": "#microsoft.graph.aadUserConversationMember", roles: ["owner"], "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${connectedUserId}')`, }, ...input.participants.map((participant) => ({ "@odata.type": "#microsoft.graph.aadUserConversationMember", roles: [participant.role], "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${participant.user.replaceAll("'", "''")}')`, })), ], ...(input.topic && { topic: input.topic }), }, method: "POST", }), ) }) /** Gets one Teams chat. */ export const getMicrosoftTeamsChat = defineAction("Get Microsoft Teams chat") .describe("Gets one Microsoft Teams chat.") .account("microsoft", TEAMS_CHAT_READ_REQUIREMENT) .input(CHAT_ID_INPUT_SCHEMA) .output(TEAMS_CHAT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => GRAPH_CHAT_SCHEMA.parse( await getTeamsGraphApi(account.secret).request( `/chats/${encodeURIComponent(input.chatId)}`, ), ), ) /** Lists members of one Teams chat. */ export const listMicrosoftTeamsChatMembers = defineAction( "List Microsoft Teams chat members", ) .describe("Lists participants in one Microsoft Teams chat.") .account("microsoft", TEAMS_CHAT_MEMBER_READ_REQUIREMENT) .input( CHAT_ID_INPUT_SCHEMA.extend({ /** Maximum members returned across Graph pages. */ limit: LIMIT_SCHEMA, }), ) .output(TEAMS_MEMBER_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => listMembers( account.secret, `/chats/${encodeURIComponent(input.chatId)}/members`, input.limit, ), ) /** Lists messages in one Teams chat. */ export const listMicrosoftTeamsChatMessages = defineAction( "List Microsoft Teams chat messages", ) .describe("Lists messages in one Microsoft Teams chat.") .account("microsoft", TEAMS_CHAT_MESSAGE_READ_REQUIREMENT) .input( CHAT_ID_INPUT_SCHEMA.extend({ /** Maximum messages returned across Graph pages. */ limit: LIMIT_SCHEMA, }), ) .output(TEAMS_CHAT_MESSAGE_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => listMessages( account.secret, `/chats/${encodeURIComponent(input.chatId)}/messages`, input.limit, ), ) /** Gets one message from a Teams chat. */ export const getMicrosoftTeamsChatMessage = defineAction( "Get Microsoft Teams chat message", ) .describe("Gets one message from a Microsoft Teams chat.") .account("microsoft", TEAMS_CHAT_MESSAGE_READ_REQUIREMENT) .input( CHAT_ID_INPUT_SCHEMA.extend({ /** Opaque Teams message identifier. */ messageId: z.string().min(1), }), ) .output(TEAMS_CHAT_MESSAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => GRAPH_CHAT_MESSAGE_SCHEMA.parse( await getTeamsGraphApi(account.secret).request( `/chats/${encodeURIComponent(input.chatId)}/messages/${encodeURIComponent(input.messageId)}`, ), ), ) /** Sends a message to an existing Teams chat. */ export const sendMicrosoftTeamsChatMessage = defineAction( "Send Microsoft Teams chat message", ) .describe("Sends a message to an existing Microsoft Teams chat.") .account("microsoft", TEAMS_CHAT_MESSAGE_SEND_REQUIREMENT) .input(CHAT_ID_INPUT_SCHEMA.extend(CHAT_MESSAGE_CONTENT_SCHEMA.shape)) .output(TEAMS_CHAT_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => sendTeamsMessage( account.secret, `/chats/${encodeURIComponent(input.chatId)}/messages`, input, ), ) /** * Resolves an exact joined-team display name or ID. * * @param secret - Resolved Microsoft integration secret. * @param reference - Team display name or ID. */ async function resolveTeamId( secret: Record, reference: string, ) { const { request } = getTeamsGraphApi(secret) const teams = await listTeamsGraphCollection( request, "/me/joinedTeams", GRAPH_TEAM_SCHEMA, MAX_LIMIT, ) const idMatch = teams.find(({ teamId }) => teamId === reference) if (idMatch) return idMatch.teamId const normalizedReference = reference.trim().toLowerCase() const nameMatches = teams.filter( ({ displayName }) => displayName.trim().toLowerCase() === normalizedReference, ) if (nameMatches.length === 1) return nameMatches[0]!.teamId if (nameMatches.length > 1) { throw new Error( `Microsoft team "${reference}" is ambiguous; matching IDs: ${nameMatches.map(({ teamId }) => teamId).join(", ")}.`, ) } throw new Error(`Microsoft team "${reference}" was not found.`) } /** * Resolves exact team and channel display names or IDs. * * @param secret - Resolved Microsoft integration secret. * @param input - Team and channel references. */ async function resolveChannelReference( secret: Record, input: z.output, ) { const teamId = await resolveTeamId(secret, input.team) const { request } = getTeamsGraphApi(secret) const channels = await listTeamsGraphCollection( request, `/teams/${encodeURIComponent(teamId)}/channels`, GRAPH_CHANNEL_SCHEMA, MAX_LIMIT, ) const idMatch = channels.find(({ channelId }) => channelId === input.channel) if (idMatch) return { channelId: idMatch.channelId, teamId } const normalizedReference = input.channel.trim().toLowerCase() const nameMatches = channels.filter( ({ displayName }) => displayName.trim().toLowerCase() === normalizedReference, ) if (nameMatches.length === 1) { return { channelId: nameMatches[0]!.channelId, teamId } } if (nameMatches.length > 1) { throw new Error( `Microsoft Teams channel "${input.channel}" is ambiguous; matching IDs: ${nameMatches.map(({ channelId }) => channelId).join(", ")}.`, ) } throw new Error( `Microsoft Teams channel "${input.channel}" was not found in team "${input.team}".`, ) } /** * Lists and validates conversation members. * * @param secret - Resolved Microsoft secret. * @param path - Graph members collection path. * @param requestedLimit - Maximum members to return. */ async function listMembers( secret: Record, path: string, requestedLimit?: number, ) { const { request } = getTeamsGraphApi(secret) const limit = requestedLimit ?? DEFAULT_LIMIT return listTeamsGraphCollection( request, `${path}?$top=${Math.min(999, limit)}`, GRAPH_MEMBER_SCHEMA, limit, ) } /** * Lists and validates Teams messages. * * @param secret - Resolved Microsoft secret. * @param path - Graph messages collection path. * @param requestedLimit - Maximum messages to return. */ async function listMessages( secret: Record, path: string, requestedLimit?: number, ) { const { request } = getTeamsGraphApi(secret) const limit = requestedLimit ?? DEFAULT_LIMIT return listTeamsGraphCollection( request, `${path}?$top=${Math.min(GRAPH_PAGE_SIZE, limit)}`, GRAPH_CHAT_MESSAGE_SCHEMA, limit, ) } /** * Sends and validates one Teams message. * * @param secret - Resolved Microsoft secret. * @param path - Graph messages collection path. * @param content - Message body and content type. */ async function sendTeamsMessage( secret: Record, path: string, content: z.output, ) { return GRAPH_CHAT_MESSAGE_SCHEMA.parse( await getTeamsGraphApi(secret).request(path, { body: { body: { content: content.body, contentType: content.bodyType, }, }, method: "POST", }), ) } /** * Builds a root or reply channel-message path. * * @param input - Team, channel, and message identifiers. * @param input.channelId - Opaque channel identifier. * @param input.messageId - Opaque message identifier. * @param input.teamId - Opaque team identifier. * @param replyToMessageId - Root identifier when retrieving a reply. */ function channelMessagePath( input: { channelId: string messageId: string teamId: string }, replyToMessageId?: string, ) { const root = `/teams/${encodeURIComponent(input.teamId)}/channels/${encodeURIComponent(input.channelId)}/messages` return replyToMessageId ? `${root}/${encodeURIComponent(replyToMessageId)}/replies/${encodeURIComponent(input.messageId)}` : `${root}/${encodeURIComponent(input.messageId)}` }