feat!: move msteams to plugin

This commit is contained in:
Peter Steinberger
2026-01-16 02:58:08 +00:00
parent dae34f3a61
commit d9f9e93dee
73 changed files with 711 additions and 243 deletions

View File

@@ -2,6 +2,7 @@
## 2026.1.15 (unreleased)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.

View File

@@ -17,7 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [iMessage](/channels/imessage) — macOS only; native integration.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.

View File

@@ -3,20 +3,43 @@ summary: "Microsoft Teams bot support status, capabilities, and configuration"
read_when:
- Working on MS Teams channel features
---
# Microsoft Teams (Bot Framework)
# Microsoft Teams (plugin)
> "Abandon all hope, ye who enter here."
Updated: 2026-01-08
Updated: 2026-01-16
Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards.
## Plugin required
Microsoft Teams ships as a plugin and is not bundled with the core install.
**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin.
Explainable: keeps core installs lighter and lets MS Teams dependencies update independently.
Install via CLI (npm registry):
```bash
clawdbot plugins install @clawdbot/msteams
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/msteams
```
If you choose Teams during configure/onboarding and a git checkout is detected,
Clawdbot will offer the local install path automatically.
Details: [Plugins](/plugin)
## Quick setup (beginner)
1) Create an **Azure Bot** (App ID + client secret + tenant ID).
2) Configure Clawdbot with those credentials.
3) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
4) Install the Teams app package and start the gateway.
1) Install the Microsoft Teams plugin.
2) Create an **Azure Bot** (App ID + client secret + tenant ID).
3) Configure Clawdbot with those credentials.
4) Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
5) Install the Teams app package and start the gateway.
Minimal config:
```json5
@@ -73,11 +96,12 @@ Example:
```
## How it works
1. Create an **Azure Bot** (App ID + secret + tenant ID).
2. Build a **Teams app package** that references the bot and includes the RSC permissions below.
3. Upload/install the Teams app into a team (or personal scope for DMs).
4. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway.
5. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default.
1. Install the Microsoft Teams plugin.
2. Create an **Azure Bot** (App ID + secret + tenant ID).
3. Build a **Teams app package** that references the bot and includes the RSC permissions below.
4. Upload/install the Teams app into a team (or personal scope for DMs).
5. Configure `msteams` in `~/.clawdbot/clawdbot.json` (or env vars) and start the gateway.
6. The gateway listens for Bot Framework webhook traffic on `/api/messages` by default.
## Azure Bot Setup (Prerequisites)
@@ -166,13 +190,17 @@ This is often easier than hand-editing JSON manifests.
3. Check gateway logs for incoming activity
## Setup (minimal text-only)
1. **Bot registration**
1. **Install the Microsoft Teams plugin**
- From npm: `clawdbot plugins install @clawdbot/msteams`
- From a local checkout: `clawdbot plugins install ./extensions/msteams`
2. **Bot registration**
- Create an Azure Bot (see above) and note:
- App ID
- Client secret (App password)
- Tenant ID (single-tenant)
2. **Teams app manifest**
3. **Teams app manifest**
- Include a `bot` entry with `botId = <App ID>`.
- Scopes: `personal`, `team`, `groupChat`.
- `supportsFiles: true` (required for personal scope file handling).
@@ -180,7 +208,7 @@ This is often easier than hand-editing JSON manifests.
- Create icons: `outline.png` (32x32) and `color.png` (192x192).
- Zip all three files together: `manifest.json`, `outline.png`, `color.png`.
3. **Configure Clawdbot**
4. **Configure Clawdbot**
```json
{
"msteams": {
@@ -198,12 +226,12 @@ This is often easier than hand-editing JSON manifests.
- `MSTEAMS_APP_PASSWORD`
- `MSTEAMS_TENANT_ID`
4. **Bot endpoint**
5. **Bot endpoint**
- Set the Azure Bot Messaging Endpoint to:
- `https://<host>:3978/api/messages` (or your chosen path/port).
5. **Run the gateway**
- The Teams channel starts automatically when `msteams` config exists and credentials are set.
6. **Run the gateway**
- The Teams channel starts automatically when the plugin is installed and `msteams` config exists with credentials.
## History context
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.

View File

@@ -35,9 +35,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
## Available plugins (official)
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register:

View File

@@ -12,6 +12,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `1.1.0`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.

View File

@@ -0,0 +1,6 @@
# Changelog
## 2026.1.15
### Features
- Microsoft Teams channel plugin (Bot Framework) with polls, media, threads, and gateway monitor.

View File

@@ -0,0 +1,14 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import { msteamsPlugin } from "./src/channel.js";
const plugin = {
id: "msteams",
name: "Microsoft Teams",
description: "Microsoft Teams channel plugin (Bot Framework)",
register(api: ClawdbotPluginApi) {
api.registerChannel({ plugin: msteamsPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,16 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.15",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
},
"dependencies": {
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
"express": "^5.2.1",
"proper-lockfile": "^4.1.2"
}
}

View File

@@ -6,11 +6,19 @@ const saveMediaBufferMock = vi.fn(async () => ({
contentType: "image/png",
}));
vi.mock("../media/mime.js", () => ({
const modulePaths = vi.hoisted(() => {
const downloadModuleUrl = new URL("./attachments/download.js", import.meta.url);
return {
mimeModulePath: new URL("../../../../src/media/mime.js", downloadModuleUrl).pathname,
storeModulePath: new URL("../../../../src/media/store.js", downloadModuleUrl).pathname,
};
});
vi.mock(modulePaths.mimeModulePath, () => ({
detectMime: (...args: unknown[]) => detectMimeMock(...args),
}));
vi.mock("../media/store.js", () => ({
vi.mock(modulePaths.storeModulePath, () => ({
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
}));

View File

@@ -1,5 +1,5 @@
import { detectMime } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import { detectMime } from "../../../../src/media/mime.js";
import { saveMediaBuffer } from "../../../../src/media/store.js";
import {
extractInlineImageCandidates,
inferPlaceholder,

View File

@@ -1,5 +1,5 @@
import { detectMime } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import { detectMime } from "../../../../src/media/mime.js";
import { saveMediaBuffer } from "../../../../src/media/store.js";
import { downloadMSTeamsImageAttachments } from "./download.js";
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
import type {

View File

@@ -1,12 +1,12 @@
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { createMSTeamsPollStoreFs } from "../../msteams/polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "../../msteams/send.js";
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { msteamsOnboardingAdapter } from "./onboarding/msteams.js";
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
import type { ClawdbotConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js";
import { msteamsOnboardingAdapter } from "./onboarding.js";
import { msteamsOutbound } from "./outbound.js";
import { sendMessageMSTeams } from "./send.js";
import { resolveMSTeamsCredentials } from "./token.js";
type ResolvedMSTeamsAccount = {
accountId: string;
@@ -17,10 +17,12 @@ type ResolvedMSTeamsAccount = {
const meta = {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot)",
docsPath: "/msteams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
docsLabel: "msteams",
blurb: "bot via Microsoft Teams.",
blurb: "Bot Framework; enterprise support.",
aliases: ["teams"],
order: 60,
} as const;
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
@@ -120,58 +122,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
return ["poll"] satisfies ChannelMessageActionName[];
},
},
outbound: {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, deps }) => {
const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
const send =
deps?.sendMSTeams ??
((to, text, opts) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
const result = await send(to, text, { mediaUrl });
return { channel: "msteams", ...result };
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;
const result = await sendPollMSTeams({
cfg,
to,
question: poll.question,
options: poll.options,
maxSelections,
});
const pollStore = createMSTeamsPollStoreFs();
await pollStore.createPoll({
id: result.pollId,
question: poll.question,
options: poll.options,
maxSelections,
createdAt: new Date().toISOString(),
conversationId: result.conversationId,
messageId: result.messageId,
votes: {},
});
return result;
},
},
outbound: msteamsOutbound,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
@@ -204,7 +155,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
},
gateway: {
startAccount: async (ctx) => {
const { monitorMSTeamsProvider } = await import("../../msteams/index.js");
const { monitorMSTeamsProvider } = await import("./index.js");
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(`starting provider (port ${port})`);

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
import type { StoredConversationReference } from "./conversation-store.js";
import {
type MSTeamsAdapter,

View File

@@ -1,7 +1,7 @@
import { chunkMarkdownText } from "../auto-reply/chunk.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { MSTeamsReplyStyle } from "../config/types.js";
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import type { MSTeamsReplyStyle } from "../../../src/config/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { classifyMSTeamsSendError } from "./errors.js";

View File

@@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "../config/types.js";
import { danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import type { ClawdbotConfig } from "../../../src/config/types.js";
import { danger } from "../../../src/globals.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";

View File

@@ -1,23 +1,23 @@
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../../../../src/auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
} from "../../../../src/auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "../../auto-reply/reply/history.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
} from "../../../../src/auto-reply/reply/history.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
} from "../../../../src/pairing/pairing-store.js";
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
import {
buildMSTeamsAttachmentPlaceholder,

View File

@@ -1,8 +1,8 @@
import type { Request, Response } from "express";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import type { ClawdbotConfig } from "../config/types.js";
import { getChildLogger } from "../logging.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
import type { ClawdbotConfig } from "../../../src/config/types.js";
import { getChildLogger } from "../../../src/logging.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import { formatUnknownError } from "./errors.js";

View File

@@ -1,11 +1,15 @@
import type { ClawdbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import { resolveMSTeamsCredentials } from "../../../msteams/token.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { addWildcardAllowFrom } from "./helpers.js";
import type { ClawdbotConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../../../src/channels/plugins/onboarding-types.js";
import { addWildcardAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js";
import { resolveMSTeamsCredentials } from "./token.js";
const channel = "msteams" as const;
@@ -34,7 +38,7 @@ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void
"2) Add a client secret (App Password)",
"3) Set webhook URL + messaging endpoint",
"Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.",
`Docs: ${formatDocsLink("/msteams", "msteams")}`,
`Docs: ${formatDocsLink("/channels/msteams", "msteams")}`,
].join("\n"),
"MS Teams credentials",
);

View File

@@ -1,7 +1,8 @@
import { chunkMarkdownText } from "../../../auto-reply/chunk.js";
import { createMSTeamsPollStoreFs } from "../../../msteams/polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "../../../msteams/send.js";
import type { ChannelOutboundAdapter } from "../types.js";
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import { createMSTeamsPollStoreFs } from "./polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { MSTeamsConfig } from "../config/types.js";
import type { MSTeamsConfig } from "../../../src/config/types.js";
import {
isMSTeamsGroupAllowed,
resolveMSTeamsReplyPolicy,

View File

@@ -4,7 +4,7 @@ import type {
MSTeamsConfig,
MSTeamsReplyStyle,
MSTeamsTeamConfig,
} from "../config/types.js";
} from "../../../src/config/types.js";
export type MSTeamsResolvedRouteConfig = {
teamConfig?: MSTeamsTeamConfig;

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { MSTeamsConfig } from "../config/types.js";
import type { MSTeamsConfig } from "../../../src/config/types.js";
const hostMockState = vi.hoisted(() => ({
tokenError: null as Error | null,

View File

@@ -1,4 +1,4 @@
import type { MSTeamsConfig } from "../config/types.js";
import type { MSTeamsConfig } from "../../../src/config/types.js";
import { formatUnknownError } from "./errors.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";

View File

@@ -1,8 +1,8 @@
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../agents/identity.js";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import type { ClawdbotConfig, MSTeamsReplyStyle } from "../config/types.js";
import { danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../../src/agents/identity.js";
import { createReplyDispatcherWithTyping } from "../../../src/auto-reply/reply/reply-dispatcher.js";
import type { ClawdbotConfig, MSTeamsReplyStyle } from "../../../src/config/types.js";
import { danger } from "../../../src/globals.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
import type { StoredConversationReference } from "./conversation-store.js";
import {
classifyMSTeamsSendError,

View File

@@ -1,5 +1,5 @@
import type { ClawdbotConfig } from "../config/types.js";
import type { getChildLogger as getChildLoggerFn } from "../logging.js";
import type { ClawdbotConfig } from "../../../src/config/types.js";
import type { getChildLogger as getChildLoggerFn } from "../../../src/logging.js";
import type {
MSTeamsConversationStore,
StoredConversationReference,

View File

@@ -1,4 +1,4 @@
import type { ClawdbotConfig } from "../config/types.js";
import type { ClawdbotConfig } from "../../../src/config/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import {

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { resolveStateDir } from "../../../src/config/paths.js";
export type MSTeamsStorePathOptions = {
env?: NodeJS.ProcessEnv;

View File

@@ -1,4 +1,4 @@
import type { MSTeamsConfig } from "../config/types.js";
import type { MSTeamsConfig } from "../../../src/config/types.js";
export type MSTeamsCredentials = {
appId: string;

View File

@@ -26,7 +26,6 @@
"dist/infra/**",
"dist/macos/**",
"dist/media/**",
"dist/msteams/**",
"dist/process/**",
"dist/plugins/**",
"dist/security/**",
@@ -144,9 +143,6 @@
"@mariozechner/pi-ai": "0.46.0",
"@mariozechner/pi-coding-agent": "^0.46.0",
"@mariozechner/pi-tui": "^0.46.0",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
"@sinclair/typebox": "0.34.47",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.13.0",

27
pnpm-lock.yaml generated
View File

@@ -44,15 +44,6 @@ importers:
'@mariozechner/pi-tui':
specifier: ^0.46.0
version: 0.46.0
'@microsoft/agents-hosting':
specifier: ^1.1.1
version: 1.1.1
'@microsoft/agents-hosting-express':
specifier: ^1.1.1
version: 1.1.1
'@microsoft/agents-hosting-extensions-teams':
specifier: ^1.1.1
version: 1.1.1
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
@@ -245,6 +236,24 @@ importers:
specifier: 40.0.0
version: 40.0.0
extensions/msteams:
dependencies:
'@microsoft/agents-hosting':
specifier: ^1.1.1
version: 1.1.1
'@microsoft/agents-hosting-express':
specifier: ^1.1.1
version: 1.1.1
'@microsoft/agents-hosting-extensions-teams':
specifier: ^1.1.1
version: 1.1.1
express:
specifier: ^5.2.1
version: 5.2.1
proper-lockfile:
specifier: ^4.1.2
version: 4.1.2
extensions/voice-call:
dependencies:
'@sinclair/typebox':

View File

@@ -10,7 +10,6 @@ type PackResult = { files?: PackFile[] };
const requiredPaths = [
"dist/discord/send.js",
"dist/hooks/gmail.js",
"dist/msteams/send.js",
"dist/whatsapp/normalize.js",
];
const forbiddenPrefixes = ["dist/Clawdbot.app/"];
@@ -68,6 +67,7 @@ function checkPluginVersions() {
for (const item of mismatches) {
console.error(` - ${item}`);
}
console.error("release-check: run `pnpm plugins:sync` to align plugin versions.");
process.exit(1);
}
}

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
const mocks = vi.hoisted(() => ({
@@ -22,9 +25,6 @@ vi.mock("../../discord/send.js", () => ({
vi.mock("../../imessage/send.js", () => ({
sendMessageIMessage: mocks.sendMessageIMessage,
}));
vi.mock("../../msteams/send.js", () => ({
sendMessageMSTeams: mocks.sendMessageMSTeams,
}));
vi.mock("../../signal/send.js", () => ({
sendMessageSignal: mocks.sendMessageSignal,
}));
@@ -41,6 +41,14 @@ vi.mock("../../web/outbound.js", () => ({
const { routeReply } = await import("./route-reply.js");
describe("routeReply", () => {
beforeEach(() => {
setActivePluginRegistry(emptyRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("skips sends when abort signal is already aborted", async () => {
mocks.sendMessageSlack.mockClear();
const controller = new AbortController();
@@ -221,6 +229,17 @@ describe("routeReply", () => {
it("routes MS Teams via proactive sender", async () => {
mocks.sendMessageMSTeams.mockClear();
setActivePluginRegistry(
createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({
outbound: createMSTeamsOutbound(),
}),
},
]),
);
const cfg = {
channels: {
msteams: {
@@ -243,3 +262,46 @@ describe("routeReply", () => {
);
});
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const emptyRegistry = createRegistry([]);
const createMSTeamsOutbound = (): ChannelOutboundAdapter => ({
deliveryMode: "direct",
sendText: async ({ cfg, to, text }) => {
const result = await mocks.sendMessageMSTeams({ cfg, to, text });
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl }) => {
const result = await mocks.sendMessageMSTeams({ cfg, to, text, mediaUrl });
return { channel: "msteams", ...result };
},
});
const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
outbound: params.outbound,
});

View File

@@ -293,27 +293,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
}),
},
},
msteams: {
id: "msteams",
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
threads: true,
media: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
},
};
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
describe("channel plugin catalog", () => {
it("includes Microsoft Teams", () => {
const entry = getChannelPluginCatalogEntry("msteams");
expect(entry?.install.npmSpec).toBe("@clawdbot/msteams");
expect(entry?.meta.aliases).toContain("teams");
});
it("lists plugin catalog entries", () => {
const ids = listChannelPluginCatalogEntries().map((entry) => entry.id);
expect(ids).toContain("msteams");
});
});

View File

@@ -11,6 +11,24 @@ export type ChannelPluginCatalogEntry = {
};
const CATALOG: ChannelPluginCatalogEntry[] = [
{
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
docsLabel: "msteams",
blurb: "Bot Framework; enterprise support.",
aliases: ["teams"],
order: 60,
},
install: {
npmSpec: "@clawdbot/msteams",
localPath: "extensions/msteams",
defaultChoice: "npm",
},
},
{
id: "matrix",
meta: {

View File

@@ -1,7 +1,6 @@
import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeChatChannelId } from "../registry.js";
import { discordPlugin } from "./discord.js";
import { imessagePlugin } from "./imessage.js";
import { msteamsPlugin } from "./msteams.js";
import { signalPlugin } from "./signal.js";
import { slackPlugin } from "./slack.js";
import { telegramPlugin } from "./telegram.js";
@@ -27,7 +26,6 @@ function resolveCoreChannels(): ChannelPlugin[] {
slackPlugin,
signalPlugin,
imessagePlugin,
msteamsPlugin,
];
}
@@ -85,7 +83,6 @@ export function normalizeChannelId(raw?: string | null): ChannelId | null {
export {
discordPlugin,
imessagePlugin,
msteamsPlugin,
signalPlugin,
slackPlugin,
telegramPlugin,

View File

@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { loadChannelPlugin } from "./load.js";
import { loadChannelOutboundAdapter } from "./outbound/load.js";
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const emptyRegistry = createRegistry([]);
const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async () => ({ channel: "msteams", messageId: "m1" }),
sendMedia: async () => ({ channel: "msteams", messageId: "m2" }),
};
const msteamsPlugin: ChannelPlugin = {
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
aliases: ["teams"],
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
outbound: msteamsOutbound,
};
const registryWithMSTeams = createRegistry([
{ pluginId: "msteams", plugin: msteamsPlugin, source: "test" },
]);
describe("channel plugin loader", () => {
beforeEach(() => {
setActivePluginRegistry(emptyRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("loads channel plugins from the active registry", async () => {
setActivePluginRegistry(registryWithMSTeams);
const plugin = await loadChannelPlugin("msteams");
expect(plugin).toBe(msteamsPlugin);
});
it("loads outbound adapters from registered plugins", async () => {
setActivePluginRegistry(registryWithMSTeams);
const outbound = await loadChannelOutboundAdapter("msteams");
expect(outbound).toBe(msteamsOutbound);
});
});

View File

@@ -15,7 +15,6 @@ const LOADERS: Record<ChatChannelId, PluginLoader> = {
slack: async () => (await import("./slack.js")).slackPlugin,
signal: async () => (await import("./signal.js")).signalPlugin,
imessage: async () => (await import("./imessage.js")).imessagePlugin,
msteams: async () => (await import("./msteams.js")).msteamsPlugin,
};
const cache = new Map<ChannelId, ChannelPlugin>();

View File

@@ -16,7 +16,6 @@ const LOADERS: Record<ChatChannelId, OutboundLoader> = {
slack: async () => (await import("./slack.js")).slackOutbound,
signal: async () => (await import("./signal.js")).signalOutbound,
imessage: async () => (await import("./imessage.js")).imessageOutbound,
msteams: async () => (await import("./msteams.js")).msteamsOutbound,
};
const cache = new Map<ChannelId, ChannelOutboundAdapter>();

View File

@@ -9,7 +9,6 @@ import {
describe("channel registry", () => {
it("normalizes aliases", () => {
expect(normalizeChatChannelId("imsg")).toBe("imessage");
expect(normalizeChatChannelId("teams")).toBe("msteams");
expect(normalizeChatChannelId("web")).toBeNull();
});
@@ -18,6 +17,11 @@ describe("channel registry", () => {
expect(channels[0]?.id).toBe("telegram");
});
it("does not include MS Teams by default", () => {
const channels = listChatChannels();
expect(channels.some((channel) => channel.id === "msteams")).toBe(false);
});
it("formats selection lines with docs labels", () => {
const channels = listChatChannels();
const first = channels[0];

View File

@@ -9,7 +9,6 @@ export const CHAT_CHANNEL_ORDER = [
"slack",
"signal",
"imessage",
"msteams",
] as const;
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
@@ -74,19 +73,10 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
docsLabel: "imessage",
blurb: "this is still a work in progress.",
},
msteams: {
id: "msteams",
label: "MS Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
docsLabel: "msteams",
blurb: "supported (Bot Framework).",
},
};
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
imsg: "imessage",
teams: "msteams",
};
const normalizeChannelKey = (raw?: string | null): string | undefined => {

View File

@@ -1,9 +1,7 @@
import { logWebSelfId, sendMessageWhatsApp } from "../channels/web/index.js";
import type { ClawdbotConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
import { sendMessageMSTeams } from "../msteams/send.js";
import { sendMessageSignal } from "../signal/send.js";
import { sendMessageSlack } from "../slack/send.js";
import { sendMessageTelegram } from "../telegram/send.js";
@@ -15,7 +13,6 @@ export type CliDeps = {
sendMessageSlack: typeof sendMessageSlack;
sendMessageSignal: typeof sendMessageSignal;
sendMessageIMessage: typeof sendMessageIMessage;
sendMessageMSTeams: typeof sendMessageMSTeams;
};
export function createDefaultDeps(): CliDeps {
@@ -26,12 +23,11 @@ export function createDefaultDeps(): CliDeps {
sendMessageSlack,
sendMessageSignal,
sendMessageIMessage,
sendMessageMSTeams,
};
}
// Provider docking: extend this mapping when adding new outbound send deps.
export function createOutboundSendDeps(deps: CliDeps, cfg: ClawdbotConfig): OutboundSendDeps {
export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps {
return {
sendWhatsApp: deps.sendMessageWhatsApp,
sendTelegram: deps.sendMessageTelegram,
@@ -39,16 +35,6 @@ export function createOutboundSendDeps(deps: CliDeps, cfg: ClawdbotConfig): Outb
sendSlack: deps.sendMessageSlack,
sendSignal: deps.sendMessageSignal,
sendIMessage: deps.sendMessageIMessage,
// Provider docking: MS Teams send requires full cfg (credentials), wrap to match OutboundSendDeps.
sendMSTeams: deps.sendMessageMSTeams
? async (to, text, opts) =>
await deps.sendMessageMSTeams({
cfg,
to,
text,
mediaUrl: opts?.mediaUrl,
})
: undefined,
};
}

View File

@@ -116,7 +116,7 @@ export async function deliverAgentCommandResult(params: {
bestEffort: bestEffortDeliver,
onError: (err) => logDeliveryError(err),
onPayload: logPayload,
deps: createOutboundSendDeps(deps, cfg),
deps: createOutboundSendDeps(deps),
});
}
}

View File

@@ -2,7 +2,7 @@ import {
CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName,
} from "../channels/plugins/types.js";
import type { CliDeps } from "../cli/deps.js";
import { createOutboundSendDeps, type CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
@@ -23,16 +23,7 @@ export async function messageCommand(
throw new Error(`Unknown message action: ${action}`);
}
const outboundDeps: OutboundSendDeps = {
sendWhatsApp: deps.sendMessageWhatsApp,
sendTelegram: deps.sendMessageTelegram,
sendDiscord: deps.sendMessageDiscord,
sendSlack: deps.sendMessageSlack,
sendSignal: deps.sendMessageSignal,
sendIMessage: deps.sendMessageIMessage,
sendMSTeams: (to, text, opts) =>
deps.sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }),
};
const outboundDeps: OutboundSendDeps = createOutboundSendDeps(deps);
const run = async () =>
await runMessageAction({

View File

@@ -1,8 +1,19 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { resolveChannelCapabilities } from "./channel-capabilities.js";
import type { ClawdbotConfig } from "./config.js";
describe("resolveChannelCapabilities", () => {
beforeEach(() => {
setActivePluginRegistry(emptyRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("returns undefined for missing inputs", () => {
expect(resolveChannelCapabilities({})).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig })).toBeUndefined();
@@ -74,6 +85,15 @@ describe("resolveChannelCapabilities", () => {
});
it("supports msteams capabilities", () => {
setActivePluginRegistry(
createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin(),
},
]),
);
const cfg = {
channels: { msteams: { capabilities: [" polls ", ""] } },
} satisfies Partial<ClawdbotConfig>;
@@ -86,3 +106,33 @@ describe("resolveChannelCapabilities", () => {
).toEqual(["polls"]);
});
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const emptyRegistry = createRegistry([]);
const createMSTeamsPlugin = (): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
});

View File

@@ -28,7 +28,7 @@ import {
normalizeThinkLevel,
supportsXHighThinking,
} from "../../auto-reply/thinking.js";
import type { CliDeps } from "../../cli/deps.js";
import { createOutboundSendDeps, type CliDeps } from "../../cli/deps.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js";
import type { AgentDefaultsConfig } from "../../config/types.js";
@@ -355,23 +355,7 @@ export async function runCronIsolatedAgentTurn(params: {
accountId: resolvedDelivery.accountId,
payloads,
bestEffort: bestEffortDeliver,
deps: {
sendWhatsApp: params.deps.sendMessageWhatsApp,
sendTelegram: params.deps.sendMessageTelegram,
sendDiscord: params.deps.sendMessageDiscord,
sendSlack: params.deps.sendMessageSlack,
sendSignal: params.deps.sendMessageSignal,
sendIMessage: params.deps.sendMessageIMessage,
sendMSTeams: params.deps.sendMessageMSTeams
? async (to, text, opts) =>
await params.deps.sendMessageMSTeams({
cfg: params.cfg,
to,
text,
mediaUrl: opts?.mediaUrl,
})
: undefined,
},
deps: createOutboundSendDeps(params.deps),
});
} catch (err) {
if (!bestEffortDeliver) {

View File

@@ -1,6 +1,9 @@
import type { IncomingMessage } from "node:http";
import { describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import {
extractHookToken,
normalizeAgentPayload,
@@ -9,6 +12,13 @@ import {
} from "./hooks.js";
describe("gateway hooks helpers", () => {
beforeEach(() => {
setActivePluginRegistry(emptyRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
test("resolveHooksConfig normalizes paths + requires token", () => {
const base = {
hooks: {
@@ -84,6 +94,15 @@ describe("gateway hooks helpers", () => {
expect(imsg.value.channel).toBe("imessage");
}
setActivePluginRegistry(
createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({ aliases: ["teams"] }),
},
]),
);
const teams = normalizeAgentPayload(
{ message: "yo", channel: "teams" },
{ idFactory: () => "x" },
@@ -97,3 +116,34 @@ describe("gateway hooks helpers", () => {
expect(bad.ok).toBe(false);
});
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const emptyRegistry = createRegistry([]);
const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
aliases: params.aliases,
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
});

View File

@@ -1,9 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
agentCommand,
@@ -19,6 +22,33 @@ import {
installGatewayTestHooks();
const registryState = vi.hoisted(() => ({
registry: {
plugins: [],
tools: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
} as PluginRegistry,
}));
vi.mock("./server-plugins.js", async () => {
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
return {
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
setActivePluginRegistry(registryState.registry);
return {
pluginRegistry: registryState.registry,
gatewayMethods: params.baseMethods ?? [],
};
},
};
});
const _BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
@@ -28,7 +58,26 @@ function expectChannels(call: Record<string, unknown>, channel: string) {
}
describe("gateway server agent", () => {
beforeEach(() => {
registryState.registry = emptyRegistry;
setActivePluginRegistry(emptyRegistry);
});
afterEach(() => {
registryState.registry = emptyRegistry;
setActivePluginRegistry(emptyRegistry);
});
test("agent routes main last-channel msteams", async () => {
const registry = createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin(),
},
]);
registryState.registry = registry;
setActivePluginRegistry(registry);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
@@ -73,6 +122,15 @@ describe("gateway server agent", () => {
});
test("agent accepts channel aliases (imsg/teams)", async () => {
const registry = createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({ aliases: ["teams"] }),
},
]);
registryState.registry = registry;
setActivePluginRegistry(registry);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
@@ -410,3 +468,34 @@ describe("gateway server agent", () => {
await server.close();
});
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const emptyRegistry = createRegistry([]);
const createMSTeamsPlugin = (params?: { aliases?: string[] }): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
aliases: params?.aliases,
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
});

View File

@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { sendMessage, sendPoll } from "./message.js";
const callGatewayMock = vi.fn();
@@ -11,6 +14,11 @@ vi.mock("../../gateway/call.js", () => ({
describe("sendMessage channel normalization", () => {
beforeEach(() => {
callGatewayMock.mockReset();
setActivePluginRegistry(emptyRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("normalizes Teams alias", async () => {
@@ -18,6 +26,18 @@ describe("sendMessage channel normalization", () => {
messageId: "m1",
conversationId: "c1",
}));
setActivePluginRegistry(
createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({
outbound: createMSTeamsOutbound(),
aliases: ["teams"],
}),
},
]),
);
const result = await sendMessage({
cfg: {},
to: "conversation:19:abc@thread.tacv2",
@@ -48,10 +68,27 @@ describe("sendMessage channel normalization", () => {
describe("sendPoll channel normalization", () => {
beforeEach(() => {
callGatewayMock.mockReset();
setActivePluginRegistry(emptyRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("normalizes Teams alias for polls", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
setActivePluginRegistry(
createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({
aliases: ["teams"],
outbound: createMSTeamsOutbound({ includePoll: true }),
}),
},
]),
);
const result = await sendPoll({
cfg: {},
@@ -68,3 +105,64 @@ describe("sendPoll channel normalization", () => {
expect(result.channel).toBe("msteams");
});
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const emptyRegistry = createRegistry([]);
const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({
deliveryMode: "direct",
sendText: async ({ deps, to, text }) => {
const send = deps?.sendMSTeams;
if (!send) {
throw new Error("sendMSTeams missing");
}
const result = await send(to, text);
return { channel: "msteams", ...result };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
const send = deps?.sendMSTeams;
if (!send) {
throw new Error("sendMSTeams missing");
}
const result = await send(to, text, { mediaUrl });
return { channel: "msteams", ...result };
},
...(opts?.includePoll
? {
pollMaxOptions: 12,
sendPoll: async () => ({ channel: "msteams", messageId: "p1" }),
}
: {}),
});
const createMSTeamsPlugin = (params: {
aliases?: string[];
outbound: ChannelOutboundAdapter;
}): ChannelPlugin => ({
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
aliases: params.aliases,
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
outbound: params.outbound,
});

View File

@@ -1,13 +1,61 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { resolveGatewayMessageChannel } from "./message-channel.js";
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const emptyRegistry = createRegistry([]);
const msteamsPlugin = {
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
blurb: "Bot Framework; enterprise support.",
aliases: ["teams"],
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
} satisfies ChannelPlugin;
describe("message-channel", () => {
beforeEach(() => {
setActivePluginRegistry(emptyRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("normalizes gateway message channels and rejects unknown values", () => {
expect(resolveGatewayMessageChannel("discord")).toBe("discord");
expect(resolveGatewayMessageChannel(" imsg ")).toBe("imessage");
expect(resolveGatewayMessageChannel("teams")).toBe("msteams");
expect(resolveGatewayMessageChannel("web")).toBeUndefined();
expect(resolveGatewayMessageChannel("nope")).toBeUndefined();
});
it("normalizes plugin aliases when registered", () => {
setActivePluginRegistry(
createRegistry([{ pluginId: "msteams", plugin: msteamsPlugin, source: "test" }]),
);
expect(resolveGatewayMessageChannel("teams")).toBe("msteams");
});
});