diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f6057c1..06362fc1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-` 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. diff --git a/docs/channels/index.md b/docs/channels/index.md index 76a697dc3..b6b9559da 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -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. diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 4c48ba327..7c4cfb54d 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -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 = `. - 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://: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. diff --git a/docs/plugin.md b/docs/plugin.md index a137c6b28..376154aaf 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -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: diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 04685ded8..18b83f365 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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. diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md new file mode 100644 index 000000000..cff23fbf9 --- /dev/null +++ b/extensions/msteams/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## 2026.1.15 + +### Features +- Microsoft Teams channel plugin (Bot Framework) with polls, media, threads, and gateway monitor. diff --git a/extensions/msteams/index.ts b/extensions/msteams/index.ts new file mode 100644 index 000000000..8fcb92729 --- /dev/null +++ b/extensions/msteams/index.ts @@ -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; diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json new file mode 100644 index 000000000..f1bd2efbe --- /dev/null +++ b/extensions/msteams/package.json @@ -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" + } +} diff --git a/src/msteams/attachments.test.ts b/extensions/msteams/src/attachments.test.ts similarity index 96% rename from src/msteams/attachments.test.ts rename to extensions/msteams/src/attachments.test.ts index 579b606c8..87c24ce04 100644 --- a/src/msteams/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -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), })); diff --git a/src/msteams/attachments.ts b/extensions/msteams/src/attachments.ts similarity index 100% rename from src/msteams/attachments.ts rename to extensions/msteams/src/attachments.ts diff --git a/src/msteams/attachments/download.ts b/extensions/msteams/src/attachments/download.ts similarity index 97% rename from src/msteams/attachments/download.ts rename to extensions/msteams/src/attachments/download.ts index 2d50928f7..870b23753 100644 --- a/src/msteams/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -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, diff --git a/src/msteams/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts similarity index 98% rename from src/msteams/attachments/graph.ts rename to extensions/msteams/src/attachments/graph.ts index 18665a915..e81a345eb 100644 --- a/src/msteams/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -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 { diff --git a/src/msteams/attachments/html.ts b/extensions/msteams/src/attachments/html.ts similarity index 100% rename from src/msteams/attachments/html.ts rename to extensions/msteams/src/attachments/html.ts diff --git a/src/msteams/attachments/payload.ts b/extensions/msteams/src/attachments/payload.ts similarity index 100% rename from src/msteams/attachments/payload.ts rename to extensions/msteams/src/attachments/payload.ts diff --git a/src/msteams/attachments/shared.ts b/extensions/msteams/src/attachments/shared.ts similarity index 100% rename from src/msteams/attachments/shared.ts rename to extensions/msteams/src/attachments/shared.ts diff --git a/src/msteams/attachments/types.ts b/extensions/msteams/src/attachments/types.ts similarity index 100% rename from src/msteams/attachments/types.ts rename to extensions/msteams/src/attachments/types.ts diff --git a/src/channels/plugins/msteams.ts b/extensions/msteams/src/channel.ts similarity index 64% rename from src/channels/plugins/msteams.ts rename to extensions/msteams/src/channel.ts index e59071215..5b23c10f4 100644 --- a/src/channels/plugins/msteams.ts +++ b/extensions/msteams/src/channel.ts @@ -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 = { @@ -120,58 +122,7 @@ export const msteamsPlugin: ChannelPlugin = { 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 ", - ), - }; - } - 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 = { }, 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})`); diff --git a/src/msteams/conversation-store-fs.test.ts b/extensions/msteams/src/conversation-store-fs.test.ts similarity index 100% rename from src/msteams/conversation-store-fs.test.ts rename to extensions/msteams/src/conversation-store-fs.test.ts diff --git a/src/msteams/conversation-store-fs.ts b/extensions/msteams/src/conversation-store-fs.ts similarity index 100% rename from src/msteams/conversation-store-fs.ts rename to extensions/msteams/src/conversation-store-fs.ts diff --git a/src/msteams/conversation-store-memory.ts b/extensions/msteams/src/conversation-store-memory.ts similarity index 100% rename from src/msteams/conversation-store-memory.ts rename to extensions/msteams/src/conversation-store-memory.ts diff --git a/src/msteams/conversation-store.ts b/extensions/msteams/src/conversation-store.ts similarity index 100% rename from src/msteams/conversation-store.ts rename to extensions/msteams/src/conversation-store.ts diff --git a/src/msteams/errors.test.ts b/extensions/msteams/src/errors.test.ts similarity index 100% rename from src/msteams/errors.test.ts rename to extensions/msteams/src/errors.test.ts diff --git a/src/msteams/errors.ts b/extensions/msteams/src/errors.ts similarity index 100% rename from src/msteams/errors.ts rename to extensions/msteams/src/errors.ts diff --git a/src/msteams/inbound.test.ts b/extensions/msteams/src/inbound.test.ts similarity index 100% rename from src/msteams/inbound.test.ts rename to extensions/msteams/src/inbound.test.ts diff --git a/src/msteams/inbound.ts b/extensions/msteams/src/inbound.ts similarity index 100% rename from src/msteams/inbound.ts rename to extensions/msteams/src/inbound.ts diff --git a/src/msteams/index.ts b/extensions/msteams/src/index.ts similarity index 100% rename from src/msteams/index.ts rename to extensions/msteams/src/index.ts diff --git a/src/msteams/messenger.test.ts b/extensions/msteams/src/messenger.test.ts similarity index 98% rename from src/msteams/messenger.test.ts rename to extensions/msteams/src/messenger.test.ts index b253312b4..80b49a233 100644 --- a/src/msteams/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -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, diff --git a/src/msteams/messenger.ts b/extensions/msteams/src/messenger.ts similarity index 96% rename from src/msteams/messenger.ts rename to extensions/msteams/src/messenger.ts index c111228a2..3ffcbfe4a 100644 --- a/src/msteams/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -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"; diff --git a/src/msteams/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts similarity index 91% rename from src/msteams/monitor-handler.ts rename to extensions/msteams/src/monitor-handler.ts index 01539a839..41952cb82 100644 --- a/src/msteams/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -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"; diff --git a/src/msteams/monitor-handler/inbound-media.ts b/extensions/msteams/src/monitor-handler/inbound-media.ts similarity index 100% rename from src/msteams/monitor-handler/inbound-media.ts rename to extensions/msteams/src/monitor-handler/inbound-media.ts diff --git a/src/msteams/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts similarity index 95% rename from src/msteams/monitor-handler/message-handler.ts rename to extensions/msteams/src/monitor-handler/message-handler.ts index b93ddc3b8..c4dd29a4e 100644 --- a/src/msteams/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -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, diff --git a/src/msteams/monitor-types.ts b/extensions/msteams/src/monitor-types.ts similarity index 100% rename from src/msteams/monitor-types.ts rename to extensions/msteams/src/monitor-types.ts diff --git a/src/msteams/monitor.ts b/extensions/msteams/src/monitor.ts similarity index 94% rename from src/msteams/monitor.ts rename to extensions/msteams/src/monitor.ts index 29eaf8bb4..d74f0ae94 100644 --- a/src/msteams/monitor.ts +++ b/extensions/msteams/src/monitor.ts @@ -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"; diff --git a/src/channels/plugins/onboarding/msteams.ts b/extensions/msteams/src/onboarding.ts similarity index 88% rename from src/channels/plugins/onboarding/msteams.ts rename to extensions/msteams/src/onboarding.ts index 48e857144..54391cea0 100644 --- a/src/channels/plugins/onboarding/msteams.ts +++ b/extensions/msteams/src/onboarding.ts @@ -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 ({ tokenError: null as Error | null, diff --git a/src/msteams/probe.ts b/extensions/msteams/src/probe.ts similarity index 93% rename from src/msteams/probe.ts rename to extensions/msteams/src/probe.ts index 9bd6f6a75..bb8dcb942 100644 --- a/src/msteams/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -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"; diff --git a/src/msteams/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts similarity index 87% rename from src/msteams/reply-dispatcher.ts rename to extensions/msteams/src/reply-dispatcher.ts index 6c35ee1f7..81496000e 100644 --- a/src/msteams/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -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, diff --git a/src/msteams/sdk-types.ts b/extensions/msteams/src/sdk-types.ts similarity index 100% rename from src/msteams/sdk-types.ts rename to extensions/msteams/src/sdk-types.ts diff --git a/src/msteams/sdk.ts b/extensions/msteams/src/sdk.ts similarity index 100% rename from src/msteams/sdk.ts rename to extensions/msteams/src/sdk.ts diff --git a/src/msteams/send-context.ts b/extensions/msteams/src/send-context.ts similarity index 96% rename from src/msteams/send-context.ts rename to extensions/msteams/src/send-context.ts index 8ff72c61b..327bc37b9 100644 --- a/src/msteams/send-context.ts +++ b/extensions/msteams/src/send-context.ts @@ -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, diff --git a/src/msteams/send.ts b/extensions/msteams/src/send.ts similarity index 98% rename from src/msteams/send.ts rename to extensions/msteams/src/send.ts index edea036ce..52b7da66c 100644 --- a/src/msteams/send.ts +++ b/extensions/msteams/src/send.ts @@ -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 { diff --git a/src/msteams/storage.ts b/extensions/msteams/src/storage.ts similarity index 90% rename from src/msteams/storage.ts rename to extensions/msteams/src/storage.ts index 693f27414..6a9b599fb 100644 --- a/src/msteams/storage.ts +++ b/extensions/msteams/src/storage.ts @@ -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; diff --git a/src/msteams/store-fs.ts b/extensions/msteams/src/store-fs.ts similarity index 100% rename from src/msteams/store-fs.ts rename to extensions/msteams/src/store-fs.ts diff --git a/src/msteams/token.ts b/extensions/msteams/src/token.ts similarity index 89% rename from src/msteams/token.ts rename to extensions/msteams/src/token.ts index f6f786713..977edaee4 100644 --- a/src/msteams/token.ts +++ b/extensions/msteams/src/token.ts @@ -1,4 +1,4 @@ -import type { MSTeamsConfig } from "../config/types.js"; +import type { MSTeamsConfig } from "../../../src/config/types.js"; export type MSTeamsCredentials = { appId: string; diff --git a/package.json b/package.json index 80d790040..3c58d10e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03e03f8a1..71bc8d12c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/scripts/release-check.ts b/scripts/release-check.ts index e6cf1a178..d3377ce8d 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -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); } } diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 3ae1be3e2..7466ff860 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -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, +}); diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 000a37fb4..b206751d8 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -293,27 +293,6 @@ const DOCKS: Record = { }), }, }, - 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 { diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts new file mode 100644 index 000000000..2df29a95c --- /dev/null +++ b/src/channels/plugins/catalog.test.ts @@ -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"); + }); +}); diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 8ae13c26d..0625161b1 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -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: { diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index 06d176220..33e48fa91 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -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, diff --git a/src/channels/plugins/load.test.ts b/src/channels/plugins/load.test.ts new file mode 100644 index 000000000..7281c43da --- /dev/null +++ b/src/channels/plugins/load.test.ts @@ -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); + }); +}); diff --git a/src/channels/plugins/load.ts b/src/channels/plugins/load.ts index 18e3a9398..3eeffab04 100644 --- a/src/channels/plugins/load.ts +++ b/src/channels/plugins/load.ts @@ -15,7 +15,6 @@ const LOADERS: Record = { 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(); diff --git a/src/channels/plugins/outbound/load.ts b/src/channels/plugins/outbound/load.ts index d227cad66..29d8df1f6 100644 --- a/src/channels/plugins/outbound/load.ts +++ b/src/channels/plugins/outbound/load.ts @@ -16,7 +16,6 @@ const LOADERS: Record = { 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(); diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts index 8680644f0..bfd47d2c7 100644 --- a/src/channels/registry.test.ts +++ b/src/channels/registry.test.ts @@ -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]; diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 4c10a59df..dffd15f60 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -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 = { 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 = { imsg: "imessage", - teams: "msteams", }; const normalizeChannelKey = (raw?: string | null): string | undefined => { diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 928adc50f..f14013a0f 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -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, }; } diff --git a/src/commands/agent/delivery.ts b/src/commands/agent/delivery.ts index aad20ebfd..4c24a5b61 100644 --- a/src/commands/agent/delivery.ts +++ b/src/commands/agent/delivery.ts @@ -116,7 +116,7 @@ export async function deliverAgentCommandResult(params: { bestEffort: bestEffortDeliver, onError: (err) => logDeliveryError(err), onPayload: logPayload, - deps: createOutboundSendDeps(deps, cfg), + deps: createOutboundSendDeps(deps), }); } } diff --git a/src/commands/message.ts b/src/commands/message.ts index 1ac2f0a49..22b56b1f9 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -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({ diff --git a/src/config/channel-capabilities.test.ts b/src/config/channel-capabilities.test.ts index 07039a4a2..c53b5fa00 100644 --- a/src/config/channel-capabilities.test.ts +++ b/src/config/channel-capabilities.test.ts @@ -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; @@ -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: () => ({}), + }, +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 3d8b3e862..6e1128bb0 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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) { diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index d9a564e40..cba156cb2 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -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: () => ({}), + }, +}); diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index 6e26464b4..3f4d427e9 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -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, 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: () => ({}), + }, +}); diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index db03010ed..05446ea6f 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -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, +}); diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index 29f63f9cf..f61ee8fd3 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -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"); + }); });