From 0d0e998cd99c091099a43ae88b43cabebb1f16d2 Mon Sep 17 00:00:00 2001 From: Linghan Hu Date: Tue, 7 Jul 2026 07:10:44 +0000 Subject: [PATCH 1/3] feat: add microsoft teams cli support --- turbo/apps/api/src/lib/teams-bot-activity.ts | 38 +++ .../src/signals/external/teams-bot-client.ts | 106 +++++++- turbo/apps/api/src/signals/route.ts | 8 + .../__tests__/run-lifecycle.bdd.test.ts | 3 +- .../__tests__/zero-integrations-teams.test.ts | 233 +++++++++++++++++ .../routes/__tests__/zero-teams-bot.test.ts | 158 ++++++++++- .../zero-integrations-teams-download-file.ts | 247 ++++++++++++++++++ .../routes/zero-integrations-teams-message.ts | 116 ++++++++ ...zero-integrations-teams-upload-complete.ts | 218 ++++++++++++++++ .../zero-integrations-teams-upload-init.ts | 62 +++++ .../services/run-uploaded-files.service.ts | 61 +++++ .../src/signals/services/teams-file-token.ts | 65 +++++ .../services/zero-runs-create.service.ts | 4 +- .../services/zero-teams-dispatch.service.ts | 117 ++++++++- .../zero-capability-visibility.test.ts | 21 ++ .../teams/__tests__/download-file.test.ts | 112 ++++++++ .../zero/teams/__tests__/send.test.ts | 80 ++++++ .../zero/teams/__tests__/upload-file.test.ts | 138 ++++++++++ .../src/commands/zero/teams/download-file.ts | 40 +++ .../apps/cli/src/commands/zero/teams/index.ts | 22 ++ .../src/commands/zero/teams/message/index.ts | 13 + .../src/commands/zero/teams/message/send.ts | 60 +++++ .../src/commands/zero/teams/upload-file.ts | 115 ++++++++ .../src/lib/api/domains/integrations-teams.ts | 138 ++++++++++ turbo/apps/cli/src/lib/api/index.ts | 8 + turbo/apps/cli/src/zero.ts | 12 + .../src/contracts/__tests__/composes.test.ts | 8 +- .../api-contracts/src/contracts/composes.ts | 5 + .../api-contracts/src/contracts/index.ts | 10 + .../src/contracts/integrations.ts | 144 ++++++++++ .../src/contracts/zero-teams-bot.ts | 12 + .../db/src/schema/run-uploaded-file.ts | 1 + 32 files changed, 2354 insertions(+), 21 deletions(-) create mode 100644 turbo/apps/api/src/signals/routes/__tests__/zero-integrations-teams.test.ts create mode 100644 turbo/apps/api/src/signals/routes/zero-integrations-teams-download-file.ts create mode 100644 turbo/apps/api/src/signals/routes/zero-integrations-teams-message.ts create mode 100644 turbo/apps/api/src/signals/routes/zero-integrations-teams-upload-complete.ts create mode 100644 turbo/apps/api/src/signals/routes/zero-integrations-teams-upload-init.ts create mode 100644 turbo/apps/api/src/signals/services/teams-file-token.ts create mode 100644 turbo/apps/cli/src/commands/zero/teams/__tests__/download-file.test.ts create mode 100644 turbo/apps/cli/src/commands/zero/teams/__tests__/send.test.ts create mode 100644 turbo/apps/cli/src/commands/zero/teams/__tests__/upload-file.test.ts create mode 100644 turbo/apps/cli/src/commands/zero/teams/download-file.ts create mode 100644 turbo/apps/cli/src/commands/zero/teams/index.ts create mode 100644 turbo/apps/cli/src/commands/zero/teams/message/index.ts create mode 100644 turbo/apps/cli/src/commands/zero/teams/message/send.ts create mode 100644 turbo/apps/cli/src/commands/zero/teams/upload-file.ts create mode 100644 turbo/apps/cli/src/lib/api/domains/integrations-teams.ts diff --git a/turbo/apps/api/src/lib/teams-bot-activity.ts b/turbo/apps/api/src/lib/teams-bot-activity.ts index 783b7cefb09..1fa8b156fe6 100644 --- a/turbo/apps/api/src/lib/teams-bot-activity.ts +++ b/turbo/apps/api/src/lib/teams-bot-activity.ts @@ -1,5 +1,6 @@ import type { TeamsActor, + TeamsInboundAttachment, TeamsInboundActivity, } from "@vm0/api-contracts/contracts/zero-teams-bot"; @@ -86,6 +87,42 @@ function normalizeActorArray(values: readonly unknown[]): TeamsActor[] { }); } +function normalizeAttachment( + value: unknown, +): TeamsInboundAttachment | undefined { + if (!isRecord(value)) { + return undefined; + } + + const content = value.content; + const attachment = { + id: readString(value, "id"), + contentType: readString(value, "contentType"), + contentUrl: readString(value, "contentUrl"), + name: readString(value, "name"), + content: isRecord(content) ? content : null, + }; + if ( + attachment.id || + attachment.contentType || + attachment.contentUrl || + attachment.name || + attachment.content + ) { + return attachment; + } + return undefined; +} + +function normalizeAttachments( + values: readonly unknown[], +): TeamsInboundAttachment[] { + return values.flatMap((value) => { + const attachment = normalizeAttachment(value); + return attachment ? [attachment] : []; + }); +} + function normalizeWhitespace(text: string): string { return text.replace(/\s+/g, " ").trim(); } @@ -252,6 +289,7 @@ function messageActivity( text: mentionNormalization.text, value, mentionsRecipient: mentionNormalization.mentionsRecipient, + attachments: normalizeAttachments(readArray(activity, "attachments")), }; } diff --git a/turbo/apps/api/src/signals/external/teams-bot-client.ts b/turbo/apps/api/src/signals/external/teams-bot-client.ts index f440d5bbe42..23b2fadf0ac 100644 --- a/turbo/apps/api/src/signals/external/teams-bot-client.ts +++ b/turbo/apps/api/src/signals/external/teams-bot-client.ts @@ -87,6 +87,10 @@ type FetchTeamsGraphMessagesResult = | { readonly kind: "ok"; readonly messages: readonly TeamsGraphMessage[] } | TeamsApiErrorResult; +type FetchTeamsFileResult = + | { readonly kind: "ok"; readonly response: Response } + | TeamsApiErrorResult; + export type TeamsGraphMessage = z.infer; interface TeamsBotCredentials { @@ -142,11 +146,21 @@ export interface TeamsAdaptiveCard { readonly actions?: readonly TeamsAdaptiveCardAction[]; } -interface TeamsActivityAttachment { +interface TeamsAdaptiveCardAttachment { readonly contentType: "application/vnd.microsoft.card.adaptive"; readonly content: TeamsAdaptiveCard; } +interface TeamsFileAttachment { + readonly contentType: string; + readonly contentUrl?: string; + readonly name?: string; +} + +type TeamsActivityAttachment = + | TeamsAdaptiveCardAttachment + | TeamsFileAttachment; + interface TeamsActivityBody { readonly type: "message" | "typing"; readonly text?: string; @@ -362,6 +376,17 @@ function teamsGraphChannelMessageUrl(args: { return url.toString(); } +function shouldAuthorizeTeamsFileDownload(url: string): boolean { + const parsed = safeUrlParse(url); + if (!parsed) { + return false; + } + return ( + parsed.hostname.toLowerCase().endsWith(".trafficmanager.net") && + parsed.pathname.includes("/v3/attachments/") + ); +} + async function fetchTeamsGraphJson(args: { readonly tenantId: string; readonly url: string; @@ -407,6 +432,41 @@ async function fetchTeamsGraphJson(args: { return { kind: "ok", data: parsed.data }; } +export async function fetchTeamsFile(args: { + readonly tenantId: string; + readonly url: string; + readonly signal: AbortSignal; +}): Promise { + const headers: Record = { + accept: "application/octet-stream", + }; + + if (shouldAuthorizeTeamsFileDownload(args.url)) { + const accessToken = await fetchTeamsBotAccessToken({ + tenantId: args.tenantId, + signal: args.signal, + }); + if (accessToken.kind === "teams-error") { + return accessToken; + } + headers.authorization = `Bearer ${accessToken.accessToken}`; + } + + const responseResult = await settle( + fetch(args.url, { + method: "GET", + headers, + signal: args.signal, + }), + args.signal, + ); + if (!responseResult.ok) { + return teamsApiError(502, networkErrorMessage(responseResult.error)); + } + + return { kind: "ok", response: responseResult.value }; +} + async function postTeamsActivity(args: { readonly serviceUrl: string; readonly conversationId: string; @@ -611,6 +671,39 @@ export function sendTeamsMessageReply(args: { readonly card?: TeamsAdaptiveCard; readonly signal: AbortSignal; }): Promise { + return sendTeamsMessage({ + serviceUrl: args.serviceUrl, + conversationId: args.conversationId, + activityId: args.activityId, + tenantId: args.tenantId, + text: args.text, + card: args.card, + signal: args.signal, + }); +} + +export function sendTeamsMessage(args: { + readonly serviceUrl: string; + readonly conversationId: string; + readonly activityId?: string; + readonly tenantId: string; + readonly text: string; + readonly card?: TeamsAdaptiveCard; + readonly attachments?: readonly TeamsActivityAttachment[]; + readonly signal: AbortSignal; +}): Promise { + const attachments: readonly TeamsActivityAttachment[] = [ + ...(args.card + ? [ + { + contentType: "application/vnd.microsoft.card.adaptive" as const, + content: args.card, + }, + ] + : []), + ...(args.attachments ?? []), + ]; + return postTeamsActivity({ serviceUrl: args.serviceUrl, conversationId: args.conversationId, @@ -623,16 +716,7 @@ export function sendTeamsMessageReply(args: { ? { summary: args.text } : { text: args.text, textFormat: "markdown" }), replyToId: args.activityId, - ...(args.card - ? { - attachments: [ - { - contentType: "application/vnd.microsoft.card.adaptive", - content: args.card, - }, - ], - } - : {}), + ...(attachments.length > 0 ? { attachments } : {}), channelData: { tenant: { id: args.tenantId }, }, diff --git a/turbo/apps/api/src/signals/route.ts b/turbo/apps/api/src/signals/route.ts index e3782e2951c..c1d277f6338 100644 --- a/turbo/apps/api/src/signals/route.ts +++ b/turbo/apps/api/src/signals/route.ts @@ -152,6 +152,10 @@ import { zeroIntegrationsSlackRoutes } from "./routes/zero-integrations-slack"; import { zeroIntegrationsSlackMessageRoutes } from "./routes/zero-integrations-slack-message"; import { zeroIntegrationsSlackUploadCompleteRoutes } from "./routes/zero-integrations-slack-upload-complete"; import { zeroIntegrationsSlackUploadInitRoutes } from "./routes/zero-integrations-slack-upload-init"; +import { zeroIntegrationsTeamsDownloadFileRoutes } from "./routes/zero-integrations-teams-download-file"; +import { zeroIntegrationsTeamsMessageRoutes } from "./routes/zero-integrations-teams-message"; +import { zeroIntegrationsTeamsUploadCompleteRoutes } from "./routes/zero-integrations-teams-upload-complete"; +import { zeroIntegrationsTeamsUploadInitRoutes } from "./routes/zero-integrations-teams-upload-init"; import { zeroIntegrationsTelegramRoutes } from "./routes/zero-integrations-telegram"; import { zeroIntegrationsTelegramMessageRoutes } from "./routes/zero-integrations-telegram-message"; import { zeroIntegrationsTelegramUploadCompleteRoutes } from "./routes/zero-integrations-telegram-upload-complete"; @@ -360,6 +364,10 @@ export const ROUTES: readonly RouteEntry[] = [ ...zeroIntegrationsSlackMessageRoutes, ...zeroIntegrationsSlackUploadCompleteRoutes, ...zeroIntegrationsSlackUploadInitRoutes, + ...zeroIntegrationsTeamsDownloadFileRoutes, + ...zeroIntegrationsTeamsMessageRoutes, + ...zeroIntegrationsTeamsUploadCompleteRoutes, + ...zeroIntegrationsTeamsUploadInitRoutes, ...zeroSlackChannelsRoutes, ...zeroSteamPlayerRoutes, ...zeroIntegrationsTelegramRoutes, diff --git a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts index c3c1150dca8..f7ac869379f 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/run-lifecycle.bdd.test.ts @@ -5656,10 +5656,11 @@ describe("RUN-01: zero runner context, queue promotion, and skills", () => { "zero developer-support --help", "zero maps --help", "zero slack message send --help", + "zero teams message send --help", "zero telegram bot list", "zero telegram message send --help", "zero phone message --help", - "do not invent `zero github message`, `zero teams message`, or `zero email message` commands", + "do not invent `zero github message` or `zero email message` commands", ]) { expect(appendSystemPrompt).toContain(toolHint); } diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-integrations-teams.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-integrations-teams.test.ts new file mode 100644 index 00000000000..1c0c1de45e2 --- /dev/null +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-integrations-teams.test.ts @@ -0,0 +1,233 @@ +import { randomUUID } from "node:crypto"; + +import { createStore } from "ccstate"; +import { + integrationsTeamsMessageContract, + integrationsTeamsUploadCompleteContract, +} from "@vm0/api-contracts/contracts/integrations"; +import { zeroTeamsConnectContract } from "@vm0/api-contracts/contracts/zero-teams-connect"; +import { HttpResponse, http } from "msw"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { mockEnv } from "../../../lib/env"; +import { now } from "../../../lib/time"; +import { server } from "../../../mocks/server"; +import { accept, setupApp, testContext } from "../../../__tests__/test-helpers"; +import { signSandboxJwtForTests } from "../../auth/tokens"; +import { seedOrgMembership$ } from "./helpers/zero-org-membership"; +import { createZeroRouteMocks } from "./helpers/zero-route-test"; +import { + installTeamsForTest, + removeTeamsForTest, + setupTeamsConnectTestEnv, + teamsConnectFixture, + type TeamsConnectFixture, +} from "./helpers/zero-teams-connect"; + +const context = testContext(); +const store = createStore(); +const mocks = createZeroRouteMocks(context); +const SERVICE_URL = "https://smba.trafficmanager.net/amer/"; + +interface CapturedTeamsActivity { + body?: Record; + authorization?: string | null; +} + +function currentSecond(): number { + return Math.floor(now() / 1000); +} + +function zeroToken(args: { + readonly userId: string; + readonly orgId: string; + readonly runId: string; + readonly capabilities?: readonly string[]; +}): string { + const seconds = currentSecond(); + return signSandboxJwtForTests({ + scope: "zero", + userId: args.userId, + orgId: args.orgId, + runId: args.runId, + capabilities: (args.capabilities ?? ["teams:write"]) as never, + iat: seconds, + exp: seconds + 60, + }); +} + +function teamsFixture(): TeamsConnectFixture { + return teamsConnectFixture({ + orgId: `org_teams_cli_${randomUUID().slice(0, 8)}`, + userId: `user_teams_cli_${randomUUID().slice(0, 8)}`, + serviceUrl: SERVICE_URL, + }); +} + +function mockOutgoingTeams(captured: CapturedTeamsActivity): void { + server.use( + http.post( + "https://login.microsoftonline.com/:tenant/oauth2/v2.0/token", + async ({ request }) => { + const form = await request.formData(); + expect(form.get("scope")).toBe("https://api.botframework.com/.default"); + return HttpResponse.json({ access_token: "bot-framework-token" }); + }, + ), + http.post( + "https://smba.trafficmanager.net/amer/v3/conversations/:conversationId/activities/:activityId", + async ({ request }) => { + captured.authorization = request.headers.get("authorization"); + captured.body = (await request.json()) as Record; + return HttpResponse.json({ id: "teams-activity-1" }); + }, + ), + http.post( + "https://smba.trafficmanager.net/amer/v3/conversations/:conversationId/activities", + async ({ request }) => { + captured.authorization = request.headers.get("authorization"); + captured.body = (await request.json()) as Record; + return HttpResponse.json({ id: "teams-activity-1" }); + }, + ), + ); +} + +async function seedConnectedTeams(fixture: TeamsConnectFixture): Promise { + await store.set( + seedOrgMembership$, + { orgId: fixture.orgId, userId: fixture.userId, role: "admin" }, + context.signal, + ); + await installTeamsForTest(context.signal, fixture); + mocks.clerk.session(fixture.userId, fixture.orgId, "org:admin"); + + const client = setupApp({ context })(zeroTeamsConnectContract); + await accept( + client.connect({ + headers: { authorization: "Bearer clerk-session" }, + body: { + tenantId: fixture.teamsTenantId, + teamsUserId: fixture.teamsUserId, + teamsUserDisplayName: "Ada Lovelace", + teamsUserPrincipalName: "ada@example.com", + }, + }), + [200], + ); +} + +describe("Microsoft Teams integration CLI routes", () => { + const fixtures: TeamsConnectFixture[] = []; + + beforeEach(() => { + setupTeamsConnectTestEnv("https://app.vm0.test"); + mockEnv("MICROSOFT_TEAMS_BOT_APP_PASSWORD", "bot-password"); + }); + + afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop(); + if (fixture) { + await removeTeamsForTest(context.signal, fixture); + } + } + }); + + it("sends a Teams message through the installed bot", async () => { + const fixture = teamsFixture(); + fixtures.push(fixture); + await seedConnectedTeams(fixture); + const captured: CapturedTeamsActivity = {}; + mockOutgoingTeams(captured); + + const client = setupApp({ context })(integrationsTeamsMessageContract); + const response = await accept( + client.sendMessage({ + body: { + conversationId: "19:thread@thread.tacv2", + activityId: "root-activity", + text: "Hello from Teams CLI", + }, + headers: { + authorization: `Bearer ${zeroToken({ + userId: fixture.userId, + orgId: fixture.orgId, + runId: `run_${randomUUID()}`, + })}`, + }, + }), + [200], + ); + + expect(response.body).toStrictEqual({ + ok: true, + activityId: "teams-activity-1", + conversationId: "19:thread@thread.tacv2", + }); + expect(captured.authorization).toBe("Bearer bot-framework-token"); + expect(captured.body).toMatchObject({ + type: "message", + text: "Hello from Teams CLI", + textFormat: "markdown", + replyToId: "root-activity", + channelData: { tenant: { id: fixture.teamsTenantId } }, + }); + }); + + it("sends an uploaded file URL as a Teams attachment", async () => { + const fixture = teamsFixture(); + fixtures.push(fixture); + await seedConnectedTeams(fixture); + const captured: CapturedTeamsActivity = {}; + mockOutgoingTeams(captured); + + const uploadId = randomUUID(); + mocks.s3.listObjects([ + { + bucket: "test-user-artifacts", + key: `artifacts/${fixture.userId}/${uploadId}/report.pdf`, + size: 1234, + }, + ]); + + const client = setupApp({ context })( + integrationsTeamsUploadCompleteContract, + ); + const response = await accept( + client.complete({ + body: { + uploadId, + conversationId: "19:thread@thread.tacv2", + activityId: "root-activity", + contentType: "application/pdf", + text: "Daily report", + }, + headers: { authorization: "Bearer clerk-session" }, + }), + [200], + ); + + expect(response.body).toMatchObject({ + activityId: "teams-activity-1", + conversationId: "19:thread@thread.tacv2", + filename: "report.pdf", + mimetype: "application/pdf", + size: 1234, + }); + expect(response.body.url).toContain(uploadId); + expect(captured.body?.text).toContain("Daily report"); + expect(captured.body?.text).toContain("[report.pdf]"); + expect(captured.body).toMatchObject({ + type: "message", + replyToId: "root-activity", + attachments: [ + { + contentType: "application/pdf", + contentUrl: response.body.url, + name: "report.pdf", + }, + ], + }); + }); +}); diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts index 5799bfc09c0..8fd342cca19 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts @@ -1,4 +1,9 @@ -import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto"; +import { + createSign, + generateKeyPairSync, + randomUUID, + type KeyObject, +} from "node:crypto"; import { zeroTeamsConnectContract } from "@vm0/api-contracts/contracts/zero-teams-connect"; import { FeatureSwitchKey } from "@vm0/connectors/feature-switch-key"; @@ -6,18 +11,21 @@ import { HttpResponse, http } from "msw"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createAppWithRoutes } from "../../../app-factory-core"; -import { verifyZeroToken } from "../../auth/tokens"; +import { signSandboxJwtForTests, verifyZeroToken } from "../../auth/tokens"; import { flushWaitUntilForTest } from "../../context/wait-until"; import { mockEnv, mockOptionalEnv } from "../../../lib/env"; +import { clearTeamsBotAuthCacheForTest } from "../../../lib/teams-bot-auth"; import { now } from "../../../lib/time"; import { server } from "../../../mocks/server"; import { accept, setupApp, testContext } from "../../../__tests__/test-helpers"; +import { ROUTES } from "../../route"; import { zeroTeamsBotRoutes } from "../zero-teams-bot"; import { createAuthOrgAgentsBddApi } from "./helpers/api-bdd-auth-org"; import { createComputerUseBddApi } from "./helpers/api-bdd-computer-use"; import { createRunsAutomationsApi } from "./helpers/api-bdd-runs-automations"; import { createUserConfigBddApi } from "./helpers/api-bdd-user-config"; import { + installTeamsForTest, removeTeamsForTest, setupTeamsConnectTestEnv, teamsConnectFixture, @@ -373,6 +381,23 @@ function teamsToken( }); } +function zeroToken(args: { + readonly userId: string; + readonly orgId: string; + readonly capabilities?: readonly string[]; +}): string { + const seconds = Math.floor(now() / 1000); + return signSandboxJwtForTests({ + scope: "zero", + userId: args.userId, + orgId: args.orgId, + runId: `run_${randomUUID()}`, + capabilities: (args.capabilities ?? ["teams:write"]) as never, + iat: seconds, + exp: seconds + 60, + }); +} + function teamsMessageActivity( fixture: TeamsConnectFixture = botFixture(), overrides: Readonly> = {}, @@ -627,6 +652,7 @@ describe("POST /api/zero/teams/bot", () => { beforeEach(() => { setupTeamsConnectTestEnv(APP_ORIGIN); mockEnv("MICROSOFT_TEAMS_BOT_APP_PASSWORD", BOT_APP_PASSWORD); + mockEnv("SECRETS_ENCRYPTION_KEY", "a".repeat(64)); mockEnv("VM0_WEB_URL", "https://www.vm0.test"); mockEnv("VM0_API_URL", "https://api.vm0.test"); mockOptionalEnv("RUNNER_DEFAULT_GROUP", "vm0/test"); @@ -903,6 +929,130 @@ describe("POST /api/zero/teams/bot", () => { expect(outboundRequests[0]?.body).not.toHaveProperty("text"); }); + it("injects Teams file attachments into the run prompt and downloads them", async () => { + botFrameworkHandlers(); + const fixture = await trackTeamsFixture( + Promise.resolve(teamsConnectFixture()), + ); + const actor = authOrgApi.user({ + userId: fixture.userId, + orgId: fixture.orgId, + orgRole: "org:admin", + }); + const runnerGroup = runsApi.configureRunnerGroup(); + context.mocks.ably.publish.mockResolvedValue(undefined); + authOrgApi.acceptAgentStorageWrites(); + runsApi.acceptStorageDownloads(); + runsApi.acceptTelemetryIngest(); + const agent = await authOrgApi.createAgent(actor, { + displayName: "Teams file agent", + visibility: "public", + }); + await authOrgApi.setDefaultAgent(actor, agent.agentId); + await runsApi.grantProEntitlement(actor); + await runsApi.ensureOrgModelProvider(actor); + await installTeamsForTest(context.signal, fixture); + await connectTeamsFixture(fixture); + clearTeamsBotAuthCacheForTest(); + botFrameworkHandlers(); + teamsOutboundHandlers(fixture.serviceUrl, fixture.teamsTenantId); + + const downloadUrl = "https://contoso.sharepoint.com/sites/docs/spec.png"; + const response = await postTeamsActivity({ + activity: teamsMessageActivity(fixture, { + id: "activity-file-dm", + conversation: { + id: "a:personal-conversation", + conversationType: "personal", + }, + channelData: { + tenant: { id: fixture.teamsTenantId, name: fixture.teamsTenantName }, + teamsAppId: "teams-app-test", + }, + text: "please inspect this", + entities: [], + replyToId: null, + attachments: [ + { + contentType: "application/vnd.microsoft.teams.file.download.info", + name: "spec.png", + content: { + downloadUrl, + uniqueId: "drive-item-1", + fileType: "png", + }, + }, + ], + }), + token: teamsToken(), + }); + + expect(response.status).toBe(200); + const body = await readTeamsBotResponseAndFlush(response); + expect(body).not.toHaveProperty("dispatch"); + const list = await runsApi.listAgentRuns(actor, { limit: 20 }); + const run = list.runs.find((item) => { + return ( + item.prompt.includes("please inspect this") && + item.prompt.includes("[Teams file] spec.png (image/png)") + ); + }); + expect(run).toBeDefined(); + const runId = run?.id; + if (!runId) { + throw new Error("Expected Teams file run id"); + } + await runsApi.heartbeatRunner(runnerGroup); + const claim = await runsApi.claimRunnerJob(runId); + expect(claim.prompt).toContain("please inspect this"); + expect(claim.prompt).toContain("[Teams file] spec.png (image/png)"); + expect(claim.appendSystemPrompt).toContain("zero teams download-file -h"); + + const fileIdMatch = claim.prompt.match(/ {3}\[ID\] ([^\n]+)/u); + const fileId = fileIdMatch?.[1]; + expect(fileId).toBeTruthy(); + expect(fileId).not.toContain(downloadUrl); + + const fileBytes = Buffer.from("teams file bytes"); + server.use( + http.get(downloadUrl, ({ request }) => { + expect(request.headers.get("authorization")).toBeNull(); + return new HttpResponse(fileBytes, { + status: 200, + headers: { + "content-type": "image/png", + "content-length": String(fileBytes.length), + }, + }); + }), + ); + + const app = createAppWithRoutes({ + signal: context.signal, + routes: ROUTES, + }); + const downloadResponse = await app.request( + `/api/zero/integrations/teams/download-file?${new URLSearchParams({ + file_id: fileId ?? "", + }).toString()}`, + { + headers: { + authorization: `Bearer ${zeroToken({ + userId: fixture.userId, + orgId: fixture.orgId, + })}`, + }, + }, + ); + + expect(downloadResponse.status).toBe(200); + expect(downloadResponse.headers.get("content-type")).toBe("image/png"); + expect(downloadResponse.headers.get("x-file-mimetype")).toBe("image/png"); + expect(downloadResponse.headers.get("x-file-name")).toBe("spec.png"); + const receivedBytes = Buffer.from(await downloadResponse.arrayBuffer()); + expect(receivedBytes.equals(fileBytes)).toBeTruthy(); + }); + it("preserves non-bot Teams mentions in message text", async () => { botFrameworkHandlers(); @@ -1593,6 +1743,10 @@ describe("POST /api/zero/teams/bot", () => { "You are currently running inside: Microsoft Teams", ); expect(appendSystemPrompt).toContain("Microsoft Teams messaging and files"); + expect(appendSystemPrompt).toContain("zero teams --help"); + expect(appendSystemPrompt).toContain("zero teams message send -h"); + expect(appendSystemPrompt).toContain("zero teams download-file -h"); + expect(appendSystemPrompt).toContain("zero teams upload-file -h"); expect(currentIntegrationPrompt).toContain( `Tenant ID: ${fixture.teamsTenantId}`, ); diff --git a/turbo/apps/api/src/signals/routes/zero-integrations-teams-download-file.ts b/turbo/apps/api/src/signals/routes/zero-integrations-teams-download-file.ts new file mode 100644 index 00000000000..fcf0ee5ed8c --- /dev/null +++ b/turbo/apps/api/src/signals/routes/zero-integrations-teams-download-file.ts @@ -0,0 +1,247 @@ +import { command } from "ccstate"; +import { initContract } from "@vm0/api-contracts/contracts/trpc-contract"; +import { authHeadersSchema } from "@vm0/api-contracts/contracts/base"; +import { apiErrorSchema } from "@vm0/api-contracts/contracts/errors"; +import { z } from "zod"; + +import { inferMimetype } from "../../lib/mimetype"; +import { authRoute } from "../auth/auth-route"; +import { queryOf } from "../context/request"; +import { fetchTeamsFile } from "../external/teams-bot-client"; +import { + decodeTeamsFileToken, + type TeamsFileTokenPayload, +} from "../services/teams-file-token"; +import type { RouteEntry } from "../route-entry"; +import { safeUriComponentDecode } from "../utils"; + +const c = initContract(); +const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; + +const teamsDownloadFileContract = c.router({ + download: { + method: "GET", + path: "/api/zero/integrations/teams/download-file", + headers: authHeadersSchema, + query: z.object({ + file_id: z.string().optional(), + }), + responses: { + 200: c.otherResponse({ + contentType: "application/octet-stream", + body: z.unknown(), + }), + 400: apiErrorSchema, + 401: apiErrorSchema, + 403: apiErrorSchema, + 404: apiErrorSchema, + 413: apiErrorSchema, + 502: apiErrorSchema, + }, + summary: "Download a Microsoft Teams file from a signed file id", + }, +}); + +const ALLOWED_TEAMS_FILE_HOSTS = [ + "1drv.ms", + "onedrive.live.com", + "sharepoint.com", + "trafficmanager.net", + "microsoft.com", + "office.com", +] as const; + +const EXTENSION_BY_CONTENT_TYPE: Readonly> = { + "application/json": "json", + "application/pdf": "pdf", + "application/zip": "zip", + "image/gif": "gif", + "image/jpeg": "jpg", + "image/png": "png", + "image/svg+xml": "svg", + "image/webp": "webp", + "text/csv": "csv", + "text/markdown": "md", + "text/plain": "txt", + "video/mp4": "mp4", + "video/quicktime": "mov", +}; + +function jsonResponse(status: number, message: string, code: string): Response { + return Response.json({ error: { message, code } }, { status }); +} + +function parseContentLength(value: string | null): number | undefined { + if (!value) { + return undefined; + } + const size = Number(value); + return Number.isSafeInteger(size) && size >= 0 ? size : undefined; +} + +function allowedTeamsFileHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return ALLOWED_TEAMS_FILE_HOSTS.some((allowed) => { + return host === allowed || host.endsWith(`.${allowed}`); + }); +} + +function isAllowedTeamsDownloadUrl(url: string): boolean { + if (!URL.canParse(url)) { + return false; + } + const parsed = new URL(url); + return parsed.protocol === "https:" && allowedTeamsFileHost(parsed.hostname); +} + +function filenameFromContentDisposition(value: string | null): string | null { + if (!value) { + return null; + } + + const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/iu); + if (utf8Match?.[1]) { + return decodeFilename(utf8Match[1].trim()); + } + + const quotedMatch = value.match(/filename="([^"]+)"/iu); + return quotedMatch?.[1] ?? null; +} + +function decodeFilename(value: string): string { + return safeUriComponentDecode(value) ?? value; +} + +function filenameFromDownloadUrl(url: string): string | null { + const parsed = new URL(url); + const segment = parsed.pathname.split("/").filter(Boolean).pop(); + return segment ? decodeFilename(segment) : null; +} + +function extensionFromContentType(contentType: string | null): string | null { + const normalized = contentType?.split(";")[0]?.trim().toLowerCase(); + return normalized ? (EXTENSION_BY_CONTENT_TYPE[normalized] ?? null) : null; +} + +function fallbackFilename(contentType: string | null): string { + const extension = extensionFromContentType(contentType); + return extension ? `teams-file.${extension}` : "teams-file"; +} + +function sanitizeDownloadFilename(filename: string): string { + return filename.trim().replace(/[/\\]/gu, "_").slice(0, 255) || "teams-file"; +} + +function resolveFilename(args: { + readonly payload: TeamsFileTokenPayload; + readonly response: Response; +}): string { + const responseContentType = args.response.headers.get("content-type"); + return sanitizeDownloadFilename( + args.payload.name ?? + filenameFromContentDisposition( + args.response.headers.get("content-disposition"), + ) ?? + filenameFromDownloadUrl(args.payload.url) ?? + fallbackFilename(responseContentType), + ); +} + +const download$ = command(async ({ get }, signal: AbortSignal) => { + const query = get(queryOf(teamsDownloadFileContract.download)); + if (!query.file_id) { + return jsonResponse(400, "file_id is required", "BAD_REQUEST"); + } + + const payload = decodeTeamsFileToken(query.file_id); + if (!payload) { + return jsonResponse(400, "Invalid Microsoft Teams file id", "BAD_REQUEST"); + } + if (!isAllowedTeamsDownloadUrl(payload.url)) { + return jsonResponse( + 400, + "Only Microsoft Teams file attachment URLs can be downloaded", + "BAD_REQUEST", + ); + } + + const downloadResult = await fetchTeamsFile({ + tenantId: payload.tenantId, + url: payload.url, + signal, + }); + signal.throwIfAborted(); + if (downloadResult.kind === "teams-error") { + return jsonResponse( + 502, + "Failed to download Microsoft Teams file", + "BAD_GATEWAY", + ); + } + + const downloadResponse = downloadResult.response; + if (!downloadResponse.ok) { + const status = downloadResponse.status === 404 ? 404 : 502; + return jsonResponse( + status, + `Failed to download Microsoft Teams file: ${downloadResponse.status}`, + status === 404 ? "NOT_FOUND" : "BAD_GATEWAY", + ); + } + if (!downloadResponse.body) { + return jsonResponse( + 502, + "Microsoft Teams download response has no body", + "EMPTY_BODY", + ); + } + + const contentLength = downloadResponse.headers.get("content-length"); + const contentLengthBytes = parseContentLength(contentLength); + if ( + contentLengthBytes !== undefined && + contentLengthBytes > MAX_FILE_SIZE_BYTES + ) { + return jsonResponse( + 413, + `File exceeds maximum size of ${MAX_FILE_SIZE_BYTES} bytes`, + "PAYLOAD_TOO_LARGE", + ); + } + + const responseContentType = downloadResponse.headers.get("content-type"); + if (responseContentType?.includes("text/html")) { + return jsonResponse( + 502, + "Microsoft Teams returned an unexpected HTML file response", + "BAD_GATEWAY", + ); + } + + const filename = resolveFilename({ payload, response: downloadResponse }); + const contentType = + payload.contentType ?? responseContentType ?? inferMimetype(filename); + + const headers = new Headers(); + headers.set("Content-Type", contentType); + headers.set("X-File-Name", encodeURIComponent(filename)); + headers.set("X-File-Mimetype", contentType); + if (contentLength) { + headers.set("Content-Length", contentLength); + } + + return new Response(downloadResponse.body, { status: 200, headers }); +}); + +const teamsWriteAuth = { + requireOrganization: true, + missingOrganizationStatus: 401, + requiredCapability: "teams:write", +} as const; + +export const zeroIntegrationsTeamsDownloadFileRoutes: readonly RouteEntry[] = [ + { + route: teamsDownloadFileContract.download, + handler: authRoute(teamsWriteAuth, download$), + }, +]; diff --git a/turbo/apps/api/src/signals/routes/zero-integrations-teams-message.ts b/turbo/apps/api/src/signals/routes/zero-integrations-teams-message.ts new file mode 100644 index 00000000000..512eab3b802 --- /dev/null +++ b/turbo/apps/api/src/signals/routes/zero-integrations-teams-message.ts @@ -0,0 +1,116 @@ +import { command } from "ccstate"; +import { integrationsTeamsMessageContract } from "@vm0/api-contracts/contracts/integrations"; +import { teamsOrgInstallations } from "@vm0/db/schema/teams-org-installation"; +import { eq } from "drizzle-orm"; + +import { organizationAuthContext$ } from "../auth/auth-context"; +import { authRoute } from "../auth/auth-route"; +import { bodyResultOf } from "../context/request"; +import { db$, type ReadonlyDb } from "../external/db"; +import { sendTeamsMessage } from "../external/teams-bot-client"; +import type { RouteEntry } from "../route-entry"; + +interface TeamsInstallation { + readonly teamsTenantId: string; + readonly serviceUrl: string | null; +} + +function routeError( + status: Status, + message: string, + code: string, +) { + return { status, body: { error: { message, code } } }; +} + +async function loadInstallation( + db: ReadonlyDb, + orgId: string, +): Promise { + const [installation] = await db + .select({ + teamsTenantId: teamsOrgInstallations.teamsTenantId, + serviceUrl: teamsOrgInstallations.serviceUrl, + }) + .from(teamsOrgInstallations) + .where(eq(teamsOrgInstallations.orgId, orgId)) + .limit(1); + return installation; +} + +function teamsErrorResponse( + result: Extract< + Awaited>, + { readonly kind: "teams-error" } + >, +) { + return routeError( + result.status >= 500 ? 502 : 400, + `Microsoft Teams API error: ${result.error}`, + "TEAMS_ERROR", + ); +} + +const sendMessageInner$ = command(async ({ get }, signal: AbortSignal) => { + const auth = get(organizationAuthContext$); + const bodyResult = await get( + bodyResultOf(integrationsTeamsMessageContract.sendMessage), + ); + signal.throwIfAborted(); + if (!bodyResult.ok) { + return bodyResult.response; + } + const body = bodyResult.data; + + const installation = await loadInstallation(get(db$), auth.orgId); + signal.throwIfAborted(); + if (!installation) { + return routeError( + 404, + "No Microsoft Teams installation found for this organization", + "NOT_FOUND", + ); + } + if (!installation.serviceUrl) { + return routeError( + 404, + "Microsoft Teams installation has no service URL yet. Send a message to the Teams bot first.", + "NOT_FOUND", + ); + } + + const result = await sendTeamsMessage({ + serviceUrl: installation.serviceUrl, + conversationId: body.conversationId, + activityId: body.activityId, + tenantId: installation.teamsTenantId, + text: body.text, + signal, + }); + signal.throwIfAborted(); + if (result.kind === "teams-error") { + return teamsErrorResponse(result); + } + + return { + status: 200 as const, + body: { + ok: true as const, + activityId: result.activityId, + conversationId: body.conversationId, + }, + }; +}); + +const teamsWriteAuth = { + requireOrganization: true, + missingOrganizationStatus: 401, + requiredCapability: "teams:write", +} as const; + +export const zeroIntegrationsTeamsMessageRoutes: readonly RouteEntry[] = [ + { + route: integrationsTeamsMessageContract.sendMessage, + handler: authRoute(teamsWriteAuth, sendMessageInner$), + }, +]; diff --git a/turbo/apps/api/src/signals/routes/zero-integrations-teams-upload-complete.ts b/turbo/apps/api/src/signals/routes/zero-integrations-teams-upload-complete.ts new file mode 100644 index 00000000000..d67fc855224 --- /dev/null +++ b/turbo/apps/api/src/signals/routes/zero-integrations-teams-upload-complete.ts @@ -0,0 +1,218 @@ +import { command } from "ccstate"; +import { + integrationsTeamsUploadCompleteContract, + type TeamsUploadCompleteBody, +} from "@vm0/api-contracts/contracts/integrations"; +import { teamsOrgInstallations } from "@vm0/db/schema/teams-org-installation"; +import { eq } from "drizzle-orm"; + +import { env } from "../../lib/env"; +import { buildArtifactPrefix, buildFileUrl } from "../../lib/file-url"; +import { inferMimetype } from "../../lib/mimetype"; +import { organizationAuthContext$ } from "../auth/auth-context"; +import { authRoute } from "../auth/auth-route"; +import { bodyResultOf } from "../context/request"; +import { writeDb$, type Db } from "../external/db"; +import { listS3Objects } from "../external/s3"; +import { sendTeamsMessage } from "../external/teams-bot-client"; +import { recordTeamsUploadedFile$ } from "../services/run-uploaded-files.service"; +import type { RouteEntry } from "../route-entry"; + +interface TeamsInstallation { + readonly teamsTenantId: string; + readonly serviceUrl: string | null; +} + +interface UploadedFileInfo { + readonly key: string; + readonly size: number; + readonly filename: string; + readonly fileUrl: string; +} + +function routeError( + status: Status, + message: string, + code: string, +) { + return { status, body: { error: { message, code } } }; +} + +async function loadInstallation( + db: Db, + orgId: string, +): Promise { + const [installation] = await db + .select({ + teamsTenantId: teamsOrgInstallations.teamsTenantId, + serviceUrl: teamsOrgInstallations.serviceUrl, + }) + .from(teamsOrgInstallations) + .where(eq(teamsOrgInstallations.orgId, orgId)) + .limit(1); + return installation; +} + +function buildTeamsFileText(args: { + readonly body: TeamsUploadCompleteBody; + readonly file: UploadedFileInfo; +}): string { + const fileLink = `[${args.file.filename}](${args.file.fileUrl})`; + return [args.body.text, fileLink] + .filter((part): part is string => { + return Boolean(part); + }) + .join("\n\n"); +} + +function buildMetadata(args: { + readonly body: TeamsUploadCompleteBody; + readonly s3Key: string; + readonly sourceUrl: string; + readonly teamsActivityId: string | undefined; +}): Record { + return { + conversationId: args.body.conversationId, + uploadId: args.body.uploadId, + s3Key: args.s3Key, + sourceUrl: args.sourceUrl, + ...(args.body.activityId ? { activityId: args.body.activityId } : {}), + ...(args.body.text ? { text: args.body.text } : {}), + teamsMessage: args.teamsActivityId + ? { activityId: args.teamsActivityId } + : {}, + }; +} + +function teamsErrorResponse( + result: Extract< + Awaited>, + { readonly kind: "teams-error" } + >, +) { + return routeError( + result.status >= 500 ? 502 : 400, + `Microsoft Teams API error: ${result.error}`, + "TEAMS_ERROR", + ); +} + +const complete$ = command(async ({ get, set }, signal: AbortSignal) => { + const auth = get(organizationAuthContext$); + const runId = + "runId" in auth && typeof auth.runId === "string" ? auth.runId : undefined; + const bodyResult = await get( + bodyResultOf(integrationsTeamsUploadCompleteContract.complete), + ); + signal.throwIfAborted(); + if (!bodyResult.ok) { + return bodyResult.response; + } + const body = bodyResult.data; + + const db = set(writeDb$); + const installation = await loadInstallation(db, auth.orgId); + signal.throwIfAborted(); + if (!installation) { + return routeError( + 404, + "No Microsoft Teams installation found for this organization", + "NOT_FOUND", + ); + } + if (!installation.serviceUrl) { + return routeError( + 404, + "Microsoft Teams installation has no service URL yet. Send a message to the Teams bot first.", + "NOT_FOUND", + ); + } + + const bucket = env("R2_USER_ARTIFACTS_BUCKET_NAME"); + const prefix = buildArtifactPrefix(auth.userId, body.uploadId); + const objects = await get(listS3Objects(bucket, prefix)); + signal.throwIfAborted(); + const object = objects[0]; + if (!object) { + return routeError(404, "Uploaded file not found", "NOT_FOUND"); + } + + const filename = object.key.split("/").pop() ?? body.uploadId; + const file: UploadedFileInfo = { + key: object.key, + size: object.size, + filename, + fileUrl: buildFileUrl(auth.userId, body.uploadId, filename), + }; + const mimetype = body.contentType ?? inferMimetype(file.filename); + + const result = await sendTeamsMessage({ + serviceUrl: installation.serviceUrl, + conversationId: body.conversationId, + activityId: body.activityId, + tenantId: installation.teamsTenantId, + text: buildTeamsFileText({ body, file }), + attachments: [ + { + contentType: mimetype, + contentUrl: file.fileUrl, + name: file.filename, + }, + ], + signal, + }); + signal.throwIfAborted(); + if (result.kind === "teams-error") { + return teamsErrorResponse(result); + } + + const externalId = + result.activityId ?? `${body.conversationId}:${body.uploadId}`; + await set( + recordTeamsUploadedFile$, + { + runId, + externalId, + userId: auth.userId, + orgId: auth.orgId, + filename: file.filename, + contentType: mimetype, + sizeBytes: file.size, + url: file.fileUrl, + metadata: buildMetadata({ + body, + s3Key: file.key, + sourceUrl: file.fileUrl, + teamsActivityId: result.activityId, + }), + }, + signal, + ); + signal.throwIfAborted(); + + return { + status: 200 as const, + body: { + activityId: result.activityId, + conversationId: body.conversationId, + filename: file.filename, + mimetype, + size: file.size, + url: file.fileUrl, + }, + }; +}); + +const teamsWriteAuth = { + requireOrganization: true, + missingOrganizationStatus: 401, + requiredCapability: "teams:write", +} as const; + +export const zeroIntegrationsTeamsUploadCompleteRoutes: readonly RouteEntry[] = + [ + { + route: integrationsTeamsUploadCompleteContract.complete, + handler: authRoute(teamsWriteAuth, complete$), + }, + ]; diff --git a/turbo/apps/api/src/signals/routes/zero-integrations-teams-upload-init.ts b/turbo/apps/api/src/signals/routes/zero-integrations-teams-upload-init.ts new file mode 100644 index 00000000000..94dfd53edad --- /dev/null +++ b/turbo/apps/api/src/signals/routes/zero-integrations-teams-upload-init.ts @@ -0,0 +1,62 @@ +import { command } from "ccstate"; +import { integrationsTeamsUploadInitContract } from "@vm0/api-contracts/contracts/integrations"; + +import { env } from "../../lib/env"; +import { + buildArtifactKey, + buildFileUrl, + sanitizeArtifactFilename, +} from "../../lib/file-url"; +import { authContext$ } from "../auth/auth-context"; +import { authRoute } from "../auth/auth-route"; +import { bodyResultOf } from "../context/request"; +import { generatePresignedPutUrl } from "../external/s3"; +import type { RouteEntry } from "../route-entry"; + +const PUT_URL_TTL_SECONDS = 3600; + +const init$ = command(async ({ get }, signal: AbortSignal) => { + const auth = get(authContext$); + const bodyResult = await get( + bodyResultOf(integrationsTeamsUploadInitContract.init), + ); + signal.throwIfAborted(); + if (!bodyResult.ok) { + return bodyResult.response; + } + + const body = bodyResult.data; + const uploadId = crypto.randomUUID(); + const filename = sanitizeArtifactFilename(body.filename); + const s3Key = buildArtifactKey(auth.userId, uploadId, filename); + const bucket = env("R2_USER_ARTIFACTS_BUCKET_NAME"); + const uploadUrl = await get( + generatePresignedPutUrl( + bucket, + s3Key, + body.contentType, + PUT_URL_TTL_SECONDS, + true, + ), + ); + signal.throwIfAborted(); + + return { + status: 200 as const, + body: { + uploadId, + uploadUrl, + fileUrl: buildFileUrl(auth.userId, uploadId, filename), + filename, + contentType: body.contentType, + size: body.length, + }, + }; +}); + +export const zeroIntegrationsTeamsUploadInitRoutes: readonly RouteEntry[] = [ + { + route: integrationsTeamsUploadInitContract.init, + handler: authRoute({ requiredCapability: "teams:write" }, init$), + }, +]; diff --git a/turbo/apps/api/src/signals/services/run-uploaded-files.service.ts b/turbo/apps/api/src/signals/services/run-uploaded-files.service.ts index e07df1051c2..3a3e63d26e6 100644 --- a/turbo/apps/api/src/signals/services/run-uploaded-files.service.ts +++ b/turbo/apps/api/src/signals/services/run-uploaded-files.service.ts @@ -380,6 +380,18 @@ interface RecordSlackUploadedFileArgs { readonly metadata: Record; } +interface RecordTeamsUploadedFileArgs { + readonly runId: string | undefined; + readonly externalId: string; + readonly userId: string; + readonly orgId: string; + readonly filename: string; + readonly contentType: string; + readonly sizeBytes: number; + readonly url: string; + readonly metadata: Record; +} + interface RecordAgentPhoneUploadedFileArgs { readonly runId: string | undefined; readonly externalId: string; @@ -453,6 +465,55 @@ export const recordGithubUploadedFile$ = command( }, ); +export const recordTeamsUploadedFile$ = command( + async ( + { set }, + args: RecordTeamsUploadedFileArgs, + signal: AbortSignal, + ): Promise => { + if (!args.runId) { + return; + } + const writeDb = set(writeDb$); + const source = await sourceForRun(writeDb, args.runId, "teams", signal); + + await writeDb + .insert(runUploadedFiles) + .values({ + runId: args.runId, + source, + externalId: args.externalId, + userId: args.userId, + orgId: args.orgId, + filename: args.filename, + contentType: args.contentType, + sizeBytes: args.sizeBytes, + url: args.url, + metadata: args.metadata, + }) + .onConflictDoUpdate({ + target: [ + runUploadedFiles.runId, + runUploadedFiles.source, + runUploadedFiles.externalId, + ], + set: { + userId: args.userId, + orgId: args.orgId, + filename: args.filename, + contentType: args.contentType, + sizeBytes: args.sizeBytes, + url: args.url, + metadata: args.metadata, + updatedAt: sql`now()`, + }, + }); + signal.throwIfAborted(); + + await publishArtifactsChangedForRun(writeDb, args.runId, signal); + }, +); + /** * Insert (or upsert) a `run_uploaded_files` row for an AgentPhone-delivered * upload, then publish the chat-thread artifacts-changed signal if the diff --git a/turbo/apps/api/src/signals/services/teams-file-token.ts b/turbo/apps/api/src/signals/services/teams-file-token.ts new file mode 100644 index 00000000000..ea2ba2cb4a2 --- /dev/null +++ b/turbo/apps/api/src/signals/services/teams-file-token.ts @@ -0,0 +1,65 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +import { z } from "zod"; + +import { env } from "../../lib/env"; +import { safeJsonParse } from "../utils"; + +const teamsFileTokenPayloadSchema = z.object({ + tenantId: z.string().min(1), + url: z.string().url(), + name: z.string().min(1).optional(), + contentType: z.string().min(1).optional(), +}); + +export type TeamsFileTokenPayload = z.infer; + +function signingSecret(): string { + return env("SECRETS_ENCRYPTION_KEY"); +} + +function signatureFor(encodedPayload: string): string { + return createHmac("sha256", signingSecret()) + .update(encodedPayload) + .digest("base64url"); +} + +function constantTimeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "base64url"); + const rightBuffer = Buffer.from(right, "base64url"); + return ( + leftBuffer.byteLength === rightBuffer.byteLength && + timingSafeEqual(leftBuffer, rightBuffer) + ); +} + +export function encodeTeamsFileToken(payload: TeamsFileTokenPayload): string { + const parsed = teamsFileTokenPayloadSchema.parse(payload); + const encodedPayload = Buffer.from(JSON.stringify(parsed), "utf8").toString( + "base64url", + ); + return `${encodedPayload}.${signatureFor(encodedPayload)}`; +} + +export function decodeTeamsFileToken( + token: string, +): TeamsFileTokenPayload | null { + const parts = token.split("."); + if (parts.length !== 2) { + return null; + } + + const [encodedPayload, signature] = parts; + if (!encodedPayload || !signature) { + return null; + } + if (!constantTimeEqual(signature, signatureFor(encodedPayload))) { + return null; + } + + const payload = safeJsonParse( + Buffer.from(encodedPayload, "base64url").toString("utf8"), + ); + const parsed = teamsFileTokenPayloadSchema.safeParse(payload); + return parsed.success ? parsed.data : null; +} diff --git a/turbo/apps/api/src/signals/services/zero-runs-create.service.ts b/turbo/apps/api/src/signals/services/zero-runs-create.service.ts index 6aa2d70008f..ef82f683905 100644 --- a/turbo/apps/api/src/signals/services/zero-runs-create.service.ts +++ b/turbo/apps/api/src/signals/services/zero-runs-create.service.ts @@ -269,7 +269,7 @@ function buildIntegrationToolsPrompt( case "web": { return [ "- Web chat files: use `zero web download-file -h` when a web chat message includes a `[Web file]` block. `zero web upload-file -h` can share a local file back to the web chat user when file delivery is needed.", - "- Cross-integration messages from web chat: if the user explicitly asks you to send or post through another integration, use the integration CLI and ask for the destination when it is missing. Telegram: `zero telegram bot list` to choose the bot, then `zero telegram message send --help` for chats, replies, and forum topics. AgentPhone/SMS: `zero phone message --help`. GitHub, Microsoft Teams, and email do not currently have dedicated Zero message-send commands, so do not invent `zero github message`, `zero teams message`, or `zero email message` commands.", + "- Cross-integration messages from web chat: if the user explicitly asks you to send or post through another integration, use the integration CLI and ask for the destination when it is missing. Microsoft Teams: `zero teams message send --help` for conversations and thread replies. Telegram: `zero telegram bot list` to choose the bot, then `zero telegram message send --help` for chats, replies, and forum topics. AgentPhone/SMS: `zero phone message --help`. GitHub and email do not currently have dedicated Zero message-send commands, so do not invent `zero github message` or `zero email message` commands.", ...localFileContextLines, ]; } @@ -281,7 +281,7 @@ function buildIntegrationToolsPrompt( } case "teams": { return [ - "- Microsoft Teams messaging and files: normal replies are automatically sent to the originating conversation, so extra messaging commands are only for explicit additional delivery targets. Do not use Slack or Telegram commands for Microsoft Teams delivery.", + "- Microsoft Teams messaging and files: use `zero teams --help`. Normal replies are automatically sent to the originating conversation, so Teams commands are for different conversations, thread replies, or explicit extra messages/files. Use `zero teams message send -h` for extra messages, `zero teams download-file -h` for `[Teams file]` blocks, and `zero teams upload-file -h` when file delivery is needed. Do not use Slack or Telegram commands for Microsoft Teams delivery.", ...localFileContextLines, ]; } diff --git a/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts b/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts index 981a8465a8d..ce63c05d33e 100644 --- a/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts +++ b/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts @@ -16,11 +16,15 @@ import { teamsOrgInstallations } from "@vm0/db/schema/teams-org-installation"; import { teamsOrgThreadSessions } from "@vm0/db/schema/teams-org-thread-session"; import { teamsUserAgentPreferences } from "@vm0/db/schema/teams-user-agent-preference"; import { zeroAgents } from "@vm0/db/schema/zero-agent"; -import type { TeamsInboundActivity } from "@vm0/api-contracts/contracts/zero-teams-bot"; +import type { + TeamsInboundActivity, + TeamsInboundAttachment, +} from "@vm0/api-contracts/contracts/zero-teams-bot"; import { and, desc, eq, isNull, or } from "drizzle-orm"; import { convert } from "html-to-text"; import { env } from "../../lib/env"; +import { inferMimetype } from "../../lib/mimetype"; import { logger } from "../../lib/log"; import { nowDate } from "../../lib/time"; import { writeDb$, type Db } from "../external/db"; @@ -48,6 +52,7 @@ import { teamsOrgCallbackPayloadSchema, type TeamsOrgCallbackPayload, } from "./teams-org-callback-payload"; +import { encodeTeamsFileToken } from "./teams-file-token"; import { updateUserModelPreference$, userModelPreference, @@ -71,6 +76,8 @@ const TEAMS_AGENT_PICKER_INPUT_ID = "selectedComposeId"; const TEAMS_MODEL_PICKER_INPUT_ID = "selectedModel"; const TEAMS_AGENT_PICKER_ORG_DEFAULT_VALUE = "__org_default__"; const TEAMS_THINKING_REACTION_TYPE = "1f4ad_thoughtballoon"; +const TEAMS_FILE_DOWNLOAD_INFO_CONTENT_TYPE = + "application/vnd.microsoft.teams.file.download.info"; type TeamsBotCommand = "help" | "connect" | "disconnect" | "switch" | "model"; type TeamsCardAction = "switch_agent" | "switch_model"; @@ -107,6 +114,12 @@ interface TeamsModelPickerOption { readonly isDefault: boolean; } +interface TeamsPromptFile { + readonly fileId: string; + readonly name: string; + readonly contentType: string; +} + type EffectiveComposeResolution = | { readonly status: "resolved"; @@ -386,6 +399,103 @@ function buildTeamsModelPickerCard(args: { }; } +function stringRecordValue( + source: Readonly> | null, + key: string, +): string | null { + const value = source?.[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function teamsAttachmentDownloadUrl( + attachment: TeamsInboundAttachment, +): string | null { + return ( + stringRecordValue(attachment.content, "downloadUrl") ?? + attachment.contentUrl + ); +} + +function teamsAttachmentName(attachment: TeamsInboundAttachment): string { + return ( + attachment.name ?? + stringRecordValue(attachment.content, "name") ?? + stringRecordValue(attachment.content, "fileName") ?? + "teams-file" + ); +} + +function teamsAttachmentContentType( + attachment: TeamsInboundAttachment, + filename: string, +): string { + if ( + attachment.contentType && + attachment.contentType !== TEAMS_FILE_DOWNLOAD_INFO_CONTENT_TYPE + ) { + return attachment.contentType; + } + + const fileType = stringRecordValue(attachment.content, "fileType"); + if (fileType && !filename.endsWith(`.${fileType}`)) { + return inferMimetype(`${filename}.${fileType}`); + } + return inferMimetype(filename); +} + +function teamsPromptFile( + activity: TeamsMessageActivity, + attachment: TeamsInboundAttachment, +): TeamsPromptFile | null { + const url = teamsAttachmentDownloadUrl(attachment); + if (!url || !URL.canParse(url)) { + return null; + } + + const name = teamsAttachmentName(attachment); + const contentType = teamsAttachmentContentType(attachment, name); + return { + fileId: encodeTeamsFileToken({ + tenantId: activity.tenantId, + url, + name, + contentType, + }), + name, + contentType, + }; +} + +function teamsPromptFiles(activity: TeamsMessageActivity): TeamsPromptFile[] { + return activity.attachments.flatMap((attachment) => { + const file = teamsPromptFile(activity, attachment); + return file ? [file] : []; + }); +} + +function formatTeamsFileForContext(file: TeamsPromptFile): string { + return [ + `[Teams file] ${file.name} (${file.contentType})`, + ` [ID] ${file.fileId}`, + ].join("\n"); +} + +function appendTeamsFilesToPrompt( + prompt: string, + files: readonly TeamsPromptFile[], +): string { + if (files.length === 0) { + return prompt; + } + + const fileContext = files.map(formatTeamsFileForContext).join("\n"); + return [prompt, fileContext] + .filter((part) => { + return part.length > 0; + }) + .join("\n\n"); +} + async function installationForTenant( db: Db, tenantId: string, @@ -1773,7 +1883,8 @@ export const dispatchTeamsMessageToAgent$ = command( } const prompt = activity.text.trim(); - if (!prompt && !cardAction) { + const promptFiles = cardAction ? [] : teamsPromptFiles(activity); + if (!prompt && promptFiles.length === 0 && !cardAction) { return { kind: "notice", replyText: "Please include a message for Zero.", @@ -1847,7 +1958,7 @@ export const dispatchTeamsMessageToAgent$ = command( runResolvedTeamsAgentForActivity$, { db, - prompt, + prompt: appendTeamsFilesToPrompt(prompt, promptFiles), activity, installation: boundInstallation, connection, diff --git a/turbo/apps/cli/src/__tests__/zero-capability-visibility.test.ts b/turbo/apps/cli/src/__tests__/zero-capability-visibility.test.ts index 43f77fc44ab..c245b621c89 100644 --- a/turbo/apps/cli/src/__tests__/zero-capability-visibility.test.ts +++ b/turbo/apps/cli/src/__tests__/zero-capability-visibility.test.ts @@ -28,6 +28,7 @@ function buildCommands(): Command[] { new Command("secret"), new Command("github"), new Command("slack"), + new Command("teams"), new Command("telegram"), new Command("phone"), new Command("variable"), @@ -165,6 +166,7 @@ describe("registerZeroCommands", () => { "secret", "github", "slack", + "teams", "telegram", "phone", "variable", @@ -725,9 +727,28 @@ describe("registerZeroCommands", () => { const prog = buildProgram(); expect(hiddenCommandNames(prog)).toContain("telegram"); + expect(hiddenCommandNames(prog)).toContain("teams"); expect(hiddenCommandNames(prog)).toContain("phone"); }); + it("should show teams when teams:write capability is present", () => { + const token = buildZeroToken({ + scope: "zero", + capabilities: ["teams:write"], + }); + vi.stubEnv("ZERO_TOKEN", token); + + const prog = buildProgram(); + + expect(visibleCommandNames(prog)).toContain("teams"); + expect(buildZeroHelpText(decodeZeroTokenPayload(token))).toContain( + "Send Teams?", + ); + expect(buildZeroHelpText(decodeZeroTokenPayload(token))).toContain( + "Download Teams?", + ); + }); + it("should show logs when agent-run:read capability is present", () => { const token = buildZeroToken({ scope: "zero", diff --git a/turbo/apps/cli/src/commands/zero/teams/__tests__/download-file.test.ts b/turbo/apps/cli/src/commands/zero/teams/__tests__/download-file.test.ts new file mode 100644 index 00000000000..e56ff5e2ab0 --- /dev/null +++ b/turbo/apps/cli/src/commands/zero/teams/__tests__/download-file.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for zero teams download-file command. + */ + +import { existsSync, mkdirSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import chalk from "chalk"; +import { server } from "../../../../mocks/server"; +import { downloadFileCommand } from "../download-file"; + +const DOWNLOAD_URL = + "http://localhost:3000/api/zero/integrations/teams/download-file"; + +describe("zero teams download-file command", () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called"); + }) as never); + const mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + const mockConsoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + let tmpDir: string; + + beforeEach(() => { + chalk.level = 0; + mockConsoleLog.mockClear(); + mockConsoleError.mockClear(); + vi.stubEnv("VM0_API_URL", "http://localhost:3000"); + vi.stubEnv("VM0_TOKEN", "test-token"); + + tmpDir = join(tmpdir(), `teams-download-file-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("streams file bytes to the provided output path and prints JSON result", async () => { + const payload = Buffer.from("hello teams"); + const outPath = join(tmpDir, "result.png"); + + server.use( + http.get(DOWNLOAD_URL, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("file_id")).toBe("teams-file-id"); + expect(request.headers.get("authorization")).toBe("Bearer test-token"); + return new HttpResponse(payload, { + status: 200, + headers: { + "content-type": "image/png", + "content-length": String(payload.length), + "x-file-mimetype": "image/png", + }, + }); + }), + ); + + await downloadFileCommand.parseAsync([ + "node", + "cli", + "teams-file-id", + "-o", + outPath, + ]); + + expect(existsSync(outPath)).toBe(true); + expect(readFileSync(outPath).equals(payload)).toBe(true); + + const stdout = mockConsoleLog.mock.calls.flat().join("\n"); + const parsed = JSON.parse(stdout) as Record; + expect(parsed).toMatchObject({ + path: outPath, + mimetype: "image/png", + size: payload.length, + }); + }); + + it("surfaces API errors", async () => { + server.use( + http.get(DOWNLOAD_URL, () => { + return HttpResponse.json( + { + error: { + message: "Invalid Microsoft Teams file id", + code: "BAD_REQUEST", + }, + }, + { status: 400 }, + ); + }), + ); + + await expect(async () => { + await downloadFileCommand.parseAsync([ + "node", + "cli", + "bad-id", + "-o", + join(tmpDir, "bad.bin"), + ]); + }).rejects.toThrow("process.exit called"); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining("Invalid Microsoft Teams file id"), + ); + }); +}); diff --git a/turbo/apps/cli/src/commands/zero/teams/__tests__/send.test.ts b/turbo/apps/cli/src/commands/zero/teams/__tests__/send.test.ts new file mode 100644 index 00000000000..88e99439e98 --- /dev/null +++ b/turbo/apps/cli/src/commands/zero/teams/__tests__/send.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for zero teams message send command. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import chalk from "chalk"; +import { server } from "../../../../mocks/server"; +import { sendCommand } from "../message/send"; + +const TEAMS_MESSAGE_URL = + "http://localhost:3000/api/zero/integrations/teams/message"; + +describe("zero teams message send command", () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called"); + }) as never); + const mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + const mockConsoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + beforeEach(() => { + chalk.level = 0; + mockConsoleLog.mockClear(); + mockConsoleError.mockClear(); + vi.stubEnv("VM0_API_URL", "http://localhost:3000"); + vi.stubEnv("VM0_TOKEN", "test-token"); + }); + + it("sends a message with conversation and activity IDs", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(TEAMS_MESSAGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ + ok: true, + activityId: "teams-activity-1", + conversationId: "19:thread@thread.tacv2", + }); + }), + ); + + await sendCommand.parseAsync([ + "node", + "cli", + "--conversation-id", + "19:thread@thread.tacv2", + "--activity-id", + "root-activity", + "--text", + "hello Teams", + ]); + + expect(capturedBody).toStrictEqual({ + conversationId: "19:thread@thread.tacv2", + activityId: "root-activity", + text: "hello Teams", + }); + const logCalls = mockConsoleLog.mock.calls.flat().join("\n"); + expect(logCalls).toContain("Message sent"); + expect(logCalls).toContain("activity_id: teams-activity-1"); + }); + + it("errors when message text is missing", async () => { + await expect(async () => { + await sendCommand.parseAsync([ + "node", + "cli", + "--conversation-id", + "19:thread@thread.tacv2", + ]); + }).rejects.toThrow("process.exit called"); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining("Either --text or piped stdin must be provided"), + ); + }); +}); diff --git a/turbo/apps/cli/src/commands/zero/teams/__tests__/upload-file.test.ts b/turbo/apps/cli/src/commands/zero/teams/__tests__/upload-file.test.ts new file mode 100644 index 00000000000..9b5001d3ab4 --- /dev/null +++ b/turbo/apps/cli/src/commands/zero/teams/__tests__/upload-file.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for zero teams upload-file command. + */ + +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "../../../../mocks/server"; +import { uploadFileCommand } from "../upload-file"; + +const UPLOAD_INIT_URL = + "http://localhost:3000/api/zero/integrations/teams/upload-file/init"; +const UPLOAD_COMPLETE_URL = + "http://localhost:3000/api/zero/integrations/teams/upload-file/complete"; +const R2_UPLOAD_URL = "https://mock-r2.test/teams-upload"; + +describe("zero teams upload-file command", () => { + vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit called"); + }) as never); + const mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + const mockConsoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + let tmpDir: string; + let testFilePath: string; + + beforeEach(() => { + mockConsoleLog.mockClear(); + mockConsoleError.mockClear(); + vi.stubEnv("VM0_API_URL", "http://localhost:3000"); + vi.stubEnv("VM0_TOKEN", "test-token"); + + tmpDir = join(tmpdir(), `teams-upload-file-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + testFilePath = join(tmpDir, "report.pdf"); + writeFileSync(testFilePath, "teams pdf content"); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("uploads a file to R2 and completes Teams delivery", async () => { + let putReceivedContentType: string | null = null; + let completeBody: Record | undefined; + + server.use( + http.post(UPLOAD_INIT_URL, async ({ request }) => { + expect(request.headers.get("authorization")).toBe("Bearer test-token"); + const body = (await request.json()) as Record; + expect(body).toMatchObject({ + filename: "report.pdf", + contentType: "application/pdf", + length: 17, + }); + return HttpResponse.json({ + uploadId: "00000000-0000-4000-8000-000000000001", + uploadUrl: R2_UPLOAD_URL, + fileUrl: + "https://app.example/f/user/00000000-0000-4000-8000-000000000001/report.pdf", + filename: "report.pdf", + contentType: "application/pdf", + size: 17, + }); + }), + http.put(R2_UPLOAD_URL, async ({ request }) => { + putReceivedContentType = request.headers.get("content-type"); + const bytes = Buffer.from(await request.arrayBuffer()); + expect(bytes.toString()).toBe("teams pdf content"); + return new HttpResponse(null, { status: 200 }); + }), + http.post(UPLOAD_COMPLETE_URL, async ({ request }) => { + completeBody = (await request.json()) as Record; + return HttpResponse.json({ + activityId: "teams-activity-1", + conversationId: "19:thread@thread.tacv2", + filename: "report.pdf", + mimetype: "application/pdf", + size: 17, + url: "https://app.example/f/user/00000000-0000-4000-8000-000000000001/report.pdf", + }); + }), + ); + + await uploadFileCommand.parseAsync([ + "node", + "cli", + "--file", + testFilePath, + "--conversation-id", + "19:thread@thread.tacv2", + "--activity-id", + "root-activity", + "--text", + "Daily report", + ]); + + expect(putReceivedContentType).toBe("application/pdf"); + expect(completeBody).toStrictEqual({ + uploadId: "00000000-0000-4000-8000-000000000001", + conversationId: "19:thread@thread.tacv2", + activityId: "root-activity", + contentType: "application/pdf", + text: "Daily report", + }); + + const stdout = mockConsoleLog.mock.calls.flat().join("\n"); + const parsed = JSON.parse(stdout) as Record; + expect(parsed).toMatchObject({ + activityId: "teams-activity-1", + conversationId: "19:thread@thread.tacv2", + filename: "report.pdf", + mimetype: "application/pdf", + size: 17, + }); + }); + + it("errors when the file does not exist", async () => { + await expect(async () => { + await uploadFileCommand.parseAsync([ + "node", + "cli", + "--file", + join(tmpDir, "missing.pdf"), + "--conversation-id", + "19:thread@thread.tacv2", + ]); + }).rejects.toThrow("process.exit called"); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining("File not found"), + ); + }); +}); diff --git a/turbo/apps/cli/src/commands/zero/teams/download-file.ts b/turbo/apps/cli/src/commands/zero/teams/download-file.ts new file mode 100644 index 00000000000..531836e5fea --- /dev/null +++ b/turbo/apps/cli/src/commands/zero/teams/download-file.ts @@ -0,0 +1,40 @@ +import { basename, join } from "path"; +import { tmpdir } from "os"; +import { Command } from "commander"; +import { downloadTeamsFile } from "../../../lib/api"; +import { withErrorHandler } from "../../../lib/command"; + +function defaultOutPath(fileId: string): string { + return join(tmpdir(), `teams-${basename(fileId).slice(0, 48)}`); +} + +export const downloadFileCommand = new Command() + .name("download-file") + .description("Download a Microsoft Teams file by signed file id") + .argument("", "Microsoft Teams file id from a [Teams file] block") + .option( + "-o, --out ", + "Output path for the downloaded file (default: /tmp/teams-)", + ) + .addHelpText( + "after", + ` +Examples: + Download to default temp path: zero teams download-file + Download to explicit path: zero teams download-file -o /tmp/file.png + +Output: + Prints a JSON object to stdout on success: + {"path":"/tmp/teams-...","mimetype":"image/png","size":12345} + +Notes: + - Use the file id from a [Teams file] block in the prompt + - Streams the file bytes directly to disk`, + ) + .action( + withErrorHandler(async (fileId: string, options: { out?: string }) => { + const outPath = options.out ?? defaultOutPath(fileId); + const result = await downloadTeamsFile(fileId, outPath); + console.log(JSON.stringify(result)); + }), + ); diff --git a/turbo/apps/cli/src/commands/zero/teams/index.ts b/turbo/apps/cli/src/commands/zero/teams/index.ts new file mode 100644 index 00000000000..2a21559a231 --- /dev/null +++ b/turbo/apps/cli/src/commands/zero/teams/index.ts @@ -0,0 +1,22 @@ +import { Command } from "commander"; +import { zeroTeamsMessageCommand } from "./message"; +import { uploadFileCommand } from "./upload-file"; +import { downloadFileCommand } from "./download-file"; + +export const zeroTeamsCommand = new Command() + .name("teams") + .description( + "Send messages, upload files, and download files from Microsoft Teams as the bot", + ) + .addCommand(zeroTeamsMessageCommand) + .addCommand(uploadFileCommand) + .addCommand(downloadFileCommand) + .addHelpText( + "after", + ` +Examples: + Send a message: zero teams message send -c -t "Hello!" + Reply in a thread: zero teams message send -c --activity-id -t "reply" + Upload a file: zero teams upload-file -f /tmp/report.pdf -c + Download a file: zero teams download-file -o /tmp/out.png`, + ); diff --git a/turbo/apps/cli/src/commands/zero/teams/message/index.ts b/turbo/apps/cli/src/commands/zero/teams/message/index.ts new file mode 100644 index 00000000000..3710f53be3a --- /dev/null +++ b/turbo/apps/cli/src/commands/zero/teams/message/index.ts @@ -0,0 +1,13 @@ +import { Command } from "commander"; +import { sendCommand } from "./send"; + +export const zeroTeamsMessageCommand = new Command() + .name("message") + .description("Manage Microsoft Teams messages") + .addCommand(sendCommand) + .addHelpText( + "after", + ` +Examples: + zero teams message send -c -t "Hello!"`, + ); diff --git a/turbo/apps/cli/src/commands/zero/teams/message/send.ts b/turbo/apps/cli/src/commands/zero/teams/message/send.ts new file mode 100644 index 00000000000..7ce96ba0cd0 --- /dev/null +++ b/turbo/apps/cli/src/commands/zero/teams/message/send.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "fs"; +import { Command } from "commander"; +import chalk from "chalk"; +import { sendTeamsMessage } from "../../../../lib/api"; +import { withErrorHandler } from "../../../../lib/command"; + +export const sendCommand = new Command() + .name("send") + .description("Send a message to a Microsoft Teams conversation as the bot") + .requiredOption("-c, --conversation-id ", "Teams conversation ID") + .option("-t, --text ", "Message text") + .option("--activity-id ", "Activity ID to reply to") + .addHelpText( + "after", + ` +Examples: + Simple message: zero teams message send -c 19:thread@thread.tacv2 -t "Hello!" + Thread reply: zero teams message send -c 19:thread@thread.tacv2 --activity-id root-activity -t "reply" + +Notes: + - Message text can be provided with --text or piped on stdin + - Use the Conversation ID and Activity ID from the current Teams run prompt`, + ) + .action( + withErrorHandler( + async (options: { + conversationId: string; + text?: string; + activityId?: string; + }) => { + let text = options.text; + if (!text && !process.stdin.isTTY) { + try { + text = readFileSync("/dev/stdin", "utf8").trim(); + } catch { + // stdin not readable; fall through to the missing-text validation. + } + } + + if (!text) { + throw new Error("Either --text or piped stdin must be provided", { + cause: new Error( + 'Usage: zero teams message send -c CONVERSATION_ID -t "your message"', + ), + }); + } + + const result = await sendTeamsMessage({ + conversationId: options.conversationId, + activityId: options.activityId, + text, + }); + + const activityInfo = result.activityId + ? ` (activity_id: ${result.activityId})` + : ""; + console.log(chalk.green(`✓ Message sent${activityInfo}`)); + }, + ), + ); diff --git a/turbo/apps/cli/src/commands/zero/teams/upload-file.ts b/turbo/apps/cli/src/commands/zero/teams/upload-file.ts new file mode 100644 index 00000000000..fd39d4d72da --- /dev/null +++ b/turbo/apps/cli/src/commands/zero/teams/upload-file.ts @@ -0,0 +1,115 @@ +import { readFileSync, statSync } from "fs"; +import { basename, extname } from "path"; +import { Command } from "commander"; +import { completeTeamsFileUpload, initTeamsFileUpload } from "../../../lib/api"; +import { withErrorHandler } from "../../../lib/command"; + +const MIME_BY_EXTENSION: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".csv": "text/csv", + ".md": "text/markdown", + ".json": "application/json", +}; + +function inferContentType(localPath: string): string { + const ext = extname(localPath).toLowerCase(); + return MIME_BY_EXTENSION[ext] ?? "application/octet-stream"; +} + +export const uploadFileCommand = new Command() + .name("upload-file") + .description( + "Upload a local file to a Microsoft Teams conversation as the bot", + ) + .requiredOption("-f, --file ", "Local file path to upload") + .requiredOption("-c, --conversation-id ", "Teams conversation ID") + .option("--activity-id ", "Activity ID to reply to") + .option("-t, --text ", "Message text to accompany the file") + .option("--content-type ", "Override inferred content type") + .addHelpText( + "after", + ` +Examples: + Upload a file: zero teams upload-file -f /tmp/report.pdf -c 19:thread@thread.tacv2 + Upload to thread: zero teams upload-file -f /tmp/log.txt -c 19:thread@thread.tacv2 --activity-id root-activity + With message text: zero teams upload-file -f /tmp/data.csv -c 19:thread@thread.tacv2 -t "Daily report" + +Output: + Prints a JSON object to stdout on success: + {"activityId":"...","conversationId":"19:...","filename":"report.pdf","mimetype":"application/pdf","size":12345,"url":"https://..."} + +Notes: + - Uploads through VM0 storage first, then sends the Teams message with the file URL + - Use the Conversation ID and Activity ID from the current Teams run prompt`, + ) + .action( + withErrorHandler( + async (options: { + file: string; + conversationId: string; + activityId?: string; + text?: string; + contentType?: string; + }) => { + let fileSize: number; + try { + const stat = statSync(options.file); + if (!stat.isFile()) { + throw new Error(`Not a regular file: ${options.file}`); + } + fileSize = stat.size; + } catch (error) { + if (error instanceof Error && error.message.startsWith("Not ")) { + throw error; + } + throw new Error(`File not found: ${options.file}`); + } + + if (fileSize === 0) { + throw new Error("File is empty"); + } + + const filename = basename(options.file); + const contentType = + options.contentType ?? inferContentType(options.file); + const prepared = await initTeamsFileUpload({ + filename, + contentType, + length: fileSize, + }); + + const fileContent = readFileSync(options.file); + const uploadResponse = await fetch(prepared.uploadUrl, { + method: "PUT", + headers: { "Content-Type": prepared.contentType }, + body: new Uint8Array(fileContent), + }); + + if (!uploadResponse.ok) { + throw new Error( + `File upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`, + ); + } + + const result = await completeTeamsFileUpload({ + uploadId: prepared.uploadId, + conversationId: options.conversationId, + activityId: options.activityId, + contentType: prepared.contentType, + text: options.text, + }); + + console.log(JSON.stringify(result)); + }, + ), + ); diff --git a/turbo/apps/cli/src/lib/api/domains/integrations-teams.ts b/turbo/apps/cli/src/lib/api/domains/integrations-teams.ts new file mode 100644 index 00000000000..b7e4a6fe57a --- /dev/null +++ b/turbo/apps/cli/src/lib/api/domains/integrations-teams.ts @@ -0,0 +1,138 @@ +import { createWriteStream } from "node:fs"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { initClient } from "@vm0/api-contracts/contracts/trpc-contract"; +import { + integrationsTeamsMessageContract, + integrationsTeamsUploadCompleteContract, + integrationsTeamsUploadInitContract, + type SendTeamsMessageBody, + type SendTeamsMessageResponse, + type TeamsUploadCompleteBody, + type TeamsUploadCompleteResponse, + type TeamsUploadInitBody, + type TeamsUploadInitResponse, +} from "@vm0/api-contracts/contracts/integrations"; +import { + ApiRequestError, + getBaseUrl, + getClientConfig, + handleError, +} from "../core/client-factory"; +import { getActiveToken } from "../config"; +import { headersWithCliClientHeaders } from "../client-headers"; + +export async function sendTeamsMessage( + body: SendTeamsMessageBody, +): Promise { + const config = await getClientConfig(); + const client = initClient(integrationsTeamsMessageContract, config); + + const result = await client.sendMessage({ body, headers: {} }); + + if (result.status === 200) { + return result.body; + } + + handleError(result, "Failed to send Microsoft Teams message"); +} + +export async function initTeamsFileUpload( + body: TeamsUploadInitBody, +): Promise { + const config = await getClientConfig(); + const client = initClient(integrationsTeamsUploadInitContract, config); + + const result = await client.init({ body, headers: {} }); + + if (result.status === 200) { + return result.body; + } + + handleError(result, "Failed to initialize Microsoft Teams file upload"); +} + +export async function completeTeamsFileUpload( + body: TeamsUploadCompleteBody, +): Promise { + const config = await getClientConfig(); + const client = initClient(integrationsTeamsUploadCompleteContract, config); + + const result = await client.complete({ body, headers: {} }); + + if (result.status === 200) { + return result.body; + } + + handleError(result, "Failed to complete Microsoft Teams file upload"); +} + +interface DownloadTeamsFileResult { + path: string; + mimetype: string; + size: number; +} + +export async function downloadTeamsFile( + fileId: string, + outPath: string, +): Promise { + const baseUrl = await getBaseUrl(); + const token = await getActiveToken(); + if (!token) { + throw new ApiRequestError("Not authenticated", "UNAUTHORIZED", 401); + } + + const url = new URL("/api/zero/integrations/teams/download-file", baseUrl); + url.searchParams.set("file_id", fileId); + + const headers: Record = { + Authorization: `Bearer ${token}`, + }; + const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + if (bypassSecret) { + headers["x-vercel-protection-bypass"] = bypassSecret; + } + + const response = await fetch(url, { + headers: headersWithCliClientHeaders(headers), + }); + + if (!response.ok) { + let message = `Failed to download Microsoft Teams file (HTTP ${response.status})`; + let code = "UNKNOWN"; + try { + const body = (await response.json()) as { + error?: { message?: string; code?: string }; + }; + if (body.error?.message) message = body.error.message; + if (body.error?.code) code = body.error.code; + } catch { + // Keep the generic HTTP message when the server returned non-JSON. + } + throw new ApiRequestError(message, code, response.status); + } + + if (!response.body) { + throw new ApiRequestError( + "Microsoft Teams download response has no body", + "EMPTY_BODY", + 502, + ); + } + + const mimetype = + response.headers.get("x-file-mimetype") ?? + response.headers.get("content-type") ?? + "application/octet-stream"; + + await pipeline( + Readable.fromWeb(response.body as never), + createWriteStream(outPath), + ); + + const contentLengthHeader = response.headers.get("content-length"); + const size = contentLengthHeader ? Number(contentLengthHeader) : 0; + + return { path: outPath, mimetype, size }; +} diff --git a/turbo/apps/cli/src/lib/api/index.ts b/turbo/apps/cli/src/lib/api/index.ts index f3d1452ad04..a95d0aae373 100644 --- a/turbo/apps/cli/src/lib/api/index.ts +++ b/turbo/apps/cli/src/lib/api/index.ts @@ -170,6 +170,14 @@ export { completeTelegramFileUpload, } from "./domains/integrations-telegram"; +// Domain modules - Integrations Teams +export { + sendTeamsMessage, + initTeamsFileUpload, + completeTeamsFileUpload, + downloadTeamsFile, +} from "./domains/integrations-teams"; + // Domain modules - Integrations GitHub export { downloadGithubFile, diff --git a/turbo/apps/cli/src/zero.ts b/turbo/apps/cli/src/zero.ts index d44eef36065..38d74838f64 100644 --- a/turbo/apps/cli/src/zero.ts +++ b/turbo/apps/cli/src/zero.ts @@ -44,6 +44,7 @@ const COMMAND_CAPABILITY_MAP: Record< resource: null, github: ["github:read", "github:write"], slack: "slack:write", + teams: "teams:write", telegram: ["telegram:read", "telegram:write"], phone: ["phone:read", "phone:write"], whoami: null, @@ -169,6 +170,14 @@ const ZERO_COMMAND_DEFINITIONS: readonly ZeroCommandDefinition[] = [ return (await import("./commands/zero/slack")).zeroSlackCommand; }, }, + { + name: "teams", + description: + "Send Microsoft Teams messages, upload files, and download files", + load: async () => { + return (await import("./commands/zero/teams")).zeroTeamsCommand; + }, + }, { name: "telegram", description: @@ -401,6 +410,9 @@ export function buildZeroHelpText( ? [] : [" Buy credits? zero credit 20000"]), " Send a Slack message? zero slack message send --help", + " Send Teams? zero teams message send --help", + " Upload Teams? zero teams upload-file --help", + " Download Teams? zero teams download-file --help", " Upload GitHub? zero github upload-file --help", " Download GitHub? zero github download-file --help", " List Telegram bots? zero telegram bot list", diff --git a/turbo/packages/api-contracts/src/contracts/__tests__/composes.test.ts b/turbo/packages/api-contracts/src/contracts/__tests__/composes.test.ts index d78d066292c..ae5bf6ee80a 100644 --- a/turbo/packages/api-contracts/src/contracts/__tests__/composes.test.ts +++ b/turbo/packages/api-contracts/src/contracts/__tests__/composes.test.ts @@ -40,8 +40,8 @@ describe("agentDefinitionSchema strips unknown experimental_capabilities", () => }); describe("ZERO_CAPABILITIES", () => { - it("should have exactly 29 capabilities", () => { - expect(ZERO_CAPABILITIES).toHaveLength(29); + it("should have exactly 30 capabilities", () => { + expect(ZERO_CAPABILITIES).toHaveLength(30); }); it("should follow {resource}:{action} naming pattern", () => { @@ -65,6 +65,10 @@ describe("ZERO_CAPABILITIES", () => { expect(ZERO_CAPABILITIES).toContain("telegram:write"); }); + it("should include teams write capability", () => { + expect(ZERO_CAPABILITIES).toContain("teams:write"); + }); + it("should include phone read and write capabilities", () => { expect(ZERO_CAPABILITIES).toContain("phone:read"); expect(ZERO_CAPABILITIES).toContain("phone:write"); diff --git a/turbo/packages/api-contracts/src/contracts/composes.ts b/turbo/packages/api-contracts/src/contracts/composes.ts index 29b53f006e2..148d6a533dd 100644 --- a/turbo/packages/api-contracts/src/contracts/composes.ts +++ b/turbo/packages/api-contracts/src/contracts/composes.ts @@ -49,6 +49,7 @@ export const ZERO_CAPABILITIES = [ "github:read", "github:write", "slack:write", + "teams:write", "phone:read", "phone:write", "telegram:read", @@ -108,6 +109,10 @@ export const ZERO_CAPABILITY_META: Record = label: "Send GitHub comments and files", }, "slack:write": { group: "Integrations", label: "Send Slack messages" }, + "teams:write": { + group: "Integrations", + label: "Send Microsoft Teams messages and files", + }, "phone:read": { group: "Integrations", label: "Download AgentPhone files", diff --git a/turbo/packages/api-contracts/src/contracts/index.ts b/turbo/packages/api-contracts/src/contracts/index.ts index 987f45d00c3..ad157b09256 100644 --- a/turbo/packages/api-contracts/src/contracts/index.ts +++ b/turbo/packages/api-contracts/src/contracts/index.ts @@ -1251,6 +1251,10 @@ export { type IntegrationsTelegramMessageContract, type SendTelegramMessageBody, type SendTelegramMessageResponse, + integrationsTeamsMessageContract, + type IntegrationsTeamsMessageContract, + type SendTeamsMessageBody, + type SendTeamsMessageResponse, integrationsPhoneMessageContract, type IntegrationsPhoneMessageContract, type SendPhoneMessageBody, @@ -1270,9 +1274,15 @@ export { integrationsTelegramUploadInitContract, type TelegramUploadInitBody, type TelegramUploadInitResponse, + integrationsTeamsUploadInitContract, + type TeamsUploadInitBody, + type TeamsUploadInitResponse, integrationsTelegramUploadCompleteContract, type TelegramUploadCompleteBody, type TelegramUploadCompleteResponse, + integrationsTeamsUploadCompleteContract, + type TeamsUploadCompleteBody, + type TeamsUploadCompleteResponse, integrationsPhoneUploadInitContract, type PhoneUploadInitBody, type PhoneUploadInitResponse, diff --git a/turbo/packages/api-contracts/src/contracts/integrations.ts b/turbo/packages/api-contracts/src/contracts/integrations.ts index 462aa602247..b4accc348a0 100644 --- a/turbo/packages/api-contracts/src/contracts/integrations.ts +++ b/turbo/packages/api-contracts/src/contracts/integrations.ts @@ -108,6 +108,52 @@ export const integrationsTelegramMessageContract = c.router({ export type IntegrationsTelegramMessageContract = typeof integrationsTelegramMessageContract; +/** + * Integration Microsoft Teams message contract + * POST /api/zero/integrations/teams/message + * + * Sends a Microsoft Teams message via the org's installed Teams bot. + * Requires `teams:write` capability (via ZERO_TOKEN). + */ +const sendTeamsMessageBodySchema = z.object({ + conversationId: z.string().min(1, "Conversation ID is required"), + text: z.string().min(1, "Message text is required"), + activityId: z.string().min(1, "Activity ID is required").optional(), +}); + +export type SendTeamsMessageBody = z.infer; + +const sendTeamsMessageResponseSchema = z.object({ + ok: z.literal(true), + activityId: z.string().optional(), + conversationId: z.string(), +}); + +export type SendTeamsMessageResponse = z.infer< + typeof sendTeamsMessageResponseSchema +>; + +export const integrationsTeamsMessageContract = c.router({ + sendMessage: { + method: "POST", + path: "/api/zero/integrations/teams/message", + headers: authHeadersSchema, + body: sendTeamsMessageBodySchema, + responses: { + 200: sendTeamsMessageResponseSchema, + 400: apiErrorSchema, + 401: apiErrorSchema, + 403: apiErrorSchema, + 404: apiErrorSchema, + 502: apiErrorSchema, + }, + summary: "Send a Microsoft Teams message via org bot", + }, +}); + +export type IntegrationsTeamsMessageContract = + typeof integrationsTeamsMessageContract; + /** * Integration AgentPhone message contract * POST /api/zero/integrations/phone/message @@ -332,6 +378,52 @@ export const integrationsTelegramUploadInitContract = c.router({ }, }); +/** + * Integration Microsoft Teams file upload — init contract + * POST /api/zero/integrations/teams/upload-file/init + * + * Requests a pre-signed upload URL for a temporary VM0-hosted file. The CLI + * uploads the file body directly to R2, then the complete route sends a Teams + * message with the file attachment and public file URL. + * Requires `teams:write` capability (via ZERO_TOKEN). + */ +const teamsUploadInitBodySchema = z.object({ + filename: z.string().min(1, "Filename is required").max(255), + contentType: z.string().min(1, "Content type is required").max(200), + length: z.number().int().positive("File length must be a positive integer"), +}); + +export type TeamsUploadInitBody = z.infer; + +const teamsUploadInitResponseSchema = z.object({ + uploadId: z.string().uuid(), + uploadUrl: z.string(), + fileUrl: z.string(), + filename: z.string(), + contentType: z.string(), + size: z.number().int().nonnegative(), +}); + +export type TeamsUploadInitResponse = z.infer< + typeof teamsUploadInitResponseSchema +>; + +export const integrationsTeamsUploadInitContract = c.router({ + init: { + method: "POST", + path: "/api/zero/integrations/teams/upload-file/init", + headers: authHeadersSchema, + body: teamsUploadInitBodySchema, + responses: { + 200: teamsUploadInitResponseSchema, + 400: apiErrorSchema, + 401: apiErrorSchema, + 403: apiErrorSchema, + }, + summary: "Get a pre-signed upload URL for Microsoft Teams file delivery", + }, +}); + /** * Integration Telegram file upload — complete contract * POST /api/zero/integrations/telegram/upload-file/complete @@ -385,6 +477,58 @@ export const integrationsTelegramUploadCompleteContract = c.router({ }, }); +/** + * Integration Microsoft Teams file upload — complete contract + * POST /api/zero/integrations/teams/upload-file/complete + * + * Sends an uploaded file URL to a Microsoft Teams conversation using the org's + * installed Teams bot. + * Requires `teams:write` capability (via ZERO_TOKEN). + */ +const teamsUploadCompleteBodySchema = z.object({ + uploadId: z.string().uuid("Upload ID must be a UUID"), + conversationId: z.string().min(1, "Conversation ID is required"), + activityId: z.string().min(1, "Activity ID is required").optional(), + contentType: z.string().min(1).max(200).optional(), + text: z.string().max(4000).optional(), +}); + +export type TeamsUploadCompleteBody = z.infer< + typeof teamsUploadCompleteBodySchema +>; + +const teamsUploadCompleteResponseSchema = z.object({ + activityId: z.string().optional(), + conversationId: z.string(), + filename: z.string(), + mimetype: z.string(), + size: z.number().int().nonnegative(), + url: z.string(), +}); + +export type TeamsUploadCompleteResponse = z.infer< + typeof teamsUploadCompleteResponseSchema +>; + +export const integrationsTeamsUploadCompleteContract = c.router({ + complete: { + method: "POST", + path: "/api/zero/integrations/teams/upload-file/complete", + headers: authHeadersSchema, + body: teamsUploadCompleteBodySchema, + responses: { + 200: teamsUploadCompleteResponseSchema, + 400: apiErrorSchema, + 401: apiErrorSchema, + 403: apiErrorSchema, + 404: apiErrorSchema, + 502: apiErrorSchema, + }, + summary: + "Finalize Microsoft Teams file upload and send it to a conversation", + }, +}); + /** * Integration GitHub file upload — init contract * POST /api/zero/integrations/github/upload-file/init diff --git a/turbo/packages/api-contracts/src/contracts/zero-teams-bot.ts b/turbo/packages/api-contracts/src/contracts/zero-teams-bot.ts index 0b947532850..edd635745e2 100644 --- a/turbo/packages/api-contracts/src/contracts/zero-teams-bot.ts +++ b/turbo/packages/api-contracts/src/contracts/zero-teams-bot.ts @@ -12,6 +12,14 @@ const teamsActorSchema = z.object({ userPrincipalName: z.string().nullable(), }); +const teamsInboundAttachmentSchema = z.object({ + id: z.string().nullable(), + contentType: z.string().nullable(), + contentUrl: z.string().nullable(), + name: z.string().nullable(), + content: z.record(z.string(), z.unknown()).nullable(), +}); + const teamsActivityBaseSchema = z.object({ activityId: z.string().nullable(), tenantId: z.string(), @@ -37,6 +45,7 @@ export const teamsInboundMessageActivitySchema = teamsActivityBaseSchema.extend( text: z.string(), value: z.record(z.string(), z.unknown()).nullable(), mentionsRecipient: z.boolean(), + attachments: z.array(teamsInboundAttachmentSchema), }, ); @@ -124,6 +133,9 @@ export const zeroTeamsBotContract = c.router({ }); export type TeamsActor = z.infer; +export type TeamsInboundAttachment = z.infer< + typeof teamsInboundAttachmentSchema +>; export type TeamsInboundActivity = z.infer; export type TeamsBotIngressResponse = z.infer< typeof teamsBotIngressResponseSchema diff --git a/turbo/packages/db/src/schema/run-uploaded-file.ts b/turbo/packages/db/src/schema/run-uploaded-file.ts index dcfd4b2c3a0..45024f43bfa 100644 --- a/turbo/packages/db/src/schema/run-uploaded-file.ts +++ b/turbo/packages/db/src/schema/run-uploaded-file.ts @@ -17,6 +17,7 @@ export const RUN_UPLOADED_FILE_SOURCES = [ "automation", "web", "slack", + "teams", "email", "telegram", "agentphone", From 8d4933ab470a01987522e3e2861b802679f940e2 Mon Sep 17 00:00:00 2001 From: Linghan Hu Date: Thu, 9 Jul 2026 11:09:30 +0000 Subject: [PATCH 2/3] feat: align teams integration with slack messaging --- .../src/signals/external/teams-bot-client.ts | 163 ++++++++--- .../__tests__/helpers/zero-teams-connect.ts | 4 +- .../__tests__/zero-integrations-teams.test.ts | 89 ++++++- .../routes/__tests__/zero-teams-bot.test.ts | 98 ++++++- .../__tests__/zero-teams-connect.test.ts | 47 ++++ .../zero-integrations-teams-download-file.ts | 3 + .../routes/zero-integrations-teams-message.ts | 158 ++++++++++- .../src/signals/services/teams-file-token.ts | 1 + .../services/zero-teams-connect.service.ts | 33 +++ .../services/zero-teams-dispatch.service.ts | 252 +++++++++++++++++- .../zero/teams/__tests__/send.test.ts | 61 ++++- .../apps/cli/src/commands/zero/teams/index.ts | 3 +- .../src/commands/zero/teams/message/index.ts | 3 +- .../src/commands/zero/teams/message/send.ts | 116 ++++++-- .../src/views/zero-page/zero-works-page.tsx | 83 +++++- .../src/contracts/integrations.ts | 49 +++- .../src/contracts/zero-teams-connect.ts | 2 + 17 files changed, 1060 insertions(+), 105 deletions(-) diff --git a/turbo/apps/api/src/signals/external/teams-bot-client.ts b/turbo/apps/api/src/signals/external/teams-bot-client.ts index 23b2fadf0ac..607793f5f66 100644 --- a/turbo/apps/api/src/signals/external/teams-bot-client.ts +++ b/turbo/apps/api/src/signals/external/teams-bot-client.ts @@ -31,6 +31,33 @@ const teamsGraphIdentitySchema = z .object({ id: z.string().nullable().optional(), displayName: z.string().nullable().optional(), + userPrincipalName: z.string().nullable().optional(), + mail: z.string().nullable().optional(), + }) + .passthrough(); + +const teamsGraphAttachmentSchema = z + .object({ + id: z.string().nullable().optional(), + contentType: z.string().nullable().optional(), + contentUrl: z.string().nullable().optional(), + name: z.string().nullable().optional(), + content: z.unknown().optional(), + }) + .passthrough(); + +const teamsGraphMentionSchema = z + .object({ + id: z.number().optional(), + mentionText: z.string().nullable().optional(), + mentioned: z + .object({ + user: teamsGraphIdentitySchema.nullable().optional(), + application: teamsGraphIdentitySchema.nullable().optional(), + device: teamsGraphIdentitySchema.nullable().optional(), + }) + .nullable() + .optional(), }) .passthrough(); @@ -54,6 +81,8 @@ const teamsGraphMessageSchema = z }) .nullable() .optional(), + attachments: z.array(teamsGraphAttachmentSchema).optional(), + mentions: z.array(teamsGraphMentionSchema).optional(), }) .passthrough(); @@ -63,6 +92,15 @@ const teamsGraphMessagesResponseSchema = z }) .passthrough(); +const teamsGraphUserSchema = z + .object({ + id: z.string().optional(), + displayName: z.string().nullable().optional(), + userPrincipalName: z.string().nullable().optional(), + mail: z.string().nullable().optional(), + }) + .passthrough(); + type TeamsApiErrorResult = { readonly kind: "teams-error"; readonly status: number; @@ -92,58 +130,24 @@ type FetchTeamsFileResult = | TeamsApiErrorResult; export type TeamsGraphMessage = z.infer; +export type TeamsGraphAttachment = z.infer; -interface TeamsBotCredentials { - readonly appId: string; - readonly appPassword: string; -} - -interface TeamsAdaptiveCardTextBlock { - readonly type: "TextBlock"; - readonly text: string; - readonly wrap?: boolean; -} - -interface TeamsAdaptiveCardChoice { - readonly title: string; - readonly value: string; -} - -interface TeamsAdaptiveCardChoiceSetInput { - readonly type: "Input.ChoiceSet"; +export interface TeamsGraphUserInfo { readonly id: string; - readonly label?: string; - readonly style?: "compact" | "expanded"; - readonly isMultiSelect?: false; - readonly value?: string; - readonly choices: readonly TeamsAdaptiveCardChoice[]; -} - -type TeamsAdaptiveCardBodyElement = - | TeamsAdaptiveCardTextBlock - | TeamsAdaptiveCardChoiceSetInput; - -interface TeamsAdaptiveCardOpenUrlAction { - readonly type: "Action.OpenUrl"; - readonly title: string; - readonly url: string; + readonly displayName: string | null; + readonly userPrincipalName: string | null; } -interface TeamsAdaptiveCardSubmitAction { - readonly type: "Action.Submit"; - readonly title: string; - readonly data?: Readonly>; +interface TeamsBotCredentials { + readonly appId: string; + readonly appPassword: string; } -type TeamsAdaptiveCardAction = - | TeamsAdaptiveCardOpenUrlAction - | TeamsAdaptiveCardSubmitAction; - export interface TeamsAdaptiveCard { readonly type: "AdaptiveCard"; - readonly version: "1.4"; - readonly body: readonly TeamsAdaptiveCardBodyElement[]; - readonly actions?: readonly TeamsAdaptiveCardAction[]; + readonly version: string; + readonly body?: readonly Readonly>[]; + readonly actions?: readonly Readonly>[]; } interface TeamsAdaptiveCardAttachment { @@ -376,6 +380,14 @@ function teamsGraphChannelMessageUrl(args: { return url.toString(); } +function teamsGraphUserUrl(userId: string): string { + const url = new URL( + `${MICROSOFT_GRAPH_BASE_URL}/users/${encodeURIComponent(userId)}`, + ); + url.searchParams.set("$select", "id,displayName,userPrincipalName,mail"); + return url.toString(); +} + function shouldAuthorizeTeamsFileDownload(url: string): boolean { const parsed = safeUrlParse(url); if (!parsed) { @@ -467,6 +479,69 @@ export async function fetchTeamsFile(args: { return { kind: "ok", response: responseResult.value }; } +export async function fetchTeamsUsers(args: { + readonly tenantId: string; + readonly userIds: readonly string[]; + readonly signal: AbortSignal; +}): Promise< + | { + readonly kind: "ok"; + readonly users: ReadonlyMap; + } + | TeamsApiErrorResult +> { + const accessToken = await fetchTeamsGraphAccessToken({ + tenantId: args.tenantId, + signal: args.signal, + }); + if (accessToken.kind === "teams-error") { + return accessToken; + } + + const users = new Map(); + for (const userId of [...new Set(args.userIds)].slice(0, 20)) { + const responseResult = await settle( + fetch(teamsGraphUserUrl(userId), { + method: "GET", + headers: { + authorization: `Bearer ${accessToken.accessToken}`, + }, + signal: args.signal, + }), + args.signal, + ); + if (!responseResult.ok) { + return teamsApiError(502, networkErrorMessage(responseResult.error)); + } + + const response = responseResult.value; + const responseText = await response.text(); + args.signal.throwIfAborted(); + if (response.status === 404) { + continue; + } + if (!response.ok) { + return teamsApiError( + response.status, + responseText || `Microsoft Graph API returned HTTP ${response.status}`, + ); + } + + const parsed = teamsGraphUserSchema.safeParse(safeJsonParse(responseText)); + if (!parsed.success || !parsed.data.id) { + continue; + } + users.set(parsed.data.id, { + id: parsed.data.id, + displayName: parsed.data.displayName ?? null, + userPrincipalName: + parsed.data.userPrincipalName ?? parsed.data.mail ?? null, + }); + } + + return { kind: "ok", users }; +} + async function postTeamsActivity(args: { readonly serviceUrl: string; readonly conversationId: string; diff --git a/turbo/apps/api/src/signals/routes/__tests__/helpers/zero-teams-connect.ts b/turbo/apps/api/src/signals/routes/__tests__/helpers/zero-teams-connect.ts index f79e821b530..50bb40915cb 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/helpers/zero-teams-connect.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/helpers/zero-teams-connect.ts @@ -148,7 +148,7 @@ export function teamsMessageActivityForTest( tenant: { id: fixture.teamsTenantId, name: fixture.teamsTenantName }, team: { id: fixture.teamsTeamId, name: fixture.teamsTeamName }, channel: { id: "19:channel@thread.tacv2", name: "General" }, - teamsAppId: "teams-app-test", + teamsAppId: BOT_APP_ID, }, from: { id: fixture.teamsUserId, @@ -187,7 +187,7 @@ function teamsBotRemovedActivity( tenant: { id: fixture.teamsTenantId, name: fixture.teamsTenantName }, team: { id: fixture.teamsTeamId, name: fixture.teamsTeamName }, channel: { id: "19:channel@thread.tacv2", name: "General" }, - teamsAppId: "teams-app-test", + teamsAppId: BOT_APP_ID, }, recipient: { id: "28:bot-1", name: "Zero" }, membersRemoved: [{ id: "28:bot-1", name: "Zero" }], diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-integrations-teams.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-integrations-teams.test.ts index 1c0c1de45e2..b98600d3e43 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-integrations-teams.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-integrations-teams.test.ts @@ -30,6 +30,8 @@ const mocks = createZeroRouteMocks(context); const SERVICE_URL = "https://smba.trafficmanager.net/amer/"; interface CapturedTeamsActivity { + conversationBody?: Record; + conversationId?: string; body?: Record; authorization?: string | null; } @@ -75,17 +77,36 @@ function mockOutgoingTeams(captured: CapturedTeamsActivity): void { }, ), http.post( - "https://smba.trafficmanager.net/amer/v3/conversations/:conversationId/activities/:activityId", + "https://smba.trafficmanager.net/amer/v3/conversations", async ({ request }) => { captured.authorization = request.headers.get("authorization"); + captured.conversationBody = (await request.json()) as Record< + string, + unknown + >; + return HttpResponse.json({ id: "a:teams-dm-conversation" }); + }, + ), + http.post( + "https://smba.trafficmanager.net/amer/v3/conversations/:conversationId/activities/:activityId", + async ({ params, request }) => { + captured.authorization = request.headers.get("authorization"); + captured.conversationId = + typeof params.conversationId === "string" + ? params.conversationId + : undefined; captured.body = (await request.json()) as Record; return HttpResponse.json({ id: "teams-activity-1" }); }, ), http.post( "https://smba.trafficmanager.net/amer/v3/conversations/:conversationId/activities", - async ({ request }) => { + async ({ params, request }) => { captured.authorization = request.headers.get("authorization"); + captured.conversationId = + typeof params.conversationId === "string" + ? params.conversationId + : undefined; captured.body = (await request.json()) as Record; return HttpResponse.json({ id: "teams-activity-1" }); }, @@ -175,6 +196,70 @@ describe("Microsoft Teams integration CLI routes", () => { }); }); + it("sends a Teams DM with an Adaptive Card through the installed bot", async () => { + const fixture = teamsFixture(); + fixtures.push(fixture); + await seedConnectedTeams(fixture); + const captured: CapturedTeamsActivity = {}; + mockOutgoingTeams(captured); + + const client = setupApp({ context })(integrationsTeamsMessageContract); + const response = await accept( + client.sendMessage({ + body: { + user: "me", + text: "Pick a workflow", + card: { + type: "AdaptiveCard", + version: "1.4", + body: [ + { + type: "TextBlock", + text: "Pick a workflow", + wrap: true, + }, + ], + }, + }, + headers: { + authorization: `Bearer ${zeroToken({ + userId: fixture.userId, + orgId: fixture.orgId, + runId: `run_${randomUUID()}`, + })}`, + }, + }), + [200], + ); + + expect(response.body).toStrictEqual({ + ok: true, + activityId: "teams-activity-1", + conversationId: "a:teams-dm-conversation", + }); + expect(captured.conversationBody).toMatchObject({ + bot: { id: "28:bot-1", name: "Zero" }, + members: [{ id: fixture.teamsUserId, name: "Ada Lovelace" }], + isGroup: false, + channelData: { tenant: { id: fixture.teamsTenantId } }, + }); + expect(captured.conversationId).toBe("a:teams-dm-conversation"); + expect(captured.body).toMatchObject({ + type: "message", + summary: "Pick a workflow", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: { + type: "AdaptiveCard", + version: "1.4", + }, + }, + ], + channelData: { tenant: { id: fixture.teamsTenantId } }, + }); + }); + it("sends an uploaded file URL as a Teams attachment", async () => { const fixture = teamsFixture(); fixtures.push(fixture); diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts index 8fd342cca19..71d325e47f8 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts @@ -235,6 +235,10 @@ interface TeamsGraphMessageFixture { readonly createdDateTime: string; readonly senderId?: string; readonly senderName?: string; + readonly senderPrincipalName?: string | null; + readonly graphUserPrincipalName?: string | null; + readonly attachments?: readonly Record[]; + readonly mentions?: readonly Record[]; } function graphTokenUrl(tenantId: string): string { @@ -254,15 +258,50 @@ function teamsGraphMessage( user: { id: message.senderId ?? "29:user-1", displayName: message.senderName ?? "Ada Lovelace", + ...(message.senderPrincipalName !== undefined + ? { userPrincipalName: message.senderPrincipalName } + : {}), }, }, body: { contentType: "html", content: `

${message.text}

`, }, + ...(message.attachments ? { attachments: message.attachments } : {}), + ...(message.mentions ? { mentions: message.mentions } : {}), }; } +function teamsGraphUserMap( + messages: readonly TeamsGraphMessageFixture[], +): ReadonlyMap< + string, + { + readonly displayName: string; + readonly userPrincipalName: string | null; + } +> { + const users = new Map< + string, + { + readonly displayName: string; + readonly userPrincipalName: string | null; + } + >(); + for (const message of messages) { + const senderId = message.senderId ?? "29:user-1"; + const existing = users.get(senderId); + const userPrincipalName = + message.graphUserPrincipalName ?? existing?.userPrincipalName ?? null; + users.set(senderId, { + displayName: + message.senderName ?? existing?.displayName ?? "Ada Lovelace", + userPrincipalName, + }); + } + return users; +} + function teamsGraphHistoryHandlers(args: { readonly tenantId: string; readonly channelMessages: readonly TeamsGraphMessageFixture[]; @@ -272,6 +311,11 @@ function teamsGraphHistoryHandlers(args: { >; }): string[] { const requests: string[] = []; + const users = teamsGraphUserMap([ + ...args.channelMessages, + ...Object.values(args.threadRoots), + ...Object.values(args.threadReplies).flat(), + ]); server.use( http.post(graphTokenUrl(args.tenantId), async ({ request }) => { const form = await request.formData(); @@ -337,6 +381,21 @@ function teamsGraphHistoryHandlers(args: { ); }, ), + http.get("https://graph.microsoft.com/v1.0/users/:userId", ({ params }) => { + const userId = typeof params.userId === "string" ? params.userId : ""; + const user = users.get(userId); + requests.push(`user:${userId}`); + return user + ? HttpResponse.json({ + id: userId, + displayName: user.displayName, + userPrincipalName: user.userPrincipalName, + }) + : HttpResponse.json( + { error: { code: "NotFound", message: "User not found" } }, + { status: 404 }, + ); + }), ); return requests; } @@ -1581,15 +1640,39 @@ describe("POST /api/zero/teams/bot", () => { text: "remember the deployment target", createdDateTime: "2026-06-30T09:10:00.000Z", senderId: fixture.teamsUserId, + graphUserPrincipalName: "ada@example.com", }, }; const threadReplies: Record = { "root-dispatch": [ { id: "activity-context-1", - text: "confirm the target is staging", + text: 'confirm with Grace Hopper that the target is staging', createdDateTime: "2026-06-30T09:11:00.000Z", senderId: fixture.teamsUserId, + mentions: [ + { + id: 0, + mentionText: 'Grace Hopper', + mentioned: { + user: { + id: "29:user-grace", + displayName: "Grace Hopper", + }, + }, + }, + ], + attachments: [ + { + id: "teams-file-plan-1", + name: "deployment-plan.pdf", + contentType: "application/vnd.microsoft.teams.file.download.info", + content: { + downloadUrl: "https://files.example.test/deployment-plan.pdf", + fileType: "pdf", + }, + }, + ], }, { id: "activity-dispatch-1", @@ -1779,13 +1862,22 @@ describe("POST /api/zero/teams/bot", () => { ); expect(teamsThreadContext).toContain("- RELATIVE_INDEX: -1"); expect(teamsThreadContext).toContain( - `- SENDER: {id: ${fixture.teamsUserId}, name: Ada Lovelace}`, + `- SENDER: {id: ${fixture.teamsUserId}, name: Ada Lovelace, email: ada@example.com}`, ); expect(teamsThreadContext).toContain("remember the deployment target"); - expect(teamsThreadContext).toContain("confirm the target is staging"); + expect(teamsThreadContext).toContain( + "confirm with @Grace Hopper (29:user-grace) that the target is staging", + ); + expect(teamsThreadContext).toContain( + "[Teams file] deployment-plan.pdf (application/pdf)", + ); + expect(teamsThreadContext).toContain( + "[Teams attachment ID] teams-file-plan-1", + ); expect(teamsThreadContext).not.toContain("ship the Teams dispatch"); expect(graphRequests).toContain("thread-root:root-dispatch"); expect(graphRequests).toContain("thread-replies:root-dispatch"); + expect(graphRequests).toContain(`user:${fixture.teamsUserId}`); }); it("includes Teams thread computer use host bindings in queued zero tokens", async () => { diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-teams-connect.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-teams-connect.test.ts index c743ccf48ca..42a4e5760ba 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-teams-connect.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-teams-connect.test.ts @@ -11,9 +11,11 @@ import { } from "./helpers/zero-route-test"; import { installTeamsForTest, + postTeamsActivityForTest, removeTeamsForTest, setupTeamsConnectTestEnv, teamsConnectFixture, + teamsMessageActivityForTest, type TeamsConnectFixture, } from "./helpers/zero-teams-connect"; @@ -208,6 +210,51 @@ describe("GET /api/zero/integrations/teams/connect", () => { teamId: fixture.teamsTeamId, teamName: fixture.teamsTeamName, defaultAgentName: null, + permissionMismatch: false, + reinstallUrl: null, + }); + }); + + it("returns a Teams reinstall URL to admins when the installed app id is stale", async () => { + const fixture = await seedTeamsInstallation(track); + mocks.clerk.session(fixture.userId, fixture.orgId, "org:admin"); + const client = setupApp({ context })(zeroTeamsConnectContract); + await accept( + client.connect({ + headers: { authorization: "Bearer clerk-session" }, + body: connectBody(fixture), + }), + [200], + ); + + const seedActivity = teamsMessageActivityForTest(fixture, { + id: "activity-stale-teams-app", + }); + const channelData = seedActivity.channelData as Record; + const response = await postTeamsActivityForTest({ + signal: context.signal, + activity: { + ...seedActivity, + channelData: { + ...channelData, + teamsAppId: "22222222-2222-2222-2222-222222222222", + }, + }, + }); + expect(response.ok).toBeTruthy(); + + const status = await accept( + client.getStatus({ + headers: { authorization: "Bearer clerk-session" }, + }), + [200], + ); + + expect(status.body).toMatchObject({ + isInstalled: true, + isAdmin: true, + permissionMismatch: true, + reinstallUrl: teamsInstallUrl(fixture.teamsTenantId), }); }); diff --git a/turbo/apps/api/src/signals/routes/zero-integrations-teams-download-file.ts b/turbo/apps/api/src/signals/routes/zero-integrations-teams-download-file.ts index fcf0ee5ed8c..596d8df168f 100644 --- a/turbo/apps/api/src/signals/routes/zero-integrations-teams-download-file.ts +++ b/turbo/apps/api/src/signals/routes/zero-integrations-teams-download-file.ts @@ -226,6 +226,9 @@ const download$ = command(async ({ get }, signal: AbortSignal) => { headers.set("Content-Type", contentType); headers.set("X-File-Name", encodeURIComponent(filename)); headers.set("X-File-Mimetype", contentType); + if (payload.id) { + headers.set("X-Teams-Attachment-ID", encodeURIComponent(payload.id)); + } if (contentLength) { headers.set("Content-Length", contentLength); } diff --git a/turbo/apps/api/src/signals/routes/zero-integrations-teams-message.ts b/turbo/apps/api/src/signals/routes/zero-integrations-teams-message.ts index 512eab3b802..038ceda5b82 100644 --- a/turbo/apps/api/src/signals/routes/zero-integrations-teams-message.ts +++ b/turbo/apps/api/src/signals/routes/zero-integrations-teams-message.ts @@ -1,18 +1,29 @@ import { command } from "ccstate"; import { integrationsTeamsMessageContract } from "@vm0/api-contracts/contracts/integrations"; +import { teamsOrgConnections } from "@vm0/db/schema/teams-org-connection"; import { teamsOrgInstallations } from "@vm0/db/schema/teams-org-installation"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { organizationAuthContext$ } from "../auth/auth-context"; import { authRoute } from "../auth/auth-route"; import { bodyResultOf } from "../context/request"; import { db$, type ReadonlyDb } from "../external/db"; -import { sendTeamsMessage } from "../external/teams-bot-client"; +import { + createTeamsPersonalConversation, + sendTeamsMessage, +} from "../external/teams-bot-client"; import type { RouteEntry } from "../route-entry"; interface TeamsInstallation { readonly teamsTenantId: string; readonly serviceUrl: string | null; + readonly botId: string | null; + readonly botName: string | null; +} + +interface TeamsMessageTarget { + readonly conversationId: string; + readonly activityId: string | undefined; } function routeError( @@ -31,6 +42,8 @@ async function loadInstallation( .select({ teamsTenantId: teamsOrgInstallations.teamsTenantId, serviceUrl: teamsOrgInstallations.serviceUrl, + botId: teamsOrgInstallations.botId, + botName: teamsOrgInstallations.botName, }) .from(teamsOrgInstallations) .where(eq(teamsOrgInstallations.orgId, orgId)) @@ -38,12 +51,11 @@ async function loadInstallation( return installation; } -function teamsErrorResponse( - result: Extract< - Awaited>, - { readonly kind: "teams-error" } - >, -) { +function teamsErrorResponse(result: { + readonly kind: "teams-error"; + readonly status: number; + readonly error: string; +}) { return routeError( result.status >= 500 ? 502 : 400, `Microsoft Teams API error: ${result.error}`, @@ -51,6 +63,112 @@ function teamsErrorResponse( ); } +async function resolveConnectedTeamsUser(args: { + readonly db: ReadonlyDb; + readonly tenantId: string; + readonly vm0UserId: string; +}): Promise< + | { + readonly kind: "ok"; + readonly teamsUserId: string; + readonly teamsUserDisplayName: string | null; + } + | { readonly kind: "not_found" } +> { + const [connection] = await args.db + .select({ + teamsUserId: teamsOrgConnections.teamsUserId, + teamsUserDisplayName: teamsOrgConnections.teamsUserDisplayName, + }) + .from(teamsOrgConnections) + .where( + and( + eq(teamsOrgConnections.teamsTenantId, args.tenantId), + eq(teamsOrgConnections.vm0UserId, args.vm0UserId), + ), + ) + .limit(1); + + if (!connection?.teamsUserId) { + return { kind: "not_found" }; + } + + return { + kind: "ok", + teamsUserId: connection.teamsUserId, + teamsUserDisplayName: connection.teamsUserDisplayName, + }; +} + +async function resolveTeamsMessageTarget(args: { + readonly db: ReadonlyDb; + readonly installation: TeamsInstallation; + readonly userId: string; + readonly body: { + readonly conversationId?: string; + readonly user?: string; + readonly activityId?: string; + }; + readonly signal: AbortSignal; +}): Promise> { + if (args.body.conversationId) { + return { + conversationId: args.body.conversationId, + activityId: args.body.activityId, + }; + } + + if (!args.installation.botId) { + return routeError( + 404, + "Microsoft Teams installation has no bot identity yet. Send a message to the Teams bot first.", + "NOT_FOUND", + ); + } + + const targetUser = + args.body.user === "me" + ? await resolveConnectedTeamsUser({ + db: args.db, + tenantId: args.installation.teamsTenantId, + vm0UserId: args.userId, + }) + : { + kind: "ok" as const, + teamsUserId: args.body.user ?? "", + teamsUserDisplayName: null, + }; + args.signal.throwIfAborted(); + + if (targetUser.kind === "not_found") { + return routeError( + 404, + "No connected Microsoft Teams user found for this organization", + "NOT_FOUND", + ); + } + + const conversation = await createTeamsPersonalConversation({ + serviceUrl: args.installation.serviceUrl ?? "", + tenantId: args.installation.teamsTenantId, + botId: args.installation.botId, + botName: args.installation.botName, + teamsUserId: targetUser.teamsUserId, + teamsUserDisplayName: targetUser.teamsUserDisplayName, + signal: args.signal, + }); + args.signal.throwIfAborted(); + + if (conversation.kind === "teams-error") { + return teamsErrorResponse(conversation); + } + + return { + conversationId: conversation.conversationId, + activityId: undefined, + }; +} + const sendMessageInner$ = command(async ({ get }, signal: AbortSignal) => { const auth = get(organizationAuthContext$); const bodyResult = await get( @@ -62,7 +180,8 @@ const sendMessageInner$ = command(async ({ get }, signal: AbortSignal) => { } const body = bodyResult.data; - const installation = await loadInstallation(get(db$), auth.orgId); + const db = get(db$); + const installation = await loadInstallation(db, auth.orgId); signal.throwIfAborted(); if (!installation) { return routeError( @@ -79,12 +198,25 @@ const sendMessageInner$ = command(async ({ get }, signal: AbortSignal) => { ); } + const target = await resolveTeamsMessageTarget({ + db, + installation, + userId: auth.userId, + body, + signal, + }); + signal.throwIfAborted(); + if ("status" in target) { + return target; + } + const result = await sendTeamsMessage({ serviceUrl: installation.serviceUrl, - conversationId: body.conversationId, - activityId: body.activityId, + conversationId: target.conversationId, + activityId: target.activityId, tenantId: installation.teamsTenantId, - text: body.text, + text: body.text ?? "Adaptive card", + card: body.card, signal, }); signal.throwIfAborted(); @@ -97,7 +229,7 @@ const sendMessageInner$ = command(async ({ get }, signal: AbortSignal) => { body: { ok: true as const, activityId: result.activityId, - conversationId: body.conversationId, + conversationId: target.conversationId, }, }; }); diff --git a/turbo/apps/api/src/signals/services/teams-file-token.ts b/turbo/apps/api/src/signals/services/teams-file-token.ts index ea2ba2cb4a2..a3a6c49387c 100644 --- a/turbo/apps/api/src/signals/services/teams-file-token.ts +++ b/turbo/apps/api/src/signals/services/teams-file-token.ts @@ -8,6 +8,7 @@ import { safeJsonParse } from "../utils"; const teamsFileTokenPayloadSchema = z.object({ tenantId: z.string().min(1), url: z.string().url(), + id: z.string().min(1).optional(), name: z.string().min(1).optional(), contentType: z.string().min(1).optional(), }); diff --git a/turbo/apps/api/src/signals/services/zero-teams-connect.service.ts b/turbo/apps/api/src/signals/services/zero-teams-connect.service.ts index da676435473..c16d13a98fa 100644 --- a/turbo/apps/api/src/signals/services/zero-teams-connect.service.ts +++ b/turbo/apps/api/src/signals/services/zero-teams-connect.service.ts @@ -417,6 +417,8 @@ interface TeamsConnectStatus { readonly teamName?: string | null; readonly defaultAgentName?: string | null; readonly agentOrgSlug?: string | null; + readonly permissionMismatch?: boolean | null; + readonly reinstallUrl?: string | null; readonly environment?: TeamsEnvironment; } @@ -581,6 +583,33 @@ function inactiveTeamsStatus(args: { }; } +function teamsPermissionStatus(args: { + readonly installation: TeamsInstallation; + readonly isAdmin: boolean; +}): { + readonly permissionMismatch?: boolean | null; + readonly reinstallUrl?: string | null; +} { + if (!args.isAdmin) { + return {}; + } + + const configuredAppId = env("MICROSOFT_TEAMS_BOT_APP_ID"); + const installedAppId = args.installation.teamsAppId; + const permissionMismatch = Boolean( + configuredAppId && + installedAppId && + configuredAppId.toLowerCase() !== installedAppId.toLowerCase(), + ); + + return { + permissionMismatch, + reinstallUrl: permissionMismatch + ? buildTeamsInstallUrl(args.installation.teamsTenantId) + : null, + }; +} + function activeTeamsStatus(args: { readonly installation: TeamsInstallation; readonly connection: typeof teamsOrgConnections.$inferSelect | undefined; @@ -605,6 +634,10 @@ function activeTeamsStatus(args: { teamId: args.installation.teamsTeamId, teamName: args.installation.teamsTeamName, defaultAgentName: args.connectedFields?.defaultAgentName ?? null, + ...teamsPermissionStatus({ + installation: args.installation, + isAdmin: args.isAdmin, + }), }; if (!args.connectedFields) { return status; diff --git a/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts b/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts index ce63c05d33e..ecf500568a7 100644 --- a/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts +++ b/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts @@ -32,12 +32,15 @@ import { fetchTeamsChannelMessage, fetchTeamsChannelMessageReplies, fetchTeamsChannelMessages, + fetchTeamsUsers, sendTeamsReaction, sendTeamsTypingActivity, type TeamsAdaptiveCard, + type TeamsGraphAttachment, type TeamsGraphMessage, + type TeamsGraphUserInfo, } from "../external/teams-bot-client"; -import { settle } from "../utils"; +import { safeJsonParse, settle } from "../utils"; import { dispatchFailedRunCallbacks } from "./agent-run-callback.service"; import { formatIntegrationRunError$ } from "./integration-run-errors.service"; import { @@ -94,6 +97,7 @@ interface TeamsContextMessage { readonly senderId: string; readonly senderName: string | null; readonly senderPrincipalName: string | null; + readonly files: readonly TeamsPromptFile[]; } interface TeamsAgent { @@ -116,6 +120,7 @@ interface TeamsModelPickerOption { interface TeamsPromptFile { readonly fileId: string; + readonly sourceId?: string; readonly name: string; readonly contentType: string; } @@ -407,6 +412,21 @@ function stringRecordValue( return typeof value === "string" && value.length > 0 ? value : null; } +function recordFromUnknown( + value: unknown, +): Readonly> | null { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Readonly>; + } + if (typeof value !== "string" || value.length === 0) { + return null; + } + const parsed = safeJsonParse(value); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Readonly>) + : null; +} + function teamsAttachmentDownloadUrl( attachment: TeamsInboundAttachment, ): string | null { @@ -458,9 +478,11 @@ function teamsPromptFile( fileId: encodeTeamsFileToken({ tenantId: activity.tenantId, url, + id: attachment.id ?? undefined, name, contentType, }), + sourceId: attachment.id ?? undefined, name, contentType, }; @@ -476,6 +498,7 @@ function teamsPromptFiles(activity: TeamsMessageActivity): TeamsPromptFile[] { function formatTeamsFileForContext(file: TeamsPromptFile): string { return [ `[Teams file] ${file.name} (${file.contentType})`, + ...(file.sourceId ? [` [Teams attachment ID] ${file.sourceId}`] : []), ` [ID] ${file.fileId}`, ].join("\n"); } @@ -496,6 +519,87 @@ function appendTeamsFilesToPrompt( .join("\n\n"); } +function graphAttachmentContent( + attachment: TeamsGraphAttachment, +): Readonly> | null { + return recordFromUnknown(attachment.content); +} + +function teamsGraphAttachmentDownloadUrl( + attachment: TeamsGraphAttachment, +): string | null { + const content = graphAttachmentContent(attachment); + return ( + stringRecordValue(content, "downloadUrl") ?? + attachment.contentUrl ?? + stringRecordValue(content, "contentUrl") + ); +} + +function teamsGraphAttachmentName(attachment: TeamsGraphAttachment): string { + const content = graphAttachmentContent(attachment); + return ( + attachment.name ?? + stringRecordValue(content, "name") ?? + stringRecordValue(content, "fileName") ?? + "teams-file" + ); +} + +function teamsGraphAttachmentContentType( + attachment: TeamsGraphAttachment, + filename: string, +): string { + const content = graphAttachmentContent(attachment); + if ( + attachment.contentType && + attachment.contentType !== TEAMS_FILE_DOWNLOAD_INFO_CONTENT_TYPE + ) { + return attachment.contentType; + } + + const fileType = stringRecordValue(content, "fileType"); + if (fileType && !filename.endsWith(`.${fileType}`)) { + return inferMimetype(`${filename}.${fileType}`); + } + return inferMimetype(filename); +} + +function teamsGraphPromptFile( + tenantId: string, + attachment: TeamsGraphAttachment, +): TeamsPromptFile | null { + const url = teamsGraphAttachmentDownloadUrl(attachment); + if (!url || !URL.canParse(url)) { + return null; + } + + const name = teamsGraphAttachmentName(attachment); + const contentType = teamsGraphAttachmentContentType(attachment, name); + return { + fileId: encodeTeamsFileToken({ + tenantId, + url, + id: attachment.id ?? undefined, + name, + contentType, + }), + sourceId: attachment.id ?? undefined, + name, + contentType, + }; +} + +function teamsGraphPromptFiles( + tenantId: string, + message: TeamsGraphMessage, +): TeamsPromptFile[] { + return (message.attachments ?? []).flatMap((attachment) => { + const file = teamsGraphPromptFile(tenantId, attachment); + return file ? [file] : []; + }); +} + async function installationForTenant( db: Db, tenantId: string, @@ -958,14 +1062,18 @@ function formatTeamsContextMessage( message: TeamsContextMessage, relativeIndex: number, ): string { - return [ + const parts = [ "---", "", `- RELATIVE_INDEX: ${relativeIndex}`, formatTeamsSenderBlock(message), "", message.text, - ].join("\n"); + ]; + if (message.files.length > 0) { + parts.push(...message.files.map(formatTeamsFileForContext)); + } + return parts.join("\n"); } const TEAMS_CONTEXT_PREAMBLE = [ @@ -1005,8 +1113,52 @@ function formatTeamsContext( )}\n\n---`; } +function plainTeamsMentionLabel(mentionText: string): string { + return mentionText + .replace(/]*>/giu, "") + .replace(/<\/at>/giu, "") + .trim(); +} + +function teamsGraphMentionReplacement( + mention: NonNullable[number], +): string | null { + const mentioned = + mention.mentioned?.user ?? + mention.mentioned?.application ?? + mention.mentioned?.device; + const label = + mentioned?.displayName ?? + (mention.mentionText ? plainTeamsMentionLabel(mention.mentionText) : null); + if (!label) { + return null; + } + return mentioned?.id ? `@${label} (${mentioned.id})` : `@${label}`; +} + +function replaceTeamsGraphMentions( + content: string, + mentions: readonly NonNullable[number][], +): string { + let result = content; + for (const mention of mentions) { + if (!mention.mentionText) { + continue; + } + const replacement = teamsGraphMentionReplacement(mention); + if (!replacement) { + continue; + } + result = result.split(mention.mentionText).join(replacement); + } + return result; +} + function teamsGraphMessageText(message: TeamsGraphMessage): string { - const content = message.body?.content?.trim() ?? ""; + const content = replaceTeamsGraphMentions( + message.body?.content?.trim() ?? "", + message.mentions ?? [], + ); if (message.body?.contentType === "html") { return convert(content, { wordwrap: false }).trim(); } @@ -1015,21 +1167,29 @@ function teamsGraphMessageText(message: TeamsGraphMessage): string { function teamsGraphMessageSender( message: TeamsGraphMessage, + userInfoMap: ReadonlyMap, ): Pick< TeamsContextMessage, "senderId" | "senderName" | "senderPrincipalName" > { const sender = message.from?.user ?? message.from?.application ?? message.from?.device; + const userInfo = sender?.id ? userInfoMap.get(sender.id) : undefined; return { senderId: sender?.id ?? "unknown", - senderName: sender?.displayName ?? null, - senderPrincipalName: null, + senderName: userInfo?.displayName ?? sender?.displayName ?? null, + senderPrincipalName: + userInfo?.userPrincipalName ?? + sender?.userPrincipalName ?? + sender?.mail ?? + null, }; } function teamsGraphContextMessage( + tenantId: string, message: TeamsGraphMessage, + userInfoMap: ReadonlyMap, ): TeamsContextMessage | null { if (message.messageType && message.messageType !== "message") { return null; @@ -1044,7 +1204,8 @@ function teamsGraphContextMessage( id: message.id ?? null, createdDateTime: message.createdDateTime ?? null, text, - ...teamsGraphMessageSender(message), + files: teamsGraphPromptFiles(tenantId, message), + ...teamsGraphMessageSender(message, userInfoMap), }; } @@ -1063,20 +1224,70 @@ function sortTeamsContextMessages( } function teamsContextMessages( + tenantId: string, messages: readonly TeamsGraphMessage[], excludedIds: ReadonlySet, + userInfoMap: ReadonlyMap, ): TeamsContextMessage[] { return sortTeamsContextMessages( messages.flatMap((message) => { if (message.id && excludedIds.has(message.id)) { return []; } - const contextMessage = teamsGraphContextMessage(message); + const contextMessage = teamsGraphContextMessage( + tenantId, + message, + userInfoMap, + ); return contextMessage ? [contextMessage] : []; }), ); } +function teamsGraphSenderUserIds( + messages: readonly TeamsGraphMessage[], +): readonly string[] { + return [ + ...new Set( + messages.flatMap((message) => { + const sender = message.from?.user; + const userId = sender?.id; + if (sender?.userPrincipalName || sender?.mail) { + return []; + } + return userId ? [userId] : []; + }), + ), + ]; +} + +async function fetchTeamsGraphUserInfoMap(args: { + readonly tenantId: string; + readonly messages: readonly TeamsGraphMessage[]; + readonly signal: AbortSignal; +}): Promise> { + const userIds = teamsGraphSenderUserIds(args.messages); + if (userIds.length === 0) { + return new Map(); + } + + const result = await fetchTeamsUsers({ + tenantId: args.tenantId, + userIds, + signal: args.signal, + }); + args.signal.throwIfAborted(); + if (result.kind === "teams-error") { + L.debug("Teams user context fetch failed", { + tenantId: args.tenantId, + status: result.status, + error: result.error, + }); + return new Map(); + } + return result.users; +} + function isTeamsThreadReply(activity: TeamsMessageActivity): boolean { return Boolean( activity.activityId && activity.threadId !== activity.activityId, @@ -1171,10 +1382,19 @@ async function fetchTeamsThreadContext(args: { return ""; } + const messages = [args.rootMessage, ...repliesResult.messages]; + const userInfoMap = await fetchTeamsGraphUserInfoMap({ + tenantId: activity.tenantId, + messages, + signal: args.signal, + }); + return formatTeamsThreadContext( teamsContextMessages( - [args.rootMessage, ...repliesResult.messages], + activity.tenantId, + messages, currentTeamsActivityIds(activity), + userInfoMap, ), ); } @@ -1223,10 +1443,22 @@ async function fetchRecentTeamsChannelContext(args: { return ""; } + const messages = teamsMessagesBeforeReference( + result.messages, + args.beforeMessage, + ); + const userInfoMap = await fetchTeamsGraphUserInfoMap({ + tenantId: activity.tenantId, + messages, + signal: args.signal, + }); + return formatRecentTeamsChannelContext( teamsContextMessages( - teamsMessagesBeforeReference(result.messages, args.beforeMessage), + activity.tenantId, + messages, recentChannelContextExcludedIds(activity), + userInfoMap, ), ); } diff --git a/turbo/apps/cli/src/commands/zero/teams/__tests__/send.test.ts b/turbo/apps/cli/src/commands/zero/teams/__tests__/send.test.ts index 88e99439e98..17dd84d1402 100644 --- a/turbo/apps/cli/src/commands/zero/teams/__tests__/send.test.ts +++ b/turbo/apps/cli/src/commands/zero/teams/__tests__/send.test.ts @@ -63,6 +63,51 @@ describe("zero teams message send command", () => { expect(logCalls).toContain("activity_id: teams-activity-1"); }); + it("sends a DM with an Adaptive Card", async () => { + let capturedBody: Record | undefined; + + server.use( + http.post(TEAMS_MESSAGE_URL, async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ + ok: true, + activityId: "teams-dm-activity-1", + conversationId: "a:teams-dm-conversation", + }); + }), + ); + + await sendCommand.parseAsync([ + "node", + "cli", + "--user", + "me", + "--text", + "Pick a workflow", + "--card", + '{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","text":"Pick a workflow","wrap":true}]}', + ]); + + expect(capturedBody).toStrictEqual({ + user: "me", + text: "Pick a workflow", + card: { + type: "AdaptiveCard", + version: "1.4", + body: [ + { + type: "TextBlock", + text: "Pick a workflow", + wrap: true, + }, + ], + }, + }); + const logCalls = mockConsoleLog.mock.calls.flat().join("\n"); + expect(logCalls).toContain("Message sent"); + expect(logCalls).toContain("activity_id: teams-dm-activity-1"); + }); + it("errors when message text is missing", async () => { await expect(async () => { await sendCommand.parseAsync([ @@ -74,7 +119,21 @@ describe("zero teams message send command", () => { }).rejects.toThrow("process.exit called"); expect(mockConsoleError).toHaveBeenCalledWith( - expect.stringContaining("Either --text or piped stdin must be provided"), + expect.stringContaining( + "Either --text, --card, or piped stdin must be provided", + ), + ); + }); + + it("errors when a target is missing", async () => { + await expect(async () => { + await sendCommand.parseAsync(["node", "cli", "--text", "hello"]); + }).rejects.toThrow("process.exit called"); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + "Either --conversation-id or --user must be provided", + ), ); }); }); diff --git a/turbo/apps/cli/src/commands/zero/teams/index.ts b/turbo/apps/cli/src/commands/zero/teams/index.ts index 2a21559a231..ba08f2400ca 100644 --- a/turbo/apps/cli/src/commands/zero/teams/index.ts +++ b/turbo/apps/cli/src/commands/zero/teams/index.ts @@ -16,7 +16,8 @@ export const zeroTeamsCommand = new Command() ` Examples: Send a message: zero teams message send -c -t "Hello!" - Reply in a thread: zero teams message send -c --activity-id -t "reply" + DM a user: zero teams message send -u me -t "Hello!" + Reply in a thread: zero teams message send -c --thread -t "reply" Upload a file: zero teams upload-file -f /tmp/report.pdf -c Download a file: zero teams download-file -o /tmp/out.png`, ); diff --git a/turbo/apps/cli/src/commands/zero/teams/message/index.ts b/turbo/apps/cli/src/commands/zero/teams/message/index.ts index 3710f53be3a..d72e7447b6a 100644 --- a/turbo/apps/cli/src/commands/zero/teams/message/index.ts +++ b/turbo/apps/cli/src/commands/zero/teams/message/index.ts @@ -9,5 +9,6 @@ export const zeroTeamsMessageCommand = new Command() "after", ` Examples: - zero teams message send -c -t "Hello!"`, + zero teams message send -c -t "Hello!" + zero teams message send -u me -t "Hello!"`, ); diff --git a/turbo/apps/cli/src/commands/zero/teams/message/send.ts b/turbo/apps/cli/src/commands/zero/teams/message/send.ts index 7ce96ba0cd0..61657a59908 100644 --- a/turbo/apps/cli/src/commands/zero/teams/message/send.ts +++ b/turbo/apps/cli/src/commands/zero/teams/message/send.ts @@ -1,34 +1,111 @@ import { readFileSync } from "fs"; import { Command } from "commander"; +import type { SendTeamsMessageBody } from "@vm0/api-contracts/contracts/integrations"; import chalk from "chalk"; import { sendTeamsMessage } from "../../../../lib/api"; import { withErrorHandler } from "../../../../lib/command"; +type TeamsCardInput = NonNullable; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseTeamsCard(value: string): TeamsCardInput { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("Invalid JSON for --card flag", { + cause: new Error("Provide a valid Adaptive Card JSON object"), + }); + } + + if ( + !isRecord(parsed) || + parsed.type !== "AdaptiveCard" || + typeof parsed.version !== "string" + ) { + throw new Error("Invalid Adaptive Card for --card flag", { + cause: new Error( + 'Provide a JSON object with "type": "AdaptiveCard" and a version', + ), + }); + } + + return parsed as TeamsCardInput; +} + export const sendCommand = new Command() .name("send") - .description("Send a message to a Microsoft Teams conversation as the bot") - .requiredOption("-c, --conversation-id ", "Teams conversation ID") + .description("Send a message to a Microsoft Teams conversation or DM a user") + .option("-c, --conversation-id ", "Teams conversation ID") + .option("-u, --user ", 'Teams user ID for DM (use "me" for yourself)') .option("-t, --text ", "Message text") .option("--activity-id ", "Activity ID to reply to") + .option("--thread ", "Alias for --activity-id") + .option("--card ", "Adaptive Card JSON string") .addHelpText( "after", ` Examples: - Simple message: zero teams message send -c 19:thread@thread.tacv2 -t "Hello!" - Thread reply: zero teams message send -c 19:thread@thread.tacv2 --activity-id root-activity -t "reply" + Simple message: zero teams message send -c 19:thread@thread.tacv2 -t "Hello!" + DM a user: zero teams message send -u 29:user-id -t "Hello!" + DM yourself: zero teams message send -u me -t "Hello!" + Thread reply: zero teams message send -c 19:thread@thread.tacv2 --thread root-activity -t "reply" + Adaptive Card: zero teams message send -c 19:thread@thread.tacv2 --card '{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","text":"Hello","wrap":true}]}' Notes: - - Message text can be provided with --text or piped on stdin + - Either --conversation-id or --user is required; they are mutually exclusive + - Either --text or --card is required; text can be provided with --text or piped on stdin - Use the Conversation ID and Activity ID from the current Teams run prompt`, ) .action( withErrorHandler( async (options: { - conversationId: string; + conversationId?: string; + user?: string; text?: string; activityId?: string; + thread?: string; + card?: string; }) => { let text = options.text; + const { conversationId, user, card: cardJson } = options; + + if (!conversationId && !user) { + throw new Error( + "Either --conversation-id or --user must be provided", + { + cause: new Error( + 'Usage: zero teams message send -c CONVERSATION_ID -t "your message"\n zero teams message send -u USER_ID -t "your message"', + ), + }, + ); + } + if (conversationId && user) { + throw new Error( + "--conversation-id and --user are mutually exclusive", + { + cause: new Error( + "Provide either --conversation-id to send to a conversation or --user to DM a user, not both", + ), + }, + ); + } + + const activityId = options.activityId ?? options.thread; + if (user && activityId) { + throw new Error( + "--activity-id and --thread can only be used with --conversation-id", + { + cause: new Error( + "Thread replies require an existing Teams conversation", + ), + }, + ); + } + if (!text && !process.stdin.isTTY) { try { text = readFileSync("/dev/stdin", "utf8").trim(); @@ -37,18 +114,27 @@ Notes: } } - if (!text) { - throw new Error("Either --text or piped stdin must be provided", { - cause: new Error( - 'Usage: zero teams message send -c CONVERSATION_ID -t "your message"', - ), - }); + const card = cardJson ? parseTeamsCard(cardJson) : undefined; + + if (!text && !card) { + throw new Error( + "Either --text, --card, or piped stdin must be provided", + { + cause: new Error( + 'Usage: zero teams message send -c CONVERSATION_ID -t "your message"', + ), + }, + ); } + const body: SendTeamsMessageBody = { + ...(conversationId ? { conversationId } : { user }), + ...(activityId ? { activityId } : {}), + ...(text ? { text } : {}), + ...(card ? { card } : {}), + }; const result = await sendTeamsMessage({ - conversationId: options.conversationId, - activityId: options.activityId, - text, + ...body, }); const activityInfo = result.activityId diff --git a/turbo/apps/platform/src/views/zero-page/zero-works-page.tsx b/turbo/apps/platform/src/views/zero-page/zero-works-page.tsx index 6c17eac5310..da39938cede 100644 --- a/turbo/apps/platform/src/views/zero-page/zero-works-page.tsx +++ b/turbo/apps/platform/src/views/zero-page/zero-works-page.tsx @@ -1,5 +1,6 @@ import { useGet, useSet, useLastLoadable, useLoadable } from "ccstate-react"; import { useLoadableSet } from "ccstate-react/experimental"; +import type { TeamsConnectStatus } from "@vm0/api-contracts/contracts/zero-teams-connect"; import { pageSignal$ } from "../../signals/page-signal.ts"; import { IconAlertTriangle, @@ -403,6 +404,66 @@ function TeamsCardActions({ ); } +function teamsPermissionReinstallUrl(args: { + isAdmin: boolean; + permissionMismatch: boolean; + reinstallUrl: string | null | undefined; +}): string | null { + if (!args.isAdmin || !args.permissionMismatch || !args.reinstallUrl) { + return null; + } + return args.reinstallUrl; +} + +function TeamsPermissionWarning({ + reinstallUrl, +}: { + reinstallUrl: string | null; +}) { + if (!reinstallUrl) { + return null; + } + + return ( +
+ + + Microsoft Teams permissions have been updated + + +
+ ); +} + +function teamsConnectedDetail( + teamsData: TeamsConnectStatus | null, +): string | null | undefined { + return teamsData?.teamName ?? teamsData?.tenantName ?? teamsData?.tenantId; +} + +function teamsCardDescription(args: { + isInstalled: boolean; + isConnected: boolean; + isAdmin: boolean; +}): string { + if (!args.isInstalled && !args.isAdmin) { + return "Ask your admin to install the Microsoft Teams integration"; + } + if (args.isInstalled && !args.isConnected) { + return "Connect your Microsoft account to finish setup"; + } + return "Team communication and collaboration"; +} + function TeamsCard({ displayName }: { displayName: string }) { const teamsDataLoadable = useLastLoadable(teamsOrgData$); const teamsData = @@ -419,14 +480,18 @@ function TeamsCard({ displayName }: { displayName: string }) { const isConnected = teamsData?.isConnected ?? false; const isInstalled = teamsData?.isInstalled ?? false; const isAdmin = teamsData?.isAdmin ?? false; - const connectedDetail = - teamsData?.teamName ?? teamsData?.tenantName ?? teamsData?.tenantId; - const description = - !isInstalled && !isAdmin - ? "Ask your admin to install the Microsoft Teams integration" - : isInstalled && !isConnected - ? "Connect your Microsoft account to finish setup" - : "Team communication and collaboration"; + const connectedDetail = teamsConnectedDetail(teamsData); + const permissionMismatch = teamsData?.permissionMismatch === true; + const reinstallUrl = teamsPermissionReinstallUrl({ + isAdmin, + permissionMismatch, + reinstallUrl: teamsData?.reinstallUrl, + }); + const description = teamsCardDescription({ + isInstalled, + isConnected, + isAdmin, + }); return ( <> @@ -457,6 +522,8 @@ function TeamsCard({ displayName }: { displayName: string }) { }} /> + + { + return Boolean(body.conversationId) !== Boolean(body.user); + }, + { + message: "Exactly one of conversationId or user is required", + path: ["conversationId"], + }, + ) + .refine( + (body) => { + return Boolean(body.text) || Boolean(body.card); + }, + { + message: "Message text or card is required", + path: ["text"], + }, + ) + .refine( + (body) => { + return !(body.user && body.activityId); + }, + { + message: "activityId can only be used with conversationId", + path: ["activityId"], + }, + ); export type SendTeamsMessageBody = z.infer; diff --git a/turbo/packages/api-contracts/src/contracts/zero-teams-connect.ts b/turbo/packages/api-contracts/src/contracts/zero-teams-connect.ts index c59df1b5577..c46adfe3d7e 100644 --- a/turbo/packages/api-contracts/src/contracts/zero-teams-connect.ts +++ b/turbo/packages/api-contracts/src/contracts/zero-teams-connect.ts @@ -26,6 +26,8 @@ const teamsConnectStatusSchema = z.object({ teamName: nullableStringSchema.optional(), defaultAgentName: nullableStringSchema.optional(), agentOrgSlug: nullableStringSchema.optional(), + permissionMismatch: z.boolean().optional(), + reinstallUrl: nullableStringSchema.optional(), environment: teamsEnvironmentSchema.optional(), }); From 5d25ee4b38dba9bd5d834eb723a1cf68c74edb25 Mon Sep 17 00:00:00 2001 From: Linghan Hu Date: Fri, 10 Jul 2026 08:37:38 +0000 Subject: [PATCH 3/3] fix: address pr review findings (round 1) --- .../routes/__tests__/zero-teams-bot.test.ts | 23 +++++++++++++++++++ .../services/zero-teams-dispatch.service.ts | 5 ++-- turbo/apps/cli/src/__tests__/zero.test.ts | 4 ++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts b/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts index 71d325e47f8..66581b18561 100644 --- a/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts +++ b/turbo/apps/api/src/signals/routes/__tests__/zero-teams-bot.test.ts @@ -1674,6 +1674,23 @@ describe("POST /api/zero/teams/bot", () => { }, ], }, + { + id: "activity-context-file-only", + text: "", + createdDateTime: "2026-06-30T09:11:30.000Z", + senderId: fixture.teamsUserId, + attachments: [ + { + id: "teams-file-checklist-1", + name: "release-checklist.txt", + contentType: "application/vnd.microsoft.teams.file.download.info", + content: { + downloadUrl: "https://files.example.test/release-checklist.txt", + fileType: "txt", + }, + }, + ], + }, { id: "activity-dispatch-1", text: "ship the Teams dispatch", @@ -1874,6 +1891,12 @@ describe("POST /api/zero/teams/bot", () => { expect(teamsThreadContext).toContain( "[Teams attachment ID] teams-file-plan-1", ); + expect(teamsThreadContext).toContain( + "[Teams file] release-checklist.txt (text/plain)", + ); + expect(teamsThreadContext).toContain( + "[Teams attachment ID] teams-file-checklist-1", + ); expect(teamsThreadContext).not.toContain("ship the Teams dispatch"); expect(graphRequests).toContain("thread-root:root-dispatch"); expect(graphRequests).toContain("thread-replies:root-dispatch"); diff --git a/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts b/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts index ecf500568a7..c4d07cb0354 100644 --- a/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts +++ b/turbo/apps/api/src/signals/services/zero-teams-dispatch.service.ts @@ -1196,7 +1196,8 @@ function teamsGraphContextMessage( } const text = teamsGraphMessageText(message); - if (!text) { + const files = teamsGraphPromptFiles(tenantId, message); + if (!text && files.length === 0) { return null; } @@ -1204,7 +1205,7 @@ function teamsGraphContextMessage( id: message.id ?? null, createdDateTime: message.createdDateTime ?? null, text, - files: teamsGraphPromptFiles(tenantId, message), + files, ...teamsGraphMessageSender(message, userInfoMap), }; } diff --git a/turbo/apps/cli/src/__tests__/zero.test.ts b/turbo/apps/cli/src/__tests__/zero.test.ts index d58b3a7299d..72282b534a7 100644 --- a/turbo/apps/cli/src/__tests__/zero.test.ts +++ b/turbo/apps/cli/src/__tests__/zero.test.ts @@ -70,7 +70,7 @@ describe("zero CLI program", () => { } }); - it("should have exactly 34 commands", () => { - expect(commandNames).toHaveLength(34); + it("should have exactly 35 commands", () => { + expect(commandNames).toHaveLength(35); }); });