feat(eve): add guided Teams Connect setup (#3243)

Signed-off-by: owenkephart <owen.kephart@vercel.com>
This commit is contained in:
OwenKephart
2026-09-16 13:01:22 -07:00
committed by GitHub
parent 31bed12d69
commit 0fa8916e9c
9 changed files with 401 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"eve": patch
---
Add guided Microsoft Teams setup through `eve add channel/teams`. The flow delegates app creation, installation, and Activity routing to the Vercel Connect CLI, then scaffolds the Connect-backed channel.
+11 -13
View File
@@ -1680,24 +1680,22 @@
"name": "channel/teams",
"type": "registry:item",
"title": "Microsoft Teams",
"description": "Bring your agent into Teams chats and channels.",
"envVars": {
"TEAMS_APP_ID": "",
"TEAMS_APP_PASSWORD": ""
},
"files": [
{
"path": "registry/channels/teams.ts",
"type": "registry:file",
"target": "agent/channels/teams.ts"
}
],
"description": "Run an eve agent as a managed Microsoft Teams bot, with guided Vercel Connect setup.",
"meta": {
"eve": {
"setup": {
"command": "eve",
"package": "eve",
"bin": "eve",
"args": ["integration", "setup", "teams"]
},
"requires": ">=0.53.0",
"docs": "/docs/channels/teams",
"implementation": "native"
}
}
},
"dependencies": ["@vercel/connect@>=2.1.0"],
"devDependencies": ["vercel@>=58.5.1"]
},
{
"name": "channel/telegram",
+5 -16
View File
@@ -43,12 +43,14 @@ const registrySlugsByCatalogSlug: Readonly<Record<string, string>> = {
};
const setupKindsByCatalogSlug: Readonly<Record<string, string>> = {
slack: "slack",
discord: "discord",
github: "github",
"linear-agent": "linear",
eve: "web",
linq: "linq",
photon: "photon",
teams: "teams",
};
const adapterDependenciesByCatalogSlug: Readonly<Record<string, string>> = {
@@ -129,8 +131,6 @@ for (const [index, item] of items.entries()) {
const entry = registryEntries[index];
if (entry === undefined) throw new Error(`Unexpected channel registry item "${item.name}".`);
const registrySlug = expectedSlugs[index];
if (entry.slug === "eve") {
if (
item.dependencies?.some((dependency) => dependency === "ai" || dependency.startsWith("ai@"))
@@ -146,20 +146,9 @@ for (const [index, item] of items.entries()) {
}
}
if (
entry.slug === "slack" ||
entry.slug === "discord" ||
entry.slug === "github" ||
entry.slug === "linear-agent" ||
entry.slug === "eve" ||
entry.slug === "linq" ||
entry.slug === "photon"
) {
const expectedArgs = [
"integration",
"setup",
setupKindsByCatalogSlug[entry.slug] ?? registrySlug,
];
const setupKind = setupKindsByCatalogSlug[entry.slug];
if (setupKind !== undefined) {
const expectedArgs = ["integration", "setup", setupKind];
if (
JSON.stringify(setups) !==
JSON.stringify([{ command: "eve", package: "eve", bin: "eve", args: expectedArgs }])
+29
View File
@@ -14,6 +14,35 @@ import { teamsChannel } from "eve/channels/teams";
export default teamsChannel();
```
### Vercel Connect
For a managed Teams bot on Vercel, run:
```bash
eve add channel/teams
```
The guided flow runs the Vercel Connect CLI, which takes you through managed
Microsoft Teams app creation and installation. It routes verified Activities to
`/eve/v1/teams` and writes:
```ts title="agent/channels/teams.ts"
import { connectTeamsCredentials } from "@vercel/connect/eve";
import { teamsChannel } from "eve/channels/teams";
export default teamsChannel({
credentials: connectTeamsCredentials("microsoft-teams/my-agent"),
});
```
Vercel Connect owns the Microsoft administrator consent and app installation
flow. After deployment, mention the bot in a channel or send it a personal
message.
### Portable credentials
To manage the bot registration and credentials yourself, configure:
```bash
MICROSOFT_APP_ID=...
MICROSOFT_APP_PASSWORD=...
@@ -10,6 +10,7 @@ import {
} from "./self-modification/setup.js";
import { SHOPIFY_SETUP } from "./shopify/setup.js";
import { SLACK_SETUP } from "./slack/setup.js";
import { TEAMS_SETUP } from "./teams/setup.js";
import type { SetupIntegration } from "./types.js";
import { WEB_SETUP } from "./web/setup.js";
@@ -23,6 +24,7 @@ export const SETUP_INTEGRATIONS: readonly SetupIntegration[] = [
LINEAR_SETUP,
LINQ_SETUP,
PHOTON_SETUP,
TEAMS_SETUP,
SHOPIFY_SETUP,
SELF_MODIFICATION_SETUP,
SELF_MODIFICATION_PRODUCTION_SETUP,
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import type { ChannelSetupLog } from "#setup/cli/index.js";
import { parseCreatedTeamsConnector, provisionTeamsConnector } from "./connect.js";
function log(): ChannelSetupLog {
return {
message: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
commandOutput: vi.fn(),
};
}
describe("Microsoft Teams Connect provisioning", () => {
it("parses a connector created by the Vercel CLI", () => {
expect(
parseCreatedTeamsConnector(JSON.stringify({ id: "scl_teams", uid: "microsoft-teams/agent" })),
).toEqual({ id: "scl_teams", uid: "microsoft-teams/agent" });
expect(parseCreatedTeamsConnector("invalid")).toBeUndefined();
});
it("delegates app creation to Connect and registers the trigger destination", async () => {
const runVercelCaptureStdout = vi.fn(async () => ({
ok: true as const,
stdout: JSON.stringify({ id: "scl_teams", uid: "microsoft-teams/agent" }),
stderr: "",
}));
const runVercel = vi.fn(async () => true);
await expect(
provisionTeamsConnector({
name: "Agent",
log: log(),
project: { orgId: "team_123", projectId: "prj_123" },
projectRoot: "/project",
deps: { runVercel, runVercelCaptureStdout },
}),
).resolves.toEqual({ id: "scl_teams", uid: "microsoft-teams/agent" });
expect(runVercelCaptureStdout).toHaveBeenCalledWith(
[
"connect",
"create",
"microsoft-teams",
"--name",
"Agent",
"-F",
"json",
"--scope",
"team_123",
],
expect.objectContaining({ cwd: "/project", nonInteractive: true }),
);
expect(runVercel).toHaveBeenCalledWith(
[
"connect",
"attach",
"microsoft-teams/agent",
"--project",
"prj_123",
"--environment",
"production",
"--triggers",
"--trigger-path",
"/eve/v1/teams",
"--yes",
"--scope",
"team_123",
],
expect.objectContaining({ cwd: "/project", nonInteractive: true }),
);
});
it("reports the recovery command when trigger registration fails", async () => {
const runVercelCaptureStdout = vi.fn(async () => ({
ok: true as const,
stdout: JSON.stringify({ id: "scl_teams", uid: "microsoft-teams/agent" }),
stderr: "",
}));
const runVercel = vi.fn(async () => false);
await expect(
provisionTeamsConnector({
name: "Agent",
log: log(),
project: { orgId: "team_123", projectId: "prj_123" },
projectRoot: "/project",
deps: { runVercel, runVercelCaptureStdout },
}),
).rejects.toThrow(
"vercel connect attach microsoft-teams/agent --project prj_123 --environment production --triggers --trigger-path /eve/v1/teams --yes --scope team_123",
);
});
});
@@ -0,0 +1,109 @@
import { createPromptCommandOutput, withPhase, type ChannelSetupLog } from "#setup/cli/index.js";
import type { VercelProjectReference } from "#setup/project-resolution.js";
import { runVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js";
import { z } from "zod";
export const TEAMS_TRIGGER_PATH = "/eve/v1/teams";
export interface TeamsConnectorRef {
id: string;
uid: string;
}
export interface ProvisionTeamsConnectorDeps {
runVercel: typeof runVercel;
runVercelCaptureStdout: typeof runVercelCaptureStdout;
}
const TeamsConnectorSchema = z.object({
id: z.string().min(1),
uid: z.string().min(1),
});
export function parseCreatedTeamsConnector(stdout: string): TeamsConnectorRef | undefined {
try {
const parsed = TeamsConnectorSchema.safeParse(JSON.parse(stdout));
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
}
/** Creates and installs a Teams app through the Vercel Connect CLI. */
export async function provisionTeamsConnector(input: {
name: string;
log: ChannelSetupLog;
project: VercelProjectReference;
projectRoot: string;
signal?: AbortSignal;
deps?: ProvisionTeamsConnectorDeps;
}): Promise<TeamsConnectorRef> {
const deps = input.deps ?? { runVercel, runVercelCaptureStdout };
const onOutput = createPromptCommandOutput(input.log);
const result = await withPhase(input.log, "Creating Microsoft Teams connector…", () =>
deps.runVercelCaptureStdout(
[
"connect",
"create",
"microsoft-teams",
"--name",
input.name,
"-F",
"json",
"--scope",
input.project.orgId,
],
{
cwd: input.projectRoot,
nonInteractive: true,
onOutput,
signal: input.signal,
},
),
);
input.signal?.throwIfAborted();
if (!result.ok) {
const detail = [result.stderr, result.stdout].find(
(value): value is string => value !== undefined && value.trim().length > 0,
);
throw new Error(
detail
? `Microsoft Teams connector creation failed:\n${detail}`
: "Microsoft Teams connector creation failed.",
);
}
const connector = parseCreatedTeamsConnector(result.stdout);
if (connector === undefined)
throw new Error("Vercel returned an invalid Microsoft Teams connector.");
const attachArgs = [
"connect",
"attach",
connector.uid,
"--project",
input.project.projectId,
"--environment",
"production",
"--triggers",
"--trigger-path",
TEAMS_TRIGGER_PATH,
"--yes",
"--scope",
input.project.orgId,
];
const attached = await withPhase(input.log, "Connecting Microsoft Teams activities…", () =>
deps.runVercel(attachArgs, {
cwd: input.projectRoot,
nonInteractive: true,
onOutput,
signal: input.signal,
}),
);
input.signal?.throwIfAborted();
if (!attached) {
throw new Error(
`Microsoft Teams connector was created, but its trigger destination could not be registered. Run \`vercel ${attachArgs.join(" ")}\`.`,
);
}
return connector;
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { createFakePrompter } from "#internal/testing/fake-prompter.js";
import { headlessAsker, withAnswers } from "#setup/ask.js";
import { integrationSetupEnvironment } from "../shared/environment.js";
import { createSetupContexts } from "../shared/ui.js";
import { applyTeamsSetup, prepareTeamsSetup, type TeamsSetupDeps } from "./setup.js";
function deps(): TeamsSetupDeps {
return {
provisionConnector: vi.fn(async () => ({
id: "scl_teams",
uid: "microsoft-teams/agent",
})),
runVercel: vi.fn(),
runVercelCaptureStdout: vi.fn(),
writeTextFile: vi.fn(async () => {}),
};
}
function contexts(answers: Record<string, unknown>) {
return createSetupContexts({
appRoot: "/project",
asker: withAnswers(answers)(headlessAsker()),
environment: integrationSetupEnvironment("authenticated", { kind: "unresolved" }),
prompter: createFakePrompter().prompter,
resolveVercelProject: vi.fn(async () => ({ orgId: "team", projectId: "project" })),
});
}
describe("Microsoft Teams setup", () => {
it("delegates setup to the Connect CLI and scaffolds its connector", async () => {
const effects = deps();
const ctx = contexts({ "teams.bot-name": " Agent " });
const plan = await prepareTeamsSetup(ctx.prepare);
expect(effects.provisionConnector).not.toHaveBeenCalled();
await expect(applyTeamsSetup(plan, ctx.apply, effects)).resolves.toMatchObject({
deploymentRequired: true,
});
expect(effects.provisionConnector).toHaveBeenCalledWith(
expect.objectContaining({
name: "Agent",
project: { orgId: "team", projectId: "project" },
}),
);
expect(effects.writeTextFile).toHaveBeenCalledWith(
"/project/agent/channels/teams.ts",
expect.stringContaining('connectTeamsCredentials("microsoft-teams/agent")'),
{ force: undefined },
);
});
});
@@ -0,0 +1,89 @@
import { join } from "node:path";
import { text } from "#setup/ask.js";
import type { VercelProjectReference } from "#setup/project-resolution.js";
import { runVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js";
import { writeTextFile } from "#setup/scaffold/files.js";
import { provisionTeamsConnector } from "./connect.js";
import {
defineSetupIntegration,
type SetupApplyContext,
type SetupPrepareContext,
} from "../types.js";
export interface TeamsSetupDeps {
provisionConnector: typeof provisionTeamsConnector;
runVercel: typeof runVercel;
runVercelCaptureStdout: typeof runVercelCaptureStdout;
writeTextFile: typeof writeTextFile;
}
const defaultDeps: TeamsSetupDeps = {
provisionConnector: provisionTeamsConnector,
runVercel,
runVercelCaptureStdout,
writeTextFile,
};
function connectTemplate(uid: string): string {
return `import { connectTeamsCredentials } from "@vercel/connect/eve";
import { teamsChannel } from "eve/channels/teams";
export default teamsChannel({
credentials: connectTeamsCredentials(${JSON.stringify(uid)}),
});
`;
}
export interface TeamsSetupPlan {
name: string;
project: VercelProjectReference;
}
export async function prepareTeamsSetup(context: SetupPrepareContext): Promise<TeamsSetupPlan> {
const name = await context.asker.ask(
text({
key: "teams.bot-name",
message: "Microsoft Teams bot name",
detected: "eve agent",
required: true,
validate: (value) => (value.trim().length === 0 ? "A bot name is required." : null),
}),
);
const project = await context.resolveVercelProject("Microsoft Teams");
return { name: name.trim(), project };
}
export async function applyTeamsSetup(
plan: TeamsSetupPlan,
context: SetupApplyContext,
deps: TeamsSetupDeps = defaultDeps,
) {
const connector = await deps.provisionConnector({
...plan,
log: context.presenter.log,
projectRoot: context.appRoot,
signal: context.signal,
deps,
});
await deps.writeTextFile(
join(context.appRoot, "agent/channels/teams.ts"),
connectTemplate(connector.uid),
{ force: context.force },
);
context.presenter.log.success("Scaffolded channel: teams");
context.presenter.nextSteps([
"Deploy the agent.",
"Mention the bot in a channel, or send it a message in a personal chat.",
]);
return { facts: [], deploymentRequired: true as const };
}
export const TEAMS_SETUP = defineSetupIntegration({
kind: "teams",
label: "Microsoft Teams",
hint: "Managed Teams bot with Vercel Connect",
prepare: prepareTeamsSetup,
apply: applyTeamsSetup,
});