mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
feat(eve): add guided Teams Connect setup (#3243)
Signed-off-by: owenkephart <owen.kephart@vercel.com>
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 }])
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user