From ecd4c9c4f5750d106aa706397a6f46fb89b05dbd Mon Sep 17 00:00:00 2001
From: Tobias Bischoff <>
Date: Thu, 8 Jan 2026 15:10:18 +0100
Subject: [PATCH 001/152] Onboarding: add MiniMax hosted API key option
---
docs/cli/index.md | 3 +-
scripts/bench-model.ts | 2 +-
src/agents/minimax.live.test.ts | 2 +-
src/agents/model-auth.ts | 1 +
src/cli/program.ts | 5 +-
src/commands/auth-choice-options.ts | 1 +
src/commands/auth-choice.ts | 22 +++++++
src/commands/configure.ts | 18 ++++++
src/commands/onboard-auth.ts | 85 +++++++++++++++++++++++++
src/commands/onboard-non-interactive.ts | 16 +++++
src/commands/onboard-types.ts | 2 +
11 files changed, 153 insertions(+), 4 deletions(-)
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 0c9c52108..d5b7acd4c 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -166,8 +166,9 @@ Options:
- `--workspace
`
- `--non-interactive`
- `--mode `
-- `--auth-choice `
+- `--auth-choice `
- `--anthropic-api-key `
+- `--minimax-api-key `
- `--gateway-port `
- `--gateway-bind `
- `--gateway-auth `
diff --git a/scripts/bench-model.ts b/scripts/bench-model.ts
index 32ed20ad0..0b3a60d01 100644
--- a/scripts/bench-model.ts
+++ b/scripts/bench-model.ts
@@ -88,7 +88,7 @@ async function main(): Promise {
const minimaxBaseUrl =
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
const minimaxModelId =
- process.env.MINIMAX_MODEL?.trim() || "minimax-m2.1";
+ process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const minimaxModel: Model<"openai-completions"> = {
id: minimaxModelId,
diff --git a/src/agents/minimax.live.test.ts b/src/agents/minimax.live.test.ts
index 666943876..53f033af1 100644
--- a/src/agents/minimax.live.test.ts
+++ b/src/agents/minimax.live.test.ts
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL =
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
-const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "minimax-m2.1";
+const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts
index 1716f7800..da6786a3e 100644
--- a/src/agents/model-auth.ts
+++ b/src/agents/model-auth.ts
@@ -135,6 +135,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
+ minimax: "MINIMAX_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
};
diff --git a/src/cli/program.ts b/src/cli/program.ts
index 8c09a5758..4b777094d 100644
--- a/src/cli/program.ts
+++ b/src/cli/program.ts
@@ -232,9 +232,10 @@ export function buildProgram() {
.option("--mode ", "Wizard mode: local|remote")
.option(
"--auth-choice ",
- "Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|apiKey|minimax|skip",
+ "Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|apiKey|minimax-cloud|minimax|skip",
)
.option("--anthropic-api-key ", "Anthropic API key")
+ .option("--minimax-api-key ", "MiniMax API key")
.option("--gateway-port ", "Gateway port")
.option("--gateway-bind ", "Gateway bind: loopback|lan|tailnet|auto")
.option("--gateway-auth ", "Gateway auth: off|token|password")
@@ -264,10 +265,12 @@ export function buildProgram() {
| "codex-cli"
| "antigravity"
| "apiKey"
+ | "minimax-cloud"
| "minimax"
| "skip"
| undefined,
anthropicApiKey: opts.anthropicApiKey as string | undefined,
+ minimaxApiKey: opts.minimaxApiKey as string | undefined,
gatewayPort:
typeof opts.gatewayPort === "string"
? Number.parseInt(opts.gatewayPort, 10)
diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts
index 4feacf9f2..50cd7c766 100644
--- a/src/commands/auth-choice-options.ts
+++ b/src/commands/auth-choice-options.ts
@@ -77,6 +77,7 @@ export function buildAuthChoiceOptions(params: {
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
});
options.push({ value: "apiKey", label: "Anthropic API key" });
+ options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" });
diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts
index 195bcf50b..b7febead2 100644
--- a/src/commands/auth-choice.ts
+++ b/src/commands/auth-choice.ts
@@ -28,8 +28,12 @@ import {
import {
applyAuthProfileConfig,
applyMinimaxConfig,
+ applyMinimaxHostedConfig,
+ applyMinimaxHostedProviderConfig,
applyMinimaxProviderConfig,
+ MINIMAX_HOSTED_MODEL_REF,
setAnthropicApiKey,
+ setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
@@ -397,6 +401,24 @@ export async function applyAuthChoice(params: {
provider: "anthropic",
mode: "api_key",
});
+ } else if (params.authChoice === "minimax-cloud") {
+ const key = await params.prompter.text({
+ message: "Enter MiniMax API key",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ await setMinimaxApiKey(String(key).trim(), params.agentDir);
+ nextConfig = applyAuthProfileConfig(nextConfig, {
+ profileId: "minimax:default",
+ provider: "minimax",
+ mode: "api_key",
+ });
+ if (params.setDefaultModel) {
+ nextConfig = applyMinimaxHostedConfig(nextConfig);
+ } else {
+ nextConfig = applyMinimaxHostedProviderConfig(nextConfig);
+ agentModelOverride = MINIMAX_HOSTED_MODEL_REF;
+ await noteAgentModel(MINIMAX_HOSTED_MODEL_REF);
+ }
} else if (params.authChoice === "minimax") {
if (params.setDefaultModel) {
nextConfig = applyMinimaxConfig(nextConfig);
diff --git a/src/commands/configure.ts b/src/commands/configure.ts
index 549e3d95d..ef85bd0ce 100644
--- a/src/commands/configure.ts
+++ b/src/commands/configure.ts
@@ -52,7 +52,9 @@ import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
+ applyMinimaxHostedConfig,
setAnthropicApiKey,
+ setMinimaxApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import {
@@ -296,6 +298,7 @@ async function promptAuthConfig(
| "codex-cli"
| "antigravity"
| "apiKey"
+ | "minimax-cloud"
| "minimax"
| "skip";
@@ -522,6 +525,21 @@ async function promptAuthConfig(
provider: "anthropic",
mode: "api_key",
});
+ } else if (authChoice === "minimax-cloud") {
+ const key = guardCancel(
+ await text({
+ message: "Enter MiniMax API key",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ runtime,
+ );
+ await setMinimaxApiKey(String(key).trim());
+ next = applyAuthProfileConfig(next, {
+ profileId: "minimax:default",
+ provider: "minimax",
+ mode: "api_key",
+ });
+ next = applyMinimaxHostedConfig(next);
} else if (authChoice === "minimax") {
next = applyMinimaxConfig(next);
}
diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts
index db51f4b84..b65c72a88 100644
--- a/src/commands/onboard-auth.ts
+++ b/src/commands/onboard-auth.ts
@@ -3,6 +3,12 @@ import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import type { ClawdbotConfig } from "../config/config.js";
+const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
+export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1";
+const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
+const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
+export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
+
export async function writeOAuthCredentials(
provider: OAuthProvider,
creds: OAuthCredentials,
@@ -33,6 +39,19 @@ export async function setAnthropicApiKey(key: string, agentDir?: string) {
});
}
+export async function setMinimaxApiKey(key: string, agentDir?: string) {
+ // Write to the multi-agent path so gateway finds credentials on startup
+ upsertAuthProfile({
+ profileId: "minimax:default",
+ credential: {
+ type: "api_key",
+ provider: "minimax",
+ key,
+ },
+ agentDir: agentDir ?? resolveDefaultAgentDir(),
+ });
+}
+
export function applyAuthProfileConfig(
cfg: ClawdbotConfig,
params: {
@@ -119,6 +138,49 @@ export function applyMinimaxProviderConfig(
};
}
+export function applyMinimaxHostedProviderConfig(
+ cfg: ClawdbotConfig,
+ params?: { baseUrl?: string },
+): ClawdbotConfig {
+ const models = { ...cfg.agent?.models };
+ models[MINIMAX_HOSTED_MODEL_REF] = {
+ ...models[MINIMAX_HOSTED_MODEL_REF],
+ alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax",
+ };
+
+ const providers = { ...cfg.models?.providers };
+ if (!providers.minimax) {
+ providers.minimax = {
+ baseUrl: params?.baseUrl?.trim() || DEFAULT_MINIMAX_BASE_URL,
+ apiKey: "minimax",
+ api: "openai-completions",
+ models: [
+ {
+ id: MINIMAX_HOSTED_MODEL_ID,
+ name: "MiniMax M2.1",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
+ maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
+ },
+ ],
+ };
+ }
+
+ return {
+ ...cfg,
+ agent: {
+ ...cfg.agent,
+ models,
+ },
+ models: {
+ mode: cfg.models?.mode ?? "merge",
+ providers,
+ },
+ };
+}
+
export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const next = applyMinimaxProviderConfig(cfg);
return {
@@ -138,3 +200,26 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
},
};
}
+
+export function applyMinimaxHostedConfig(
+ cfg: ClawdbotConfig,
+ params?: { baseUrl?: string },
+): ClawdbotConfig {
+ const next = applyMinimaxHostedProviderConfig(cfg, params);
+ return {
+ ...next,
+ agent: {
+ ...next.agent,
+ model: {
+ ...(next.agent?.model &&
+ "fallbacks" in (next.agent.model as Record)
+ ? {
+ fallbacks: (next.agent.model as { fallbacks?: string[] })
+ .fallbacks,
+ }
+ : undefined),
+ primary: MINIMAX_HOSTED_MODEL_REF,
+ },
+ },
+ };
+}
diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts
index 7e3821fa1..8338484c6 100644
--- a/src/commands/onboard-non-interactive.ts
+++ b/src/commands/onboard-non-interactive.ts
@@ -25,7 +25,9 @@ import { healthCommand } from "./health.js";
import {
applyAuthProfileConfig,
applyMinimaxConfig,
+ applyMinimaxHostedConfig,
setAnthropicApiKey,
+ setMinimaxApiKey,
} from "./onboard-auth.js";
import {
applyWizardMetadata,
@@ -117,6 +119,20 @@ export async function runNonInteractiveOnboarding(
provider: "anthropic",
mode: "api_key",
});
+ } else if (authChoice === "minimax-cloud") {
+ const key = opts.minimaxApiKey?.trim();
+ if (!key) {
+ runtime.error("Missing --minimax-api-key");
+ runtime.exit(1);
+ return;
+ }
+ await setMinimaxApiKey(key);
+ nextConfig = applyAuthProfileConfig(nextConfig, {
+ profileId: "minimax:default",
+ provider: "minimax",
+ mode: "api_key",
+ });
+ nextConfig = applyMinimaxHostedConfig(nextConfig);
} else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore();
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts
index 09feace3b..53333e5ab 100644
--- a/src/commands/onboard-types.ts
+++ b/src/commands/onboard-types.ts
@@ -9,6 +9,7 @@ export type AuthChoice =
| "codex-cli"
| "antigravity"
| "apiKey"
+ | "minimax-cloud"
| "minimax"
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";
@@ -24,6 +25,7 @@ export type OnboardOptions = {
nonInteractive?: boolean;
authChoice?: AuthChoice;
anthropicApiKey?: string;
+ minimaxApiKey?: string;
gatewayPort?: number;
gatewayBind?: GatewayBind;
gatewayAuth?: GatewayAuthChoice;
From 3149d6d331b8ab758a63f923a150022d895ee31b Mon Sep 17 00:00:00 2001
From: Tobias Bischoff <>
Date: Thu, 8 Jan 2026 15:16:53 +0100
Subject: [PATCH 002/152] Telegram: cast fetch to grammy client type
---
src/telegram/send.ts | 14 +++++++++-----
src/telegram/webhook-set.ts | 12 +++++++++---
2 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/src/telegram/send.ts b/src/telegram/send.ts
index d15fa0616..62873510c 100644
--- a/src/telegram/send.ts
+++ b/src/telegram/send.ts
@@ -1,5 +1,5 @@
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
-import { Bot, InputFile } from "grammy";
+import { Bot, InputFile, type ApiClientOptions } from "grammy";
import { loadConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RetryConfig } from "../infra/retry.js";
@@ -113,10 +113,12 @@ export async function sendMessageTelegram(
// Use provided api or create a new Bot instance. The nullish coalescing
// operator ensures api is always defined (Bot.api is always non-null).
const fetchImpl = resolveTelegramFetch();
+ const client: ApiClientOptions | undefined = fetchImpl
+ ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
+ : undefined;
const api =
opts.api ??
- new Bot(token, fetchImpl ? { client: { fetch: fetchImpl } } : undefined)
- .api;
+ new Bot(token, client ? { client } : undefined).api;
const mediaUrl = opts.mediaUrl?.trim();
// Build optional params for forum topics and reply threading.
@@ -271,10 +273,12 @@ export async function reactMessageTelegram(
const chatId = normalizeChatId(String(chatIdInput));
const messageId = normalizeMessageId(messageIdInput);
const fetchImpl = resolveTelegramFetch();
+ const client: ApiClientOptions | undefined = fetchImpl
+ ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
+ : undefined;
const api =
opts.api ??
- new Bot(token, fetchImpl ? { client: { fetch: fetchImpl } } : undefined)
- .api;
+ new Bot(token, client ? { client } : undefined).api;
const request = createTelegramRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts
index fc81c1106..fd68a84be 100644
--- a/src/telegram/webhook-set.ts
+++ b/src/telegram/webhook-set.ts
@@ -1,4 +1,4 @@
-import { Bot } from "grammy";
+import { Bot, type ApiClientOptions } from "grammy";
import { resolveTelegramFetch } from "./fetch.js";
export async function setTelegramWebhook(opts: {
@@ -8,9 +8,12 @@ export async function setTelegramWebhook(opts: {
dropPendingUpdates?: boolean;
}) {
const fetchImpl = resolveTelegramFetch();
+ const client: ApiClientOptions | undefined = fetchImpl
+ ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
+ : undefined;
const bot = new Bot(
opts.token,
- fetchImpl ? { client: { fetch: fetchImpl } } : undefined,
+ client ? { client } : undefined,
);
await bot.api.setWebhook(opts.url, {
secret_token: opts.secret,
@@ -20,9 +23,12 @@ export async function setTelegramWebhook(opts: {
export async function deleteTelegramWebhook(opts: { token: string }) {
const fetchImpl = resolveTelegramFetch();
+ const client: ApiClientOptions | undefined = fetchImpl
+ ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
+ : undefined;
const bot = new Bot(
opts.token,
- fetchImpl ? { client: { fetch: fetchImpl } } : undefined,
+ client ? { client } : undefined,
);
await bot.api.deleteWebhook();
}
From d258c68ca1ab4c1b42675add5b3319209dcb8223 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 10:39:00 +0100
Subject: [PATCH 003/152] feat: add gateway dev config options
---
CHANGELOG.md | 1 +
docs/cli/gateway.md | 21 +++
docs/cli/index.md | 2 +
src/cli/gateway-cli.ts | 126 +++++++++++++++++
src/commands/gateway-status.test.ts | 45 +++++++
src/commands/gateway-status.ts | 185 +++++++++++++++++++++----
src/config/types.ts | 7 +
src/infra/ssh-tunnel.ts | 202 ++++++++++++++++++++++++++++
8 files changed, 561 insertions(+), 28 deletions(-)
create mode 100644 src/infra/ssh-tunnel.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58654420d..65e094dae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -85,6 +85,7 @@
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
+- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md). — thanks @steipete
## 2026.1.8
diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md
index 0e80a7c73..87f799c2a 100644
--- a/docs/cli/gateway.md
+++ b/docs/cli/gateway.md
@@ -39,6 +39,8 @@ Notes:
- `--password `: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
- `--tailscale `: expose the Gateway via Tailscale.
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
+- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md).
+- `--reset`: recreate the dev config (requires `--dev`).
- `--force`: kill any existing listener on the selected port before starting.
- `--verbose`: verbose logs.
- `--claude-cli-logs`: only show claude-cli logs in the console (and enable its stdout/stderr).
@@ -82,6 +84,25 @@ clawdbot gateway status
clawdbot gateway status --json
```
+#### Remote over SSH (Mac app parity)
+
+The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:`.
+
+CLI equivalent:
+
+```bash
+clawdbot gateway status --ssh steipete@peters-mac-studio-1
+```
+
+Options:
+- `--ssh `: `user@host` or `user@host:port` (port defaults to `22`).
+- `--ssh-identity `: identity file.
+- `--ssh-auto`: pick the first discovered bridge host as SSH target (LAN/WAB only).
+
+Config (optional, used as defaults):
+- `gateway.remote.sshTarget`
+- `gateway.remote.sshIdentity`
+
### `gateway call `
Low-level RPC helper.
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 26e8dae8a..594965be7 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -409,6 +409,8 @@ Options:
- `--tailscale `
- `--tailscale-reset-on-exit`
- `--allow-unconfigured`
+- `--dev`
+- `--reset`
- `--force` (kill existing listener on port)
- `--verbose`
- `--ws-log `
diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts
index 82541fa60..ef0b2a376 100644
--- a/src/cli/gateway-cli.ts
+++ b/src/cli/gateway-cli.ts
@@ -1,13 +1,18 @@
import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
import type { Command } from "commander";
+import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { gatewayStatusCommand } from "../commands/gateway-status.js";
+import { moveToTrash } from "../commands/onboard-helpers.js";
import {
CONFIG_PATH_CLAWDBOT,
type GatewayAuthMode,
loadConfig,
readConfigFileSnapshot,
resolveGatewayPort,
+ writeConfigFile,
} from "../config/config.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
@@ -34,6 +39,7 @@ import {
} from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
+import { resolveUserPath } from "../utils.js";
import { forceFreePortAndWait } from "./ports.js";
import { withProgress } from "./progress.js";
@@ -62,6 +68,8 @@ type GatewayRunOpts = {
compact?: boolean;
rawStream?: boolean;
rawStreamPath?: unknown;
+ dev?: boolean;
+ reset?: boolean;
};
type GatewayRunParams = {
@@ -69,6 +77,33 @@ type GatewayRunParams = {
};
const gatewayLog = createSubsystemLogger("gateway");
+const DEV_IDENTITY_NAME = "Clawdbot Dev";
+const DEV_IDENTITY_THEME = "helpful debug droid";
+const DEV_IDENTITY_EMOJI = "🤖";
+const DEV_AGENT_WORKSPACE_SUFFIX = "dev";
+const DEV_AGENTS_TEMPLATE = `# AGENTS.md - Clawdbot Dev Workspace
+
+Default dev workspace for clawdbot gateway --dev.
+
+- Keep replies concise and direct.
+- Prefer observable debugging steps and logs.
+- Avoid destructive actions unless asked.
+`;
+const DEV_SOUL_TEMPLATE = `# SOUL.md - Dev Persona
+
+Helpful robotic debugging assistant.
+
+- Concise, structured answers.
+- Ask for missing context before guessing.
+- Prefer reproducible steps and logs.
+`;
+const DEV_IDENTITY_TEMPLATE = `# IDENTITY.md - Agent Identity
+
+- Name: ${DEV_IDENTITY_NAME}
+- Creature: debug droid
+- Vibe: ${DEV_IDENTITY_THEME}
+- Emoji: ${DEV_IDENTITY_EMOJI}
+`;
type GatewayRunSignalAction = "stop" | "restart";
@@ -93,6 +128,72 @@ const toOptionString = (value: unknown): string | undefined => {
return undefined;
};
+const resolveDevWorkspaceDir = (
+ env: NodeJS.ProcessEnv = process.env,
+): string => {
+ const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
+ return `${baseDir}-${DEV_AGENT_WORKSPACE_SUFFIX}`;
+};
+
+async function writeFileIfMissing(filePath: string, content: string) {
+ try {
+ await fs.promises.writeFile(filePath, content, {
+ encoding: "utf-8",
+ flag: "wx",
+ });
+ } catch (err) {
+ const anyErr = err as { code?: string };
+ if (anyErr.code !== "EEXIST") throw err;
+ }
+}
+
+async function ensureDevWorkspace(dir: string) {
+ const resolvedDir = resolveUserPath(dir);
+ await fs.promises.mkdir(resolvedDir, { recursive: true });
+ await writeFileIfMissing(
+ path.join(resolvedDir, "AGENTS.md"),
+ DEV_AGENTS_TEMPLATE,
+ );
+ await writeFileIfMissing(
+ path.join(resolvedDir, "SOUL.md"),
+ DEV_SOUL_TEMPLATE,
+ );
+ await writeFileIfMissing(
+ path.join(resolvedDir, "IDENTITY.md"),
+ DEV_IDENTITY_TEMPLATE,
+ );
+}
+
+async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
+ const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
+ if (opts.reset && configExists) {
+ await moveToTrash(CONFIG_PATH_CLAWDBOT, defaultRuntime);
+ }
+
+ const shouldWrite = opts.reset || !configExists;
+ if (!shouldWrite) return;
+
+ const workspace = resolveDevWorkspaceDir();
+ await writeConfigFile({
+ gateway: {
+ mode: "local",
+ bind: "loopback",
+ },
+ agent: {
+ workspace,
+ skipBootstrap: true,
+ },
+ identity: {
+ name: DEV_IDENTITY_NAME,
+ theme: DEV_IDENTITY_THEME,
+ emoji: DEV_IDENTITY_EMOJI,
+ },
+ });
+ await ensureDevWorkspace(workspace);
+ defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
+ defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
+}
+
type GatewayDiscoverOpts = {
timeout?: string;
json?: boolean;
@@ -403,6 +504,11 @@ async function runGatewayCommand(
opts: GatewayRunOpts,
params: GatewayRunParams = {},
) {
+ if (opts.reset && !opts.dev) {
+ defaultRuntime.error("Use --reset with --dev.");
+ defaultRuntime.exit(1);
+ return;
+ }
if (params.legacyTokenEnv) {
const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN;
if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) {
@@ -439,6 +545,10 @@ async function runGatewayCommand(
process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath;
}
+ if (opts.dev) {
+ await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
+ }
+
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
@@ -692,6 +802,12 @@ function addGatewayRunCommand(
"Allow gateway start without gateway.mode=local in config",
false,
)
+ .option(
+ "--dev",
+ "Create a dev config + workspace if missing (no BOOTSTRAP.md)",
+ false,
+ )
+ .option("--reset", "Recreate dev config (requires --dev)", false)
.option(
"--force",
"Kill any existing listener on the target port before starting",
@@ -825,6 +941,16 @@ export function registerGatewayCli(program: Command) {
"--url ",
"Explicit Gateway WebSocket URL (still probes localhost)",
)
+ .option(
+ "--ssh ",
+ "SSH target for remote gateway tunnel (user@host or user@host:port)",
+ )
+ .option("--ssh-identity ", "SSH identity file path")
+ .option(
+ "--ssh-auto",
+ "Try to derive an SSH target from Bonjour discovery",
+ false,
+ )
.option("--token ", "Gateway token (applies to all probes)")
.option("--password ", "Gateway password (applies to all probes)")
.option("--timeout ", "Overall probe budget in ms", "3000")
diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts
index 64d536f98..6e375553a 100644
--- a/src/commands/gateway-status.test.ts
+++ b/src/commands/gateway-status.test.ts
@@ -10,6 +10,15 @@ const loadConfig = vi.fn(() => ({
const resolveGatewayPort = vi.fn(() => 18789);
const discoverGatewayBeacons = vi.fn(async () => []);
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
+const sshStop = vi.fn(async () => {});
+const startSshPortForward = vi.fn(async () => ({
+ parsedTarget: { user: "me", host: "studio", port: 22 },
+ localPort: 18789,
+ remotePort: 18789,
+ pid: 123,
+ stderr: [],
+ stop: sshStop,
+}));
const probeGateway = vi.fn(async ({ url }: { url: string }) => {
if (url.includes("127.0.0.1")) {
return {
@@ -71,6 +80,10 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
}));
+vi.mock("../infra/ssh-tunnel.js", () => ({
+ startSshPortForward: (opts: unknown) => startSshPortForward(opts),
+}));
+
vi.mock("../gateway/probe.js", () => ({
probeGateway: (opts: unknown) => probeGateway(opts),
}));
@@ -128,4 +141,36 @@ describe("gateway-status command", () => {
expect(targets[0]?.health).toBeTruthy();
expect(targets[0]?.summary).toBeTruthy();
});
+
+ it("supports SSH tunnel targets", async () => {
+ const runtimeLogs: string[] = [];
+ const runtime = {
+ log: (msg: string) => runtimeLogs.push(msg),
+ error: (_msg: string) => {},
+ exit: (code: number) => {
+ throw new Error(`__exit__:${code}`);
+ },
+ };
+
+ startSshPortForward.mockClear();
+ sshStop.mockClear();
+ probeGateway.mockClear();
+
+ const { gatewayStatusCommand } = await import("./gateway-status.js");
+ await gatewayStatusCommand(
+ { timeout: "1000", json: true, ssh: "me@studio" },
+ runtime as unknown as import("../runtime.js").RuntimeEnv,
+ );
+
+ expect(startSshPortForward).toHaveBeenCalledTimes(1);
+ expect(probeGateway).toHaveBeenCalled();
+ expect(sshStop).toHaveBeenCalledTimes(1);
+
+ const parsed = JSON.parse(runtimeLogs.join("\n")) as Record<
+ string,
+ unknown
+ >;
+ const targets = parsed.targets as Array>;
+ expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
+ });
});
diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts
index daa41ca39..592ee0e53 100644
--- a/src/commands/gateway-status.ts
+++ b/src/commands/gateway-status.ts
@@ -3,17 +3,25 @@ import { loadConfig, resolveGatewayPort } from "../config/config.js";
import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js";
import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
+import { startSshPortForward } from "../infra/ssh-tunnel.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
-type TargetKind = "explicit" | "configRemote" | "localLoopback";
+type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
type GatewayStatusTarget = {
id: string;
kind: TargetKind;
url: string;
active: boolean;
+ tunnel?: {
+ kind: "ssh";
+ target: string;
+ localPort: number;
+ remotePort: number;
+ pid: number | null;
+ };
};
type GatewayConfigSummary = {
@@ -121,9 +129,17 @@ function resolveTargets(
function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
if (kind === "localLoopback") return Math.min(800, overallMs);
+ if (kind === "sshTunnel") return Math.min(2000, overallMs);
return Math.min(1500, overallMs);
}
+function sanitizeSshTarget(value: unknown): string | null {
+ if (typeof value !== "string") return null;
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ return trimmed.replace(/^ssh\s+/, "");
+}
+
function resolveAuthForTarget(
cfg: ClawdbotConfig,
target: GatewayStatusTarget,
@@ -292,11 +308,13 @@ function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
const kindLabel =
target.kind === "localLoopback"
? "Local loopback"
- : target.kind === "configRemote"
- ? target.active
- ? "Remote (configured)"
- : "Remote (configured, inactive)"
- : "URL (explicit)";
+ : target.kind === "sshTunnel"
+ ? "Remote over SSH"
+ : target.kind === "configRemote"
+ ? target.active
+ ? "Remote (configured)"
+ : "Remote (configured, inactive)"
+ : "URL (explicit)";
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
}
@@ -319,6 +337,9 @@ export async function gatewayStatusCommand(
password?: string;
timeout?: unknown;
json?: boolean;
+ ssh?: string;
+ sshIdentity?: string;
+ sshAuto?: boolean;
},
runtime: RuntimeEnv,
) {
@@ -327,7 +348,7 @@ export async function gatewayStatusCommand(
const rich = isRich() && opts.json !== true;
const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000);
- const targets = resolveTargets(cfg, opts.url);
+ const baseTargets = resolveTargets(cfg, opts.url);
const network = buildNetworkHints(cfg);
const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs);
@@ -335,19 +356,16 @@ export async function gatewayStatusCommand(
timeoutMs: discoveryTimeoutMs,
});
- const probePromises = targets.map(async (target) => {
- const auth = resolveAuthForTarget(cfg, target, {
- token: typeof opts.token === "string" ? opts.token : undefined,
- password: typeof opts.password === "string" ? opts.password : undefined,
- });
- const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
- const probe = await probeGateway({ url: target.url, auth, timeoutMs });
- const configSummary = probe.configSnapshot
- ? extractConfigSummary(probe.configSnapshot)
- : null;
- const self = pickGatewaySelfPresence(probe.presence);
- return { target, probe, configSummary, self };
- });
+ let sshTarget =
+ sanitizeSshTarget(opts.ssh) ??
+ sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
+ const sshIdentity =
+ sanitizeSshTarget(opts.sshIdentity) ??
+ sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
+ const remotePort = resolveGatewayPort(cfg);
+
+ let sshTunnelError: string | null = null;
+ let sshTunnelStarted = false;
const { discovery, probed } = await withProgress(
{
@@ -356,15 +374,111 @@ export async function gatewayStatusCommand(
enabled: opts.json !== true,
},
async () => {
- const [discoveryRes, probesRes] = await Promise.allSettled([
- discoveryPromise,
- Promise.all(probePromises),
- ]);
- return {
- discovery:
- discoveryRes.status === "fulfilled" ? discoveryRes.value : [],
- probed: probesRes.status === "fulfilled" ? probesRes.value : [],
+ const tryStartTunnel = async () => {
+ if (!sshTarget) return null;
+ try {
+ const tunnel = await startSshPortForward({
+ target: sshTarget,
+ identity: sshIdentity ?? undefined,
+ localPortPreferred: remotePort,
+ remotePort,
+ timeoutMs: Math.min(1500, overallTimeoutMs),
+ });
+ sshTunnelStarted = true;
+ return tunnel;
+ } catch (err) {
+ sshTunnelError = err instanceof Error ? err.message : String(err);
+ return null;
+ }
};
+
+ const discoveryTask = discoveryPromise.catch(() => []);
+ const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null);
+
+ const [discovery, tunnelFirst] = await Promise.all([
+ discoveryTask,
+ tunnelTask,
+ ]);
+
+ if (!sshTarget && opts.sshAuto) {
+ const user = process.env.USER?.trim() || "";
+ const candidates = discovery
+ .map((b) => {
+ const host = b.tailnetDns || b.lanHost || b.host;
+ if (!host?.trim()) return null;
+ const sshPort =
+ typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22;
+ const base = user ? `${user}@${host.trim()}` : host.trim();
+ return sshPort !== 22 ? `${base}:${sshPort}` : base;
+ })
+ .filter((x): x is string => Boolean(x));
+ if (candidates.length > 0) sshTarget = candidates[0] ?? null;
+ }
+
+ const tunnel =
+ tunnelFirst ||
+ (sshTarget && !sshTunnelStarted && !sshTunnelError
+ ? await tryStartTunnel()
+ : null);
+
+ const tunnelTarget: GatewayStatusTarget | null = tunnel
+ ? {
+ id: "sshTunnel",
+ kind: "sshTunnel",
+ url: `ws://127.0.0.1:${tunnel.localPort}`,
+ active: true,
+ tunnel: {
+ kind: "ssh",
+ target: sshTarget ?? "",
+ localPort: tunnel.localPort,
+ remotePort,
+ pid: tunnel.pid,
+ },
+ }
+ : null;
+
+ const targets: GatewayStatusTarget[] = tunnelTarget
+ ? [
+ tunnelTarget,
+ ...baseTargets.filter((t) => t.url !== tunnelTarget.url),
+ ]
+ : baseTargets;
+
+ try {
+ const probed = await Promise.all(
+ targets.map(async (target) => {
+ const auth = resolveAuthForTarget(cfg, target, {
+ token: typeof opts.token === "string" ? opts.token : undefined,
+ password:
+ typeof opts.password === "string" ? opts.password : undefined,
+ });
+ const timeoutMs = resolveProbeBudgetMs(
+ overallTimeoutMs,
+ target.kind,
+ );
+ const probe = await probeGateway({
+ url: target.url,
+ auth,
+ timeoutMs,
+ });
+ const configSummary = probe.configSnapshot
+ ? extractConfigSummary(probe.configSnapshot)
+ : null;
+ const self = pickGatewaySelfPresence(probe.presence);
+ return { target, probe, configSummary, self };
+ }),
+ );
+
+ return { discovery, probed };
+ } finally {
+ if (tunnel) {
+ try {
+ await tunnel.stop();
+ } catch {
+ // best-effort
+ }
+ }
+ }
},
);
@@ -373,6 +487,7 @@ export async function gatewayStatusCommand(
const multipleGateways = reachable.length > 1;
const primary =
reachable.find((p) => p.target.kind === "explicit") ??
+ reachable.find((p) => p.target.kind === "sshTunnel") ??
reachable.find((p) => p.target.kind === "configRemote") ??
reachable.find((p) => p.target.kind === "localLoopback") ??
null;
@@ -382,6 +497,14 @@ export async function gatewayStatusCommand(
message: string;
targetIds?: string[];
}> = [];
+ if (sshTarget && !sshTunnelStarted) {
+ warnings.push({
+ code: "ssh_tunnel_failed",
+ message: sshTunnelError
+ ? `SSH tunnel failed: ${String(sshTunnelError)}`
+ : "SSH tunnel failed to start; falling back to direct probes.",
+ });
+ }
if (multipleGateways) {
warnings.push({
code: "multiple_gateways",
@@ -427,6 +550,7 @@ export async function gatewayStatusCommand(
kind: p.target.kind,
url: p.target.url,
active: p.target.active,
+ tunnel: p.target.tunnel ?? null,
connect: {
ok: p.probe.ok,
latencyMs: p.probe.connectLatencyMs,
@@ -486,6 +610,11 @@ export async function gatewayStatusCommand(
for (const p of probed) {
runtime.log(renderTargetHeader(p.target, rich));
runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`);
+ if (p.target.tunnel?.kind === "ssh") {
+ runtime.log(
+ ` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, p.target.tunnel.target)}`,
+ );
+ }
if (p.probe.ok && p.self) {
const host = p.self.host ?? "unknown";
const ip = p.self.ip ? ` (${p.self.ip})` : "";
diff --git a/src/config/types.ts b/src/config/types.ts
index b99312195..1ccc17df6 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -875,6 +875,13 @@ export type GatewayTailscaleConfig = {
export type GatewayRemoteConfig = {
/** Remote Gateway WebSocket URL (ws:// or wss://). */
url?: string;
+ /**
+ * Remote gateway over SSH, forwarding the gateway port to localhost.
+ * Format: "user@host" or "user@host:port" (port defaults to 22).
+ */
+ sshTarget?: string;
+ /** Optional SSH identity file path. */
+ sshIdentity?: string;
/** Token for remote auth (when the gateway requires token auth). */
token?: string;
/** Password for remote auth (when the gateway requires password auth). */
diff --git a/src/infra/ssh-tunnel.ts b/src/infra/ssh-tunnel.ts
new file mode 100644
index 000000000..5b459c0b9
--- /dev/null
+++ b/src/infra/ssh-tunnel.ts
@@ -0,0 +1,202 @@
+import { spawn } from "node:child_process";
+import net from "node:net";
+
+import { ensurePortAvailable } from "./ports.js";
+
+export type SshParsedTarget = {
+ user?: string;
+ host: string;
+ port: number;
+};
+
+export type SshTunnel = {
+ parsedTarget: SshParsedTarget;
+ localPort: number;
+ remotePort: number;
+ pid: number | null;
+ stderr: string[];
+ stop: () => Promise;
+};
+
+function isErrno(err: unknown): err is NodeJS.ErrnoException {
+ return Boolean(err && typeof err === "object" && "code" in err);
+}
+
+export function parseSshTarget(raw: string): SshParsedTarget | null {
+ const trimmed = raw.trim().replace(/^ssh\s+/, "");
+ if (!trimmed) return null;
+
+ const [userPart, hostPart] = trimmed.includes("@")
+ ? ((): [string | undefined, string] => {
+ const idx = trimmed.indexOf("@");
+ const user = trimmed.slice(0, idx).trim();
+ const host = trimmed.slice(idx + 1).trim();
+ return [user || undefined, host];
+ })()
+ : [undefined, trimmed];
+
+ const colonIdx = hostPart.lastIndexOf(":");
+ if (colonIdx > 0 && colonIdx < hostPart.length - 1) {
+ const host = hostPart.slice(0, colonIdx).trim();
+ const portRaw = hostPart.slice(colonIdx + 1).trim();
+ const port = Number.parseInt(portRaw, 10);
+ if (!host || !Number.isFinite(port) || port <= 0) return null;
+ return { user: userPart, host, port };
+ }
+
+ if (!hostPart) return null;
+ return { user: userPart, host: hostPart, port: 22 };
+}
+
+async function pickEphemeralPort(): Promise {
+ return await new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const addr = server.address();
+ server.close(() => {
+ if (!addr || typeof addr === "string") {
+ reject(new Error("failed to allocate a local port"));
+ return;
+ }
+ resolve(addr.port);
+ });
+ });
+ });
+}
+
+async function canConnectLocal(port: number): Promise {
+ return await new Promise((resolve) => {
+ const socket = net.connect({ host: "127.0.0.1", port });
+ const done = (ok: boolean) => {
+ socket.removeAllListeners();
+ socket.destroy();
+ resolve(ok);
+ };
+ socket.once("connect", () => done(true));
+ socket.once("error", () => done(false));
+ socket.setTimeout(250, () => done(false));
+ });
+}
+
+async function waitForLocalListener(
+ port: number,
+ timeoutMs: number,
+): Promise {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < timeoutMs) {
+ if (await canConnectLocal(port)) return;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ throw new Error(`ssh tunnel did not start listening on localhost:${port}`);
+}
+
+export async function startSshPortForward(opts: {
+ target: string;
+ identity?: string;
+ localPortPreferred: number;
+ remotePort: number;
+ timeoutMs: number;
+}): Promise {
+ const parsed = parseSshTarget(opts.target);
+ if (!parsed) throw new Error(`invalid SSH target: ${opts.target}`);
+
+ let localPort = opts.localPortPreferred;
+ try {
+ await ensurePortAvailable(localPort);
+ } catch (err) {
+ if (isErrno(err) && err.code === "EADDRINUSE") {
+ localPort = await pickEphemeralPort();
+ } else {
+ throw err;
+ }
+ }
+
+ const userHost = parsed.user ? `${parsed.user}@${parsed.host}` : parsed.host;
+ const args = [
+ "-N",
+ "-L",
+ `${localPort}:127.0.0.1:${opts.remotePort}`,
+ "-p",
+ String(parsed.port),
+ "-o",
+ "ExitOnForwardFailure=yes",
+ "-o",
+ "BatchMode=yes",
+ "-o",
+ "StrictHostKeyChecking=accept-new",
+ "-o",
+ "UpdateHostKeys=yes",
+ "-o",
+ "ConnectTimeout=5",
+ "-o",
+ "ServerAliveInterval=15",
+ "-o",
+ "ServerAliveCountMax=3",
+ ];
+ if (opts.identity?.trim()) {
+ args.push("-i", opts.identity.trim());
+ }
+ args.push(userHost);
+
+ const stderr: string[] = [];
+ const child = spawn("/usr/bin/ssh", args, {
+ stdio: ["ignore", "ignore", "pipe"],
+ });
+ child.stderr?.setEncoding("utf8");
+ child.stderr?.on("data", (chunk) => {
+ const lines = String(chunk)
+ .split("\n")
+ .map((l) => l.trim())
+ .filter(Boolean);
+ stderr.push(...lines);
+ });
+
+ const stop = async () => {
+ if (child.killed) return;
+ child.kill("SIGTERM");
+ await new Promise((resolve) => {
+ const t = setTimeout(() => {
+ try {
+ child.kill("SIGKILL");
+ } finally {
+ resolve();
+ }
+ }, 1500);
+ child.once("exit", () => {
+ clearTimeout(t);
+ resolve();
+ });
+ });
+ };
+
+ try {
+ await Promise.race([
+ waitForLocalListener(localPort, Math.max(250, opts.timeoutMs)),
+ new Promise((_, reject) => {
+ child.once("exit", (code, signal) => {
+ reject(
+ new Error(
+ `ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`,
+ ),
+ );
+ });
+ }),
+ ]);
+ } catch (err) {
+ await stop();
+ const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : "";
+ throw new Error(
+ `${err instanceof Error ? err.message : String(err)}${suffix}`,
+ );
+ }
+
+ return {
+ parsedTarget: parsed,
+ localPort,
+ remotePort: opts.remotePort,
+ pid: typeof child.pid === "number" ? child.pid : null,
+ stderr,
+ stop,
+ };
+}
From fe69bc94399ffa069911b6d9ac7cd6c4074a8d38 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 10:50:19 +0100
Subject: [PATCH 004/152] fix(config): allow gateway.remote ssh fields
---
CHANGELOG.md | 1 +
src/config/schema.ts | 7 +++++++
src/config/zod-schema.ts | 2 ++
3 files changed, 10 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65e094dae..fb7a5f896 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -83,6 +83,7 @@
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
+- Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md). — thanks @steipete
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 34906f28a..a299c47e3 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -81,6 +81,8 @@ const GROUP_ORDER: Record = {
const FIELD_LABELS: Record = {
"gateway.remote.url": "Remote Gateway URL",
+ "gateway.remote.sshTarget": "Remote Gateway SSH Target",
+ "gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
"gateway.remote.token": "Remote Gateway Token",
"gateway.remote.password": "Remote Gateway Password",
"gateway.auth.token": "Gateway Token",
@@ -134,6 +136,10 @@ const FIELD_LABELS: Record = {
const FIELD_HELP: Record = {
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
+ "gateway.remote.sshTarget":
+ "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
+ "gateway.remote.sshIdentity":
+ "Optional SSH identity file path (passed to ssh -i).",
"gateway.auth.token":
"Required for multi-machine access or non-loopback binds.",
"gateway.auth.password": "Required for Tailscale funnel.",
@@ -208,6 +214,7 @@ const FIELD_HELP: Record = {
const FIELD_PLACEHOLDERS: Record = {
"gateway.remote.url": "ws://host:18789",
+ "gateway.remote.sshTarget": "user@host",
"gateway.controlUi.basePath": "/clawdbot",
};
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 083d53a11..462a54cda 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -1283,6 +1283,8 @@ export const ClawdbotSchema = z.object({
remote: z
.object({
url: z.string().optional(),
+ sshTarget: z.string().optional(),
+ sshIdentity: z.string().optional(),
token: z.string().optional(),
password: z.string().optional(),
})
From 01e737e90e873c01ebf20350ef07edb9747cb2c2 Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 19:06:45 +0300
Subject: [PATCH 005/152] docs: add MS Teams provider research document
Initial research and implementation guide for adding msteams as a new
messaging provider. Includes:
- Provider structure patterns from existing implementations
- Gateway integration requirements
- Config types and validation schemas
- Onboarding flow patterns
- MS Teams Bot Framework SDK considerations
- Files to create/modify checklist
This is exploratory work - implementation plan to follow.
---
tmp/msteams-provider-research.md | 585 +++++++++++++++++++++++++++++++
1 file changed, 585 insertions(+)
create mode 100644 tmp/msteams-provider-research.md
diff --git a/tmp/msteams-provider-research.md b/tmp/msteams-provider-research.md
new file mode 100644
index 000000000..abc107797
--- /dev/null
+++ b/tmp/msteams-provider-research.md
@@ -0,0 +1,585 @@
+# MS Teams Provider Research
+
+> Exploratory notes for adding `msteams` as a new provider to Clawdbot.
+
+---
+
+## 1. Existing Provider Structure Analysis
+
+### Directory Structure Pattern
+
+Each provider follows this structure (using Slack as reference):
+
+```
+src/slack/
+├── index.ts # Public exports (barrel file)
+├── monitor.ts # Main event loop & message handling
+├── monitor.test.ts # Unit tests
+├── monitor.tool-result.test.ts # Integration tests
+├── send.ts # Outbound message delivery
+├── actions.ts # Platform API actions (reactions, edits, pins)
+├── token.ts # Token resolution & validation
+└── probe.ts # Health check / connectivity validation
+```
+
+### Key Files by Provider
+
+| Provider | Files |
+|----------|-------|
+| Telegram | bot.ts, monitor.ts, send.ts, probe.ts, token.ts, webhook.ts, download.ts, draft-stream.ts, pairing-store.ts |
+| Discord | monitor.ts, send.ts, probe.ts, token.ts |
+| Slack | monitor.ts, send.ts, actions.ts, probe.ts, token.ts |
+| Signal | monitor.ts, send.ts, probe.ts (uses signal-cli) |
+| iMessage | monitor.ts, send.ts, probe.ts (uses imsg CLI) |
+
+---
+
+## 2. Monitor Pattern (Event Loop)
+
+The `monitorXxxProvider()` function is the heart of each provider. Pattern from Slack:
+
+```typescript
+export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
+ // 1. Load configuration
+ const cfg = loadConfig();
+
+ // 2. Resolve tokens (options > env > config)
+ const botToken = resolveSlackBotToken(
+ opts.botToken ?? process.env.SLACK_BOT_TOKEN ?? cfg.slack?.botToken
+ );
+
+ // 3. Create SDK client
+ const app = new App({
+ token: botToken,
+ appToken,
+ socketMode: true,
+ });
+
+ // 4. Authenticate and cache identity
+ const auth = await app.client.auth.test({ token: botToken });
+
+ // 5. Set up caches (channel info, user info, message dedup)
+ const channelCache = new Map();
+ const userCache = new Map();
+ const seenMessages = new Map();
+
+ // 6. Register event handlers
+ app.event("message", async ({ event }) => {
+ await handleMessage(event);
+ });
+
+ // 7. Start and wait for abort signal
+ await app.start();
+ await new Promise((resolve) => {
+ opts.abortSignal?.addEventListener("abort", () => resolve());
+ });
+ await app.stop();
+}
+```
+
+### Message Processing Pipeline
+
+1. **Validation**: Check message type, ignore bots, dedup check
+2. **Channel Resolution**: Get channel metadata (name, type, topic)
+3. **Authorization Checks**: DM policy, channel allowlist, user allowlist, mention requirements
+4. **Media Download**: Fetch attachments with size limits
+5. **Acknowledgment**: Send reaction to confirm receipt
+6. **Envelope Construction**: Build `ctxPayload` with all message metadata
+7. **System Event Logging**: `enqueueSystemEvent()`
+8. **Reply Dispatcher Setup**: Configure typing indicators and threading
+9. **Dispatch to Agent**: `dispatchReplyFromConfig()`
+
+---
+
+## 3. Gateway Integration
+
+### Provider Manager (src/gateway/server-providers.ts)
+
+```typescript
+// Status types per provider
+export type SlackRuntimeStatus = {
+ running: boolean;
+ lastStartAt?: number | null;
+ lastStopAt?: number | null;
+ lastError?: string | null;
+};
+
+// Combined snapshot
+export type ProviderRuntimeSnapshot = {
+ whatsapp: WebProviderStatus;
+ telegram: TelegramRuntimeStatus;
+ discord: DiscordRuntimeStatus;
+ slack: SlackRuntimeStatus;
+ signal: SignalRuntimeStatus;
+ imessage: IMessageRuntimeStatus;
+};
+
+// Manager interface
+export type ProviderManager = {
+ getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
+ startProviders: () => Promise;
+ startSlackProvider: () => Promise;
+ stopSlackProvider: () => Promise;
+ // ... per provider
+};
+```
+
+### Lifecycle Management
+
+```typescript
+// State tracking
+let slackAbort: AbortController | null = null;
+let slackTask: Promise | null = null;
+let slackRuntime: SlackRuntimeStatus = { running: false };
+
+const startSlackProvider = async () => {
+ if (slackTask) return; // Already running
+
+ const cfg = loadConfig();
+ if (cfg.slack?.enabled === false) return;
+
+ const botToken = resolveSlackBotToken(...);
+ if (!botToken) return; // Not configured
+
+ slackAbort = new AbortController();
+ slackRuntime = { running: true, lastStartAt: Date.now() };
+
+ slackTask = monitorSlackProvider({
+ botToken,
+ runtime: slackRuntimeEnv,
+ abortSignal: slackAbort.signal,
+ })
+ .catch(err => { slackRuntime.lastError = formatError(err); })
+ .finally(() => {
+ slackAbort = null;
+ slackTask = null;
+ slackRuntime.running = false;
+ });
+};
+```
+
+### RuntimeEnv Pattern
+
+```typescript
+// Minimal interface for provider DI
+export type RuntimeEnv = {
+ log: typeof console.log;
+ error: typeof console.error;
+ exit: (code: number) => never;
+};
+
+// Created from subsystem logger
+const logSlack = logProviders.child("slack");
+const slackRuntimeEnv = runtimeForLogger(logSlack);
+```
+
+### Config Hot-Reload (src/gateway/config-reload.ts)
+
+```typescript
+const RELOAD_RULES: ReloadRule[] = [
+ { prefix: "slack", kind: "hot", actions: ["restart-provider:slack"] },
+ { prefix: "telegram", kind: "hot", actions: ["restart-provider:telegram"] },
+ // ...
+];
+```
+
+---
+
+## 4. Configuration Types
+
+### Pattern from SlackConfig (src/config/types.ts)
+
+```typescript
+export type SlackConfig = {
+ enabled?: boolean; // Master toggle
+ botToken?: string; // Primary credential
+ appToken?: string; // Socket mode credential
+ groupPolicy?: GroupPolicy; // "open" | "disabled" | "allowlist"
+ textChunkLimit?: number; // Platform message limit
+ mediaMaxMb?: number; // File size limit
+ dm?: SlackDmConfig; // DM-specific settings
+ channels?: Record; // Per-channel config
+ actions?: SlackActionConfig; // Feature gating
+ slashCommand?: SlackSlashCommandConfig; // Command config
+};
+
+export type SlackDmConfig = {
+ enabled?: boolean;
+ policy?: DmPolicy; // "pairing" | "allowlist" | "open" | "disabled"
+ allowFrom?: Array;
+ groupEnabled?: boolean;
+ groupChannels?: Array;
+};
+
+export type SlackChannelConfig = {
+ enabled?: boolean;
+ requireMention?: boolean;
+ users?: Array; // Per-channel allowlist
+ skills?: string[]; // Skill filter
+ systemPrompt?: string; // Channel-specific prompt
+};
+
+export type SlackActionConfig = {
+ reactions?: boolean;
+ messages?: boolean;
+ pins?: boolean;
+ search?: boolean;
+ // ... feature toggles
+};
+```
+
+### Where Provider Appears in Config
+
+- `ClawdbotConfig.slack` - main config block
+- `QueueModeByProvider.slack` - queue mode override
+- `AgentElevatedAllowFromConfig.slack` - elevated permissions
+- `HookMappingConfig.provider` - webhook routing
+
+---
+
+## 5. Zod Validation Schema
+
+### Pattern (src/config/zod-schema.ts)
+
+```typescript
+const SlackConfigSchema = z
+ .object({
+ enabled: z.boolean().optional(),
+ botToken: z.string().optional(),
+ appToken: z.string().optional(),
+ groupPolicy: GroupPolicySchema.optional().default("open"),
+ textChunkLimit: z.number().optional(),
+ mediaMaxMb: z.number().optional(),
+ dm: SlackDmConfigSchema.optional(),
+ channels: z.record(z.string(), SlackChannelConfigSchema).optional(),
+ actions: SlackActionConfigSchema.optional(),
+ })
+ .superRefine((value, ctx) => {
+ // Cross-field validation
+ if (value.dm?.policy === "open" && !value.dm?.allowFrom?.includes("*")) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["dm", "allowFrom"],
+ message: 'slack.dm.policy="open" requires allowFrom to include "*"',
+ });
+ }
+ })
+ .optional();
+```
+
+---
+
+## 6. Onboarding Flow
+
+### Pattern (src/commands/onboard-providers.ts)
+
+```typescript
+// 1. Status detection
+const slackConfigured = Boolean(
+ process.env.SLACK_BOT_TOKEN || cfg.slack?.botToken
+);
+
+// 2. Provider selection
+const selection = await prompter.multiselect({
+ message: "Select providers",
+ options: [
+ { value: "slack", label: "Slack", hint: slackConfigured ? "configured" : "needs token" },
+ ],
+});
+
+// 3. Credential collection
+if (selection.includes("slack")) {
+ if (process.env.SLACK_BOT_TOKEN && !cfg.slack?.botToken) {
+ const useEnv = await prompter.confirm({
+ message: "SLACK_BOT_TOKEN detected. Use env var?",
+ });
+ if (!useEnv) {
+ token = await prompter.text({ message: "Enter Slack bot token" });
+ }
+ }
+ // ... also collect app token for socket mode
+}
+
+// 4. DM policy configuration
+const policy = await selectPolicy({ label: "Slack", provider: "slack" });
+cfg = setSlackDmPolicy(cfg, policy);
+```
+
+### DM Policy Setter Helper
+
+```typescript
+function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
+ const dm = cfg.slack?.dm ?? {};
+ const allowFrom = dmPolicy === "open"
+ ? addWildcardAllowFrom(dm.allowFrom)
+ : dm.allowFrom;
+ return {
+ ...cfg,
+ slack: {
+ ...cfg.slack,
+ dm: { ...dm, policy: dmPolicy, ...(allowFrom ? { allowFrom } : {}) },
+ },
+ };
+}
+```
+
+---
+
+## 7. Probe (Health Check)
+
+### Pattern (src/slack/probe.ts)
+
+```typescript
+export type SlackProbe = {
+ ok: boolean;
+ status?: number | null;
+ error?: string | null;
+ elapsedMs?: number | null;
+ bot?: { id?: string; name?: string };
+ team?: { id?: string; name?: string };
+};
+
+export async function probeSlack(
+ token: string,
+ timeoutMs = 2500,
+): Promise {
+ const client = new WebClient(token);
+ const start = Date.now();
+
+ try {
+ const result = await withTimeout(client.auth.test(), timeoutMs);
+ if (!result.ok) {
+ return { ok: false, status: 200, error: result.error };
+ }
+ return {
+ ok: true,
+ status: 200,
+ elapsedMs: Date.now() - start,
+ bot: { id: result.user_id, name: result.user },
+ team: { id: result.team_id, name: result.team },
+ };
+ } catch (err) {
+ return { ok: false, status: err.status, error: err.message, elapsedMs: Date.now() - start };
+ }
+}
+```
+
+---
+
+## 8. Send Function
+
+### Pattern (src/slack/send.ts)
+
+```typescript
+export async function sendMessageSlack(
+ to: string,
+ message: string,
+ opts: SlackSendOpts = {},
+): Promise {
+ // 1. Parse recipient (user:X, channel:Y, #channel, @user, etc.)
+ const recipient = parseRecipient(to);
+
+ // 2. Resolve channel ID (open DM if needed)
+ const { channelId } = await resolveChannelId(client, recipient);
+
+ // 3. Chunk text to platform limit
+ const chunks = chunkMarkdownText(message, chunkLimit);
+
+ // 4. Upload media if present
+ if (opts.mediaUrl) {
+ await uploadSlackFile({ client, channelId, mediaUrl, threadTs });
+ }
+
+ // 5. Send each chunk
+ for (const chunk of chunks) {
+ await client.chat.postMessage({
+ channel: channelId,
+ text: chunk,
+ thread_ts: opts.threadTs,
+ });
+ }
+
+ return { messageId, channelId };
+}
+```
+
+---
+
+## 9. CLI Integration
+
+### Dependencies (src/cli/deps.ts)
+
+```typescript
+export type CliDeps = {
+ sendMessageWhatsApp: typeof sendMessageWhatsApp;
+ sendMessageTelegram: typeof sendMessageTelegram;
+ sendMessageDiscord: typeof sendMessageDiscord;
+ sendMessageSlack: typeof sendMessageSlack;
+ sendMessageSignal: typeof sendMessageSignal;
+ sendMessageIMessage: typeof sendMessageIMessage;
+};
+
+export function createDefaultDeps(): CliDeps {
+ return {
+ sendMessageWhatsApp,
+ sendMessageTelegram,
+ // ...
+ };
+}
+```
+
+### Send Command (src/commands/send.ts)
+
+```typescript
+const provider = (opts.provider ?? "whatsapp").toLowerCase();
+
+// Provider-specific delivery
+const results = await deliverOutboundPayloads({
+ cfg: loadConfig(),
+ provider,
+ to: resolvedTarget.to,
+ payloads: [{ text: opts.message, mediaUrl: opts.media }],
+ deps: {
+ sendSlack: deps.sendMessageSlack,
+ // ...
+ },
+});
+```
+
+---
+
+## 10. Files to Create/Modify for MS Teams
+
+### New Files (src/msteams/)
+
+```
+src/msteams/
+├── index.ts # Exports
+├── monitor.ts # Bot Framework event loop
+├── send.ts # Send via Graph API
+├── probe.ts # Health check (Graph API /me)
+├── token.ts # Token resolution
+├── actions.ts # Optional: reactions, edits, etc.
+└── *.test.ts # Tests
+```
+
+### Files to Modify
+
+| File | Changes |
+|------|---------|
+| `src/config/types.ts` | Add `MSTeamsConfig`, update `QueueModeByProvider`, `AgentElevatedAllowFromConfig`, `HookMappingConfig` |
+| `src/config/zod-schema.ts` | Add `MSTeamsConfigSchema` |
+| `src/gateway/server-providers.ts` | Add `MSTeamsRuntimeStatus`, lifecycle methods, update `ProviderRuntimeSnapshot`, `ProviderManager` |
+| `src/gateway/server.ts` | Add logger, runtimeEnv, pass to provider manager |
+| `src/gateway/config-reload.ts` | Add reload rule |
+| `src/gateway/server-methods/providers.ts` | Add status endpoint |
+| `src/cli/deps.ts` | Add `sendMessageMSTeams` |
+| `src/cli/program.ts` | Add to `--provider` options |
+| `src/commands/send.ts` | Add msteams case |
+| `src/commands/onboard-providers.ts` | Add wizard flow |
+| `src/commands/onboard-types.ts` | Add to `ProviderChoice` |
+| `docs/providers/msteams.md` | Documentation |
+
+---
+
+## 11. MS Teams SDK Options
+
+### Option A: Bot Framework SDK (@microsoft/botframework)
+
+```typescript
+import { CloudAdapter, ConfigurationBotFrameworkAuthentication } from "botbuilder";
+
+// Pros: Full-featured, handles auth, typing indicators, cards
+// Cons: More complex, requires Azure Bot registration
+```
+
+### Option B: Microsoft Graph API
+
+```typescript
+import { Client } from "@microsoft/microsoft-graph-client";
+
+// Pros: Simpler for basic messaging, direct API access
+// Cons: Less rich features, manual auth handling
+```
+
+### Recommended: Bot Framework for receiving, Graph for some sends
+
+MS Teams bots use the Bot Framework for receiving messages (webhook-based), and can use either Bot Framework or Graph API for sending.
+
+### Required Azure Resources
+
+1. **Azure Bot Registration** - Bot identity and channel configuration
+2. **App Registration** - OAuth for Graph API access
+3. **Teams App Manifest** - Defines bot capabilities in Teams
+
+### Credentials Needed
+
+```typescript
+export type MSTeamsConfig = {
+ enabled?: boolean;
+ appId?: string; // Azure AD App ID
+ appPassword?: string; // Azure AD App Secret
+ tenantId?: string; // Optional: restrict to tenant
+ // ... rest follows pattern
+};
+```
+
+---
+
+## 12. Key Differences from Slack
+
+| Aspect | Slack | MS Teams |
+|--------|-------|----------|
+| Connection | Socket Mode (WebSocket) | Webhook (HTTP POST) |
+| Auth | Bot Token + App Token | Azure AD App ID + Secret |
+| Message ID | `ts` (timestamp) | Activity ID |
+| Threading | `thread_ts` | `replyToId` in conversation |
+| Channels | Channel ID | Channel ID + Team ID |
+| DMs | `conversations.open` | Proactive messaging with conversation reference |
+| Typing | `assistant.threads.setStatus` | `sendTypingActivity()` |
+| Reactions | `reactions.add` | Separate message with reaction |
+| Media | `files.uploadV2` | Attachments in activity |
+
+---
+
+## 13. Implementation Considerations
+
+### Webhook vs Polling
+
+MS Teams uses webhooks exclusively (no polling option like Telegram). Need to:
+- Expose HTTP endpoint for Bot Framework
+- Handle activity validation (HMAC signature)
+- Consider tunneling for local dev (ngrok, Tailscale funnel)
+
+### Proactive Messaging
+
+Unlike Slack where you can message any user, Teams requires:
+- User must have interacted with bot first, OR
+- Bot must be installed in team/chat, OR
+- Use Graph API with appropriate permissions
+
+### Tenant Restrictions
+
+Enterprise Teams often restrict:
+- External app installations
+- Cross-tenant communication
+- Certain API permissions
+
+Config should support `tenantId` restriction.
+
+### Cards and Adaptive Cards
+
+Teams heavily uses Adaptive Cards for rich UI. Consider supporting:
+- Basic text (markdown subset)
+- Adaptive Card JSON
+- Hero Cards for media
+
+---
+
+## Next Steps
+
+1. **Research**: MS Teams Bot Framework SDK specifics
+2. **Azure Setup**: Document bot registration process
+3. **Implement**: Start with monitor.ts and basic send
+4. **Test**: Local dev with ngrok/tunnel
+5. **Docs**: Provider setup guide
From 3b53a8445972a4487a8ffad592896cfb2bbe86c8 Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 19:34:16 +0300
Subject: [PATCH 006/152] docs: rewrite MS Teams research as implementation
guide
Codex (gpt-5.2 xhigh) rewrote the doc:
- Added MVP scope definition
- Verified repo conventions against actual codebase
- Added 2025/2026 Microsoft guidance (CloudAdapter, single-tenant default)
- Concrete code examples (monitor, send, webhook, adapter)
- Detailed integration checklist (all files to modify)
- 9 MS Teams gotchas to plan for
- 12 actionable implementation steps
- Current references (2026-01)
---
tmp/msteams-provider-research.md | 1005 ++++++++++++++++--------------
1 file changed, 534 insertions(+), 471 deletions(-)
diff --git a/tmp/msteams-provider-research.md b/tmp/msteams-provider-research.md
index abc107797..be686bad1 100644
--- a/tmp/msteams-provider-research.md
+++ b/tmp/msteams-provider-research.md
@@ -1,585 +1,648 @@
-# MS Teams Provider Research
+# MS Teams Provider Implementation Guide (Clawdbot)
-> Exploratory notes for adding `msteams` as a new provider to Clawdbot.
+Practical implementation notes for adding `msteams` as a new provider to Clawdbot.
+
+This document is written to match **this repo’s actual conventions** (verified against `src/` as of 2026-01-07), and to be used as an implementation checklist.
---
-## 1. Existing Provider Structure Analysis
+## 0) Scope / MVP
-### Directory Structure Pattern
+**MVP (recommended first milestone)**
-Each provider follows this structure (using Slack as reference):
+- Inbound: receive DMs + channel mentions via Bot Framework webhook.
+- Outbound: reply in the same conversation (and optionally proactive follow-ups) using the **Bot Framework connector** (not Graph message-post).
+- Basic media inbound: download Teams file attachments when possible; outbound media: send link (or Adaptive Card image) initially.
+- DM security: reuse existing Clawdbot `dmPolicy` + pairing store behavior.
+
+**Nice-to-have**
+
+- Rich cards (Adaptive Cards), message update/delete, reactions, channel-wide (non-mention) listening, proactive app installation via Graph, meeting chat support, multi-bot accounts.
+
+---
+
+## 1) Repo Conventions (Verified)
+
+### 1.1 Provider layout
+
+Most providers live in `src//` and follow the Slack/Discord pattern:
```
src/slack/
-├── index.ts # Public exports (barrel file)
-├── monitor.ts # Main event loop & message handling
-├── monitor.test.ts # Unit tests
-├── monitor.tool-result.test.ts # Integration tests
-├── send.ts # Outbound message delivery
-├── actions.ts # Platform API actions (reactions, edits, pins)
-├── token.ts # Token resolution & validation
-└── probe.ts # Health check / connectivity validation
+├── index.ts
+├── monitor.ts
+├── monitor.test.ts
+├── monitor.tool-result.test.ts
+├── send.ts
+├── actions.ts
+├── token.ts
+└── probe.ts
```
-### Key Files by Provider
+Notes:
-| Provider | Files |
-|----------|-------|
-| Telegram | bot.ts, monitor.ts, send.ts, probe.ts, token.ts, webhook.ts, download.ts, draft-stream.ts, pairing-store.ts |
-| Discord | monitor.ts, send.ts, probe.ts, token.ts |
-| Slack | monitor.ts, send.ts, actions.ts, probe.ts, token.ts |
-| Signal | monitor.ts, send.ts, probe.ts (uses signal-cli) |
-| iMessage | monitor.ts, send.ts, probe.ts (uses imsg CLI) |
+- WhatsApp (web) is the exception: it’s split across `src/providers/web/` and shared helpers in `src/web/`.
+- Providers often include extra helpers (`webhook.ts`, `client.ts`, `targets.ts`, `daemon.ts`, etc.) when needed (see `src/telegram/`, `src/signal/`, `src/imessage/`).
+
+### 1.2 Monitor pattern & message pipeline
+
+Inbound providers ultimately build a `ctx` payload and call the shared pipeline:
+
+- `dispatchReplyFromConfig()` (auto-reply) + `createReplyDispatcherWithTyping()` (provider typing indicator).
+- `resolveAgentRoute()` for session key + agent routing.
+- `enqueueSystemEvent()` for human-readable “what happened” logging.
+- Pairing gates via `readProviderAllowFromStore()` and `upsertProviderPairingRequest()` for `dmPolicy=pairing`.
+
+A minimal (but accurate) sequence looks like:
+
+1. Validate activity (ignore bot echoes; ignore edits unless you want system events).
+2. Resolve peer identity + chat type + routing (`resolveAgentRoute()`).
+3. Apply access policy: DM policy + allowFrom/pairing; channel allowlist/mention requirements.
+4. Download attachments (bounded by `mediaMaxMb`).
+5. Build `ctx` envelope (matches other providers’ field names).
+6. Dispatch reply through `dispatchReplyFromConfig()`.
+
+### 1.3 Gateway lifecycle
+
+Providers started by the gateway are managed in:
+
+- `src/gateway/server-providers.ts` (start/stop + runtime snapshot)
+- `src/gateway/server.ts` (logger + `runtimeForLogger()` wiring)
+- `src/gateway/config-reload.ts` (restart rules + provider kind union)
+- `src/gateway/server-methods/providers.ts` (status endpoint)
+
+### 1.4 Outbound delivery plumbing (easy to miss)
+
+The CLI + gateway send paths share outbound helpers:
+
+- `src/infra/outbound/targets.ts` (validates `--to` per provider)
+- `src/infra/outbound/deliver.ts` (chunking + send abstraction)
+- `src/infra/outbound/format.ts` (summaries / JSON)
+- `src/gateway/server-methods/send.ts` (gateway “send” supports multiple providers)
+- `src/commands/send.ts` + `src/cli/deps.ts` (direct CLI send wiring)
+
+### 1.5 Pairing integration points
+
+Adding a new provider that supports `dmPolicy=pairing` requires:
+
+- `src/pairing/pairing-store.ts` (extend `PairingProvider`)
+- `src/cli/pairing-cli.ts` (provider list + optional notify-on-approve)
+
+### 1.6 UI surfaces
+
+The local web UI has explicit provider forms + unions:
+
+- `ui/src/ui/app.ts` (state + forms per provider)
+- `ui/src/ui/types.ts` and `ui/src/ui/ui-types.ts` (provider unions)
+- `ui/src/ui/controllers/connections.ts` (load/save config per provider)
+
+If we add `msteams`, the UI must be updated alongside backend config/types.
---
-## 2. Monitor Pattern (Event Loop)
+## 2) 2025/2026 Microsoft Guidance (What Changed)
-The `monitorXxxProvider()` function is the heart of each provider. Pattern from Slack:
+### 2.1 Bot Framework SDK v4 “modern” baseline (Node)
-```typescript
-export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
- // 1. Load configuration
- const cfg = loadConfig();
+For Node bots, Microsoft’s maintained samples now use:
- // 2. Resolve tokens (options > env > config)
- const botToken = resolveSlackBotToken(
- opts.botToken ?? process.env.SLACK_BOT_TOKEN ?? cfg.slack?.botToken
- );
+- `CloudAdapter` + `ConfigurationBotFrameworkAuthentication` (instead of older adapter patterns)
+- Express/Restify middleware to parse JSON into `req.body` before `adapter.process(...)`
- // 3. Create SDK client
- const app = new App({
- token: botToken,
- appToken,
- socketMode: true,
- });
+CloudAdapter’s request processing explicitly requires parsed JSON bodies (it will 400 if `req.body` isn’t an object).
- // 4. Authenticate and cache identity
- const auth = await app.client.auth.test({ token: botToken });
+### 2.2 Proactive messaging is required for “slow” work
- // 5. Set up caches (channel info, user info, message dedup)
- const channelCache = new Map();
- const userCache = new Map();
- const seenMessages = new Map();
+Teams delivers messages via **HTTP webhook**. If we block the request while waiting on an LLM run, we risk:
- // 6. Register event handlers
- app.event("message", async ({ event }) => {
- await handleMessage(event);
- });
+- gateway timeouts,
+- Teams retries (duplicate inbound),
+- or dropped replies.
- // 7. Start and wait for abort signal
- await app.start();
- await new Promise((resolve) => {
- opts.abortSignal?.addEventListener("abort", () => resolve());
- });
- await app.stop();
-}
-```
+Best practice for long-running work is:
-### Message Processing Pipeline
+- capture a `ConversationReference`,
+- **return quickly**,
+- then send replies later via proactive messaging (`continueConversationAsync` in CloudAdapter).
-1. **Validation**: Check message type, ignore bots, dedup check
-2. **Channel Resolution**: Get channel metadata (name, type, topic)
-3. **Authorization Checks**: DM policy, channel allowlist, user allowlist, mention requirements
-4. **Media Download**: Fetch attachments with size limits
-5. **Acknowledgment**: Send reaction to confirm receipt
-6. **Envelope Construction**: Build `ctxPayload` with all message metadata
-7. **System Event Logging**: `enqueueSystemEvent()`
-8. **Reply Dispatcher Setup**: Configure typing indicators and threading
-9. **Dispatch to Agent**: `dispatchReplyFromConfig()`
+### 2.3 Microsoft 365 Agents SDK exists (potential future path)
+
+Microsoft is actively building the **Microsoft 365 Agents SDK** (Node/TS) which positions itself as a replacement for parts of Bot Framework (`botbuilder`) for Teams and other channels.
+
+Practical implication for Clawdbot:
+
+- **Ship v1 with Bot Framework** (most stable, most docs, matches Teams docs),
+- but structure our MS Teams provider so it can be swapped to Agents SDK later (thin adapter boundary around “receive activity” + “send activity”).
+
+### 2.4 Deprecations / platform shifts to note
+
+- Creation of **new multi-tenant bots** has been announced as deprecated after **2025-07-31** (plan for **single-tenant** by default).
+- Office 365 connectors / incoming webhooks retirement has been extended to **2026-03-31** (don’t build a provider around incoming webhooks; use bots).
---
-## 3. Gateway Integration
+## 3) Recommended Architecture for Clawdbot
-### Provider Manager (src/gateway/server-providers.ts)
+### 3.1 Use Bot Framework for both receive + send
-```typescript
-// Status types per provider
-export type SlackRuntimeStatus = {
- running: boolean;
- lastStartAt?: number | null;
- lastStopAt?: number | null;
- lastError?: string | null;
-};
+Avoid “Graph API sendMessage” as the default path. For Teams, **posting chat/channel messages via Graph** is heavily constrained (often delegated-only and/or policy-restricted), while bots can reliably send messages in the conversations where they’re installed.
-// Combined snapshot
-export type ProviderRuntimeSnapshot = {
- whatsapp: WebProviderStatus;
- telegram: TelegramRuntimeStatus;
- discord: DiscordRuntimeStatus;
- slack: SlackRuntimeStatus;
- signal: SignalRuntimeStatus;
- imessage: IMessageRuntimeStatus;
-};
+**Key idea:** treat Teams as a “bot conversation provider”:
-// Manager interface
-export type ProviderManager = {
- getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
- startProviders: () => Promise;
- startSlackProvider: () => Promise;
- stopSlackProvider: () => Promise;
- // ... per provider
-};
-```
+- Receive activity via webhook.
+- Reply (and send follow-ups) via the connector using the stored conversation reference.
-### Lifecycle Management
+### 3.2 Run a dedicated webhook server inside the provider monitor
-```typescript
-// State tracking
-let slackAbort: AbortController | null = null;
-let slackTask: Promise | null = null;
-let slackRuntime: SlackRuntimeStatus = { running: false };
+This matches how Telegram webhooks are done (`src/telegram/webhook.ts`): the provider can run its own HTTP server on a configured port/path.
-const startSlackProvider = async () => {
- if (slackTask) return; // Already running
+This avoids entangling the Teams webhook with the gateway HTTP server routes and lets users expose only the Teams webhook port if desired.
- const cfg = loadConfig();
- if (cfg.slack?.enabled === false) return;
+### 3.3 Explicitly store conversation references
- const botToken = resolveSlackBotToken(...);
- if (!botToken) return; // Not configured
+To send proactive replies (or to support `clawdbot send --provider msteams ...`), we need a small store that maps a stable key to a `ConversationReference`.
- slackAbort = new AbortController();
- slackRuntime = { running: true, lastStartAt: Date.now() };
+Recommendation:
- slackTask = monitorSlackProvider({
- botToken,
- runtime: slackRuntimeEnv,
- abortSignal: slackAbort.signal,
- })
- .catch(err => { slackRuntime.lastError = formatError(err); })
- .finally(() => {
- slackAbort = null;
- slackTask = null;
- slackRuntime.running = false;
- });
-};
-```
-
-### RuntimeEnv Pattern
-
-```typescript
-// Minimal interface for provider DI
-export type RuntimeEnv = {
- log: typeof console.log;
- error: typeof console.error;
- exit: (code: number) => never;
-};
-
-// Created from subsystem logger
-const logSlack = logProviders.child("slack");
-const slackRuntimeEnv = runtimeForLogger(logSlack);
-```
-
-### Config Hot-Reload (src/gateway/config-reload.ts)
-
-```typescript
-const RELOAD_RULES: ReloadRule[] = [
- { prefix: "slack", kind: "hot", actions: ["restart-provider:slack"] },
- { prefix: "telegram", kind: "hot", actions: ["restart-provider:telegram"] },
- // ...
-];
-```
+- Key by `conversation.id` (works for DMs, group chats, channels).
+- Also store `tenantId`, `serviceUrl`, and useful labels (team/channel name when available) for debugging and allowlists.
---
-## 4. Configuration Types
+## 4) Configuration Design
-### Pattern from SlackConfig (src/config/types.ts)
+### 4.1 Proposed `msteams` config block
-```typescript
-export type SlackConfig = {
- enabled?: boolean; // Master toggle
- botToken?: string; // Primary credential
- appToken?: string; // Socket mode credential
- groupPolicy?: GroupPolicy; // "open" | "disabled" | "allowlist"
- textChunkLimit?: number; // Platform message limit
- mediaMaxMb?: number; // File size limit
- dm?: SlackDmConfig; // DM-specific settings
- channels?: Record; // Per-channel config
- actions?: SlackActionConfig; // Feature gating
- slashCommand?: SlackSlashCommandConfig; // Command config
-};
+Suggested shape (mirrors Slack/Discord style + existing `DmPolicy` and `GroupPolicy`):
-export type SlackDmConfig = {
+```ts
+export type MSTeamsConfig = {
enabled?: boolean;
- policy?: DmPolicy; // "pairing" | "allowlist" | "open" | "disabled"
- allowFrom?: Array;
- groupEnabled?: boolean;
- groupChannels?: Array;
-};
-export type SlackChannelConfig = {
- enabled?: boolean;
- requireMention?: boolean;
- users?: Array; // Per-channel allowlist
- skills?: string[]; // Skill filter
- systemPrompt?: string; // Channel-specific prompt
-};
+ // Bot registration (Azure Bot / Entra app)
+ appId?: string; // Entra app (bot) ID
+ appPassword?: string; // secret
+ tenantId?: string; // recommended: single tenant
+ appType?: "singleTenant" | "multiTenant"; // default: singleTenant
-export type SlackActionConfig = {
- reactions?: boolean;
- messages?: boolean;
- pins?: boolean;
- search?: boolean;
- // ... feature toggles
+ // Webhook listener (provider-owned HTTP server)
+ webhook?: {
+ host?: string; // default: 0.0.0.0
+ port?: number; // default: 3978 (Bot Framework conventional)
+ path?: string; // default: /msteams/messages
+ };
+
+ // Access control
+ dm?: {
+ enabled?: boolean;
+ policy?: DmPolicy; // pairing|open|disabled
+ allowFrom?: Array; // allowlist for open/allowlist-like flows
+ };
+ groupPolicy?: GroupPolicy; // open|disabled|allowlist
+ channels?: Record<
+ string,
+ {
+ enabled?: boolean;
+ requireMention?: boolean;
+ users?: Array;
+ skills?: string[];
+ systemPrompt?: string;
+ }
+ >;
+
+ // Limits
+ textChunkLimit?: number;
+ mediaMaxMb?: number;
};
```
-### Where Provider Appears in Config
+### 4.2 Env var conventions
-- `ClawdbotConfig.slack` - main config block
-- `QueueModeByProvider.slack` - queue mode override
-- `AgentElevatedAllowFromConfig.slack` - elevated permissions
-- `HookMappingConfig.provider` - webhook routing
+To match repo patterns and Microsoft docs, support both:
+
+- Clawdbot-style: `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, `MSTEAMS_TENANT_ID`
+- Bot Framework defaults: `MicrosoftAppId`, `MicrosoftAppPassword`, `MicrosoftAppTenantId`, `MicrosoftAppType`
+
+Resolution order should follow other providers: `opts > env > config`.
---
-## 5. Zod Validation Schema
+## 5) File/Module Plan (`src/msteams/`)
-### Pattern (src/config/zod-schema.ts)
+Recommended structure (intentionally similar to Slack, with Teams-specific extras):
-```typescript
-const SlackConfigSchema = z
- .object({
- enabled: z.boolean().optional(),
- botToken: z.string().optional(),
- appToken: z.string().optional(),
- groupPolicy: GroupPolicySchema.optional().default("open"),
- textChunkLimit: z.number().optional(),
- mediaMaxMb: z.number().optional(),
- dm: SlackDmConfigSchema.optional(),
- channels: z.record(z.string(), SlackChannelConfigSchema).optional(),
- actions: SlackActionConfigSchema.optional(),
- })
- .superRefine((value, ctx) => {
- // Cross-field validation
- if (value.dm?.policy === "open" && !value.dm?.allowFrom?.includes("*")) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["dm", "allowFrom"],
- message: 'slack.dm.policy="open" requires allowFrom to include "*"',
- });
- }
- })
- .optional();
+```
+src/msteams/
+├── index.ts
+├── token.ts
+├── monitor.ts
+├── webhook.ts # Express server + CloudAdapter.process
+├── conversation-store.ts # Persist ConversationReference by conversation.id
+├── send.ts # Proactive send via adapter.continueConversationAsync
+├── attachments.ts # Download helpers for Teams attachment types
+├── probe.ts # Basic credential check (optional)
+├── monitor.test.ts
+└── monitor.tool-result.test.ts
```
---
-## 6. Onboarding Flow
+## 6) Concrete Code Examples
-### Pattern (src/commands/onboard-providers.ts)
+These are not drop-in (because `botbuilder` isn’t currently a dependency in this repo), but they’re written in the style of existing providers.
-```typescript
-// 1. Status detection
-const slackConfigured = Boolean(
- process.env.SLACK_BOT_TOKEN || cfg.slack?.botToken
-);
+### 6.1 `src/msteams/token.ts` (credential resolution)
-// 2. Provider selection
-const selection = await prompter.multiselect({
- message: "Select providers",
- options: [
- { value: "slack", label: "Slack", hint: slackConfigured ? "configured" : "needs token" },
- ],
-});
+```ts
+export type ResolvedMSTeamsCreds = {
+ appId: string | null;
+ appPassword: string | null;
+ tenantId: string | null;
+ appType: "singleTenant" | "multiTenant";
+ source: {
+ appId: "opts" | "env" | "config" | "missing";
+ appPassword: "opts" | "env" | "config" | "missing";
+ };
+};
-// 3. Credential collection
-if (selection.includes("slack")) {
- if (process.env.SLACK_BOT_TOKEN && !cfg.slack?.botToken) {
- const useEnv = await prompter.confirm({
- message: "SLACK_BOT_TOKEN detected. Use env var?",
- });
- if (!useEnv) {
- token = await prompter.text({ message: "Enter Slack bot token" });
- }
- }
- // ... also collect app token for socket mode
-}
+export function resolveMSTeamsCreds(
+ cfg: { msteams?: { appId?: string; appPassword?: string; tenantId?: string; appType?: string } },
+ opts?: { appId?: string; appPassword?: string; tenantId?: string; appType?: string },
+): ResolvedMSTeamsCreds {
+ const env = process.env;
+ const appId =
+ opts?.appId?.trim() ||
+ env.MSTEAMS_APP_ID?.trim() ||
+ env.MicrosoftAppId?.trim() ||
+ cfg.msteams?.appId?.trim() ||
+ null;
+ const appPassword =
+ opts?.appPassword?.trim() ||
+ env.MSTEAMS_APP_PASSWORD?.trim() ||
+ env.MicrosoftAppPassword?.trim() ||
+ cfg.msteams?.appPassword?.trim() ||
+ null;
+ const tenantId =
+ opts?.tenantId?.trim() ||
+ env.MSTEAMS_TENANT_ID?.trim() ||
+ env.MicrosoftAppTenantId?.trim() ||
+ cfg.msteams?.tenantId?.trim() ||
+ null;
-// 4. DM policy configuration
-const policy = await selectPolicy({ label: "Slack", provider: "slack" });
-cfg = setSlackDmPolicy(cfg, policy);
-```
+ const appTypeRaw =
+ (opts?.appType || env.MicrosoftAppType || cfg.msteams?.appType || "")
+ .trim()
+ .toLowerCase();
+ const appType =
+ appTypeRaw === "multitenant" || appTypeRaw === "multi-tenant"
+ ? "multiTenant"
+ : "singleTenant";
-### DM Policy Setter Helper
-
-```typescript
-function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
- const dm = cfg.slack?.dm ?? {};
- const allowFrom = dmPolicy === "open"
- ? addWildcardAllowFrom(dm.allowFrom)
- : dm.allowFrom;
return {
- ...cfg,
- slack: {
- ...cfg.slack,
- dm: { ...dm, policy: dmPolicy, ...(allowFrom ? { allowFrom } : {}) },
+ appId,
+ appPassword,
+ tenantId,
+ appType,
+ source: {
+ appId: opts?.appId
+ ? "opts"
+ : env.MSTEAMS_APP_ID || env.MicrosoftAppId
+ ? "env"
+ : cfg.msteams?.appId
+ ? "config"
+ : "missing",
+ appPassword: opts?.appPassword
+ ? "opts"
+ : env.MSTEAMS_APP_PASSWORD || env.MicrosoftAppPassword
+ ? "env"
+ : cfg.msteams?.appPassword
+ ? "config"
+ : "missing",
},
};
}
```
----
+### 6.2 `src/msteams/webhook.ts` (Express + CloudAdapter)
-## 7. Probe (Health Check)
+Key best-practice points:
-### Pattern (src/slack/probe.ts)
+- `adapter.process(...)` requires JSON middleware (parsed `req.body`).
+- Keep request handling fast; offload long work to proactive sends.
-```typescript
-export type SlackProbe = {
- ok: boolean;
- status?: number | null;
- error?: string | null;
- elapsedMs?: number | null;
- bot?: { id?: string; name?: string };
- team?: { id?: string; name?: string };
-};
+```ts
+import express from "express";
+import type { Server } from "node:http";
+import {
+ CloudAdapter,
+ ConfigurationBotFrameworkAuthentication,
+} from "botbuilder";
+import type { RuntimeEnv } from "../runtime.js";
-export async function probeSlack(
- token: string,
- timeoutMs = 2500,
-): Promise {
- const client = new WebClient(token);
- const start = Date.now();
+export async function startMSTeamsWebhook(opts: {
+ host: string;
+ port: number;
+ path: string;
+ runtime: RuntimeEnv;
+ onTurn: (adapter: CloudAdapter) => (turnContext: unknown) => Promise;
+}) {
+ const runtime = opts.runtime;
+ const app = express();
+ app.use(express.json({ limit: "10mb" }));
- try {
- const result = await withTimeout(client.auth.test(), timeoutMs);
- if (!result.ok) {
- return { ok: false, status: 200, error: result.error };
- }
- return {
- ok: true,
- status: 200,
- elapsedMs: Date.now() - start,
- bot: { id: result.user_id, name: result.user },
- team: { id: result.team_id, name: result.team },
- };
- } catch (err) {
- return { ok: false, status: err.status, error: err.message, elapsedMs: Date.now() - start };
- }
-}
-```
+ const botFrameworkAuthentication = new ConfigurationBotFrameworkAuthentication(
+ process.env,
+ );
+ const adapter = new CloudAdapter(botFrameworkAuthentication);
----
-
-## 8. Send Function
-
-### Pattern (src/slack/send.ts)
-
-```typescript
-export async function sendMessageSlack(
- to: string,
- message: string,
- opts: SlackSendOpts = {},
-): Promise {
- // 1. Parse recipient (user:X, channel:Y, #channel, @user, etc.)
- const recipient = parseRecipient(to);
-
- // 2. Resolve channel ID (open DM if needed)
- const { channelId } = await resolveChannelId(client, recipient);
-
- // 3. Chunk text to platform limit
- const chunks = chunkMarkdownText(message, chunkLimit);
-
- // 4. Upload media if present
- if (opts.mediaUrl) {
- await uploadSlackFile({ client, channelId, mediaUrl, threadTs });
- }
-
- // 5. Send each chunk
- for (const chunk of chunks) {
- await client.chat.postMessage({
- channel: channelId,
- text: chunk,
- thread_ts: opts.threadTs,
+ app.get("/healthz", (_req, res) => res.status(200).send("ok"));
+ app.post(opts.path, async (req, res) => {
+ await adapter.process(req, res, async (turnContext) => {
+ await opts.onTurn(adapter)(turnContext);
});
- }
+ });
- return { messageId, channelId };
+ const server: Server = await new Promise((resolve) => {
+ const srv = app.listen(opts.port, opts.host, () => resolve(srv));
+ });
+
+ runtime.log?.(
+ `msteams webhook listening on http://${opts.host}:${opts.port}${opts.path}`,
+ );
+ return { adapter, server, stop: () => server.close() };
}
```
----
+### 6.3 `src/msteams/monitor.ts` (proactive dispatch pattern)
-## 9. CLI Integration
+This is the key “Clawdbot-specific” adaptation: don’t do the long LLM run inside the webhook turn.
-### Dependencies (src/cli/deps.ts)
+```ts
+import type { ConversationReference, TurnContext } from "botbuilder";
+import { TurnContext as TurnContextApi } from "botbuilder";
+import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
+import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
+import { loadConfig } from "../config/config.js";
+import { enqueueSystemEvent } from "../infra/system-events.js";
+import { resolveAgentRoute } from "../routing/resolve-route.js";
+import type { RuntimeEnv } from "../runtime.js";
+import { saveConversationReference } from "./conversation-store.js";
+import { startMSTeamsWebhook } from "./webhook.js";
-```typescript
-export type CliDeps = {
- sendMessageWhatsApp: typeof sendMessageWhatsApp;
- sendMessageTelegram: typeof sendMessageTelegram;
- sendMessageDiscord: typeof sendMessageDiscord;
- sendMessageSlack: typeof sendMessageSlack;
- sendMessageSignal: typeof sendMessageSignal;
- sendMessageIMessage: typeof sendMessageIMessage;
-};
+export async function monitorMSTeamsProvider(opts: {
+ runtime?: RuntimeEnv;
+ abortSignal?: AbortSignal;
+}) {
+ const cfg = loadConfig();
+ const runtime = opts.runtime;
+ if (cfg.msteams?.enabled === false) return;
-export function createDefaultDeps(): CliDeps {
- return {
- sendMessageWhatsApp,
- sendMessageTelegram,
- // ...
- };
+ const host = cfg.msteams?.webhook?.host ?? "0.0.0.0";
+ const port = cfg.msteams?.webhook?.port ?? 3978;
+ const path = cfg.msteams?.webhook?.path ?? "/msteams/messages";
+
+ const seen = new Map(); // activity de-dupe
+ const ttlMs = 2 * 60_000;
+
+ const { adapter, stop } = await startMSTeamsWebhook({
+ host,
+ port,
+ path,
+ runtime:
+ runtime ?? { log: console.log, error: console.error, exit: process.exit as any },
+ onTurn: (adapter) => async (ctxAny) => {
+ const context = ctxAny as TurnContext;
+ if (context.activity.type !== "message") return;
+ if (
+ !context.activity.text &&
+ (!context.activity.attachments ||
+ context.activity.attachments.length === 0)
+ )
+ return;
+
+ const activity = context.activity;
+ const convoId = activity.conversation?.id ?? "unknown";
+ const activityId = activity.id ?? "unknown";
+ const dedupeKey = `${convoId}:${activityId}`;
+ const now = Date.now();
+ for (const [key, ts] of seen) if (now - ts > ttlMs) seen.delete(key);
+ if (seen.has(dedupeKey)) return;
+ seen.set(dedupeKey, now);
+
+ const reference: ConversationReference =
+ TurnContextApi.getConversationReference(activity);
+ saveConversationReference(convoId, reference).catch(() => {});
+
+ // Kick off the long-running work without blocking the webhook request:
+ void (async () => {
+ const cfg = loadConfig();
+ const route = resolveAgentRoute({
+ cfg,
+ provider: "msteams",
+ teamId: (activity.channelData as any)?.team?.id ?? undefined,
+ peer: {
+ kind:
+ (activity.conversation as any)?.conversationType === "channel"
+ ? "channel"
+ : "dm",
+ id:
+ (activity.from as any)?.aadObjectId ??
+ activity.from?.id ??
+ "unknown",
+ },
+ });
+
+ enqueueSystemEvent(
+ `Teams message: ${String(activity.text ?? "").slice(0, 160)}`,
+ {
+ sessionKey: route.sessionKey,
+ contextKey: `msteams:message:${convoId}:${activityId}`,
+ },
+ );
+
+ const appId =
+ cfg.msteams?.appId ??
+ process.env.MSTEAMS_APP_ID ??
+ process.env.MicrosoftAppId ??
+ "";
+
+ const { dispatcher, replyOptions, markDispatchIdle } =
+ createReplyDispatcherWithTyping({
+ responsePrefix: cfg.messages?.responsePrefix,
+ onReplyStart: async () => {
+ // typing indicator
+ await adapter.continueConversationAsync(appId, reference, async (ctx) => {
+ await (ctx as any).sendActivity({ type: "typing" });
+ });
+ },
+ deliver: async (payload) => {
+ await adapter.continueConversationAsync(appId, reference, async (ctx) => {
+ await (ctx as any).sendActivity(payload.text ?? "");
+ });
+ },
+ onError: (err, info) => {
+ runtime?.error?.(`msteams ${info.kind} reply failed: ${String(err)}`);
+ },
+ });
+
+ const ctxPayload = {
+ Provider: "msteams" as const,
+ Surface: "msteams" as const,
+ From: `msteams:${activity.from?.id ?? "unknown"}`,
+ To: `conversation:${convoId}`,
+ SessionKey: route.sessionKey,
+ AccountId: route.accountId,
+ ChatType:
+ (activity.conversation as any)?.conversationType === "channel"
+ ? "room"
+ : "direct",
+ MessageSid: activityId,
+ ReplyToId: activity.replyToId ?? activityId,
+ Timestamp: activity.timestamp ? Date.parse(String(activity.timestamp)) : undefined,
+ Body: String(activity.text ?? ""),
+ };
+
+ await dispatchReplyFromConfig({
+ ctx: ctxPayload as any,
+ cfg,
+ dispatcher,
+ replyOptions,
+ });
+ markDispatchIdle();
+ })().catch((err) => runtime?.error?.(String(err)));
+ },
+ });
+
+ const shutdown = () => stop();
+ opts.abortSignal?.addEventListener("abort", shutdown, { once: true });
}
```
-### Send Command (src/commands/send.ts)
+### 6.4 Attachment download (Teams file attachments)
-```typescript
-const provider = (opts.provider ?? "whatsapp").toLowerCase();
+Teams commonly sends file uploads as an attachment with content type:
-// Provider-specific delivery
-const results = await deliverOutboundPayloads({
- cfg: loadConfig(),
- provider,
- to: resolvedTarget.to,
- payloads: [{ text: opts.message, mediaUrl: opts.media }],
- deps: {
- sendSlack: deps.sendMessageSlack,
- // ...
- },
-});
-```
+- `application/vnd.microsoft.teams.file.download.info`
----
+The `downloadUrl` is the URL to fetch (often time-limited). A minimal helper:
-## 10. Files to Create/Modify for MS Teams
-
-### New Files (src/msteams/)
-
-```
-src/msteams/
-├── index.ts # Exports
-├── monitor.ts # Bot Framework event loop
-├── send.ts # Send via Graph API
-├── probe.ts # Health check (Graph API /me)
-├── token.ts # Token resolution
-├── actions.ts # Optional: reactions, edits, etc.
-└── *.test.ts # Tests
-```
-
-### Files to Modify
-
-| File | Changes |
-|------|---------|
-| `src/config/types.ts` | Add `MSTeamsConfig`, update `QueueModeByProvider`, `AgentElevatedAllowFromConfig`, `HookMappingConfig` |
-| `src/config/zod-schema.ts` | Add `MSTeamsConfigSchema` |
-| `src/gateway/server-providers.ts` | Add `MSTeamsRuntimeStatus`, lifecycle methods, update `ProviderRuntimeSnapshot`, `ProviderManager` |
-| `src/gateway/server.ts` | Add logger, runtimeEnv, pass to provider manager |
-| `src/gateway/config-reload.ts` | Add reload rule |
-| `src/gateway/server-methods/providers.ts` | Add status endpoint |
-| `src/cli/deps.ts` | Add `sendMessageMSTeams` |
-| `src/cli/program.ts` | Add to `--provider` options |
-| `src/commands/send.ts` | Add msteams case |
-| `src/commands/onboard-providers.ts` | Add wizard flow |
-| `src/commands/onboard-types.ts` | Add to `ProviderChoice` |
-| `docs/providers/msteams.md` | Documentation |
-
----
-
-## 11. MS Teams SDK Options
-
-### Option A: Bot Framework SDK (@microsoft/botframework)
-
-```typescript
-import { CloudAdapter, ConfigurationBotFrameworkAuthentication } from "botbuilder";
-
-// Pros: Full-featured, handles auth, typing indicators, cards
-// Cons: More complex, requires Azure Bot registration
-```
-
-### Option B: Microsoft Graph API
-
-```typescript
-import { Client } from "@microsoft/microsoft-graph-client";
-
-// Pros: Simpler for basic messaging, direct API access
-// Cons: Less rich features, manual auth handling
-```
-
-### Recommended: Bot Framework for receiving, Graph for some sends
-
-MS Teams bots use the Bot Framework for receiving messages (webhook-based), and can use either Bot Framework or Graph API for sending.
-
-### Required Azure Resources
-
-1. **Azure Bot Registration** - Bot identity and channel configuration
-2. **App Registration** - OAuth for Graph API access
-3. **Teams App Manifest** - Defines bot capabilities in Teams
-
-### Credentials Needed
-
-```typescript
-export type MSTeamsConfig = {
- enabled?: boolean;
- appId?: string; // Azure AD App ID
- appPassword?: string; // Azure AD App Secret
- tenantId?: string; // Optional: restrict to tenant
- // ... rest follows pattern
+```ts
+type TeamsFileDownloadInfo = {
+ downloadUrl?: string;
+ uniqueId?: string;
+ fileType?: string;
};
+
+export function resolveTeamsDownloadUrl(att: {
+ contentType?: string;
+ content?: unknown;
+}): string | null {
+ if (att.contentType !== "application/vnd.microsoft.teams.file.download.info")
+ return null;
+ const content = (att.content ?? {}) as TeamsFileDownloadInfo;
+ const url = typeof content.downloadUrl === "string" ? content.downloadUrl.trim() : "";
+ return url ? url : null;
+}
```
----
-
-## 12. Key Differences from Slack
-
-| Aspect | Slack | MS Teams |
-|--------|-------|----------|
-| Connection | Socket Mode (WebSocket) | Webhook (HTTP POST) |
-| Auth | Bot Token + App Token | Azure AD App ID + Secret |
-| Message ID | `ts` (timestamp) | Activity ID |
-| Threading | `thread_ts` | `replyToId` in conversation |
-| Channels | Channel ID | Channel ID + Team ID |
-| DMs | `conversations.open` | Proactive messaging with conversation reference |
-| Typing | `assistant.threads.setStatus` | `sendTypingActivity()` |
-| Reactions | `reactions.add` | Separate message with reaction |
-| Media | `files.uploadV2` | Attachments in activity |
+Initial recommendation: support this type first; treat other attachment types as “link-only” until needed.
---
-## 13. Implementation Considerations
+## 7) Integration Checklist (Files to Create/Modify)
-### Webhook vs Polling
+### 7.1 New backend files
-MS Teams uses webhooks exclusively (no polling option like Telegram). Need to:
-- Expose HTTP endpoint for Bot Framework
-- Handle activity validation (HMAC signature)
-- Consider tunneling for local dev (ngrok, Tailscale funnel)
+- `src/msteams/*` (new provider implementation; see structure above)
-### Proactive Messaging
+### 7.2 Backend integration points (must update)
-Unlike Slack where you can message any user, Teams requires:
-- User must have interacted with bot first, OR
-- Bot must be installed in team/chat, OR
-- Use Graph API with appropriate permissions
+**Config & validation**
-### Tenant Restrictions
+- `src/config/types.ts` (add `MSTeamsConfig`; extend unions like `QueueModeByProvider`, `AgentElevatedAllowFromConfig`, `HookMappingConfig.provider`)
+- `src/config/zod-schema.ts` (add schema + cross-field validation for `dm.policy="open"` → allowFrom includes `"*"`, etc.)
+- `src/config/schema.ts` (labels + descriptions used by tooling/UI)
-Enterprise Teams often restrict:
-- External app installations
-- Cross-tenant communication
-- Certain API permissions
+**Gateway provider lifecycle**
-Config should support `tenantId` restriction.
+- `src/gateway/server-providers.ts` (runtime status + start/stop + snapshot)
+- `src/gateway/server.ts` (logger + runtime env wiring)
+- `src/gateway/config-reload.ts` (provider kind union + reload rules)
+- `src/gateway/server-methods/providers.ts` (status payload)
+- `src/infra/provider-summary.ts` (optional but recommended: show “Teams configured” in `clawdbot status`)
-### Cards and Adaptive Cards
+**Outbound sending**
-Teams heavily uses Adaptive Cards for rich UI. Consider supporting:
-- Basic text (markdown subset)
-- Adaptive Card JSON
-- Hero Cards for media
+- `src/infra/outbound/targets.ts` (validate `--to` format for Teams)
+- `src/infra/outbound/deliver.ts` (provider caps + handler + result union)
+- `src/infra/outbound/format.ts` (optional: add more metadata fields)
+- `src/commands/send.ts` (treat `msteams` as direct-send provider if we implement `sendMessageMSTeams`)
+- `src/cli/deps.ts` (add `sendMessageMSTeams`)
+- `src/gateway/server-methods/send.ts` (support `provider === "msteams"` for gateway sends)
+
+**Pairing**
+
+- `src/pairing/pairing-store.ts` (add `"msteams"` to `PairingProvider`)
+- `src/cli/pairing-cli.ts` (include provider in CLI; decide whether `--notify` is supported for Teams)
+
+**Onboarding wizard**
+
+- `src/commands/onboard-types.ts` (add `"msteams"` to `ProviderChoice`)
+- `src/commands/onboard-providers.ts` (collect appId/secret/tenant, write config, add primer notes)
+
+**Hooks**
+
+- `src/gateway/hooks.ts` (extend provider allowlist validation: `last|whatsapp|telegram|discord|slack|signal|imessage|msteams`)
+
+**Docs**
+
+- `docs/providers/msteams.md` (Mintlify link conventions apply under `docs/**`)
+
+### 7.3 UI integration points
+
+- `ui/src/ui/ui-types.ts` (provider unions)
+- `ui/src/ui/types.ts` (gateway status typing)
+- `ui/src/ui/controllers/connections.ts` (load/save `msteams` config)
+- `ui/src/ui/app.ts` (form state, validation, UX)
---
-## Next Steps
+## 8) MS Teams Gotchas (Plan for These)
-1. **Research**: MS Teams Bot Framework SDK specifics
-2. **Azure Setup**: Document bot registration process
-3. **Implement**: Start with monitor.ts and basic send
-4. **Test**: Local dev with ngrok/tunnel
-5. **Docs**: Provider setup guide
+1. **Webhook timeouts / retries**: don’t block the webhook while waiting on LLM output; send replies proactively and dedupe inbound activities.
+2. **Proactive messaging requirements**: the app must be installed in the chat/team; and you need a valid conversation reference (or you must create a conversation).
+3. **Threading**: channel replies often need `replyToId` to keep replies in-thread; verify behavior for channel vs chat and standardize.
+4. **Mentions**: Teams message text includes `...`; strip bot mentions before sending to the agent and implement mention gating using `entities`.
+5. **Attachment downloads**: file uploads commonly arrive as `file.download.info` with time-limited URLs; enforce `mediaMaxMb` and handle 403/expired URLs.
+6. **Formatting limits**: Teams markdown is more limited than Slack; assume “plain text + links” for v1, and only later add Adaptive Cards.
+7. **Tenant/admin restrictions**: many orgs restrict custom app install or bot scopes. Expect setup friction; document it clearly.
+8. **Single-tenant default**: multi-tenant bot creation has a deprecation cutoff (2025-07-31); prefer single-tenant in config defaults and docs.
+9. **Incoming webhooks retirement**: Office 365 connectors / incoming webhooks retirement has moved to 2026-03-31; don’t rely on it as the primary integration surface.
+
+---
+
+## References (Current as of 2026-01)
+
+- Bot Framework (Node) CloudAdapter sample: https://raw.githubusercontent.com/microsoft/BotBuilder-Samples/main/samples/javascript_nodejs/02.echo-bot/index.js
+- Teams proactive messaging overview: https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages
+- Teams bot file uploads / downloadUrl attachments: https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
+- CloudAdapter proactive API (`continueConversationAsync`): https://raw.githubusercontent.com/microsoft/botbuilder-js/main/libraries/botbuilder-core/src/cloudAdapterBase.ts
+- Microsoft 365 Agents SDK (Node/TS): https://raw.githubusercontent.com/microsoft/Agents-for-js/main/README.md
+- Office 365 connectors retirement update: https://techcommunity.microsoft.com/blog/microsoftteamsblog/retirement-of-office-365-connectors-within-microsoft-teams/4369576
+
+---
+
+## Next Steps (Actionable Implementation Order)
+
+1. **Pick SDK + add deps**: start with Bot Framework (`botbuilder`) unless you’re ready to bet on Agents SDK; add packages + types in `package.json`.
+2. **Config plumbing**: add `msteams` types + zod schema + schema metadata (`src/config/types.ts`, `src/config/zod-schema.ts`, `src/config/schema.ts`).
+3. **Provider skeleton**: add `src/msteams/index.ts`, `token.ts`, and a stub `monitor.ts` that starts/stops cleanly (abortSignal).
+4. **Webhook + echo**: implement `webhook.ts` + minimal activity handler that logs inbound text and sends a fast “ok” reply (no agent yet).
+5. **Conversation store**: persist `ConversationReference` by `conversation.id` and include tenant/serviceUrl; add a small unit test.
+6. **Agent dispatch (async)**: wire inbound messages to `dispatchReplyFromConfig()` using proactive sends (`continueConversationAsync`) to avoid webhook timeouts.
+7. **Access control**: implement DM policy + pairing (reuse existing pairing store) + mention gating in channels.
+8. **Gateway integration**: add provider manager start/stop/status wiring + config reload rules + hook provider allowlist; ensure gateway status UI reflects it.
+9. **Outbound CLI/gateway sends**: add `sendMessageMSTeams` that targets stored conversation IDs; wire `clawdbot send --provider msteams`.
+10. **Media**: implement inbound attachment download for `file.download.info` and a safe outbound strategy (link-only first, cards later).
+11. **Docs + UI + Onboard**: write `docs/providers/msteams.md`, add a minimal UI config form (appId/secret/tenant + webhook port/path), and update `clawdbot onboard` provider selection.
+12. **Hardening**: add dedupe TTL tuning, better error reporting, probe/health endpoints, and integration tests (`monitor.tool-result.test.ts`).
From 051da852a29ad14e5ad0dbc0ea07117f5d66b8dc Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 19:35:14 +0300
Subject: [PATCH 007/152] docs: rename to msteams-implementation-guide.md
---
...teams-provider-research.md => msteams-implementation-guide.md} | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename tmp/{msteams-provider-research.md => msteams-implementation-guide.md} (100%)
diff --git a/tmp/msteams-provider-research.md b/tmp/msteams-implementation-guide.md
similarity index 100%
rename from tmp/msteams-provider-research.md
rename to tmp/msteams-implementation-guide.md
From 8e02c53df2151510bd94be79d51a90f29a6dbaf8 Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 19:39:13 +0300
Subject: [PATCH 008/152] docs: add Azure Bot setup instructions
Added section 2.5 with step-by-step Azure setup:
- Create Azure Bot (single-tenant)
- Get credentials (appId, appPassword, tenantId)
- Configure messaging endpoint
- Enable Teams channel
- Local dev tunnel options (ngrok, Tailscale)
- Teams App manifest template
- Config example
---
tmp/msteams-implementation-guide.md | 120 +++++++++++++++++++++++++++-
1 file changed, 119 insertions(+), 1 deletion(-)
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index be686bad1..c8e891088 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -137,7 +137,125 @@ Practical implication for Clawdbot:
### 2.4 Deprecations / platform shifts to note
- Creation of **new multi-tenant bots** has been announced as deprecated after **2025-07-31** (plan for **single-tenant** by default).
-- Office 365 connectors / incoming webhooks retirement has been extended to **2026-03-31** (don’t build a provider around incoming webhooks; use bots).
+- Office 365 connectors / incoming webhooks retirement has been extended to **2026-03-31** (don't build a provider around incoming webhooks; use bots).
+
+---
+
+## 2.5) Azure Bot Setup (Prerequisites)
+
+Before writing code, set up the Azure Bot resource. This gives you the credentials needed for config.
+
+### Step 1: Create Azure Bot
+
+1. Go to [Azure Portal](https://portal.azure.com) → Create a resource → Search "Azure Bot"
+2. Fill in basics:
+ - **Bot handle**: e.g., `clawdbot-msteams`
+ - **Subscription / Resource Group**: your choice
+ - **Pricing tier**: F0 (free) for dev, S1 for production
+ - **Type of App**: **Single Tenant** (recommended - multi-tenant deprecated after 2025-07-31)
+ - **Creation type**: "Create new Microsoft App ID"
+3. Click Create and wait for deployment
+
+### Step 2: Get Credentials
+
+After the bot is created:
+
+1. Go to your Azure Bot resource → **Configuration**
+2. Copy **Microsoft App ID** → this is your `appId`
+3. Click "Manage Password" → go to the App Registration
+4. Under **Certificates & secrets** → New client secret → copy the **Value** → this is your `appPassword`
+5. Go to **Overview** → copy **Directory (tenant) ID** → this is your `tenantId`
+
+### Step 3: Configure Messaging Endpoint
+
+1. In Azure Bot → **Configuration**
+2. Set **Messaging endpoint** to your webhook URL:
+ - Production: `https://your-domain.com/msteams/messages`
+ - Local dev: Use a tunnel (see below)
+
+### Step 4: Enable Teams Channel
+
+1. In Azure Bot → **Channels**
+2. Click **Microsoft Teams** → Configure → Save
+3. Accept the Terms of Service
+
+### Step 5: Local Development (Tunnel)
+
+Teams can't reach `localhost`. Options:
+
+**Option A: ngrok**
+```bash
+ngrok http 3978
+# Copy the https URL, e.g., https://abc123.ngrok.io
+# Set messaging endpoint to: https://abc123.ngrok.io/msteams/messages
+```
+
+**Option B: Tailscale Funnel**
+```bash
+tailscale funnel 3978
+# Use your Tailscale funnel URL as the messaging endpoint
+```
+
+### Step 6: Create Teams App (for installation)
+
+To install the bot in Teams, you need an app manifest:
+
+1. Create `manifest.json`:
+```json
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.16",
+ "version": "1.0.0",
+ "id": "",
+ "packageName": "com.clawdbot.msteams",
+ "developer": {
+ "name": "Your Name",
+ "websiteUrl": "https://clawd.bot",
+ "privacyUrl": "https://clawd.bot/privacy",
+ "termsOfUseUrl": "https://clawd.bot/terms"
+ },
+ "name": { "short": "Clawdbot", "full": "Clawdbot MS Teams" },
+ "description": { "short": "AI assistant", "full": "Clawdbot AI assistant for Teams" },
+ "icons": { "outline": "outline.png", "color": "color.png" },
+ "accentColor": "#FF4500",
+ "bots": [
+ {
+ "botId": "",
+ "scopes": ["personal", "team", "groupChat"],
+ "supportsFiles": true,
+ "isNotificationOnly": false
+ }
+ ],
+ "permissions": ["identity", "messageTeamMembers"],
+ "validDomains": []
+}
+```
+
+2. Add 32x32 `outline.png` and 192x192 `color.png` icons
+3. Zip all three files into `clawdbot-teams.zip`
+4. In Teams → Apps → Manage your apps → Upload a custom app → Upload `clawdbot-teams.zip`
+
+### Credentials Summary
+
+After setup, you'll have:
+
+| Config Field | Source |
+|--------------|--------|
+| `appId` | Azure Bot → Configuration → Microsoft App ID |
+| `appPassword` | App Registration → Certificates & secrets → Client secret value |
+| `tenantId` | App Registration → Overview → Directory (tenant) ID |
+
+Add these to your Clawdbot config:
+```yaml
+msteams:
+ enabled: true
+ appId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ appPassword: "your-client-secret"
+ tenantId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ webhook:
+ port: 3978
+ path: /msteams/messages
+```
---
From 0bb9756e0cc6f14239a92e5e6b0b5bfd263d793b Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 19:42:45 +0300
Subject: [PATCH 009/152] docs: add useful links to Azure setup section [skip
ci]
---
tmp/msteams-implementation-guide.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index c8e891088..6cb29b6ed 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -257,6 +257,16 @@ msteams:
path: /msteams/messages
```
+### Useful Links
+
+- [Azure Portal](https://portal.azure.com)
+- [Create Azure Bot](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration)
+- [Bot Framework Overview](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-overview)
+- [Create Teams Bot](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-for-teams)
+- [Teams App Manifest Schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema)
+- [ngrok](https://ngrok.com) - local dev tunneling
+- [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) - alternative tunnel
+
---
## 3) Recommended Architecture for Clawdbot
From 7274d6e757dff96724c1baf229ae655bfe4ab31a Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 19:50:25 +0300
Subject: [PATCH 010/152] docs: add detailed Azure Bot creation fields [skip
ci]
- Direct link to Azure Bot creation page
- Field-by-field table for Project details
- Pricing tier options
- Microsoft App ID settings (Single Tenant, Create new)
- Note about SDK version requirement
---
tmp/msteams-implementation-guide.md | 38 +++++++++++++++++++++++------
1 file changed, 30 insertions(+), 8 deletions(-)
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index 6cb29b6ed..962904b59 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -147,14 +147,36 @@ Before writing code, set up the Azure Bot resource. This gives you the credentia
### Step 1: Create Azure Bot
-1. Go to [Azure Portal](https://portal.azure.com) → Create a resource → Search "Azure Bot"
-2. Fill in basics:
- - **Bot handle**: e.g., `clawdbot-msteams`
- - **Subscription / Resource Group**: your choice
- - **Pricing tier**: F0 (free) for dev, S1 for production
- - **Type of App**: **Single Tenant** (recommended - multi-tenant deprecated after 2025-07-31)
- - **Creation type**: "Create new Microsoft App ID"
-3. Click Create and wait for deployment
+1. Go to [Create Azure Bot](https://portal.azure.com/#create/Microsoft.AzureBot) (direct link)
+
+2. **Basics tab - Project details:**
+
+ | Field | Value |
+ |-------|-------|
+ | **Bot handle** | Your bot name, e.g., `clawdbot-msteams` (must be unique) |
+ | **Subscription** | Select your Azure subscription |
+ | **Resource group** | Create new or use existing (e.g., `Bots`) |
+ | **New resource group location** | Choose nearest region (e.g., `West Europe`) |
+ | **Data residency** | **Regional** (recommended for GDPR compliance) or Global |
+ | **Region** | Same as resource group location |
+
+3. **Basics tab - Pricing:**
+
+ | Field | Value |
+ |-------|-------|
+ | **Pricing tier** | **Free** for dev/testing, Standard for production |
+
+4. **Basics tab - Microsoft App ID:**
+
+ | Field | Value |
+ |-------|-------|
+ | **Type of App** | **Single Tenant** (recommended - multi-tenant deprecated after 2025-07-31) |
+ | **Creation type** | **Create new Microsoft App ID** |
+ | **Service management reference** | Leave empty |
+
+ > **Note:** Single Tenant requires BotFramework SDK 4.15.0 or higher (we'll use 4.23+)
+
+5. Click **Review + create** → **Create** and wait for deployment (~1-2 minutes)
### Step 2: Get Credentials
From d9cbecac7f421d57d50c7a7d12a930e614ad93c0 Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 21:29:39 +0300
Subject: [PATCH 011/152] feat(msteams): add MS Teams provider skeleton
- Add Microsoft 365 Agents SDK packages (@microsoft/agents-hosting,
@microsoft/agents-hosting-express, @microsoft/agents-hosting-extensions-teams)
- Add MSTeamsConfig type and zod schema
- Create src/msteams/ provider with monitor, token, send, probe
- Wire provider into gateway (server-providers.ts, server.ts)
- Add msteams to all provider type unions (hooks, queue, cron, etc.)
- Update implementation guide with new SDK and progress
---
package.json | 5 +-
pnpm-lock.yaml | 248 ++++++++++++++++++++++++++++
src/config/types.ts | 74 ++++++++-
src/config/zod-schema.ts | 50 +++++-
src/cron/isolated-agent.ts | 3 +-
src/cron/types.ts | 3 +-
src/gateway/hooks-mapping.ts | 6 +-
src/gateway/server-http.ts | 3 +-
src/gateway/server-providers.ts | 105 ++++++++++++
src/gateway/server.ts | 9 +-
src/msteams/index.ts | 4 +
src/msteams/monitor.ts | 111 +++++++++++++
src/msteams/probe.ts | 23 +++
src/msteams/send.ts | 25 +++
src/msteams/token.ts | 23 +++
tmp/msteams-implementation-guide.md | 57 ++++---
16 files changed, 708 insertions(+), 41 deletions(-)
create mode 100644 src/msteams/index.ts
create mode 100644 src/msteams/monitor.ts
create mode 100644 src/msteams/probe.ts
create mode 100644 src/msteams/send.ts
create mode 100644 src/msteams/token.ts
diff --git a/package.json b/package.json
index ef1b0ca1e..fa1861538 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
- "version": "2026.1.9",
+ "version": "2026.1.8-2",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -101,6 +101,9 @@
"@mariozechner/pi-ai": "^0.41.0",
"@mariozechner/pi-coding-agent": "^0.41.0",
"@mariozechner/pi-tui": "^0.41.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 b89d4f599..5e4aa8faa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -43,6 +43,15 @@ importers:
'@mariozechner/pi-tui':
specifier: ^0.41.0
version: 0.41.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
@@ -252,6 +261,26 @@ packages:
zod:
optional: true
+ '@azure/abort-controller@2.1.2':
+ resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==}
+ engines: {node: '>=18.0.0'}
+
+ '@azure/core-auth@1.10.1':
+ resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==}
+ engines: {node: '>=20.0.0'}
+
+ '@azure/core-util@1.13.1':
+ resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==}
+ engines: {node: '>=20.0.0'}
+
+ '@azure/msal-common@15.13.3':
+ resolution: {integrity: sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==}
+ engines: {node: '>=0.8.0'}
+
+ '@azure/msal-node@3.8.4':
+ resolution: {integrity: sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==}
+ engines: {node: '>=16'}
+
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -830,6 +859,22 @@ packages:
resolution: {integrity: sha512-FxhNyQfsQvZJBbUIPbtvBzF8yJo2JjEXVksn5cUU8Qphw8z1Uf+bRXeleH7Q7VVvGnaH9zJR3r2cfkaWxC1Jig==}
engines: {node: '>=20.0.0'}
+ '@microsoft/agents-activity@1.1.1':
+ resolution: {integrity: sha512-L7PHEHKFge99aIxV9eA7uFY3n9goYKzxcWaqLXGmxq3wMsau8hdsPzZgpV77LOQWQynLO3M5cbD8AavcVZszlQ==}
+ engines: {node: '>=20.0.0'}
+
+ '@microsoft/agents-hosting-express@1.1.1':
+ resolution: {integrity: sha512-CDStIx23U2zyS/4nZoeVgrVlVbQ+EasoqR2dLq7IfU4rUyuUrKGPdlO55rcfS6Z/spLkhCnX35jbD6EBqrTkJg==}
+ engines: {node: '>=20.0.0'}
+
+ '@microsoft/agents-hosting-extensions-teams@1.1.1':
+ resolution: {integrity: sha512-ibwwEIJEKyx0VWMDPbvMRgbk97BXDij0qYIxsn1NNPrdzu6uY/33ZW0NF8eLKiJ/fVihIFGEFDeOwoE5R2bXZA==}
+ engines: {node: '>=20.0.0'}
+
+ '@microsoft/agents-hosting@1.1.1':
+ resolution: {integrity: sha512-ZO/BU0d/NxSlbg/W4SvtHDvwS4GDYrMG5CpBh+m2vnqkl6tphM0kkfbSYZFef0BoftrinOdPZcSvdvmVqpbM2w==}
+ engines: {node: '>=20.0.0'}
+
'@mistralai/mistralai@1.10.0':
resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==}
@@ -1250,9 +1295,15 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/express-serve-static-core@4.19.7':
+ resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==}
+
'@types/express-serve-static-core@5.1.0':
resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==}
+ '@types/express@4.17.25':
+ resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
+
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
@@ -1277,6 +1328,9 @@ packages:
'@types/mime-types@2.1.4':
resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
+ '@types/mime@1.3.5':
+ resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
@@ -1310,9 +1364,15 @@ packages:
'@types/retry@0.12.5':
resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
+ '@types/send@0.17.6':
+ resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==}
+
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
+ '@types/serve-static@1.15.10':
+ resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==}
+
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
@@ -1322,6 +1382,10 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+ '@typespec/ts-http-runtime@0.3.2':
+ resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==}
+ engines: {node: '>=20.0.0'}
+
'@vitest/browser-playwright@4.0.16':
resolution: {integrity: sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==}
peerDependencies:
@@ -2000,6 +2064,10 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
+ http-proxy-agent@7.0.2:
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+ engines: {node: '>= 14'}
+
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -2087,6 +2155,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jose@4.15.9:
+ resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
+
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@@ -2124,6 +2195,10 @@ packages:
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+ jwks-rsa@3.2.0:
+ resolution: {integrity: sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==}
+ engines: {node: '>=14'}
+
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
@@ -2207,6 +2282,9 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
+ limiter@1.1.5:
+ resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
+
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
@@ -2219,6 +2297,9 @@ packages:
lit@3.3.2:
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
+ lodash.clonedeep@4.5.0:
+ resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
+
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
@@ -2256,6 +2337,13 @@ packages:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
+ lru-cache@6.0.0:
+ resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
+ engines: {node: '>=10'}
+
+ lru-memoizer@2.3.0:
+ resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
+
lucide@0.544.0:
resolution: {integrity: sha512-U5ORwr5z9Sx7bNTDFaW55RbjVdQEnAcT3vws9uz3vRT1G4XXJUDAhRZdxhFoIyHEvjmTkzzlEhjSLYM5n4mb5w==}
@@ -2409,6 +2497,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
+ object-path@0.11.8:
+ resolution: {integrity: sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==}
+ engines: {node: '>= 10.12.0'}
+
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@@ -2983,6 +3075,14 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ uuid@11.1.0:
+ resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
+ hasBin: true
+
+ uuid@8.3.2:
+ resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ hasBin: true
+
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -3131,6 +3231,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
+ yallist@4.0.0:
+ resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
@@ -3149,6 +3252,9 @@ packages:
peerDependencies:
zod: ^3.25 || ^4
+ zod@3.25.75:
+ resolution: {integrity: sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==}
+
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -3163,6 +3269,34 @@ snapshots:
optionalDependencies:
zod: 4.3.5
+ '@azure/abort-controller@2.1.2':
+ dependencies:
+ tslib: 2.8.1
+
+ '@azure/core-auth@1.10.1':
+ dependencies:
+ '@azure/abort-controller': 2.1.2
+ '@azure/core-util': 1.13.1
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@azure/core-util@1.13.1':
+ dependencies:
+ '@azure/abort-controller': 2.1.2
+ '@typespec/ts-http-runtime': 0.3.2
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@azure/msal-common@15.13.3': {}
+
+ '@azure/msal-node@3.8.4':
+ dependencies:
+ '@azure/msal-common': 15.13.3
+ jsonwebtoken: 9.0.3
+ uuid: 8.3.2
+
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -3675,6 +3809,42 @@ snapshots:
marked: 15.0.12
mime-types: 3.0.2
+ '@microsoft/agents-activity@1.1.1':
+ dependencies:
+ debug: 4.4.3
+ uuid: 11.1.0
+ zod: 3.25.75
+ transitivePeerDependencies:
+ - supports-color
+
+ '@microsoft/agents-hosting-express@1.1.1':
+ dependencies:
+ '@microsoft/agents-hosting': 1.1.1
+ express: 5.2.1
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ '@microsoft/agents-hosting-extensions-teams@1.1.1':
+ dependencies:
+ '@microsoft/agents-hosting': 1.1.1
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ '@microsoft/agents-hosting@1.1.1':
+ dependencies:
+ '@azure/core-auth': 1.10.1
+ '@azure/msal-node': 3.8.4
+ '@microsoft/agents-activity': 1.1.1
+ axios: 1.13.2
+ jsonwebtoken: 9.0.3
+ jwks-rsa: 3.2.0
+ object-path: 0.11.8
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
'@mistralai/mistralai@1.10.0':
dependencies:
zod: 3.25.76
@@ -4029,6 +4199,13 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/express-serve-static-core@4.19.7':
+ dependencies:
+ '@types/node': 25.0.3
+ '@types/qs': 6.14.0
+ '@types/range-parser': 1.2.7
+ '@types/send': 1.2.1
+
'@types/express-serve-static-core@5.1.0':
dependencies:
'@types/node': 25.0.3
@@ -4036,6 +4213,13 @@ snapshots:
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
+ '@types/express@4.17.25':
+ dependencies:
+ '@types/body-parser': 1.19.6
+ '@types/express-serve-static-core': 4.19.7
+ '@types/qs': 6.14.0
+ '@types/serve-static': 1.15.10
+
'@types/express@5.0.6':
dependencies:
'@types/body-parser': 1.19.6
@@ -4062,6 +4246,8 @@ snapshots:
'@types/mime-types@2.1.4': {}
+ '@types/mime@1.3.5': {}
+
'@types/ms@2.1.0': {}
'@types/node@10.17.60': {}
@@ -4093,10 +4279,21 @@ snapshots:
'@types/retry@0.12.5': {}
+ '@types/send@0.17.6':
+ dependencies:
+ '@types/mime': 1.3.5
+ '@types/node': 25.0.3
+
'@types/send@1.2.1':
dependencies:
'@types/node': 25.0.3
+ '@types/serve-static@1.15.10':
+ dependencies:
+ '@types/http-errors': 2.0.5
+ '@types/node': 25.0.3
+ '@types/send': 0.17.6
+
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
@@ -4108,6 +4305,14 @@ snapshots:
dependencies:
'@types/node': 25.0.3
+ '@typespec/ts-http-runtime@0.3.2':
+ dependencies:
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
'@vitest/browser-playwright@4.0.16(playwright@1.57.0)(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)':
dependencies:
'@vitest/browser': 4.0.16(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)
@@ -4905,6 +5110,13 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
+ http-proxy-agent@7.0.2:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -4983,6 +5195,8 @@ snapshots:
jiti@2.6.1: {}
+ jose@4.15.9: {}
+
js-base64@3.7.8: {}
js-tokens@4.0.0:
@@ -5031,6 +5245,17 @@ snapshots:
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
+ jwks-rsa@3.2.0:
+ dependencies:
+ '@types/express': 4.17.25
+ '@types/jsonwebtoken': 9.0.10
+ debug: 4.4.3
+ jose: 4.15.9
+ limiter: 1.1.5
+ lru-memoizer: 2.3.0
+ transitivePeerDependencies:
+ - supports-color
+
jws@4.0.1:
dependencies:
jwa: 2.0.1
@@ -5098,6 +5323,8 @@ snapshots:
lightningcss-win32-x64-msvc: 1.30.2
optional: true
+ limiter@1.1.5: {}
+
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
@@ -5118,6 +5345,8 @@ snapshots:
lit-element: 4.2.2
lit-html: 3.3.2
+ lodash.clonedeep@4.5.0: {}
+
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
@@ -5142,6 +5371,15 @@ snapshots:
lru-cache@11.2.4: {}
+ lru-cache@6.0.0:
+ dependencies:
+ yallist: 4.0.0
+
+ lru-memoizer@2.3.0:
+ dependencies:
+ lodash.clonedeep: 4.5.0
+ lru-cache: 6.0.0
+
lucide@0.544.0: {}
lucide@0.562.0: {}
@@ -5271,6 +5509,8 @@ snapshots:
object-inspect@1.13.4: {}
+ object-path@0.11.8: {}
+
obug@2.1.1: {}
ogg-opus-decoder@1.7.3:
@@ -5936,6 +6176,10 @@ snapshots:
util-deprecate@1.0.2: {}
+ uuid@11.1.0: {}
+
+ uuid@8.3.2: {}
+
vary@1.1.2: {}
vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
@@ -6044,6 +6288,8 @@ snapshots:
y18n@5.0.8: {}
+ yallist@4.0.0: {}
+
yaml@2.8.2: {}
yargs-parser@20.2.9: {}
@@ -6066,6 +6312,8 @@ snapshots:
dependencies:
zod: 4.3.5
+ zod@3.25.75: {}
+
zod@3.25.76: {}
zod@4.3.5: {}
diff --git a/src/config/types.ts b/src/config/types.ts
index 1ccc17df6..372e936d4 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -87,6 +87,7 @@ export type AgentElevatedAllowFromConfig = {
slack?: Array;
signal?: Array;
imessage?: Array;
+ msteams?: Array;
webchat?: Array;
};
@@ -214,7 +215,8 @@ export type HookMappingConfig = {
| "discord"
| "slack"
| "signal"
- | "imessage";
+ | "imessage"
+ | "msteams";
to?: string;
/** Override model for this hook (provider/model or alias). */
model?: string;
@@ -569,6 +571,64 @@ export type SignalConfig = {
accounts?: Record;
} & SignalAccountConfig;
+export type MSTeamsWebhookConfig = {
+ /** Port for the webhook server. Default: 3978. */
+ port?: number;
+ /** Path for the messages endpoint. Default: /api/messages. */
+ path?: string;
+};
+
+/** Reply style for MS Teams messages. */
+export type MSTeamsReplyStyle = "thread" | "top-level";
+
+/** Channel-level config for MS Teams. */
+export type MSTeamsChannelConfig = {
+ /** Require @mention to respond. Default: true. */
+ requireMention?: boolean;
+ /** Reply style: "thread" replies to the message, "top-level" posts a new message. */
+ replyStyle?: MSTeamsReplyStyle;
+};
+
+/** Team-level config for MS Teams. */
+export type MSTeamsTeamConfig = {
+ /** Default requireMention for channels in this team. */
+ requireMention?: boolean;
+ /** Default reply style for channels in this team. */
+ replyStyle?: MSTeamsReplyStyle;
+ /** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
+ channels?: Record;
+};
+
+export type MSTeamsConfig = {
+ /** If false, do not start the MS Teams provider. Default: true. */
+ enabled?: boolean;
+ /** Azure Bot App ID (from Azure Bot registration). */
+ appId?: string;
+ /** Azure Bot App Password / Client Secret. */
+ appPassword?: string;
+ /** Azure AD Tenant ID (for single-tenant bots). */
+ tenantId?: string;
+ /** Webhook server configuration. */
+ webhook?: MSTeamsWebhookConfig;
+ /** Direct message access policy (default: pairing). */
+ dmPolicy?: DmPolicy;
+ /** Allowlist for DM senders (AAD object IDs or UPNs). */
+ allowFrom?: Array;
+ /** Outbound text chunk size (chars). Default: 4000. */
+ textChunkLimit?: number;
+ /**
+ * Allowed host suffixes for inbound attachment downloads.
+ * Use ["*"] to allow any host (not recommended).
+ */
+ mediaAllowHosts?: Array;
+ /** Default: require @mention to respond in channels/groups. */
+ requireMention?: boolean;
+ /** Default reply style: "thread" replies to the message, "top-level" posts a new message. */
+ replyStyle?: MSTeamsReplyStyle;
+ /** Per-team config. Key is team ID (from the /team/ URL path segment). */
+ teams?: Record;
+};
+
export type IMessageAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -631,6 +691,7 @@ export type QueueModeByProvider = {
slack?: QueueMode;
signal?: QueueMode;
imessage?: QueueMode;
+ msteams?: QueueMode;
webchat?: QueueMode;
};
@@ -875,13 +936,6 @@ export type GatewayTailscaleConfig = {
export type GatewayRemoteConfig = {
/** Remote Gateway WebSocket URL (ws:// or wss://). */
url?: string;
- /**
- * Remote gateway over SSH, forwarding the gateway port to localhost.
- * Format: "user@host" or "user@host:port" (port defaults to 22).
- */
- sshTarget?: string;
- /** Optional SSH identity file path. */
- sshIdentity?: string;
/** Token for remote auth (when the gateway requires token auth). */
token?: string;
/** Password for remote auth (when the gateway requires password auth). */
@@ -1126,7 +1180,7 @@ export type ClawdbotConfig = {
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
- /** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */
+ /** Delivery target (last|whatsapp|telegram|discord|signal|imessage|msteams|none). */
target?:
| "last"
| "whatsapp"
@@ -1135,6 +1189,7 @@ export type ClawdbotConfig = {
| "slack"
| "signal"
| "imessage"
+ | "msteams"
| "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
@@ -1225,6 +1280,7 @@ export type ClawdbotConfig = {
slack?: SlackConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
+ msteams?: MSTeamsConfig;
cron?: CronConfig;
hooks?: HooksConfig;
bridge?: BridgeConfig;
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 462a54cda..7396417c2 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -109,6 +109,8 @@ const requireOpenAllowFrom = (params: {
});
};
+const MSTeamsReplyStyleSchema = z.enum(["thread", "top-level"]);
+
const RetryConfigSchema = z
.object({
attempts: z.number().int().min(1).optional(),
@@ -126,6 +128,7 @@ const QueueModeBySurfaceSchema = z
slack: QueueModeSchema.optional(),
signal: QueueModeSchema.optional(),
imessage: QueueModeSchema.optional(),
+ msteams: QueueModeSchema.optional(),
webchat: QueueModeSchema.optional(),
})
.optional();
@@ -455,6 +458,48 @@ const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
});
});
+const MSTeamsChannelSchema = z.object({
+ requireMention: z.boolean().optional(),
+ replyStyle: MSTeamsReplyStyleSchema.optional(),
+});
+
+const MSTeamsTeamSchema = z.object({
+ requireMention: z.boolean().optional(),
+ replyStyle: MSTeamsReplyStyleSchema.optional(),
+ channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
+});
+
+const MSTeamsConfigSchema = z
+ .object({
+ enabled: z.boolean().optional(),
+ appId: z.string().optional(),
+ appPassword: z.string().optional(),
+ tenantId: z.string().optional(),
+ webhook: z
+ .object({
+ port: z.number().int().positive().optional(),
+ path: z.string().optional(),
+ })
+ .optional(),
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
+ allowFrom: z.array(z.string()).optional(),
+ textChunkLimit: z.number().int().positive().optional(),
+ mediaAllowHosts: z.array(z.string()).optional(),
+ requireMention: z.boolean().optional(),
+ replyStyle: MSTeamsReplyStyleSchema.optional(),
+ teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
+ })
+ .superRefine((value, ctx) => {
+ requireOpenAllowFrom({
+ policy: value.dmPolicy,
+ allowFrom: value.allowFrom,
+ ctx,
+ path: ["allowFrom"],
+ message:
+ 'msteams.dmPolicy="open" requires msteams.allowFrom to include "*"',
+ });
+ });
+
const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@@ -742,6 +787,7 @@ const HookMappingSchema = z
z.literal("slack"),
z.literal("signal"),
z.literal("imessage"),
+ z.literal("msteams"),
])
.optional(),
to: z.string().optional(),
@@ -1049,6 +1095,7 @@ export const ClawdbotSchema = z.object({
slack: z.array(z.union([z.string(), z.number()])).optional(),
signal: z.array(z.union([z.string(), z.number()])).optional(),
imessage: z.array(z.union([z.string(), z.number()])).optional(),
+ msteams: z.array(z.union([z.string(), z.number()])).optional(),
webchat: z.array(z.union([z.string(), z.number()])).optional(),
})
.optional(),
@@ -1205,6 +1252,7 @@ export const ClawdbotSchema = z.object({
slack: SlackConfigSchema.optional(),
signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(),
+ msteams: MSTeamsConfigSchema.optional(),
bridge: z
.object({
enabled: z.boolean().optional(),
@@ -1283,8 +1331,6 @@ export const ClawdbotSchema = z.object({
remote: z
.object({
url: z.string().optional(),
- sshTarget: z.string().optional(),
- sshIdentity: z.string().optional(),
token: z.string().optional(),
password: z.string().optional(),
})
diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts
index 1bac42113..6711850b7 100644
--- a/src/cron/isolated-agent.ts
+++ b/src/cron/isolated-agent.ts
@@ -160,7 +160,8 @@ function resolveDeliveryTarget(
| "discord"
| "slack"
| "signal"
- | "imessage";
+ | "imessage"
+ | "msteams";
to?: string;
},
) {
diff --git a/src/cron/types.ts b/src/cron/types.ts
index 7a0f1009a..1112a4100 100644
--- a/src/cron/types.ts
+++ b/src/cron/types.ts
@@ -23,7 +23,8 @@ export type CronPayload =
| "discord"
| "slack"
| "signal"
- | "imessage";
+ | "imessage"
+ | "msteams";
to?: string;
bestEffortDeliver?: boolean;
};
diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts
index 3216abadd..f71fd465d 100644
--- a/src/gateway/hooks-mapping.ts
+++ b/src/gateway/hooks-mapping.ts
@@ -25,7 +25,8 @@ export type HookMappingResolved = {
| "discord"
| "slack"
| "signal"
- | "imessage";
+ | "imessage"
+ | "msteams";
to?: string;
model?: string;
thinking?: string;
@@ -65,7 +66,8 @@ export type HookAction =
| "discord"
| "slack"
| "signal"
- | "imessage";
+ | "imessage"
+ | "msteams";
to?: string;
model?: string;
thinking?: string;
diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts
index 5f6f1ddbf..4b81261cd 100644
--- a/src/gateway/server-http.ts
+++ b/src/gateway/server-http.ts
@@ -39,7 +39,8 @@ type HookDispatchers = {
| "discord"
| "slack"
| "signal"
- | "imessage";
+ | "imessage"
+ | "msteams";
to?: string;
model?: string;
thinking?: string;
diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts
index a447d89e4..c13016ecf 100644
--- a/src/gateway/server-providers.ts
+++ b/src/gateway/server-providers.ts
@@ -88,6 +88,14 @@ export type IMessageRuntimeStatus = {
dbPath?: string | null;
};
+export type MSTeamsRuntimeStatus = {
+ running: boolean;
+ lastStartAt?: number | null;
+ lastStopAt?: number | null;
+ lastError?: string | null;
+ port?: number | null;
+};
+
export type ProviderRuntimeSnapshot = {
whatsapp: WebProviderStatus;
whatsappAccounts?: Record;
@@ -101,6 +109,7 @@ export type ProviderRuntimeSnapshot = {
signalAccounts?: Record;
imessage: IMessageRuntimeStatus;
imessageAccounts?: Record;
+ msteams: MSTeamsRuntimeStatus;
};
type SubsystemLogger = ReturnType;
@@ -113,12 +122,14 @@ type ProviderManagerOptions = {
logSlack: SubsystemLogger;
logSignal: SubsystemLogger;
logIMessage: SubsystemLogger;
+ logMSTeams: SubsystemLogger;
whatsappRuntimeEnv: RuntimeEnv;
telegramRuntimeEnv: RuntimeEnv;
discordRuntimeEnv: RuntimeEnv;
slackRuntimeEnv: RuntimeEnv;
signalRuntimeEnv: RuntimeEnv;
imessageRuntimeEnv: RuntimeEnv;
+ msteamsRuntimeEnv: RuntimeEnv;
};
export type ProviderManager = {
@@ -136,6 +147,8 @@ export type ProviderManager = {
stopSignalProvider: (accountId?: string) => Promise;
startIMessageProvider: (accountId?: string) => Promise;
stopIMessageProvider: (accountId?: string) => Promise;
+ startMSTeamsProvider: () => Promise;
+ stopMSTeamsProvider: () => Promise;
markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void;
};
@@ -150,12 +163,14 @@ export function createProviderManager(
logSlack,
logSignal,
logIMessage,
+ logMSTeams,
whatsappRuntimeEnv,
telegramRuntimeEnv,
discordRuntimeEnv,
slackRuntimeEnv,
signalRuntimeEnv,
imessageRuntimeEnv,
+ msteamsRuntimeEnv,
} = opts;
const whatsappAborts = new Map();
@@ -164,7 +179,9 @@ export function createProviderManager(
const slackAborts = new Map();
const signalAborts = new Map();
const imessageAborts = new Map();
+ let msteamsAbort: AbortController | null = null;
const whatsappTasks = new Map>();
+ let msteamsTask: Promise | null = null;
const telegramTasks = new Map>();
const discordTasks = new Map>();
const slackTasks = new Map>();
@@ -224,6 +241,13 @@ export function createProviderManager(
cliPath: null,
dbPath: null,
});
+ let msteamsRuntime: MSTeamsRuntimeStatus = {
+ running: false,
+ lastStartAt: null,
+ lastStopAt: null,
+ lastError: null,
+ port: null,
+ };
const updateWhatsAppStatus = (accountId: string, next: WebProviderStatus) => {
whatsappRuntimes.set(accountId, next);
@@ -1026,6 +1050,83 @@ export function createProviderManager(
);
};
+ const startMSTeamsProvider = async () => {
+ if (msteamsTask) return;
+ const cfg = loadConfig();
+ if (!cfg.msteams) {
+ msteamsRuntime = {
+ ...msteamsRuntime,
+ running: false,
+ lastError: "not configured",
+ };
+ if (shouldLogVerbose()) {
+ logMSTeams.debug("msteams provider not configured (no msteams config)");
+ }
+ return;
+ }
+ if (cfg.msteams?.enabled === false) {
+ msteamsRuntime = {
+ ...msteamsRuntime,
+ running: false,
+ lastError: "disabled",
+ };
+ if (shouldLogVerbose()) {
+ logMSTeams.debug("msteams provider disabled (msteams.enabled=false)");
+ }
+ return;
+ }
+ const { monitorMSTeamsProvider } = await import("../msteams/index.js");
+ const port = cfg.msteams?.webhook?.port ?? 3978;
+ logMSTeams.info(`starting provider (port ${port})`);
+ msteamsAbort = new AbortController();
+ msteamsRuntime = {
+ ...msteamsRuntime,
+ running: true,
+ lastStartAt: Date.now(),
+ lastError: null,
+ port,
+ };
+ const task = monitorMSTeamsProvider({
+ cfg,
+ runtime: msteamsRuntimeEnv,
+ abortSignal: msteamsAbort.signal,
+ })
+ .catch((err) => {
+ msteamsRuntime = {
+ ...msteamsRuntime,
+ lastError: formatError(err),
+ };
+ logMSTeams.error(`provider exited: ${formatError(err)}`);
+ })
+ .finally(() => {
+ msteamsAbort = null;
+ msteamsTask = null;
+ msteamsRuntime = {
+ ...msteamsRuntime,
+ running: false,
+ lastStopAt: Date.now(),
+ };
+ });
+ msteamsTask = task;
+ };
+
+ const stopMSTeamsProvider = async () => {
+ if (!msteamsAbort && !msteamsTask) return;
+ msteamsAbort?.abort();
+ try {
+ await msteamsTask;
+ } catch {
+ // ignore
+ }
+ msteamsAbort = null;
+ msteamsTask = null;
+ msteamsRuntime = {
+ ...msteamsRuntime,
+ running: false,
+ lastStopAt: Date.now(),
+ };
+ };
+
const startProviders = async () => {
await startWhatsAppProvider();
await startDiscordProvider();
@@ -1033,6 +1134,7 @@ export function createProviderManager(
await startTelegramProvider();
await startSignalProvider();
await startIMessageProvider();
+ await startMSTeamsProvider();
};
const markWhatsAppLoggedOut = (cleared: boolean, accountId?: string) => {
@@ -1180,6 +1282,7 @@ export function createProviderManager(
signalAccounts,
imessage,
imessageAccounts,
+ msteams: { ...msteamsRuntime },
};
};
@@ -1198,6 +1301,8 @@ export function createProviderManager(
stopSignalProvider,
startIMessageProvider,
stopIMessageProvider,
+ startMSTeamsProvider,
+ stopMSTeamsProvider,
markWhatsAppLoggedOut,
};
}
diff --git a/src/gateway/server.ts b/src/gateway/server.ts
index cfd2a849d..3c244d1ba 100644
--- a/src/gateway/server.ts
+++ b/src/gateway/server.ts
@@ -183,6 +183,7 @@ const logDiscord = logProviders.child("discord");
const logSlack = logProviders.child("slack");
const logSignal = logProviders.child("signal");
const logIMessage = logProviders.child("imessage");
+const logMSTeams = logProviders.child("msteams");
const canvasRuntime = runtimeForLogger(logCanvas);
const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp);
const telegramRuntimeEnv = runtimeForLogger(logTelegram);
@@ -190,6 +191,7 @@ const discordRuntimeEnv = runtimeForLogger(logDiscord);
const slackRuntimeEnv = runtimeForLogger(logSlack);
const signalRuntimeEnv = runtimeForLogger(logSignal);
const imessageRuntimeEnv = runtimeForLogger(logIMessage);
+const msteamsRuntimeEnv = runtimeForLogger(logMSTeams);
type GatewayModelChoice = ModelCatalogEntry;
@@ -501,7 +503,8 @@ export async function startGatewayServer(
| "discord"
| "slack"
| "signal"
- | "imessage";
+ | "imessage"
+ | "msteams";
to?: string;
model?: string;
thinking?: string;
@@ -756,12 +759,14 @@ export async function startGatewayServer(
logSlack,
logSignal,
logIMessage,
+ logMSTeams,
whatsappRuntimeEnv,
telegramRuntimeEnv,
discordRuntimeEnv,
slackRuntimeEnv,
signalRuntimeEnv,
imessageRuntimeEnv,
+ msteamsRuntimeEnv,
});
const {
getRuntimeSnapshot,
@@ -772,12 +777,14 @@ export async function startGatewayServer(
startSlackProvider,
startSignalProvider,
startIMessageProvider,
+ startMSTeamsProvider,
stopWhatsAppProvider,
stopTelegramProvider,
stopDiscordProvider,
stopSlackProvider,
stopSignalProvider,
stopIMessageProvider,
+ stopMSTeamsProvider,
markWhatsAppLoggedOut,
} = providerManager;
diff --git a/src/msteams/index.ts b/src/msteams/index.ts
new file mode 100644
index 000000000..b24578cc9
--- /dev/null
+++ b/src/msteams/index.ts
@@ -0,0 +1,4 @@
+export { monitorMSTeamsProvider } from "./monitor.js";
+export { probeMSTeams } from "./probe.js";
+export { sendMessageMSTeams } from "./send.js";
+export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
new file mode 100644
index 000000000..429f5b733
--- /dev/null
+++ b/src/msteams/monitor.ts
@@ -0,0 +1,111 @@
+import type { ClawdbotConfig } from "../config/types.js";
+import { getChildLogger } from "../logging.js";
+import type { RuntimeEnv } from "../runtime.js";
+import { resolveMSTeamsCredentials } from "./token.js";
+
+const log = getChildLogger({ name: "msteams:monitor" });
+
+export type MonitorMSTeamsOpts = {
+ cfg: ClawdbotConfig;
+ runtime?: RuntimeEnv;
+ abortSignal?: AbortSignal;
+};
+
+export type MonitorMSTeamsResult = {
+ app: unknown;
+ shutdown: () => Promise;
+};
+
+export async function monitorMSTeamsProvider(
+ opts: MonitorMSTeamsOpts,
+): Promise {
+ const msteamsCfg = opts.cfg.msteams;
+ if (!msteamsCfg?.enabled) {
+ log.debug("msteams provider disabled");
+ return { app: null, shutdown: async () => {} };
+ }
+
+ const creds = resolveMSTeamsCredentials(msteamsCfg);
+ if (!creds) {
+ log.error("msteams credentials not configured");
+ return { app: null, shutdown: async () => {} };
+ }
+
+ const port = msteamsCfg.webhook?.port ?? 3978;
+ const path = msteamsCfg.webhook?.path ?? "/msteams/messages";
+
+ log.info(`starting msteams provider on port ${port}${path}`);
+
+ // Dynamic import to avoid loading SDK when provider is disabled
+ const agentsHosting = await import("@microsoft/agents-hosting");
+ const { startServer } = await import("@microsoft/agents-hosting-express");
+
+ const { ActivityHandler } = agentsHosting;
+ type TurnContext = InstanceType;
+
+ // Create activity handler using fluent API
+ const handler = new ActivityHandler()
+ .onMessage(async (context: TurnContext, next: () => Promise) => {
+ const text = context.activity?.text?.trim() ?? "";
+ const from = context.activity?.from;
+ const conversation = context.activity?.conversation;
+
+ log.debug("received message", {
+ text: text.slice(0, 100),
+ from: from?.id,
+ conversation: conversation?.id,
+ });
+
+ // TODO: Implement full message handling
+ // - Route to agent based on config
+ // - Process commands
+ // - Send reply via context.sendActivity()
+
+ // Echo for now as a test
+ await context.sendActivity(`Received: ${text}`);
+ await next();
+ })
+ .onMembersAdded(async (context: TurnContext, next: () => Promise) => {
+ const membersAdded = context.activity?.membersAdded ?? [];
+ for (const member of membersAdded) {
+ if (member.id !== context.activity?.recipient?.id) {
+ log.debug("member added", { member: member.id });
+ await context.sendActivity("Hello! I'm Clawdbot.");
+ }
+ }
+ await next();
+ });
+
+ // Auth configuration using the new SDK format
+ const authConfig = {
+ clientId: creds.appId,
+ clientSecret: creds.appPassword,
+ tenantId: creds.tenantId,
+ };
+
+ // Set env vars that startServer reads (it uses loadAuthConfigFromEnv internally)
+ process.env.clientId = creds.appId;
+ process.env.clientSecret = creds.appPassword;
+ process.env.tenantId = creds.tenantId;
+ process.env.PORT = String(port);
+
+ // Start the server
+ const expressApp = startServer(handler, authConfig);
+
+ log.info(`msteams provider started on port ${port}`);
+
+ const shutdown = async () => {
+ log.info("shutting down msteams provider");
+ // Express app doesn't have a direct close method
+ // The server is managed by startServer internally
+ };
+
+ // Handle abort signal
+ if (opts.abortSignal) {
+ opts.abortSignal.addEventListener("abort", () => {
+ void shutdown();
+ });
+ }
+
+ return { app: expressApp, shutdown };
+}
diff --git a/src/msteams/probe.ts b/src/msteams/probe.ts
new file mode 100644
index 000000000..ecb4ecae1
--- /dev/null
+++ b/src/msteams/probe.ts
@@ -0,0 +1,23 @@
+import type { MSTeamsConfig } from "../config/types.js";
+import { resolveMSTeamsCredentials } from "./token.js";
+
+export type ProbeMSTeamsResult = {
+ ok: boolean;
+ error?: string;
+ appId?: string;
+};
+
+export async function probeMSTeams(
+ cfg?: MSTeamsConfig,
+): Promise {
+ const creds = resolveMSTeamsCredentials(cfg);
+ if (!creds) {
+ return {
+ ok: false,
+ error: "missing credentials (appId, appPassword, tenantId)",
+ };
+ }
+
+ // TODO: Validate credentials by attempting to get a token
+ return { ok: true, appId: creds.appId };
+}
diff --git a/src/msteams/send.ts b/src/msteams/send.ts
new file mode 100644
index 000000000..3e62c75f7
--- /dev/null
+++ b/src/msteams/send.ts
@@ -0,0 +1,25 @@
+import type { MSTeamsConfig } from "../config/types.js";
+import { getChildLogger } from "../logging.js";
+
+const log = getChildLogger({ name: "msteams:send" });
+
+export type SendMSTeamsMessageParams = {
+ cfg: MSTeamsConfig;
+ conversationId: string;
+ text: string;
+ serviceUrl: string;
+};
+
+export type SendMSTeamsMessageResult = {
+ ok: boolean;
+ messageId?: string;
+ error?: string;
+};
+
+export async function sendMessageMSTeams(
+ _params: SendMSTeamsMessageParams,
+): Promise {
+ // TODO: Implement using CloudAdapter.continueConversationAsync
+ log.warn("sendMessageMSTeams not yet implemented");
+ return { ok: false, error: "not implemented" };
+}
diff --git a/src/msteams/token.ts b/src/msteams/token.ts
new file mode 100644
index 000000000..01d03acde
--- /dev/null
+++ b/src/msteams/token.ts
@@ -0,0 +1,23 @@
+import type { MSTeamsConfig } from "../config/types.js";
+
+export type MSTeamsCredentials = {
+ appId: string;
+ appPassword: string;
+ tenantId: string;
+};
+
+export function resolveMSTeamsCredentials(
+ cfg?: MSTeamsConfig,
+): MSTeamsCredentials | undefined {
+ const appId = cfg?.appId?.trim() || process.env.MSTEAMS_APP_ID?.trim();
+ const appPassword =
+ cfg?.appPassword?.trim() || process.env.MSTEAMS_APP_PASSWORD?.trim();
+ const tenantId =
+ cfg?.tenantId?.trim() || process.env.MSTEAMS_TENANT_ID?.trim();
+
+ if (!appId || !appPassword || !tenantId) {
+ return undefined;
+ }
+
+ return { appId, appPassword, tenantId };
+}
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index 962904b59..af1464faf 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -102,14 +102,23 @@ If we add `msteams`, the UI must be updated alongside backend config/types.
## 2) 2025/2026 Microsoft Guidance (What Changed)
-### 2.1 Bot Framework SDK v4 “modern” baseline (Node)
+### 2.1 Microsoft 365 Agents SDK (Recommended)
-For Node bots, Microsoft’s maintained samples now use:
+**UPDATE (2026-01):** The Bot Framework SDK (`botbuilder`) was deprecated in December 2025. We now use the **Microsoft 365 Agents SDK** which is the official replacement:
-- `CloudAdapter` + `ConfigurationBotFrameworkAuthentication` (instead of older adapter patterns)
-- Express/Restify middleware to parse JSON into `req.body` before `adapter.process(...)`
+```bash
+pnpm add @microsoft/agents-hosting @microsoft/agents-hosting-express @microsoft/agents-hosting-extensions-teams
+```
-CloudAdapter’s request processing explicitly requires parsed JSON bodies (it will 400 if `req.body` isn’t an object).
+The new SDK uses:
+- `ActivityHandler` with fluent API for handling activities
+- `startServer()` from `@microsoft/agents-hosting-express` for Express integration
+- `AuthConfiguration` with `clientId`, `clientSecret`, `tenantId` (new naming)
+
+Package sizes (for reference):
+- `@microsoft/agents-hosting`: ~1.4 MB
+- `@microsoft/agents-hosting-express`: ~12 KB
+- `@microsoft/agents-hosting-extensions-teams`: ~537 KB (optional, for Teams-specific features)
### 2.2 Proactive messaging is required for “slow” work
@@ -125,14 +134,11 @@ Best practice for long-running work is:
- **return quickly**,
- then send replies later via proactive messaging (`continueConversationAsync` in CloudAdapter).
-### 2.3 Microsoft 365 Agents SDK exists (potential future path)
+### 2.3 SDK Migration Complete
-Microsoft is actively building the **Microsoft 365 Agents SDK** (Node/TS) which positions itself as a replacement for parts of Bot Framework (`botbuilder`) for Teams and other channels.
+We are using the **Microsoft 365 Agents SDK** (`@microsoft/agents-hosting` v1.1.1+) as the primary SDK. The deprecated Bot Framework SDK (`botbuilder`) is NOT used.
-Practical implication for Clawdbot:
-
-- **Ship v1 with Bot Framework** (most stable, most docs, matches Teams docs),
-- but structure our MS Teams provider so it can be swapped to Agents SDK later (thin adapter boundary around “receive activity” + “send activity”).
+GitHub: https://github.com/Microsoft/Agents-for-js
### 2.4 Deprecations / platform shifts to note
@@ -784,15 +790,20 @@ Initial recommendation: support this type first; treat other attachment types as
## Next Steps (Actionable Implementation Order)
-1. **Pick SDK + add deps**: start with Bot Framework (`botbuilder`) unless you’re ready to bet on Agents SDK; add packages + types in `package.json`.
-2. **Config plumbing**: add `msteams` types + zod schema + schema metadata (`src/config/types.ts`, `src/config/zod-schema.ts`, `src/config/schema.ts`).
-3. **Provider skeleton**: add `src/msteams/index.ts`, `token.ts`, and a stub `monitor.ts` that starts/stops cleanly (abortSignal).
-4. **Webhook + echo**: implement `webhook.ts` + minimal activity handler that logs inbound text and sends a fast “ok” reply (no agent yet).
-5. **Conversation store**: persist `ConversationReference` by `conversation.id` and include tenant/serviceUrl; add a small unit test.
-6. **Agent dispatch (async)**: wire inbound messages to `dispatchReplyFromConfig()` using proactive sends (`continueConversationAsync`) to avoid webhook timeouts.
-7. **Access control**: implement DM policy + pairing (reuse existing pairing store) + mention gating in channels.
-8. **Gateway integration**: add provider manager start/stop/status wiring + config reload rules + hook provider allowlist; ensure gateway status UI reflects it.
-9. **Outbound CLI/gateway sends**: add `sendMessageMSTeams` that targets stored conversation IDs; wire `clawdbot send --provider msteams`.
-10. **Media**: implement inbound attachment download for `file.download.info` and a safe outbound strategy (link-only first, cards later).
-11. **Docs + UI + Onboard**: write `docs/providers/msteams.md`, add a minimal UI config form (appId/secret/tenant + webhook port/path), and update `clawdbot onboard` provider selection.
-12. **Hardening**: add dedupe TTL tuning, better error reporting, probe/health endpoints, and integration tests (`monitor.tool-result.test.ts`).
+### Completed (2026-01-07)
+
+1. ✅ **Add SDK packages**: Microsoft 365 Agents SDK (`@microsoft/agents-hosting`, `@microsoft/agents-hosting-express`, `@microsoft/agents-hosting-extensions-teams`)
+2. ✅ **Config plumbing**: `MSTeamsConfig` type + zod schema (`src/config/types.ts`, `src/config/zod-schema.ts`)
+3. ✅ **Provider skeleton**: `src/msteams/` with `index.ts`, `token.ts`, `probe.ts`, `send.ts`, `monitor.ts`
+4. ✅ **Gateway integration**: Provider manager start/stop wiring in `server-providers.ts` and `server.ts`
+
+### Remaining
+
+5. **Test echo bot**: Run gateway with msteams enabled, verify Teams can reach the webhook and receive echo replies.
+6. **Conversation store**: Persist `ConversationReference` by `conversation.id` for proactive messaging.
+7. **Agent dispatch (async)**: Wire inbound messages to `dispatchReplyFromConfig()` using proactive sends.
+8. **Access control**: Implement DM policy + pairing (reuse existing pairing store) + mention gating in channels.
+9. **Config reload**: Add msteams to `config-reload.ts` restart rules.
+10. **Outbound CLI/gateway sends**: Implement `sendMessageMSTeams` properly; wire `clawdbot send --provider msteams`.
+11. **Media**: Implement inbound attachment download and outbound strategy.
+12. **Docs + UI + Onboard**: Write `docs/providers/msteams.md`, add UI config form, update `clawdbot onboard`.
From 951789e9fad3fedc5154093b81c5bd70c9b2ad12 Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 22:38:48 +0300
Subject: [PATCH 012/152] wip [skip ci]
---
tmp/msteams-implementation-guide.md | 35 +++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index af1464faf..6bd6a4957 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -263,6 +263,40 @@ To install the bot in Teams, you need an app manifest:
3. Zip all three files into `clawdbot-teams.zip`
4. In Teams → Apps → Manage your apps → Upload a custom app → Upload `clawdbot-teams.zip`
+### Step 7: Test the Bot
+
+**Option A: Azure Web Chat (verify webhook first)**
+
+1. Go to Azure Portal → your Azure Bot resource
+2. Click **Test in Web Chat** (left sidebar)
+3. Send a message - you should see the echo response
+4. This confirms your webhook endpoint is working before Teams setup
+
+**Option B: Teams Developer Portal (easier than manual manifest)**
+
+1. Go to https://dev.teams.microsoft.com/apps
+2. Click **+ New app**
+3. Fill in basic info:
+ - **Short name**: Clawdbot
+ - **Full name**: Clawdbot MS Teams
+ - **Short description**: AI assistant
+ - **Full description**: Clawdbot AI assistant for Teams
+ - **Developer name**: Your Name
+ - **Website**: https://clawd.bot (or any URL)
+4. Go to **App features** → **Bot**
+5. Select **Enter a bot ID manually**
+6. Paste your App ID: `49930686-61cb-44fd-a847-545d3f3fb638` (your Azure Bot's Microsoft App ID)
+7. Check scopes: **Personal** (for DMs), optionally **Team** and **Group Chat**
+8. Save
+9. Click **Distribute** (upper right) → **Download app package** (downloads a .zip)
+10. In Teams desktop/web:
+ - Click **Apps** (left sidebar)
+ - Click **Manage your apps**
+ - Click **Upload an app** → **Upload a custom app**
+ - Select the downloaded .zip file
+11. Click **Add** to install the bot
+12. Open a chat with the bot and send a message
+
### Credentials Summary
After setup, you'll have:
@@ -288,6 +322,7 @@ msteams:
### Useful Links
- [Azure Portal](https://portal.azure.com)
+- [Teams Developer Portal](https://dev.teams.microsoft.com/apps) - create/manage Teams apps
- [Create Azure Bot](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-quickstart-registration)
- [Bot Framework Overview](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-overview)
- [Create Teams Bot](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-for-teams)
From 1c73d4510601a00b55289ad950a140be7b99a3ed Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 23:00:59 +0300
Subject: [PATCH 013/152] feat(msteams): wire agent integration for Teams
messages
- Integrate dispatchReplyFromConfig() for full agent routing
- Add msteams to TextChunkProvider and OriginatingChannelType
- Add msteams case to route-reply (proactive not yet supported)
- Strip @mention HTML tags from Teams messages
- Fix session key to exclude messageid suffix
- Add typing indicator support
- Add proper logging for debugging
---
src/auto-reply/chunk.ts | 4 +-
src/auto-reply/reply/route-reply.ts | 8 +
src/auto-reply/templating.ts | 3 +-
src/msteams/monitor.ts | 295 +++++++++++++++++++++++++---
tmp/msteams-implementation-guide.md | 52 ++++-
5 files changed, 328 insertions(+), 34 deletions(-)
diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts
index 44ab80c76..1331aa24d 100644
--- a/src/auto-reply/chunk.ts
+++ b/src/auto-reply/chunk.ts
@@ -17,7 +17,8 @@ export type TextChunkProvider =
| "slack"
| "signal"
| "imessage"
- | "webchat";
+ | "webchat"
+ | "msteams";
const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record = {
whatsapp: 4000,
@@ -27,6 +28,7 @@ const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record = {
signal: 4000,
imessage: 4000,
webchat: 4000,
+ msteams: 4000,
};
export function resolveTextChunkLimit(
diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts
index f7529c8cf..909407e78 100644
--- a/src/auto-reply/reply/route-reply.ts
+++ b/src/auto-reply/reply/route-reply.ts
@@ -145,6 +145,14 @@ export async function routeReply(
};
}
+ case "msteams": {
+ // TODO: Implement proactive messaging for MS Teams
+ return {
+ ok: false,
+ error: `MS Teams routing not yet supported for queued replies`,
+ };
+ }
+
default: {
const _exhaustive: never = channel;
return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` };
diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts
index 398290c2f..3e1212e0e 100644
--- a/src/auto-reply/templating.ts
+++ b/src/auto-reply/templating.ts
@@ -6,7 +6,8 @@ export type OriginatingChannelType =
| "signal"
| "imessage"
| "whatsapp"
- | "webchat";
+ | "webchat"
+ | "msteams";
export type MsgContext = {
Body?: string;
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
index 429f5b733..6b624f4ea 100644
--- a/src/msteams/monitor.ts
+++ b/src/msteams/monitor.ts
@@ -1,9 +1,21 @@
+import {
+ chunkMarkdownText,
+ resolveTextChunkLimit,
+} from "../auto-reply/chunk.js";
+import { formatAgentEnvelope } from "../auto-reply/envelope.js";
+import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
+import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
+import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
+import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig } from "../config/types.js";
+import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
+import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
+import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveMSTeamsCredentials } from "./token.js";
-const log = getChildLogger({ name: "msteams:monitor" });
+const log = getChildLogger({ name: "msteams" });
export type MonitorMSTeamsOpts = {
cfg: ClawdbotConfig;
@@ -16,10 +28,45 @@ export type MonitorMSTeamsResult = {
shutdown: () => Promise;
};
+type TeamsActivity = {
+ id?: string;
+ type?: string;
+ timestamp?: string | Date;
+ text?: string;
+ from?: { id?: string; name?: string; aadObjectId?: string };
+ recipient?: { id?: string; name?: string };
+ conversation?: {
+ id?: string;
+ conversationType?: string;
+ tenantId?: string;
+ isGroup?: boolean;
+ };
+ channelId?: string;
+ serviceUrl?: string;
+ membersAdded?: Array<{ id?: string; name?: string }>;
+};
+
+type TeamsTurnContext = {
+ activity: TeamsActivity;
+ sendActivity: (textOrActivity: string | object) => Promise;
+ sendActivities?: (
+ activities: Array<{ type: string } & Record>,
+ ) => Promise;
+};
+
+// Helper to convert timestamp to Date
+function parseTimestamp(ts?: string | Date): Date | undefined {
+ if (!ts) return undefined;
+ if (ts instanceof Date) return ts;
+ const date = new Date(ts);
+ return Number.isNaN(date.getTime()) ? undefined : date;
+}
+
export async function monitorMSTeamsProvider(
opts: MonitorMSTeamsOpts,
): Promise {
- const msteamsCfg = opts.cfg.msteams;
+ const cfg = opts.cfg;
+ const msteamsCfg = cfg.msteams;
if (!msteamsCfg?.enabled) {
log.debug("msteams provider disabled");
return { app: null, shutdown: async () => {} };
@@ -31,46 +78,246 @@ export async function monitorMSTeamsProvider(
return { app: null, shutdown: async () => {} };
}
- const port = msteamsCfg.webhook?.port ?? 3978;
- const path = msteamsCfg.webhook?.path ?? "/msteams/messages";
+ const runtime: RuntimeEnv = opts.runtime ?? {
+ log: console.log,
+ error: console.error,
+ exit: (code: number): never => {
+ throw new Error(`exit ${code}`);
+ },
+ };
- log.info(`starting msteams provider on port ${port}${path}`);
+ const port = msteamsCfg.webhook?.port ?? 3978;
+ const textLimit = resolveTextChunkLimit(cfg, "msteams");
+
+ log.info(`starting provider (port ${port})`);
// Dynamic import to avoid loading SDK when provider is disabled
const agentsHosting = await import("@microsoft/agents-hosting");
const { startServer } = await import("@microsoft/agents-hosting-express");
const { ActivityHandler } = agentsHosting;
- type TurnContext = InstanceType;
- // Create activity handler using fluent API
- const handler = new ActivityHandler()
- .onMessage(async (context: TurnContext, next: () => Promise) => {
- const text = context.activity?.text?.trim() ?? "";
- const from = context.activity?.from;
- const conversation = context.activity?.conversation;
+ // Helper to deliver replies via Teams SDK
+ async function deliverReplies(params: {
+ replies: ReplyPayload[];
+ context: TeamsTurnContext;
+ }) {
+ const chunkLimit = Math.min(textLimit, 4000);
+ for (const payload of params.replies) {
+ const mediaList =
+ payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
+ const text = payload.text ?? "";
+ if (!text && mediaList.length === 0) continue;
- log.debug("received message", {
- text: text.slice(0, 100),
- from: from?.id,
- conversation: conversation?.id,
+ if (mediaList.length === 0) {
+ for (const chunk of chunkMarkdownText(text, chunkLimit)) {
+ const trimmed = chunk.trim();
+ if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
+ await params.context.sendActivity(trimmed);
+ }
+ } else {
+ // For media, send text first then media URLs as separate messages
+ if (text.trim() && text.trim() !== SILENT_REPLY_TOKEN) {
+ for (const chunk of chunkMarkdownText(text, chunkLimit)) {
+ await params.context.sendActivity(chunk);
+ }
+ }
+ for (const mediaUrl of mediaList) {
+ // Teams supports adaptive cards for rich media, but for now just send URL
+ await params.context.sendActivity(mediaUrl);
+ }
+ }
+ }
+ }
+
+ // Strip Teams @mention HTML tags from message text
+ function stripMentionTags(text: string): string {
+ // Teams wraps mentions in ... tags
+ return text.replace(/.*?<\/at>/gi, "").trim();
+ }
+
+ // Handler for incoming messages
+ async function handleTeamsMessage(context: TeamsTurnContext) {
+ const activity = context.activity;
+ const rawText = activity.text?.trim() ?? "";
+ const text = stripMentionTags(rawText);
+ const from = activity.from;
+ const conversation = activity.conversation;
+
+ log.info("received message", {
+ rawText: rawText.slice(0, 50),
+ text: text.slice(0, 50),
+ from: from?.id,
+ conversation: conversation?.id,
+ });
+
+ if (!text) {
+ log.debug("skipping empty message after stripping mentions");
+ return;
+ }
+ if (!from?.id) {
+ log.debug("skipping message without from.id");
+ return;
+ }
+
+ // Teams conversation.id may include ";messageid=..." suffix - strip it for session key
+ const rawConversationId = conversation?.id ?? "";
+ const conversationId = rawConversationId.split(";")[0];
+ const conversationType = conversation?.conversationType ?? "personal";
+ const isGroupChat =
+ conversationType === "groupChat" || conversation?.isGroup === true;
+ const isChannel = conversationType === "channel";
+ const isDirectMessage = !isGroupChat && !isChannel;
+
+ const senderName = from.name ?? from.id;
+ const senderId = from.aadObjectId ?? from.id;
+
+ // Build Teams-specific identifiers
+ const teamsFrom = isDirectMessage
+ ? `msteams:${senderId}`
+ : isChannel
+ ? `msteams:channel:${conversationId}`
+ : `msteams:group:${conversationId}`;
+ const teamsTo = isDirectMessage
+ ? `user:${senderId}`
+ : `conversation:${conversationId}`;
+
+ // Resolve routing
+ const route = resolveAgentRoute({
+ cfg,
+ provider: "msteams",
+ peer: {
+ kind: isDirectMessage ? "dm" : isChannel ? "channel" : "group",
+ id: isDirectMessage ? senderId : conversationId,
+ },
+ });
+
+ const preview = text.replace(/\s+/g, " ").slice(0, 160);
+ const inboundLabel = isDirectMessage
+ ? `Teams DM from ${senderName}`
+ : `Teams message in ${conversationType} from ${senderName}`;
+
+ enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
+ sessionKey: route.sessionKey,
+ contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
+ });
+
+ // Format the message body with envelope
+ const timestamp = parseTimestamp(activity.timestamp);
+ const body = formatAgentEnvelope({
+ provider: "Teams",
+ from: senderName,
+ timestamp,
+ body: text,
+ });
+
+ // Build context payload for agent
+ const ctxPayload = {
+ Body: body,
+ From: teamsFrom,
+ To: teamsTo,
+ SessionKey: route.sessionKey,
+ AccountId: route.accountId,
+ ChatType: isDirectMessage ? "direct" : isChannel ? "room" : "group",
+ GroupSubject: !isDirectMessage ? conversationType : undefined,
+ SenderName: senderName,
+ SenderId: senderId,
+ Provider: "msteams" as const,
+ Surface: "msteams" as const,
+ MessageSid: activity.id,
+ Timestamp: timestamp?.getTime() ?? Date.now(),
+ WasMentioned: !isDirectMessage,
+ CommandAuthorized: true,
+ OriginatingChannel: "msteams" as const,
+ OriginatingTo: teamsTo,
+ };
+
+ if (shouldLogVerbose()) {
+ logVerbose(
+ `msteams inbound: from=${ctxPayload.From} preview="${preview}"`,
+ );
+ }
+
+ // Send typing indicator
+ const sendTypingIndicator = async () => {
+ try {
+ if (context.sendActivities) {
+ await context.sendActivities([{ type: "typing" }]);
+ }
+ } catch {
+ // Typing indicator is best-effort
+ }
+ };
+
+ // Create reply dispatcher
+ const { dispatcher, replyOptions, markDispatchIdle } =
+ createReplyDispatcherWithTyping({
+ responsePrefix: cfg.messages?.responsePrefix,
+ deliver: async (payload) => {
+ await deliverReplies({
+ replies: [payload],
+ context,
+ });
+ },
+ onError: (err, info) => {
+ runtime.error?.(
+ danger(`msteams ${info.kind} reply failed: ${String(err)}`),
+ );
+ },
+ onReplyStart: sendTypingIndicator,
});
- // TODO: Implement full message handling
- // - Route to agent based on config
- // - Process commands
- // - Send reply via context.sendActivity()
+ // Dispatch to agent
+ log.info("dispatching to agent", { sessionKey: route.sessionKey });
+ try {
+ const { queuedFinal, counts } = await dispatchReplyFromConfig({
+ ctx: ctxPayload,
+ cfg,
+ dispatcher,
+ replyOptions,
+ });
- // Echo for now as a test
- await context.sendActivity(`Received: ${text}`);
+ markDispatchIdle();
+ log.info("dispatch complete", { queuedFinal, counts });
+
+ if (!queuedFinal) return;
+ if (shouldLogVerbose()) {
+ const finalCount = counts.final;
+ logVerbose(
+ `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
+ );
+ }
+ } catch (err) {
+ log.error("dispatch failed", { error: String(err) });
+ runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
+ // Try to send error message back to Teams
+ try {
+ await context.sendActivity(
+ `⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ } catch {
+ // Best effort
+ }
+ }
+ }
+
+ // Create activity handler using fluent API
+ // The SDK's TurnContext is compatible with our TeamsTurnContext
+ const handler = new ActivityHandler()
+ .onMessage(async (context, next) => {
+ try {
+ await handleTeamsMessage(context as unknown as TeamsTurnContext);
+ } catch (err) {
+ runtime.error?.(danger(`msteams handler failed: ${String(err)}`));
+ }
await next();
})
- .onMembersAdded(async (context: TurnContext, next: () => Promise) => {
+ .onMembersAdded(async (context, next) => {
const membersAdded = context.activity?.membersAdded ?? [];
for (const member of membersAdded) {
if (member.id !== context.activity?.recipient?.id) {
log.debug("member added", { member: member.id });
- await context.sendActivity("Hello! I'm Clawdbot.");
+ // Don't send welcome message - let the user initiate conversation
}
}
await next();
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index 6bd6a4957..04a0889c5 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -831,14 +831,50 @@ Initial recommendation: support this type first; treat other attachment types as
2. ✅ **Config plumbing**: `MSTeamsConfig` type + zod schema (`src/config/types.ts`, `src/config/zod-schema.ts`)
3. ✅ **Provider skeleton**: `src/msteams/` with `index.ts`, `token.ts`, `probe.ts`, `send.ts`, `monitor.ts`
4. ✅ **Gateway integration**: Provider manager start/stop wiring in `server-providers.ts` and `server.ts`
+5. ✅ **Echo bot tested**: Verified end-to-end flow (Azure Bot → Tailscale → Gateway → SDK → Response)
+
+### Debugging Notes
+
+- **SDK listens on all paths**: The `startServer()` function responds to POST on any path (not just `/api/messages`), but Azure Bot default is `/api/messages`
+- **SDK handles HTTP internally**: Custom logging in monitor.ts `log.debug()` doesn't show HTTP traffic - SDK processes requests before our handler
+- **Tailscale Funnel**: Must be running separately (`tailscale funnel 3978`) - doesn't work well as background task
+- **Auth errors (401)**: Expected when testing manually without Azure JWT - means endpoint is reachable
+
+### In Progress (2026-01-07 - Session 2)
+
+6. ✅ **Agent dispatch (sync)**: Wired inbound messages to `dispatchReplyFromConfig()` - replies sent via `context.sendActivity()` within turn
+7. ✅ **Typing indicator**: Added typing indicator support via `sendActivities([{ type: "typing" }])`
+8. ✅ **Type system updates**: Added `msteams` to `TextChunkProvider`, `OriginatingChannelType`, and route-reply switch
+
+### Implementation Notes
+
+**Current Approach (Synchronous):**
+The current implementation sends replies synchronously within the Teams turn context. This works for quick responses but may timeout for slow LLM responses.
+
+```typescript
+// Current: Reply within turn context (src/msteams/monitor.ts)
+const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
+ deliver: async (payload) => {
+ await deliverReplies({ replies: [payload], context });
+ },
+ onReplyStart: sendTypingIndicator,
+});
+await dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions });
+```
+
+**Key Fields in ctxPayload:**
+- `Provider: "msteams"` / `Surface: "msteams"`
+- `From`: `msteams:` (DM) or `msteams:channel:` (channel)
+- `To`: `user:` (DM) or `conversation:` (group/channel)
+- `ChatType`: `"direct"` | `"group"` | `"room"` based on conversation type
### Remaining
-5. **Test echo bot**: Run gateway with msteams enabled, verify Teams can reach the webhook and receive echo replies.
-6. **Conversation store**: Persist `ConversationReference` by `conversation.id` for proactive messaging.
-7. **Agent dispatch (async)**: Wire inbound messages to `dispatchReplyFromConfig()` using proactive sends.
-8. **Access control**: Implement DM policy + pairing (reuse existing pairing store) + mention gating in channels.
-9. **Config reload**: Add msteams to `config-reload.ts` restart rules.
-10. **Outbound CLI/gateway sends**: Implement `sendMessageMSTeams` properly; wire `clawdbot send --provider msteams`.
-11. **Media**: Implement inbound attachment download and outbound strategy.
-12. **Docs + UI + Onboard**: Write `docs/providers/msteams.md`, add UI config form, update `clawdbot onboard`.
+9. **Test full agent flow**: Send message in Teams → verify agent responds (not just echo)
+10. **Conversation store**: Persist `ConversationReference` by `conversation.id` for proactive messaging
+11. **Proactive messaging**: For slow LLM responses, store reference and send replies asynchronously
+12. **Access control**: Implement DM policy + pairing (reuse existing pairing store) + mention gating in channels
+13. **Config reload**: Add msteams to `config-reload.ts` restart rules
+14. **Outbound CLI/gateway sends**: Implement `sendMessageMSTeams` properly; wire `clawdbot send --provider msteams`
+15. **Media**: Implement inbound attachment download and outbound strategy
+16. **Docs + UI + Onboard**: Write `docs/providers/msteams.md`, add UI config form, update `clawdbot onboard`
From e0812f8c4d7c516a0c8ffd1d02475ef9c5e56c92 Mon Sep 17 00:00:00 2001
From: Onur
Date: Wed, 7 Jan 2026 23:36:30 +0300
Subject: [PATCH 014/152] feat(msteams): add config reload, DM policy, proper
shutdown
- Add msteams to config-reload.ts (ProviderKind, ReloadAction, rules)
- Add msteams to PairingProvider for pairing code support
- Create conversation-store.ts for storing ConversationReference
- Implement DM policy check (disabled/pairing/open/allowlist)
- Fix WasMentioned to check actual bot mentions via entities
- Fix server shutdown by using custom Express server with httpServer.close()
- Pass authConfig to CloudAdapter for outbound call authentication
- Improve error logging with JSON serialization
---
src/gateway/config-reload.ts | 10 +-
src/msteams/conversation-store.ts | 122 ++++++++++++++++++++++
src/msteams/monitor.ts | 154 +++++++++++++++++++++++++---
src/pairing/pairing-store.ts | 4 +-
tmp/msteams-implementation-guide.md | 26 +++--
5 files changed, 288 insertions(+), 28 deletions(-)
create mode 100644 src/msteams/conversation-store.ts
diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts
index 72b459d4e..65303873a 100644
--- a/src/gateway/config-reload.ts
+++ b/src/gateway/config-reload.ts
@@ -17,7 +17,8 @@ export type ProviderKind =
| "discord"
| "slack"
| "signal"
- | "imessage";
+ | "imessage"
+ | "msteams";
export type GatewayReloadPlan = {
changedPaths: string[];
@@ -50,7 +51,8 @@ type ReloadAction =
| "restart-provider:discord"
| "restart-provider:slack"
| "restart-provider:signal"
- | "restart-provider:imessage";
+ | "restart-provider:imessage"
+ | "restart-provider:msteams";
const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
mode: "hybrid",
@@ -75,6 +77,7 @@ const RELOAD_RULES: ReloadRule[] = [
{ prefix: "slack", kind: "hot", actions: ["restart-provider:slack"] },
{ prefix: "signal", kind: "hot", actions: ["restart-provider:signal"] },
{ prefix: "imessage", kind: "hot", actions: ["restart-provider:imessage"] },
+ { prefix: "msteams", kind: "hot", actions: ["restart-provider:msteams"] },
{ prefix: "identity", kind: "none" },
{ prefix: "wizard", kind: "none" },
{ prefix: "logging", kind: "none" },
@@ -212,6 +215,9 @@ export function buildGatewayReloadPlan(
case "restart-provider:imessage":
plan.restartProviders.add("imessage");
break;
+ case "restart-provider:msteams":
+ plan.restartProviders.add("msteams");
+ break;
default:
break;
}
diff --git a/src/msteams/conversation-store.ts b/src/msteams/conversation-store.ts
new file mode 100644
index 000000000..d1463d521
--- /dev/null
+++ b/src/msteams/conversation-store.ts
@@ -0,0 +1,122 @@
+/**
+ * Conversation store for MS Teams proactive messaging.
+ *
+ * Stores ConversationReference objects keyed by conversation ID so we can
+ * send proactive messages later (after the webhook turn has completed).
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+
+import { resolveStateDir } from "../config/paths.js";
+
+/** Minimal ConversationReference shape for proactive messaging */
+export type StoredConversationReference = {
+ /** Activity ID from the last message */
+ activityId?: string;
+ /** User who sent the message */
+ user?: { id?: string; name?: string; aadObjectId?: string };
+ /** Bot that received the message */
+ bot?: { id?: string; name?: string };
+ /** Conversation details */
+ conversation?: { id?: string; conversationType?: string; tenantId?: string };
+ /** Channel ID (usually "msteams") */
+ channelId?: string;
+ /** Service URL for sending messages back */
+ serviceUrl?: string;
+ /** Locale */
+ locale?: string;
+};
+
+type ConversationStoreData = {
+ version: 1;
+ conversations: Record;
+};
+
+const STORE_FILENAME = "msteams-conversations.json";
+const MAX_CONVERSATIONS = 1000;
+
+function resolveStorePath(): string {
+ const stateDir = resolveStateDir(process.env);
+ return path.join(stateDir, STORE_FILENAME);
+}
+
+async function readStore(): Promise {
+ try {
+ const raw = await fs.promises.readFile(resolveStorePath(), "utf-8");
+ const data = JSON.parse(raw) as ConversationStoreData;
+ if (data.version !== 1) {
+ return { version: 1, conversations: {} };
+ }
+ return data;
+ } catch {
+ return { version: 1, conversations: {} };
+ }
+}
+
+async function writeStore(data: ConversationStoreData): Promise {
+ const filePath = resolveStorePath();
+ const dir = path.dirname(filePath);
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
+ await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
+}
+
+/**
+ * Save a conversation reference for later proactive messaging.
+ */
+export async function saveConversationReference(
+ conversationId: string,
+ reference: StoredConversationReference,
+): Promise {
+ const store = await readStore();
+
+ // Prune if over limit (keep most recent)
+ const keys = Object.keys(store.conversations);
+ if (keys.length >= MAX_CONVERSATIONS) {
+ const toRemove = keys.slice(0, keys.length - MAX_CONVERSATIONS + 1);
+ for (const key of toRemove) {
+ delete store.conversations[key];
+ }
+ }
+
+ store.conversations[conversationId] = reference;
+ await writeStore(store);
+}
+
+/**
+ * Get a stored conversation reference.
+ */
+export async function getConversationReference(
+ conversationId: string,
+): Promise {
+ const store = await readStore();
+ return store.conversations[conversationId] ?? null;
+}
+
+/**
+ * List all stored conversation references.
+ */
+export async function listConversationReferences(): Promise<
+ Array<{ conversationId: string; reference: StoredConversationReference }>
+> {
+ const store = await readStore();
+ return Object.entries(store.conversations).map(
+ ([conversationId, reference]) => ({
+ conversationId,
+ reference,
+ }),
+ );
+}
+
+/**
+ * Remove a conversation reference.
+ */
+export async function removeConversationReference(
+ conversationId: string,
+): Promise {
+ const store = await readStore();
+ if (!(conversationId in store.conversations)) return false;
+ delete store.conversations[conversationId];
+ await writeStore(store);
+ return true;
+}
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
index 6b624f4ea..278073659 100644
--- a/src/msteams/monitor.ts
+++ b/src/msteams/monitor.ts
@@ -11,8 +11,16 @@ import type { ClawdbotConfig } from "../config/types.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
+import {
+ readProviderAllowFromStore,
+ upsertProviderPairingRequest,
+} from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
+import {
+ saveConversationReference,
+ type StoredConversationReference,
+} from "./conversation-store.js";
import { resolveMSTeamsCredentials } from "./token.js";
const log = getChildLogger({ name: "msteams" });
@@ -44,6 +52,11 @@ type TeamsActivity = {
channelId?: string;
serviceUrl?: string;
membersAdded?: Array<{ id?: string; name?: string }>;
+ /** Entities including mentions */
+ entities?: Array<{
+ type?: string;
+ mentioned?: { id?: string; name?: string };
+ }>;
};
type TeamsTurnContext = {
@@ -93,9 +106,10 @@ export async function monitorMSTeamsProvider(
// Dynamic import to avoid loading SDK when provider is disabled
const agentsHosting = await import("@microsoft/agents-hosting");
- const { startServer } = await import("@microsoft/agents-hosting-express");
+ const express = await import("express");
- const { ActivityHandler } = agentsHosting;
+ const { ActivityHandler, CloudAdapter, authorizeJWT, getAuthConfigWithDefaults } =
+ agentsHosting;
// Helper to deliver replies via Teams SDK
async function deliverReplies(params: {
@@ -136,6 +150,16 @@ export async function monitorMSTeamsProvider(
return text.replace(/.*?<\/at>/gi, "").trim();
}
+ // Check if the bot was mentioned in the activity
+ function wasBotMentioned(activity: TeamsActivity): boolean {
+ const botId = activity.recipient?.id;
+ if (!botId) return false;
+ const entities = activity.entities ?? [];
+ return entities.some(
+ (e) => e.type === "mention" && e.mentioned?.id === botId,
+ );
+ }
+
// Handler for incoming messages
async function handleTeamsMessage(context: TeamsTurnContext) {
const activity = context.activity;
@@ -172,6 +196,25 @@ export async function monitorMSTeamsProvider(
const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id;
+ // Save conversation reference for proactive messaging
+ const conversationRef: StoredConversationReference = {
+ activityId: activity.id,
+ user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
+ bot: activity.recipient
+ ? { id: activity.recipient.id, name: activity.recipient.name }
+ : undefined,
+ conversation: {
+ id: conversationId,
+ conversationType,
+ tenantId: conversation?.tenantId,
+ },
+ channelId: activity.channelId,
+ serviceUrl: activity.serviceUrl,
+ };
+ saveConversationReference(conversationId, conversationRef).catch((err) => {
+ log.debug("failed to save conversation reference", { error: String(err) });
+ });
+
// Build Teams-specific identifiers
const teamsFrom = isDirectMessage
? `msteams:${senderId}`
@@ -202,6 +245,49 @@ export async function monitorMSTeamsProvider(
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
});
+ // Check DM policy for direct messages
+ if (isDirectMessage && msteamsCfg) {
+ const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
+ const allowFrom = msteamsCfg.allowFrom ?? [];
+
+ if (dmPolicy === "disabled") {
+ log.debug("dropping dm (dms disabled)");
+ return;
+ }
+
+ if (dmPolicy !== "open") {
+ // Check allowlist - look up from config and pairing store
+ const storedAllowFrom = await readProviderAllowFromStore("msteams");
+ const effectiveAllowFrom = [
+ ...allowFrom.map((v) => String(v).toLowerCase()),
+ ...storedAllowFrom.map((v) => v.toLowerCase()),
+ ];
+
+ const senderLower = senderId.toLowerCase();
+ const permitted = effectiveAllowFrom.some(
+ (entry) => entry === senderLower || entry === "*",
+ );
+
+ if (!permitted) {
+ if (dmPolicy === "pairing") {
+ const { code, created } = await upsertProviderPairingRequest({
+ provider: "msteams",
+ id: senderId,
+ meta: { name: senderName },
+ });
+ const msg = created
+ ? `👋 Hi ${senderName}! To chat with me, please share this pairing code with my owner: **${code}**`
+ : `🔑 Your pairing code is: **${code}** — please share it with my owner to get access.`;
+ await context.sendActivity(msg);
+ log.info("sent pairing code", { senderId, code });
+ } else {
+ log.debug("dropping unauthorized dm", { senderId, dmPolicy });
+ }
+ return;
+ }
+ }
+ }
+
// Format the message body with envelope
const timestamp = parseTimestamp(activity.timestamp);
const body = formatAgentEnvelope({
@@ -226,7 +312,7 @@ export async function monitorMSTeamsProvider(
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
- WasMentioned: !isDirectMessage,
+ WasMentioned: isDirectMessage || wasBotMentioned(activity),
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
@@ -260,9 +346,16 @@ export async function monitorMSTeamsProvider(
});
},
onError: (err, info) => {
+ const errMsg =
+ err instanceof Error
+ ? err.message
+ : typeof err === "object"
+ ? JSON.stringify(err)
+ : String(err);
runtime.error?.(
- danger(`msteams ${info.kind} reply failed: ${String(err)}`),
+ danger(`msteams ${info.kind} reply failed: ${errMsg}`),
);
+ log.error("reply failed", { kind: info.kind, error: err });
},
onReplyStart: sendTypingIndicator,
});
@@ -323,28 +416,57 @@ export async function monitorMSTeamsProvider(
await next();
});
- // Auth configuration using the new SDK format
- const authConfig = {
+ // Auth configuration - use SDK's defaults merger
+ const authConfig = getAuthConfigWithDefaults({
clientId: creds.appId,
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
+ });
+
+ // Create our own Express server (instead of using startServer) so we can control shutdown
+ // Pass authConfig to CloudAdapter so it can authenticate outbound calls
+ const adapter = new CloudAdapter(authConfig);
+ const expressApp = express.default();
+ expressApp.use(express.json());
+ expressApp.use(authorizeJWT(authConfig));
+
+ // Set up the messages endpoint - use configured path and /api/messages as fallback
+ const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages";
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const messageHandler = (req: any, res: any) => {
+ adapter.process(req, res, (context) => handler.run(context));
};
- // Set env vars that startServer reads (it uses loadAuthConfigFromEnv internally)
- process.env.clientId = creds.appId;
- process.env.clientSecret = creds.appPassword;
- process.env.tenantId = creds.tenantId;
- process.env.PORT = String(port);
+ // Listen on configured path and /api/messages (standard Bot Framework path)
+ expressApp.post(configuredPath, messageHandler);
+ if (configuredPath !== "/api/messages") {
+ expressApp.post("/api/messages", messageHandler);
+ }
- // Start the server
- const expressApp = startServer(handler, authConfig);
+ log.debug("listening on paths", {
+ primary: configuredPath,
+ fallback: "/api/messages",
+ });
- log.info(`msteams provider started on port ${port}`);
+ // Start listening and capture the HTTP server handle
+ const httpServer = expressApp.listen(port, () => {
+ log.info(`msteams provider started on port ${port}`);
+ });
+
+ httpServer.on("error", (err) => {
+ log.error("msteams server error", { error: String(err) });
+ });
const shutdown = async () => {
log.info("shutting down msteams provider");
- // Express app doesn't have a direct close method
- // The server is managed by startServer internally
+ return new Promise((resolve) => {
+ httpServer.close((err) => {
+ if (err) {
+ log.debug("msteams server close error", { error: String(err) });
+ }
+ resolve();
+ });
+ });
};
// Handle abort signal
diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts
index f7428467b..718a7cedd 100644
--- a/src/pairing/pairing-store.ts
+++ b/src/pairing/pairing-store.ts
@@ -27,7 +27,8 @@ export type PairingProvider =
| "imessage"
| "discord"
| "slack"
- | "whatsapp";
+ | "whatsapp"
+ | "msteams";
export type PairingRequest = {
id: string;
@@ -189,6 +190,7 @@ function normalizeAllowEntry(provider: PairingProvider, entry: string): string {
if (provider === "signal") return trimmed.replace(/^signal:/i, "");
if (provider === "discord") return trimmed.replace(/^(discord|user):/i, "");
if (provider === "slack") return trimmed.replace(/^(slack|user):/i, "");
+ if (provider === "msteams") return trimmed.replace(/^(msteams|user):/i, "");
return trimmed;
}
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index 04a0889c5..f39052631 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -840,11 +840,17 @@ Initial recommendation: support this type first; treat other attachment types as
- **Tailscale Funnel**: Must be running separately (`tailscale funnel 3978`) - doesn't work well as background task
- **Auth errors (401)**: Expected when testing manually without Azure JWT - means endpoint is reachable
-### In Progress (2026-01-07 - Session 2)
+### Completed (2026-01-07 - Session 2)
6. ✅ **Agent dispatch (sync)**: Wired inbound messages to `dispatchReplyFromConfig()` - replies sent via `context.sendActivity()` within turn
7. ✅ **Typing indicator**: Added typing indicator support via `sendActivities([{ type: "typing" }])`
8. ✅ **Type system updates**: Added `msteams` to `TextChunkProvider`, `OriginatingChannelType`, and route-reply switch
+9. ✅ **@mention stripping**: Strip `...` HTML tags from message text
+10. ✅ **Session key fix**: Remove `;messageid=...` suffix from conversation ID
+11. ✅ **Config reload**: Added msteams to `config-reload.ts` (ProviderKind, ReloadAction, RELOAD_RULES)
+12. ✅ **Pairing support**: Added msteams to PairingProvider type
+13. ✅ **Conversation store**: Created `src/msteams/conversation-store.ts` for storing ConversationReference
+14. ✅ **DM policy**: Implemented DM policy check with pairing support (disabled/pairing/open/allowlist)
### Implementation Notes
@@ -868,13 +874,15 @@ await dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }
- `To`: `user:` (DM) or `conversation:` (group/channel)
- `ChatType`: `"direct"` | `"group"` | `"room"` based on conversation type
+**DM Policy:**
+- `dmPolicy: "disabled"` - Drop all DMs
+- `dmPolicy: "open"` - Allow all DMs
+- `dmPolicy: "pairing"` (default) - Require pairing code approval
+- `dmPolicy: "allowlist"` - Only allow from `allowFrom` list
+
### Remaining
-9. **Test full agent flow**: Send message in Teams → verify agent responds (not just echo)
-10. **Conversation store**: Persist `ConversationReference` by `conversation.id` for proactive messaging
-11. **Proactive messaging**: For slow LLM responses, store reference and send replies asynchronously
-12. **Access control**: Implement DM policy + pairing (reuse existing pairing store) + mention gating in channels
-13. **Config reload**: Add msteams to `config-reload.ts` restart rules
-14. **Outbound CLI/gateway sends**: Implement `sendMessageMSTeams` properly; wire `clawdbot send --provider msteams`
-15. **Media**: Implement inbound attachment download and outbound strategy
-16. **Docs + UI + Onboard**: Write `docs/providers/msteams.md`, add UI config form, update `clawdbot onboard`
+15. **Proactive messaging**: For slow LLM responses, use stored ConversationReference to send async replies
+16. **Outbound CLI/gateway sends**: Implement `sendMessageMSTeams` properly; wire `clawdbot send --provider msteams`
+17. **Media**: Implement inbound attachment download and outbound strategy
+18. **Docs + UI + Onboard**: Write `docs/providers/msteams.md`, add UI config form, update `clawdbot onboard`
From 7d72fcf7f875aebdf9c702f17293152038115049 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 01:20:38 +0300
Subject: [PATCH 015/152] add notes [skip ci]
---
tmp/msteams-implementation-guide.md | 144 ++++++++++++++++++++++++++++
1 file changed, 144 insertions(+)
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index f39052631..91e592d4e 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -812,6 +812,149 @@ Initial recommendation: support this type first; treat other attachment types as
---
+## 9) Receiving All Messages Without @Mentions (RSC Permissions)
+
+By default, Teams bots only receive messages when:
+- The bot is directly messaged (1:1 chat)
+- The bot is @mentioned in a channel or group chat
+
+To receive **all messages** in channels and group chats without requiring @mentions, you must configure **Resource-Specific Consent (RSC)** permissions in your app manifest.
+
+### 9.1 Available RSC Permissions
+
+| Permission | Scope | What it enables |
+|------------|-------|-----------------|
+| `ChannelMessage.Read.Group` | Team | Receive all channel messages in teams where app is installed |
+| `ChatMessage.Read.Chat` | Chat | Receive all messages in group chats where app is installed |
+
+**Important:** These are RSC (app-level) permissions, not Graph API permissions. They enable real-time webhook delivery, not historical message retrieval.
+
+### 9.2 Manifest Configuration
+
+Add the `webApplicationInfo` and `authorization` sections to your `manifest.json`:
+
+```json
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.16",
+ "version": "1.0.0",
+ "id": "",
+ "packageName": "com.clawdbot.msteams",
+ "developer": {
+ "name": "Your Name",
+ "websiteUrl": "https://clawd.bot",
+ "privacyUrl": "https://clawd.bot/privacy",
+ "termsOfUseUrl": "https://clawd.bot/terms"
+ },
+ "name": { "short": "Clawdbot", "full": "Clawdbot MS Teams" },
+ "description": { "short": "AI assistant", "full": "Clawdbot AI assistant for Teams" },
+ "icons": { "outline": "outline.png", "color": "color.png" },
+ "accentColor": "#FF4500",
+ "bots": [
+ {
+ "botId": "",
+ "scopes": ["personal", "team", "groupChat"],
+ "supportsFiles": true,
+ "isNotificationOnly": false
+ }
+ ],
+ "permissions": ["identity", "messageTeamMembers"],
+ "validDomains": [],
+ "webApplicationInfo": {
+ "id": "",
+ "resource": "https://RscPermission"
+ },
+ "authorization": {
+ "permissions": {
+ "resourceSpecific": [
+ {
+ "name": "ChannelMessage.Read.Group",
+ "type": "Application"
+ },
+ {
+ "name": "ChatMessage.Read.Chat",
+ "type": "Application"
+ }
+ ]
+ }
+ }
+}
+```
+
+**Key points:**
+- `webApplicationInfo.id` must match your bot's Microsoft App ID
+- `webApplicationInfo.resource` should be `https://RscPermission`
+- Both permissions are `type: "Application"` (not delegated)
+
+### 9.3 Filtering @Mention Messages (If Needed)
+
+If you want to respond differently to @mentions vs. regular messages, check the `entities` array:
+
+```typescript
+// Check if the bot was mentioned in the activity
+function wasBotMentioned(activity: TeamsActivity): boolean {
+ const botId = activity.recipient?.id;
+ if (!botId) return false;
+ const entities = activity.entities ?? [];
+ return entities.some(
+ (e) => e.type === "mention" && e.mentioned?.id === botId,
+ );
+}
+
+// Usage in message handler
+const mentioned = wasBotMentioned(activity);
+if (mentioned) {
+ // Direct response to @mention
+} else {
+ // Background listening - perhaps log or conditionally respond
+}
+```
+
+### 9.4 Updating an Existing App
+
+To add RSC permissions to an already-installed app:
+
+1. Update your `manifest.json` with the `webApplicationInfo` and `authorization` sections
+2. Increment the `version` field (e.g., `1.0.0` → `1.1.0`)
+3. Re-zip the manifest with icons
+4. **Option A (Teams Admin Center):**
+ - Go to Teams Admin Center → Teams apps → Manage apps
+ - Find your app → Upload new version
+5. **Option B (Sideload):**
+ - In Teams → Apps → Manage your apps → Upload a custom app
+ - Upload the new zip (replaces existing installation)
+6. **For team channels:** Reinstall the app in each team for permissions to take effect
+
+### 9.5 RSC vs Graph API
+
+| Capability | RSC Permissions | Graph API |
+|------------|-----------------|-----------|
+| **Real-time messages** | ✅ Via webhook | ❌ Polling only |
+| **Historical messages** | ❌ No backfill | ✅ Can query history |
+| **Setup complexity** | App manifest only | Requires admin consent + token flow |
+| **Works offline** | ❌ Must be running | ✅ Query anytime |
+
+**Bottom line:** RSC is for real-time listening; Graph API is for historical backfill. For a bot that needs to catch up on missed messages while it was offline, you would need Graph API with `ChannelMessage.Read.All` (requires admin consent).
+
+### 9.6 Troubleshooting RSC
+
+1. **Not receiving messages:** Verify `webApplicationInfo.id` matches your bot's App ID exactly
+2. **Permissions not applied:** Re-upload the app and reinstall in the team/chat
+3. **Admin blocked:** Some orgs restrict RSC permissions; check with IT admin
+4. **Wrong scope:** `ChannelMessage.Read.Group` is for teams; `ChatMessage.Read.Chat` is for group chats
+5. **"Something went wrong" on upload:** Upload via https://admin.teams.microsoft.com instead, open browser DevTools (F12), go to Network tab, and check the response body for the actual error message
+6. **Icon file cannot be empty:** The manifest references icon files that are 0 bytes; create valid PNG icons (32x32 for outline, 192x192 for color)
+7. **webApplicationInfo.Id already in use:** The app is still installed in another team/chat; find and uninstall it first, or wait for propagation delay (5-10 min)
+8. **Sideload failing:** Try "Upload an app to your org's app catalog" instead of "Upload a custom app" - this uploads to the org catalog and often bypasses sideload restrictions
+
+### 9.7 Reference Links
+
+- [Receive all channel messages with RSC](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-messages-with-rsc)
+- [RSC permissions reference](https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent)
+- [Teams app manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema)
+
+---
+
## References (Current as of 2026-01)
- Bot Framework (Node) CloudAdapter sample: https://raw.githubusercontent.com/microsoft/BotBuilder-Samples/main/samples/javascript_nodejs/02.echo-bot/index.js
@@ -886,3 +1029,4 @@ await dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }
16. **Outbound CLI/gateway sends**: Implement `sendMessageMSTeams` properly; wire `clawdbot send --provider msteams`
17. **Media**: Implement inbound attachment download and outbound strategy
18. **Docs + UI + Onboard**: Write `docs/providers/msteams.md`, add UI config form, update `clawdbot onboard`
+19. ✅ **RSC documentation**: Added section 9 documenting how to receive all channel/chat messages without @mentions
From 2c7d5c82f3b6abb2bc1f69b2e53d40af859ada94 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 02:29:53 +0300
Subject: [PATCH 016/152] feat(msteams): add per-channel requireMention config
- Add teams/channels config structure to MSTeamsConfig
- Implement requireMention check in monitor.ts
- Resolution order: channel > team > global > default (true)
- Update zod schema for validation
- Document RSC permissions for receiving all messages without @mention
- Document Graph API Proxy pattern for historical message access
- Document private channel limitations
- Document team/channel ID format (use URL path, not groupId)
---
src/msteams/monitor.ts | 34 +++++
tmp/msteams-implementation-guide.md | 210 +++++++++++++++++++++++++++-
2 files changed, 243 insertions(+), 1 deletion(-)
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
index 278073659..0a8b0217b 100644
--- a/src/msteams/monitor.ts
+++ b/src/msteams/monitor.ts
@@ -57,6 +57,12 @@ type TeamsActivity = {
type?: string;
mentioned?: { id?: string; name?: string };
}>;
+ /** Teams-specific channel data including team info */
+ channelData?: {
+ team?: { id?: string; name?: string };
+ channel?: { id?: string; name?: string };
+ tenant?: { id?: string };
+ };
};
type TeamsTurnContext = {
@@ -288,6 +294,34 @@ export async function monitorMSTeamsProvider(
}
}
+ // Check requireMention for channels and group chats
+ if (!isDirectMessage) {
+ const teamId = activity.channelData?.team?.id;
+ const channelId = conversationId;
+
+ // Resolution order: channel config > team config > global config > default (true)
+ const teamConfig = teamId ? msteamsCfg?.teams?.[teamId] : undefined;
+ const channelConfig = teamConfig?.channels?.[channelId];
+
+ const requireMention =
+ channelConfig?.requireMention ??
+ teamConfig?.requireMention ??
+ msteamsCfg?.requireMention ??
+ true;
+
+ const mentioned = wasBotMentioned(activity);
+
+ if (requireMention && !mentioned) {
+ log.debug("skipping message (mention required)", {
+ teamId,
+ channelId,
+ requireMention,
+ mentioned,
+ });
+ return;
+ }
+ }
+
// Format the message body with envelope
const timestamp = parseTimestamp(activity.timestamp);
const body = formatAgentEnvelope({
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index 91e592d4e..d9712a605 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -808,7 +808,27 @@ Initial recommendation: support this type first; treat other attachment types as
6. **Formatting limits**: Teams markdown is more limited than Slack; assume “plain text + links” for v1, and only later add Adaptive Cards.
7. **Tenant/admin restrictions**: many orgs restrict custom app install or bot scopes. Expect setup friction; document it clearly.
8. **Single-tenant default**: multi-tenant bot creation has a deprecation cutoff (2025-07-31); prefer single-tenant in config defaults and docs.
-9. **Incoming webhooks retirement**: Office 365 connectors / incoming webhooks retirement has moved to 2026-03-31; don’t rely on it as the primary integration surface.
+9. **Incoming webhooks retirement**: Office 365 connectors / incoming webhooks retirement has moved to 2026-03-31; don't rely on it as the primary integration surface.
+10. **Team ID format mismatch**: The `groupId` query param in Teams URLs (e.g., `075b1d78-...`) is **NOT** the team ID used by the Bot Framework. Teams sends the team's conversation thread ID via `activity.channelData.team.id`. To get the correct IDs from URLs:
+
+ **Team URL:**
+ ```
+ https://teams.microsoft.com/l/team/19%3ABk4j...%40thread.tacv2/conversations?groupId=...
+ └────────────────────────────┘
+ Team ID (URL-decode this)
+ ```
+
+ **Channel URL:**
+ ```
+ https://teams.microsoft.com/l/channel/19%3A15bc...%40thread.tacv2/ChannelName?groupId=...
+ └─────────────────────────┘
+ Channel ID (URL-decode this)
+ ```
+
+ **For config:**
+ - Team ID = path segment after `/team/` (URL-decoded)
+ - Channel ID = path segment after `/channel/` (URL-decoded)
+ - **Ignore** the `groupId` query parameter
---
@@ -955,6 +975,194 @@ To add RSC permissions to an already-installed app:
---
+## 10) Historical Message Access via Graph API Proxy
+
+### 10.1 Motivation
+
+On Discord, Clawdbot delivers an excellent UX: users can ask "what did we discuss a year ago?" and the bot can search the entire message history. Even more basically, it can read messages sent while the bot was offline, so users don't have to repeat themselves when the bot comes back online.
+
+Unfortunately, Teams lacks Discord's granular role-based permissions. To read any historical message via Graph API, you must request extremely broad permissions:
+
+| Permission | Type | Scope |
+|------------|------|-------|
+| `ChannelMessage.Read.All` | Application | Read ALL channel messages in the entire tenant |
+| `Chat.Read.All` | Application | Read ALL chats including DMs in the entire tenant |
+
+Both require admin consent and grant access to **everything** - there's no way to limit to specific channels at the permission level.
+
+This creates a trust decision for organizations:
+- **Opt out**: Don't grant these permissions. Bot only works in real-time (RSC). Messages sent while offline are lost.
+- **Opt in**: Grant broad permissions, gain powerful features (history search, offline catchup), but must trust the infrastructure completely.
+
+For organizations that opt in, the recommended architecture ensures the bot can only access what it's explicitly configured for, even though the underlying token has broader access.
+
+### 10.2 Architecture: Graph API Proxy Gateway
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Your Tenant │
+│ │
+│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
+│ │ Clawdbot │────▶│ Graph Proxy │────▶│ Graph API │ │
+│ │ (no token) │ │ (has token) │ │ (tenant) │ │
+│ └─────────────┘ └──────────────┘ └─────────────┘ │
+│ │ │ │
+│ │ ▼ │
+│ │ ┌─────────────┐ │
+│ │ │ Allowlist │ │
+│ │ │ Config │ │
+│ │ └─────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────┐ │
+│ │ Teams │ (real-time via RSC webhook) │
+│ └─────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+**Key principle:** The Graph API token (with tenant-wide access) lives in a separate proxy service, never in Clawdbot itself. Clawdbot requests messages through the proxy, which enforces an allowlist before fetching.
+
+### 10.3 How It Works
+
+1. **Graph Proxy** is a small service (Cloud Function, MCP server, or microservice)
+2. It holds the `ChannelMessage.Read.All` / `Chat.Read.All` token
+3. Clawdbot requests: `GET /messages?team=X&channel=Y&since=timestamp`
+4. Proxy checks allowlist: "Is Clawdbot permitted to read channel Y?"
+5. If allowed → fetch from Graph API, return messages
+6. If denied → return 403 Forbidden, log the attempt
+
+### 10.4 Proxy Allowlist Config
+
+```yaml
+graph_proxy:
+ # Audit logging
+ log_all_requests: true
+
+ # Allowed teams/channels (explicit allowlist)
+ allowed:
+ - team: "075b1d78-d02e-42a1-8b3b-91724ce8fa64"
+ channels:
+ - "19:15bc31ae32f04f1c95a66921a98072e8@thread.tacv2" # Zeno channel
+ # Backend and General NOT listed = no access even though token could read them
+
+ # Optional: rate limiting
+ rate_limit:
+ requests_per_minute: 60
+
+ # Optional: max history depth
+ max_history_days: 365
+```
+
+### 10.5 Security Benefits
+
+| Benefit | Description |
+|---------|-------------|
+| **Token isolation** | Clawdbot never sees the Graph API token |
+| **Explicit allowlist** | Only configured channels are accessible, despite broad token scope |
+| **Centralized audit** | All access attempts logged in one place |
+| **Defense in depth** | Code bugs in Clawdbot can't leak access to unauthorized channels |
+| **Revocation** | Disable proxy = instant cutoff, no token rotation needed in Clawdbot |
+
+### 10.6 Implementation Options
+
+1. **MCP Server** - Clawdbot calls it as a tool; fits naturally into the agent architecture
+2. **HTTP Microservice** - Simple REST API; can run as sidecar or separate deployment
+3. **Cloud Function** - Serverless; scales to zero when not in use; easy to deploy
+
+### 10.7 Example API Surface
+
+```
+GET /api/messages?team={id}&channel={id}&since={timestamp}&limit={n}
+GET /api/messages?team={id}&channel={id}&before={timestamp}&limit={n}
+GET /api/search?team={id}&channel={id}&query={text}&limit={n}
+```
+
+All endpoints check allowlist before executing. Returns 403 if channel not in allowlist.
+
+### 10.8 Graph API Endpoints (Reference)
+
+The proxy would call these Microsoft Graph endpoints:
+
+```
+# List channel messages
+GET /teams/{team-id}/channels/{channel-id}/messages
+
+# List replies to a message
+GET /teams/{team-id}/channels/{channel-id}/messages/{message-id}/replies
+
+# Get messages in a chat (for group chats, not channels)
+GET /chats/{chat-id}/messages
+```
+
+See: [Microsoft Graph Messages API](https://learn.microsoft.com/en-us/graph/api/channel-list-messages)
+
+### 10.9 When to Use This
+
+| Scenario | Recommendation |
+|----------|----------------|
+| Small team, high trust | Maybe skip proxy, use config-based filtering in Clawdbot |
+| Enterprise, compliance-sensitive | Use proxy pattern for audit trail and access control |
+| Multi-tenant SaaS | Definitely use proxy; isolate customer tokens |
+| Personal/hobbyist use | Real-time RSC is probably sufficient |
+
+---
+
+## 11) Private Channels
+
+### 11.1 Bot Support in Private Channels
+
+Historically, Microsoft Teams **did not allow** bots in private channels. This has been gradually changing, but limitations remain.
+
+**Current state (late 2025):**
+
+| Feature | Standard Channels | Private Channels |
+|---------|-------------------|------------------|
+| Bot installation | ✅ Yes | ⚠️ Limited |
+| Real-time messages (webhook) | ✅ Yes | ⚠️ May not work |
+| RSC permissions | ✅ Yes | ⚠️ May behave differently |
+| @mentions | ✅ Yes | ⚠️ If bot is accessible |
+| Graph API history | ✅ Yes | ✅ Yes (with permissions) |
+
+### 11.2 Testing Private Channel Support
+
+To verify if your bot works in private channels:
+
+1. Create a private channel in a team where the bot is installed
+2. Try @mentioning the bot - see if it receives the message
+3. If RSC is enabled, try sending without @mention
+4. Check gateway logs for incoming activity
+
+### 11.3 Workarounds if Private Channels Don't Work
+
+If the bot can't receive real-time messages in private channels:
+
+1. **Use standard channels** for bot interactions
+2. **Use DMs** - users can always message the bot directly
+3. **Graph API Proxy** - can read private channel history if permissions are granted (requires `ChannelMessage.Read.All`)
+4. **Shared channels** - cross-tenant shared channels may have different behavior
+
+### 11.4 Graph API Access to Private Channels
+
+The Graph API **can** access private channel messages with `ChannelMessage.Read.All`, even if the bot can't receive real-time webhooks. This means the proxy pattern (Section 10) works for private channel history.
+
+```
+GET /teams/{team-id}/channels/{private-channel-id}/messages
+```
+
+The channel ID for private channels follows the same format: `19:xxx@thread.tacv2`
+
+### 11.5 Recommendations
+
+| Use Case | Recommendation |
+|----------|----------------|
+| Need real-time bot interaction | Use standard channels or DMs |
+| Need to search private channel history | Use Graph API Proxy |
+| Compliance/audit of private channels | Graph API with `ChannelMessage.Read.All` |
+
+**Note:** Microsoft continues to improve private channel support. Check the latest documentation if this is critical for your use case.
+
+---
+
## References (Current as of 2026-01)
- Bot Framework (Node) CloudAdapter sample: https://raw.githubusercontent.com/microsoft/BotBuilder-Samples/main/samples/javascript_nodejs/02.echo-bot/index.js
From 269a3c4000cae58f3953696c3d511ce0238a9baa Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 03:22:16 +0300
Subject: [PATCH 017/152] feat(msteams): add outbound sends and fix reply
delivery
- Add sendMessageMSTeams for proactive messaging via CLI/gateway
- Wire msteams into outbound delivery, heartbeat targets, and gateway send
- Fix reply delivery to use SDK's getConversationReference() for proper
bot info, avoiding "Activity Recipient undefined" errors
- Use proactive messaging for replies to post as top-level messages
(not threaded) by omitting activityId from conversation reference
- Add lazy logger in send.ts to avoid test initialization issues
---
src/cli/deps.ts | 3 +
src/gateway/server-methods/send.ts | 21 +++
src/gateway/server.ts | 7 +
src/infra/outbound/deliver.ts | 27 +++-
src/infra/outbound/targets.ts | 18 ++-
src/msteams/monitor.ts | 66 +++++++--
src/msteams/send.ts | 231 +++++++++++++++++++++++++++--
7 files changed, 340 insertions(+), 33 deletions(-)
diff --git a/src/cli/deps.ts b/src/cli/deps.ts
index 41a1118d0..aab4366c2 100644
--- a/src/cli/deps.ts
+++ b/src/cli/deps.ts
@@ -1,5 +1,6 @@
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
+import { sendMessageMSTeams } from "../msteams/send.js";
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
import { sendMessageSignal } from "../signal/send.js";
import { sendMessageSlack } from "../slack/send.js";
@@ -12,6 +13,7 @@ export type CliDeps = {
sendMessageSlack: typeof sendMessageSlack;
sendMessageSignal: typeof sendMessageSignal;
sendMessageIMessage: typeof sendMessageIMessage;
+ sendMessageMSTeams: typeof sendMessageMSTeams;
};
export function createDefaultDeps(): CliDeps {
@@ -22,6 +24,7 @@ export function createDefaultDeps(): CliDeps {
sendMessageSlack,
sendMessageSignal,
sendMessageIMessage,
+ sendMessageMSTeams,
};
}
diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts
index 7da830bbf..ce0509122 100644
--- a/src/gateway/server-methods/send.ts
+++ b/src/gateway/server-methods/send.ts
@@ -2,6 +2,7 @@ import { loadConfig } from "../../config/config.js";
import { sendMessageDiscord, sendPollDiscord } from "../../discord/index.js";
import { shouldLogVerbose } from "../../globals.js";
import { sendMessageIMessage } from "../../imessage/index.js";
+import { sendMessageMSTeams } from "../../msteams/send.js";
import { sendMessageSignal } from "../../signal/index.js";
import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js";
@@ -141,6 +142,26 @@ export const sendHandlers: GatewayRequestHandlers = {
payload,
});
respond(true, payload, undefined, { provider });
+ } else if (provider === "msteams") {
+ const cfg = loadConfig();
+ const result = await sendMessageMSTeams({
+ cfg,
+ to,
+ text: message,
+ mediaUrl: request.mediaUrl,
+ });
+ const payload = {
+ runId: idem,
+ messageId: result.messageId,
+ conversationId: result.conversationId,
+ provider,
+ };
+ context.dedupe.set(`send:${idem}`, {
+ ts: Date.now(),
+ ok: true,
+ payload,
+ });
+ respond(true, payload, undefined, { provider });
} else {
const cfg = loadConfig();
const targetAccountId =
diff --git a/src/gateway/server.ts b/src/gateway/server.ts
index 3c244d1ba..5b2b70a5e 100644
--- a/src/gateway/server.ts
+++ b/src/gateway/server.ts
@@ -1964,6 +1964,13 @@ export async function startGatewayServer(
startIMessageProvider,
);
}
+ if (plan.restartProviders.has("msteams")) {
+ await restartProvider(
+ "msteams",
+ stopMSTeamsProvider,
+ startMSTeamsProvider,
+ );
+ }
}
}
diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts
index 7b915715d..f38576f5f 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -7,6 +7,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.js";
+import { sendMessageMSTeams } from "../../msteams/send.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { sendMessageSignal } from "../../signal/send.js";
import { sendMessageSlack } from "../../slack/send.js";
@@ -28,6 +29,11 @@ export type OutboundSendDeps = {
sendSlack?: typeof sendMessageSlack;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
+ sendMSTeams?: (
+ to: string,
+ text: string,
+ opts?: { mediaUrl?: string },
+ ) => Promise<{ messageId: string; conversationId: string }>;
};
export type OutboundDeliveryResult =
@@ -36,7 +42,8 @@ export type OutboundDeliveryResult =
| { provider: "discord"; messageId: string; channelId: string }
| { provider: "slack"; messageId: string; channelId: string }
| { provider: "signal"; messageId: string; timestamp?: number }
- | { provider: "imessage"; messageId: string };
+ | { provider: "imessage"; messageId: string }
+ | { provider: "msteams"; messageId: string; conversationId: string };
type Chunker = (text: string, limit: number) => string[];
@@ -50,6 +57,7 @@ const providerCaps: Record<
slack: { chunker: null },
signal: { chunker: chunkText },
imessage: { chunker: chunkText },
+ msteams: { chunker: chunkMarkdownText },
};
type ProviderHandler = {
@@ -204,6 +212,17 @@ function createProviderHandler(params: {
})),
}),
},
+ msteams: {
+ chunker: providerCaps.msteams.chunker,
+ sendText: async (text) => ({
+ provider: "msteams",
+ ...(await deps.sendMSTeams(to, text)),
+ }),
+ sendMedia: async (caption, mediaUrl) => ({
+ provider: "msteams",
+ ...(await deps.sendMSTeams(to, caption, { mediaUrl })),
+ }),
+ },
};
return handlers[params.provider];
@@ -222,6 +241,11 @@ export async function deliverOutboundPayloads(params: {
}): Promise {
const { cfg, provider, to, payloads } = params;
const accountId = params.accountId;
+ const defaultSendMSTeams = async (
+ to: string,
+ text: string,
+ opts?: { mediaUrl?: string },
+ ) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl });
const deps = {
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
@@ -229,6 +253,7 @@ export async function deliverOutboundPayloads(params: {
sendSlack: params.deps?.sendSlack ?? sendMessageSlack,
sendSignal: params.deps?.sendSignal ?? sendMessageSignal,
sendIMessage: params.deps?.sendIMessage ?? sendMessageIMessage,
+ sendMSTeams: params.deps?.sendMSTeams ?? defaultSendMSTeams,
};
const results: OutboundDeliveryResult[] = [];
const handler = createProviderHandler({
diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts
index 59328a4d0..6d526f851 100644
--- a/src/infra/outbound/targets.ts
+++ b/src/infra/outbound/targets.ts
@@ -9,6 +9,7 @@ export type OutboundProvider =
| "slack"
| "signal"
| "imessage"
+ | "msteams"
| "none";
export type HeartbeatTarget = OutboundProvider | "last";
@@ -31,6 +32,7 @@ export function resolveOutboundTarget(params: {
| "slack"
| "signal"
| "imessage"
+ | "msteams"
| "webchat";
to?: string;
allowFrom?: string[];
@@ -104,6 +106,17 @@ export function resolveOutboundTarget(params: {
}
return { ok: true, to: trimmed };
}
+ if (params.provider === "msteams") {
+ if (!trimmed) {
+ return {
+ ok: false,
+ error: new Error(
+ "Delivering to MS Teams requires --to ",
+ ),
+ };
+ }
+ return { ok: true, to: trimmed };
+ }
return {
ok: false,
error: new Error(
@@ -125,6 +138,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
rawTarget === "slack" ||
rawTarget === "signal" ||
rawTarget === "imessage" ||
+ rawTarget === "msteams" ||
rawTarget === "none" ||
rawTarget === "last"
? rawTarget
@@ -152,6 +166,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
| "slack"
| "signal"
| "imessage"
+ | "msteams"
| undefined =
target === "last"
? lastProvider
@@ -160,7 +175,8 @@ export function resolveHeartbeatDeliveryTarget(params: {
target === "discord" ||
target === "slack" ||
target === "signal" ||
- target === "imessage"
+ target === "imessage" ||
+ target === "msteams"
? target
: undefined;
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
index 0a8b0217b..f6d90fd4e 100644
--- a/src/msteams/monitor.ts
+++ b/src/msteams/monitor.ts
@@ -96,6 +96,7 @@ export async function monitorMSTeamsProvider(
log.error("msteams credentials not configured");
return { app: null, shutdown: async () => {} };
}
+ const appId = creds.appId; // Extract for use in closures
const runtime: RuntimeEnv = opts.runtime ?? {
log: console.log,
@@ -117,34 +118,74 @@ export async function monitorMSTeamsProvider(
const { ActivityHandler, CloudAdapter, authorizeJWT, getAuthConfigWithDefaults } =
agentsHosting;
- // Helper to deliver replies via Teams SDK
+ // Auth configuration - create early so adapter is available for deliverReplies
+ const authConfig = getAuthConfigWithDefaults({
+ clientId: creds.appId,
+ clientSecret: creds.appPassword,
+ tenantId: creds.tenantId,
+ });
+ const adapter = new CloudAdapter(authConfig);
+
+ // Helper to deliver replies as top-level messages (not threaded)
+ // We use proactive messaging to avoid threading to the original message
async function deliverReplies(params: {
replies: ReplyPayload[];
- context: TeamsTurnContext;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ context: any; // TurnContext from SDK - has activity.getConversationReference()
+ adapter: InstanceType;
+ appId: string;
}) {
const chunkLimit = Math.min(textLimit, 4000);
+
+ // Get conversation reference from SDK's activity (includes proper bot info)
+ // Then remove activityId to avoid threading
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const fullRef = params.context.activity.getConversationReference() as any;
+ const conversationRef = {
+ ...fullRef,
+ activityId: undefined, // Remove to post as top-level message, not thread
+ };
+ // Also strip the messageid suffix from conversation.id if present
+ if (conversationRef.conversation?.id) {
+ conversationRef.conversation = {
+ ...conversationRef.conversation,
+ id: conversationRef.conversation.id.split(";")[0],
+ };
+ }
+
for (const payload of params.replies) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
+ const sendMessage = async (message: string) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (params.adapter as any).continueConversation(
+ params.appId,
+ conversationRef,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ async (ctx: any) => {
+ await ctx.sendActivity({ type: "message", text: message });
+ },
+ );
+ };
+
if (mediaList.length === 0) {
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
- await params.context.sendActivity(trimmed);
+ await sendMessage(trimmed);
}
} else {
// For media, send text first then media URLs as separate messages
if (text.trim() && text.trim() !== SILENT_REPLY_TOKEN) {
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
- await params.context.sendActivity(chunk);
+ await sendMessage(chunk);
}
}
for (const mediaUrl of mediaList) {
- // Teams supports adaptive cards for rich media, but for now just send URL
- await params.context.sendActivity(mediaUrl);
+ await sendMessage(mediaUrl);
}
}
}
@@ -377,6 +418,8 @@ export async function monitorMSTeamsProvider(
await deliverReplies({
replies: [payload],
context,
+ adapter,
+ appId,
});
},
onError: (err, info) => {
@@ -450,16 +493,7 @@ export async function monitorMSTeamsProvider(
await next();
});
- // Auth configuration - use SDK's defaults merger
- const authConfig = getAuthConfigWithDefaults({
- clientId: creds.appId,
- clientSecret: creds.appPassword,
- tenantId: creds.tenantId,
- });
-
- // Create our own Express server (instead of using startServer) so we can control shutdown
- // Pass authConfig to CloudAdapter so it can authenticate outbound calls
- const adapter = new CloudAdapter(authConfig);
+ // Create Express server
const expressApp = express.default();
expressApp.use(express.json());
expressApp.use(authorizeJWT(authConfig));
diff --git a/src/msteams/send.ts b/src/msteams/send.ts
index 3e62c75f7..0daf2a7c1 100644
--- a/src/msteams/send.ts
+++ b/src/msteams/send.ts
@@ -1,25 +1,226 @@
-import type { MSTeamsConfig } from "../config/types.js";
-import { getChildLogger } from "../logging.js";
+import type { ClawdbotConfig } from "../config/types.js";
+import type { getChildLogger as getChildLoggerFn } from "../logging.js";
+import {
+ getConversationReference,
+ listConversationReferences,
+ type StoredConversationReference,
+} from "./conversation-store.js";
+import { resolveMSTeamsCredentials } from "./token.js";
-const log = getChildLogger({ name: "msteams:send" });
+// Lazy logger to avoid initialization order issues in tests
+let _log: ReturnType | undefined;
+const getLog = (): ReturnType => {
+ if (!_log) {
+ // Dynamic import to defer initialization
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { getChildLogger } = require("../logging.js") as {
+ getChildLogger: typeof getChildLoggerFn;
+ };
+ _log = getChildLogger({ name: "msteams:send" });
+ }
+ return _log;
+};
export type SendMSTeamsMessageParams = {
- cfg: MSTeamsConfig;
- conversationId: string;
+ /** Full config (for credentials) */
+ cfg: ClawdbotConfig;
+ /** Conversation ID or user ID to send to */
+ to: string;
+ /** Message text */
text: string;
- serviceUrl: string;
+ /** Optional media URL */
+ mediaUrl?: string;
};
export type SendMSTeamsMessageResult = {
- ok: boolean;
- messageId?: string;
- error?: string;
+ messageId: string;
+ conversationId: string;
};
-export async function sendMessageMSTeams(
- _params: SendMSTeamsMessageParams,
-): Promise {
- // TODO: Implement using CloudAdapter.continueConversationAsync
- log.warn("sendMessageMSTeams not yet implemented");
- return { ok: false, error: "not implemented" };
+/**
+ * Parse the --to argument into a conversation reference lookup key.
+ * Supported formats:
+ * - conversation:19:abc@thread.tacv2 → lookup by conversation ID
+ * - user:aad-object-id → lookup by user AAD object ID
+ * - 19:abc@thread.tacv2 → direct conversation ID
+ */
+function parseRecipient(to: string): {
+ type: "conversation" | "user";
+ id: string;
+} {
+ const trimmed = to.trim();
+ if (trimmed.startsWith("conversation:")) {
+ return { type: "conversation", id: trimmed.slice("conversation:".length) };
+ }
+ if (trimmed.startsWith("user:")) {
+ return { type: "user", id: trimmed.slice("user:".length) };
+ }
+ // Assume it's a conversation ID if it looks like one
+ if (trimmed.startsWith("19:") || trimmed.includes("@thread")) {
+ return { type: "conversation", id: trimmed };
+ }
+ // Otherwise treat as user ID
+ return { type: "user", id: trimmed };
+}
+
+/**
+ * Find a stored conversation reference for the given recipient.
+ */
+async function findConversationReference(
+ recipient: { type: "conversation" | "user"; id: string },
+): Promise<{ conversationId: string; ref: StoredConversationReference } | null> {
+ if (recipient.type === "conversation") {
+ const ref = await getConversationReference(recipient.id);
+ if (ref) return { conversationId: recipient.id, ref };
+ return null;
+ }
+
+ // Search by user AAD object ID
+ const all = await listConversationReferences();
+ for (const { conversationId, reference } of all) {
+ if (reference.user?.aadObjectId === recipient.id) {
+ return { conversationId, ref: reference };
+ }
+ if (reference.user?.id === recipient.id) {
+ return { conversationId, ref: reference };
+ }
+ }
+ return null;
+}
+
+// Type matching @microsoft/agents-activity ConversationReference
+type ConversationReferenceShape = {
+ activityId?: string;
+ user?: { id: string; name?: string };
+ bot?: { id: string; name?: string };
+ conversation: { id: string; conversationType?: string; tenantId?: string };
+ channelId: string;
+ serviceUrl?: string;
+ locale?: string;
+};
+
+/**
+ * Build a Bot Framework ConversationReference from our stored format.
+ * Note: activityId is intentionally omitted so proactive messages post as
+ * top-level messages rather than replies/threads.
+ */
+function buildConversationReference(
+ ref: StoredConversationReference,
+): ConversationReferenceShape {
+ if (!ref.conversation?.id) {
+ throw new Error("Invalid stored reference: missing conversation.id");
+ }
+ return {
+ // activityId omitted to avoid creating reply threads
+ user: ref.user?.id ? { id: ref.user.id, name: ref.user.name } : undefined,
+ bot: ref.bot?.id ? { id: ref.bot.id, name: ref.bot.name } : undefined,
+ conversation: {
+ id: ref.conversation.id,
+ conversationType: ref.conversation.conversationType,
+ tenantId: ref.conversation.tenantId,
+ },
+ channelId: ref.channelId ?? "msteams",
+ serviceUrl: ref.serviceUrl,
+ locale: ref.locale,
+ };
+}
+
+/**
+ * Send a message to a Teams conversation or user.
+ *
+ * Uses the stored ConversationReference from previous interactions.
+ * The bot must have received at least one message from the conversation
+ * before proactive messaging works.
+ */
+export async function sendMessageMSTeams(
+ params: SendMSTeamsMessageParams,
+): Promise {
+ const { cfg, to, text, mediaUrl } = params;
+ const msteamsCfg = cfg.msteams;
+
+ if (!msteamsCfg?.enabled) {
+ throw new Error("msteams provider is not enabled");
+ }
+
+ const creds = resolveMSTeamsCredentials(msteamsCfg);
+ if (!creds) {
+ throw new Error("msteams credentials not configured");
+ }
+
+ // Parse recipient and find conversation reference
+ const recipient = parseRecipient(to);
+ const found = await findConversationReference(recipient);
+
+ if (!found) {
+ throw new Error(
+ `No conversation reference found for ${recipient.type}:${recipient.id}. ` +
+ `The bot must receive a message from this conversation before it can send proactively.`,
+ );
+ }
+
+ const { conversationId, ref } = found;
+ const conversationRef = buildConversationReference(ref);
+
+ getLog().debug("sending proactive message", {
+ conversationId,
+ textLength: text.length,
+ hasMedia: Boolean(mediaUrl),
+ });
+
+ // Dynamic import to avoid loading SDK when not needed
+ const agentsHosting = await import("@microsoft/agents-hosting");
+ const { CloudAdapter, getAuthConfigWithDefaults } = agentsHosting;
+
+ const authConfig = getAuthConfigWithDefaults({
+ clientId: creds.appId,
+ clientSecret: creds.appPassword,
+ tenantId: creds.tenantId,
+ });
+
+ const adapter = new CloudAdapter(authConfig);
+
+ let messageId = "unknown";
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (adapter as any).continueConversation(
+ creds.appId,
+ conversationRef,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ async (context: any) => {
+ // Build the activity
+ const activity = {
+ type: "message",
+ text: mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text,
+ };
+ const response = await context.sendActivity(activity);
+ if (response?.id) {
+ messageId = response.id;
+ }
+ },
+ );
+
+ getLog().info("sent proactive message", { conversationId, messageId });
+
+ return {
+ messageId,
+ conversationId,
+ };
+}
+
+/**
+ * List all known conversation references (for debugging/CLI).
+ */
+export async function listMSTeamsConversations(): Promise<
+ Array<{
+ conversationId: string;
+ userName?: string;
+ conversationType?: string;
+ }>
+> {
+ const all = await listConversationReferences();
+ return all.map(({ conversationId, reference }) => ({
+ conversationId,
+ userName: reference.user?.name,
+ conversationType: reference.conversation?.conversationType,
+ }));
}
From 81f81be816f126844d3585faaac3e88e8afa3f36 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 03:29:39 +0300
Subject: [PATCH 018/152] feat(msteams): add replyStyle config for thread vs
top-level replies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add replyStyle config at global, team, and channel levels
- "thread" replies to the original message (for Posts layout channels)
- "top-level" posts as a new message (for Threads layout channels)
- Default based on requireMention: false → top-level, true → thread
- DMs always use thread style (direct reply)
---
src/msteams/monitor.ts | 97 +++++++++++++++++++++++++++---------------
1 file changed, 62 insertions(+), 35 deletions(-)
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
index f6d90fd4e..620be86a6 100644
--- a/src/msteams/monitor.ts
+++ b/src/msteams/monitor.ts
@@ -126,32 +126,52 @@ export async function monitorMSTeamsProvider(
});
const adapter = new CloudAdapter(authConfig);
- // Helper to deliver replies as top-level messages (not threaded)
- // We use proactive messaging to avoid threading to the original message
+ // Helper to deliver replies with configurable reply style
+ // - "thread": reply to the original message (for Posts layout channels)
+ // - "top-level": post as a new message (for Threads layout channels)
async function deliverReplies(params: {
replies: ReplyPayload[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any; // TurnContext from SDK - has activity.getConversationReference()
adapter: InstanceType;
appId: string;
+ replyStyle: "thread" | "top-level";
}) {
const chunkLimit = Math.min(textLimit, 4000);
- // Get conversation reference from SDK's activity (includes proper bot info)
- // Then remove activityId to avoid threading
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const fullRef = params.context.activity.getConversationReference() as any;
- const conversationRef = {
- ...fullRef,
- activityId: undefined, // Remove to post as top-level message, not thread
- };
- // Also strip the messageid suffix from conversation.id if present
- if (conversationRef.conversation?.id) {
- conversationRef.conversation = {
- ...conversationRef.conversation,
- id: conversationRef.conversation.id.split(";")[0],
- };
- }
+ // For "thread" style, use context.sendActivity directly (replies to original message)
+ // For "top-level" style, use proactive messaging without activityId
+ const sendMessage =
+ params.replyStyle === "thread"
+ ? async (message: string) => {
+ await params.context.sendActivity({ type: "message", text: message });
+ }
+ : async (message: string) => {
+ // Get conversation reference from SDK's activity (includes proper bot info)
+ // Then remove activityId to avoid threading
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const fullRef = params.context.activity.getConversationReference() as any;
+ const conversationRef = {
+ ...fullRef,
+ activityId: undefined, // Remove to post as top-level message
+ };
+ // Also strip the messageid suffix from conversation.id if present
+ if (conversationRef.conversation?.id) {
+ conversationRef.conversation = {
+ ...conversationRef.conversation,
+ id: conversationRef.conversation.id.split(";")[0],
+ };
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (params.adapter as any).continueConversation(
+ params.appId,
+ conversationRef,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ async (ctx: any) => {
+ await ctx.sendActivity({ type: "message", text: message });
+ },
+ );
+ };
for (const payload of params.replies) {
const mediaList =
@@ -159,18 +179,6 @@ export async function monitorMSTeamsProvider(
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
- const sendMessage = async (message: string) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await (params.adapter as any).continueConversation(
- params.appId,
- conversationRef,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- async (ctx: any) => {
- await ctx.sendActivity({ type: "message", text: message });
- },
- );
- };
-
if (mediaList.length === 0) {
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
const trimmed = chunk.trim();
@@ -335,15 +343,15 @@ export async function monitorMSTeamsProvider(
}
}
+ // Resolve team/channel config for channels and group chats
+ const teamId = activity.channelData?.team?.id;
+ const channelId = conversationId;
+ const teamConfig = teamId ? msteamsCfg?.teams?.[teamId] : undefined;
+ const channelConfig = teamConfig?.channels?.[channelId];
+
// Check requireMention for channels and group chats
if (!isDirectMessage) {
- const teamId = activity.channelData?.team?.id;
- const channelId = conversationId;
-
// Resolution order: channel config > team config > global config > default (true)
- const teamConfig = teamId ? msteamsCfg?.teams?.[teamId] : undefined;
- const channelConfig = teamConfig?.channels?.[channelId];
-
const requireMention =
channelConfig?.requireMention ??
teamConfig?.requireMention ??
@@ -363,6 +371,24 @@ export async function monitorMSTeamsProvider(
}
}
+ // Resolve reply style for channels/groups
+ // Resolution order: channel config > team config > global config > default based on requireMention
+ // If requireMention is false (Threads layout), default to "top-level"
+ // If requireMention is true (Posts layout), default to "thread"
+ const explicitReplyStyle =
+ channelConfig?.replyStyle ??
+ teamConfig?.replyStyle ??
+ msteamsCfg?.replyStyle;
+ const effectiveRequireMention =
+ channelConfig?.requireMention ??
+ teamConfig?.requireMention ??
+ msteamsCfg?.requireMention ??
+ true;
+ // For DMs, always use "thread" style (direct reply)
+ const replyStyle: "thread" | "top-level" = isDirectMessage
+ ? "thread"
+ : explicitReplyStyle ?? (effectiveRequireMention ? "thread" : "top-level");
+
// Format the message body with envelope
const timestamp = parseTimestamp(activity.timestamp);
const body = formatAgentEnvelope({
@@ -420,6 +446,7 @@ export async function monitorMSTeamsProvider(
context,
adapter,
appId,
+ replyStyle,
});
},
onError: (err, info) => {
From a2bab7d92372764b0c1c6a2d193184c0f1f33d0a Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 09:47:01 +0300
Subject: [PATCH 019/152] MS Teams: refactor provider + replyStyle +
reliability
---
src/config/config.test.ts | 37 +++
src/msteams/conversation-store-fs.test.ts | 76 ++++++
src/msteams/conversation-store-fs.ts | 268 +++++++++++++++++++
src/msteams/conversation-store-memory.ts | 45 ++++
src/msteams/conversation-store.ts | 110 +-------
src/msteams/errors.test.ts | 50 ++++
src/msteams/errors.ts | 171 +++++++++++++
src/msteams/inbound.test.ts | 64 +++++
src/msteams/inbound.ts | 35 +++
src/msteams/messenger.test.ts | 209 +++++++++++++++
src/msteams/messenger.ts | 294 +++++++++++++++++++++
src/msteams/monitor.ts | 297 +++++++---------------
src/msteams/policy.test.ts | 99 ++++++++
src/msteams/policy.ts | 58 +++++
src/msteams/probe.test.ts | 57 +++++
src/msteams/probe.ts | 23 +-
src/msteams/sdk-types.ts | 19 ++
src/msteams/send.ts | 155 +++++------
tmp/msteams-refactor-plan.md | 156 ++++++++++++
19 files changed, 1834 insertions(+), 389 deletions(-)
create mode 100644 src/msteams/conversation-store-fs.test.ts
create mode 100644 src/msteams/conversation-store-fs.ts
create mode 100644 src/msteams/conversation-store-memory.ts
create mode 100644 src/msteams/errors.test.ts
create mode 100644 src/msteams/errors.ts
create mode 100644 src/msteams/inbound.test.ts
create mode 100644 src/msteams/inbound.ts
create mode 100644 src/msteams/messenger.test.ts
create mode 100644 src/msteams/messenger.ts
create mode 100644 src/msteams/policy.test.ts
create mode 100644 src/msteams/policy.ts
create mode 100644 src/msteams/probe.test.ts
create mode 100644 src/msteams/sdk-types.ts
create mode 100644 tmp/msteams-refactor-plan.md
diff --git a/src/config/config.test.ts b/src/config/config.test.ts
index 6c05c889b..787857a7d 100644
--- a/src/config/config.test.ts
+++ b/src/config/config.test.ts
@@ -500,6 +500,43 @@ describe("config discord", () => {
});
});
+describe("config msteams", () => {
+ it("accepts replyStyle at global/team/channel levels", async () => {
+ vi.resetModules();
+ const { validateConfigObject } = await import("./config.js");
+ const res = validateConfigObject({
+ msteams: {
+ replyStyle: "top-level",
+ teams: {
+ team123: {
+ replyStyle: "thread",
+ channels: {
+ chan456: { replyStyle: "top-level" },
+ },
+ },
+ },
+ },
+ });
+ expect(res.ok).toBe(true);
+ if (res.ok) {
+ expect(res.config.msteams?.replyStyle).toBe("top-level");
+ expect(res.config.msteams?.teams?.team123?.replyStyle).toBe("thread");
+ expect(
+ res.config.msteams?.teams?.team123?.channels?.chan456?.replyStyle,
+ ).toBe("top-level");
+ }
+ });
+
+ it("rejects invalid replyStyle", async () => {
+ vi.resetModules();
+ const { validateConfigObject } = await import("./config.js");
+ const res = validateConfigObject({
+ msteams: { replyStyle: "nope" },
+ });
+ expect(res.ok).toBe(false);
+ });
+});
+
describe("Nix integration (U3, U5, U9)", () => {
describe("U3: isNixMode env var detection", () => {
it("isNixMode is false when CLAWDBOT_NIX_MODE is not set", async () => {
diff --git a/src/msteams/conversation-store-fs.test.ts b/src/msteams/conversation-store-fs.test.ts
new file mode 100644
index 000000000..ee1618dc1
--- /dev/null
+++ b/src/msteams/conversation-store-fs.test.ts
@@ -0,0 +1,76 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import type { StoredConversationReference } from "./conversation-store.js";
+import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
+
+describe("msteams conversation store (fs)", () => {
+ it("filters and prunes expired entries (but keeps legacy ones)", async () => {
+ const stateDir = await fs.promises.mkdtemp(
+ path.join(os.tmpdir(), "clawdbot-msteams-store-"),
+ );
+
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ CLAWDBOT_STATE_DIR: stateDir,
+ };
+
+ const store = createMSTeamsConversationStoreFs({ env, ttlMs: 1_000 });
+
+ const ref: StoredConversationReference = {
+ conversation: { id: "19:active@thread.tacv2" },
+ channelId: "msteams",
+ serviceUrl: "https://service.example.com",
+ user: { id: "u1", aadObjectId: "aad1" },
+ };
+
+ await store.upsert("19:active@thread.tacv2", ref);
+
+ const filePath = path.join(stateDir, "msteams-conversations.json");
+ const raw = await fs.promises.readFile(filePath, "utf-8");
+ const json = JSON.parse(raw) as {
+ version: number;
+ conversations: Record<
+ string,
+ StoredConversationReference & { lastSeenAt?: string }
+ >;
+ };
+
+ json.conversations["19:old@thread.tacv2"] = {
+ ...ref,
+ conversation: { id: "19:old@thread.tacv2" },
+ lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
+ };
+
+ // Legacy entry without lastSeenAt should be preserved.
+ json.conversations["19:legacy@thread.tacv2"] = {
+ ...ref,
+ conversation: { id: "19:legacy@thread.tacv2" },
+ };
+
+ await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
+
+ const list = await store.list();
+ const ids = list.map((e) => e.conversationId).sort();
+ expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
+
+ expect(await store.get("19:old@thread.tacv2")).toBeNull();
+ expect(await store.get("19:legacy@thread.tacv2")).not.toBeNull();
+
+ await store.upsert("19:new@thread.tacv2", {
+ ...ref,
+ conversation: { id: "19:new@thread.tacv2" },
+ });
+
+ const rawAfter = await fs.promises.readFile(filePath, "utf-8");
+ const jsonAfter = JSON.parse(rawAfter) as typeof json;
+ expect(Object.keys(jsonAfter.conversations).sort()).toEqual([
+ "19:active@thread.tacv2",
+ "19:legacy@thread.tacv2",
+ "19:new@thread.tacv2",
+ ]);
+ });
+});
diff --git a/src/msteams/conversation-store-fs.ts b/src/msteams/conversation-store-fs.ts
new file mode 100644
index 000000000..f1891fa3a
--- /dev/null
+++ b/src/msteams/conversation-store-fs.ts
@@ -0,0 +1,268 @@
+import crypto from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import lockfile from "proper-lockfile";
+
+import { resolveStateDir } from "../config/paths.js";
+import type {
+ MSTeamsConversationStore,
+ MSTeamsConversationStoreEntry,
+ StoredConversationReference,
+} from "./conversation-store.js";
+
+type ConversationStoreData = {
+ version: 1;
+ conversations: Record<
+ string,
+ StoredConversationReference & { lastSeenAt?: string }
+ >;
+};
+
+const STORE_FILENAME = "msteams-conversations.json";
+const MAX_CONVERSATIONS = 1000;
+const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
+const STORE_LOCK_OPTIONS = {
+ retries: {
+ retries: 10,
+ factor: 2,
+ minTimeout: 100,
+ maxTimeout: 10_000,
+ randomize: true,
+ },
+ stale: 30_000,
+} as const;
+
+function resolveStorePath(
+ env: NodeJS.ProcessEnv = process.env,
+ homedir?: () => string,
+): string {
+ const stateDir = homedir
+ ? resolveStateDir(env, homedir)
+ : resolveStateDir(env);
+ return path.join(stateDir, STORE_FILENAME);
+}
+
+function safeParseJson(raw: string): T | null {
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
+async function readJsonFile(
+ filePath: string,
+ fallback: T,
+): Promise<{ value: T; exists: boolean }> {
+ try {
+ const raw = await fs.promises.readFile(filePath, "utf-8");
+ const parsed = safeParseJson(raw);
+ if (parsed == null) return { value: fallback, exists: true };
+ return { value: parsed, exists: true };
+ } catch (err) {
+ const code = (err as { code?: string }).code;
+ if (code === "ENOENT") return { value: fallback, exists: false };
+ return { value: fallback, exists: false };
+ }
+}
+
+async function writeJsonFile(filePath: string, value: unknown): Promise {
+ const dir = path.dirname(filePath);
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
+ const tmp = path.join(
+ dir,
+ `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`,
+ );
+ await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
+ encoding: "utf-8",
+ });
+ await fs.promises.chmod(tmp, 0o600);
+ await fs.promises.rename(tmp, filePath);
+}
+
+async function ensureJsonFile(filePath: string, fallback: unknown) {
+ try {
+ await fs.promises.access(filePath);
+ } catch {
+ await writeJsonFile(filePath, fallback);
+ }
+}
+
+async function withFileLock(
+ filePath: string,
+ fallback: unknown,
+ fn: () => Promise,
+): Promise {
+ await ensureJsonFile(filePath, fallback);
+ let release: (() => Promise) | undefined;
+ try {
+ release = await lockfile.lock(filePath, STORE_LOCK_OPTIONS);
+ return await fn();
+ } finally {
+ if (release) {
+ try {
+ await release();
+ } catch {
+ // ignore unlock errors
+ }
+ }
+ }
+}
+
+function parseTimestamp(value: string | undefined): number | null {
+ if (!value) return null;
+ const parsed = Date.parse(value);
+ if (!Number.isFinite(parsed)) return null;
+ return parsed;
+}
+
+function pruneToLimit(
+ conversations: Record<
+ string,
+ StoredConversationReference & { lastSeenAt?: string }
+ >,
+) {
+ const entries = Object.entries(conversations);
+ if (entries.length <= MAX_CONVERSATIONS) return conversations;
+
+ entries.sort((a, b) => {
+ const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0;
+ const bTs = parseTimestamp(b[1].lastSeenAt) ?? 0;
+ return aTs - bTs;
+ });
+
+ const keep = entries.slice(entries.length - MAX_CONVERSATIONS);
+ return Object.fromEntries(keep);
+}
+
+function pruneExpired(
+ conversations: Record<
+ string,
+ StoredConversationReference & { lastSeenAt?: string }
+ >,
+ nowMs: number,
+ ttlMs: number,
+) {
+ let removed = false;
+ const kept: typeof conversations = {};
+ for (const [conversationId, reference] of Object.entries(conversations)) {
+ const lastSeenAt = parseTimestamp(reference.lastSeenAt);
+ // Preserve legacy entries that have no lastSeenAt until they're seen again.
+ if (lastSeenAt != null && nowMs - lastSeenAt > ttlMs) {
+ removed = true;
+ continue;
+ }
+ kept[conversationId] = reference;
+ }
+ return { conversations: kept, removed };
+}
+
+function normalizeConversationId(raw: string): string {
+ return raw.split(";")[0] ?? raw;
+}
+
+export function createMSTeamsConversationStoreFs(params?: {
+ env?: NodeJS.ProcessEnv;
+ homedir?: () => string;
+ ttlMs?: number;
+}): MSTeamsConversationStore {
+ const env = params?.env ?? process.env;
+ const homedir = params?.homedir ?? os.homedir;
+ const ttlMs = params?.ttlMs ?? CONVERSATION_TTL_MS;
+ const filePath = resolveStorePath(env, homedir);
+
+ const empty: ConversationStoreData = { version: 1, conversations: {} };
+
+ const readStore = async (): Promise => {
+ const { value } = await readJsonFile(
+ filePath,
+ empty,
+ );
+ if (
+ value.version !== 1 ||
+ !value.conversations ||
+ typeof value.conversations !== "object" ||
+ Array.isArray(value.conversations)
+ ) {
+ return empty;
+ }
+ const nowMs = Date.now();
+ const pruned = pruneExpired(
+ value.conversations,
+ nowMs,
+ ttlMs,
+ ).conversations;
+ return { version: 1, conversations: pruneToLimit(pruned) };
+ };
+
+ const list = async (): Promise => {
+ const store = await readStore();
+ return Object.entries(store.conversations).map(
+ ([conversationId, reference]) => ({
+ conversationId,
+ reference,
+ }),
+ );
+ };
+
+ const get = async (
+ conversationId: string,
+ ): Promise => {
+ const store = await readStore();
+ return store.conversations[normalizeConversationId(conversationId)] ?? null;
+ };
+
+ const findByUserId = async (
+ id: string,
+ ): Promise => {
+ const target = id.trim();
+ if (!target) return null;
+ for (const entry of await list()) {
+ const { conversationId, reference } = entry;
+ if (reference.user?.aadObjectId === target) {
+ return { conversationId, reference };
+ }
+ if (reference.user?.id === target) {
+ return { conversationId, reference };
+ }
+ }
+ return null;
+ };
+
+ const upsert = async (
+ conversationId: string,
+ reference: StoredConversationReference,
+ ): Promise => {
+ const normalizedId = normalizeConversationId(conversationId);
+ await withFileLock(filePath, empty, async () => {
+ const store = await readStore();
+ store.conversations[normalizedId] = {
+ ...reference,
+ lastSeenAt: new Date().toISOString(),
+ };
+ const nowMs = Date.now();
+ store.conversations = pruneExpired(
+ store.conversations,
+ nowMs,
+ ttlMs,
+ ).conversations;
+ store.conversations = pruneToLimit(store.conversations);
+ await writeJsonFile(filePath, store);
+ });
+ };
+
+ const remove = async (conversationId: string): Promise => {
+ const normalizedId = normalizeConversationId(conversationId);
+ return await withFileLock(filePath, empty, async () => {
+ const store = await readStore();
+ if (!(normalizedId in store.conversations)) return false;
+ delete store.conversations[normalizedId];
+ await writeJsonFile(filePath, store);
+ return true;
+ });
+ };
+
+ return { upsert, get, list, remove, findByUserId };
+}
diff --git a/src/msteams/conversation-store-memory.ts b/src/msteams/conversation-store-memory.ts
new file mode 100644
index 000000000..098f09bb6
--- /dev/null
+++ b/src/msteams/conversation-store-memory.ts
@@ -0,0 +1,45 @@
+import type {
+ MSTeamsConversationStore,
+ MSTeamsConversationStoreEntry,
+ StoredConversationReference,
+} from "./conversation-store.js";
+
+export function createMSTeamsConversationStoreMemory(
+ initial: MSTeamsConversationStoreEntry[] = [],
+): MSTeamsConversationStore {
+ const map = new Map();
+ for (const { conversationId, reference } of initial) {
+ map.set(conversationId, reference);
+ }
+
+ return {
+ upsert: async (conversationId, reference) => {
+ map.set(conversationId, reference);
+ },
+ get: async (conversationId) => {
+ return map.get(conversationId) ?? null;
+ },
+ list: async () => {
+ return Array.from(map.entries()).map(([conversationId, reference]) => ({
+ conversationId,
+ reference,
+ }));
+ },
+ remove: async (conversationId) => {
+ return map.delete(conversationId);
+ },
+ findByUserId: async (id) => {
+ const target = id.trim();
+ if (!target) return null;
+ for (const [conversationId, reference] of map.entries()) {
+ if (reference.user?.aadObjectId === target) {
+ return { conversationId, reference };
+ }
+ if (reference.user?.id === target) {
+ return { conversationId, reference };
+ }
+ }
+ return null;
+ },
+ };
+}
diff --git a/src/msteams/conversation-store.ts b/src/msteams/conversation-store.ts
index d1463d521..75bd63c92 100644
--- a/src/msteams/conversation-store.ts
+++ b/src/msteams/conversation-store.ts
@@ -1,15 +1,10 @@
/**
* Conversation store for MS Teams proactive messaging.
*
- * Stores ConversationReference objects keyed by conversation ID so we can
+ * Stores ConversationReference-like objects keyed by conversation ID so we can
* send proactive messages later (after the webhook turn has completed).
*/
-import fs from "node:fs";
-import path from "node:path";
-
-import { resolveStateDir } from "../config/paths.js";
-
/** Minimal ConversationReference shape for proactive messaging */
export type StoredConversationReference = {
/** Activity ID from the last message */
@@ -28,95 +23,18 @@ export type StoredConversationReference = {
locale?: string;
};
-type ConversationStoreData = {
- version: 1;
- conversations: Record;
+export type MSTeamsConversationStoreEntry = {
+ conversationId: string;
+ reference: StoredConversationReference;
};
-const STORE_FILENAME = "msteams-conversations.json";
-const MAX_CONVERSATIONS = 1000;
-
-function resolveStorePath(): string {
- const stateDir = resolveStateDir(process.env);
- return path.join(stateDir, STORE_FILENAME);
-}
-
-async function readStore(): Promise {
- try {
- const raw = await fs.promises.readFile(resolveStorePath(), "utf-8");
- const data = JSON.parse(raw) as ConversationStoreData;
- if (data.version !== 1) {
- return { version: 1, conversations: {} };
- }
- return data;
- } catch {
- return { version: 1, conversations: {} };
- }
-}
-
-async function writeStore(data: ConversationStoreData): Promise {
- const filePath = resolveStorePath();
- const dir = path.dirname(filePath);
- await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
- await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
-}
-
-/**
- * Save a conversation reference for later proactive messaging.
- */
-export async function saveConversationReference(
- conversationId: string,
- reference: StoredConversationReference,
-): Promise {
- const store = await readStore();
-
- // Prune if over limit (keep most recent)
- const keys = Object.keys(store.conversations);
- if (keys.length >= MAX_CONVERSATIONS) {
- const toRemove = keys.slice(0, keys.length - MAX_CONVERSATIONS + 1);
- for (const key of toRemove) {
- delete store.conversations[key];
- }
- }
-
- store.conversations[conversationId] = reference;
- await writeStore(store);
-}
-
-/**
- * Get a stored conversation reference.
- */
-export async function getConversationReference(
- conversationId: string,
-): Promise {
- const store = await readStore();
- return store.conversations[conversationId] ?? null;
-}
-
-/**
- * List all stored conversation references.
- */
-export async function listConversationReferences(): Promise<
- Array<{ conversationId: string; reference: StoredConversationReference }>
-> {
- const store = await readStore();
- return Object.entries(store.conversations).map(
- ([conversationId, reference]) => ({
- conversationId,
- reference,
- }),
- );
-}
-
-/**
- * Remove a conversation reference.
- */
-export async function removeConversationReference(
- conversationId: string,
-): Promise {
- const store = await readStore();
- if (!(conversationId in store.conversations)) return false;
- delete store.conversations[conversationId];
- await writeStore(store);
- return true;
-}
+export type MSTeamsConversationStore = {
+ upsert: (
+ conversationId: string,
+ reference: StoredConversationReference,
+ ) => Promise;
+ get: (conversationId: string) => Promise;
+ list: () => Promise;
+ remove: (conversationId: string) => Promise;
+ findByUserId: (id: string) => Promise;
+};
diff --git a/src/msteams/errors.test.ts b/src/msteams/errors.test.ts
new file mode 100644
index 000000000..554305988
--- /dev/null
+++ b/src/msteams/errors.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ classifyMSTeamsSendError,
+ formatMSTeamsSendErrorHint,
+ formatUnknownError,
+} from "./errors.js";
+
+describe("msteams errors", () => {
+ it("formats unknown errors", () => {
+ expect(formatUnknownError("oops")).toBe("oops");
+ expect(formatUnknownError(null)).toBe("null");
+ });
+
+ it("classifies auth errors", () => {
+ expect(classifyMSTeamsSendError({ statusCode: 401 }).kind).toBe("auth");
+ expect(classifyMSTeamsSendError({ statusCode: 403 }).kind).toBe("auth");
+ });
+
+ it("classifies throttling errors and parses retry-after", () => {
+ expect(
+ classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5" }),
+ ).toMatchObject({
+ kind: "throttled",
+ statusCode: 429,
+ retryAfterMs: 1500,
+ });
+ });
+
+ it("classifies transient errors", () => {
+ expect(classifyMSTeamsSendError({ statusCode: 503 })).toMatchObject({
+ kind: "transient",
+ statusCode: 503,
+ });
+ });
+
+ it("classifies permanent 4xx errors", () => {
+ expect(classifyMSTeamsSendError({ statusCode: 400 })).toMatchObject({
+ kind: "permanent",
+ statusCode: 400,
+ });
+ });
+
+ it("provides actionable hints for common cases", () => {
+ expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
+ expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain(
+ "throttled",
+ );
+ });
+});
diff --git a/src/msteams/errors.ts b/src/msteams/errors.ts
new file mode 100644
index 000000000..8dd4800c9
--- /dev/null
+++ b/src/msteams/errors.ts
@@ -0,0 +1,171 @@
+export function formatUnknownError(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ if (typeof err === "string") return err;
+ if (err === null) return "null";
+ if (err === undefined) return "undefined";
+ if (
+ typeof err === "number" ||
+ typeof err === "boolean" ||
+ typeof err === "bigint"
+ ) {
+ return String(err);
+ }
+ if (typeof err === "symbol") return err.description ?? err.toString();
+ if (typeof err === "function") {
+ return err.name ? `[function ${err.name}]` : "[function]";
+ }
+ try {
+ return JSON.stringify(err) ?? "unknown error";
+ } catch {
+ return "unknown error";
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function extractStatusCode(err: unknown): number | null {
+ if (!isRecord(err)) return null;
+ const direct = err.statusCode ?? err.status;
+ if (typeof direct === "number" && Number.isFinite(direct)) return direct;
+ if (typeof direct === "string") {
+ const parsed = Number.parseInt(direct, 10);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+
+ const response = err.response;
+ if (isRecord(response)) {
+ const status = response.status;
+ if (typeof status === "number" && Number.isFinite(status)) return status;
+ if (typeof status === "string") {
+ const parsed = Number.parseInt(status, 10);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ }
+
+ return null;
+}
+
+function extractRetryAfterMs(err: unknown): number | null {
+ if (!isRecord(err)) return null;
+
+ const direct = err.retryAfterMs ?? err.retry_after_ms;
+ if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) {
+ return direct;
+ }
+
+ const retryAfter = err.retryAfter ?? err.retry_after;
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
+ return retryAfter >= 0 ? retryAfter * 1000 : null;
+ }
+ if (typeof retryAfter === "string") {
+ const parsed = Number.parseFloat(retryAfter);
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
+ }
+
+ const response = err.response;
+ if (!isRecord(response)) return null;
+
+ const headers = response.headers;
+ if (!headers) return null;
+
+ if (isRecord(headers)) {
+ const raw = headers["retry-after"] ?? headers["Retry-After"];
+ if (typeof raw === "string") {
+ const parsed = Number.parseFloat(raw);
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
+ }
+ }
+
+ // Fetch Headers-like interface
+ if (
+ typeof headers === "object" &&
+ headers !== null &&
+ "get" in headers &&
+ typeof (headers as { get?: unknown }).get === "function"
+ ) {
+ const raw = (headers as { get: (name: string) => string | null }).get(
+ "retry-after",
+ );
+ if (raw) {
+ const parsed = Number.parseFloat(raw);
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed * 1000;
+ }
+ }
+
+ return null;
+}
+
+export type MSTeamsSendErrorKind =
+ | "auth"
+ | "throttled"
+ | "transient"
+ | "permanent"
+ | "unknown";
+
+export type MSTeamsSendErrorClassification = {
+ kind: MSTeamsSendErrorKind;
+ statusCode?: number;
+ retryAfterMs?: number;
+};
+
+/**
+ * Classify outbound send errors for safe retries and actionable logs.
+ *
+ * Important: We only mark errors as retryable when we have an explicit HTTP
+ * status code that indicates the message was not accepted (e.g. 429, 5xx).
+ * For transport-level errors where delivery is ambiguous, we prefer to avoid
+ * retries to reduce the chance of duplicate posts.
+ */
+export function classifyMSTeamsSendError(
+ err: unknown,
+): MSTeamsSendErrorClassification {
+ const statusCode = extractStatusCode(err);
+ const retryAfterMs = extractRetryAfterMs(err);
+
+ if (statusCode === 401 || statusCode === 403) {
+ return { kind: "auth", statusCode };
+ }
+
+ if (statusCode === 429) {
+ return {
+ kind: "throttled",
+ statusCode,
+ retryAfterMs: retryAfterMs ?? undefined,
+ };
+ }
+
+ if (statusCode === 408 || (statusCode != null && statusCode >= 500)) {
+ return {
+ kind: "transient",
+ statusCode,
+ retryAfterMs: retryAfterMs ?? undefined,
+ };
+ }
+
+ if (statusCode != null && statusCode >= 400) {
+ return { kind: "permanent", statusCode };
+ }
+
+ return {
+ kind: "unknown",
+ statusCode: statusCode ?? undefined,
+ retryAfterMs: retryAfterMs ?? undefined,
+ };
+}
+
+export function formatMSTeamsSendErrorHint(
+ classification: MSTeamsSendErrorClassification,
+): string | undefined {
+ if (classification.kind === "auth") {
+ return "check msteams appId/appPassword/tenantId (or env vars MSTEAMS_APP_ID/MSTEAMS_APP_PASSWORD/MSTEAMS_TENANT_ID)";
+ }
+ if (classification.kind === "throttled") {
+ return "Teams throttled the bot; backing off may help";
+ }
+ if (classification.kind === "transient") {
+ return "transient Teams/Bot Framework error; retry may succeed";
+ }
+ return undefined;
+}
diff --git a/src/msteams/inbound.test.ts b/src/msteams/inbound.test.ts
new file mode 100644
index 000000000..98c9b2df4
--- /dev/null
+++ b/src/msteams/inbound.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ normalizeMSTeamsConversationId,
+ parseMSTeamsActivityTimestamp,
+ stripMSTeamsMentionTags,
+ wasMSTeamsBotMentioned,
+} from "./inbound.js";
+
+describe("msteams inbound", () => {
+ describe("stripMSTeamsMentionTags", () => {
+ it("removes ... tags and trims", () => {
+ expect(stripMSTeamsMentionTags("Bot hi")).toBe("hi");
+ expect(stripMSTeamsMentionTags("hi Bot")).toBe("hi");
+ });
+ });
+
+ describe("normalizeMSTeamsConversationId", () => {
+ it("strips the ;messageid suffix", () => {
+ expect(
+ normalizeMSTeamsConversationId(
+ "19:abc@thread.tacv2;messageid=deadbeef",
+ ),
+ ).toBe("19:abc@thread.tacv2");
+ });
+ });
+
+ describe("parseMSTeamsActivityTimestamp", () => {
+ it("returns undefined for empty/invalid values", () => {
+ expect(parseMSTeamsActivityTimestamp(undefined)).toBeUndefined();
+ expect(parseMSTeamsActivityTimestamp("not-a-date")).toBeUndefined();
+ });
+
+ it("parses string timestamps", () => {
+ const ts = parseMSTeamsActivityTimestamp("2024-01-01T00:00:00.000Z");
+ expect(ts?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
+ });
+
+ it("passes through Date instances", () => {
+ const d = new Date("2024-01-01T00:00:00.000Z");
+ expect(parseMSTeamsActivityTimestamp(d)).toBe(d);
+ });
+ });
+
+ describe("wasMSTeamsBotMentioned", () => {
+ it("returns true when a mention entity matches recipient.id", () => {
+ expect(
+ wasMSTeamsBotMentioned({
+ recipient: { id: "bot" },
+ entities: [{ type: "mention", mentioned: { id: "bot" } }],
+ }),
+ ).toBe(true);
+ });
+
+ it("returns false when there is no matching mention", () => {
+ expect(
+ wasMSTeamsBotMentioned({
+ recipient: { id: "bot" },
+ entities: [{ type: "mention", mentioned: { id: "other" } }],
+ }),
+ ).toBe(false);
+ });
+ });
+});
diff --git a/src/msteams/inbound.ts b/src/msteams/inbound.ts
new file mode 100644
index 000000000..5c37c68db
--- /dev/null
+++ b/src/msteams/inbound.ts
@@ -0,0 +1,35 @@
+export type MentionableActivity = {
+ recipient?: { id?: string } | null;
+ entities?: Array<{
+ type?: string;
+ mentioned?: { id?: string };
+ }> | null;
+};
+
+export function normalizeMSTeamsConversationId(raw: string): string {
+ return raw.split(";")[0] ?? raw;
+}
+
+export function parseMSTeamsActivityTimestamp(
+ value: unknown,
+): Date | undefined {
+ if (!value) return undefined;
+ if (value instanceof Date) return value;
+ if (typeof value !== "string") return undefined;
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? undefined : date;
+}
+
+export function stripMSTeamsMentionTags(text: string): string {
+ // Teams wraps mentions in ... tags
+ return text.replace(/.*?<\/at>/gi, "").trim();
+}
+
+export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean {
+ const botId = activity.recipient?.id;
+ if (!botId) return false;
+ const entities = activity.entities ?? [];
+ return entities.some(
+ (e) => e.type === "mention" && e.mentioned?.id === botId,
+ );
+}
diff --git a/src/msteams/messenger.test.ts b/src/msteams/messenger.test.ts
new file mode 100644
index 000000000..0fbbdb764
--- /dev/null
+++ b/src/msteams/messenger.test.ts
@@ -0,0 +1,209 @@
+import { describe, expect, it } from "vitest";
+
+import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
+import type { StoredConversationReference } from "./conversation-store.js";
+import {
+ type MSTeamsAdapter,
+ renderReplyPayloadsToMessages,
+ sendMSTeamsMessages,
+} from "./messenger.js";
+
+describe("msteams messenger", () => {
+ describe("renderReplyPayloadsToMessages", () => {
+ it("filters silent replies", () => {
+ const messages = renderReplyPayloadsToMessages(
+ [{ text: SILENT_REPLY_TOKEN }],
+ { textChunkLimit: 4000 },
+ );
+ expect(messages).toEqual([]);
+ });
+
+ it("splits media into separate messages by default", () => {
+ const messages = renderReplyPayloadsToMessages(
+ [{ text: "hi", mediaUrl: "https://example.com/a.png" }],
+ { textChunkLimit: 4000 },
+ );
+ expect(messages).toEqual(["hi", "https://example.com/a.png"]);
+ });
+
+ it("supports inline media mode", () => {
+ const messages = renderReplyPayloadsToMessages(
+ [{ text: "hi", mediaUrl: "https://example.com/a.png" }],
+ { textChunkLimit: 4000, mediaMode: "inline" },
+ );
+ expect(messages).toEqual(["hi\n\nhttps://example.com/a.png"]);
+ });
+
+ it("chunks long text when enabled", () => {
+ const long = "hello ".repeat(200);
+ const messages = renderReplyPayloadsToMessages([{ text: long }], {
+ textChunkLimit: 50,
+ });
+ expect(messages.length).toBeGreaterThan(1);
+ });
+ });
+
+ describe("sendMSTeamsMessages", () => {
+ const baseRef: StoredConversationReference = {
+ activityId: "activity123",
+ conversation: { id: "19:abc@thread.tacv2;messageid=deadbeef" },
+ channelId: "msteams",
+ serviceUrl: "https://service.example.com",
+ };
+
+ it("sends thread messages via the provided context", async () => {
+ const sent: string[] = [];
+ const ctx = {
+ sendActivity: async (activity: unknown) => {
+ const { text } = activity as { text?: string };
+ sent.push(text ?? "");
+ return { id: `id:${text ?? ""}` };
+ },
+ };
+
+ const adapter: MSTeamsAdapter = {
+ continueConversation: async () => {},
+ };
+
+ const ids = await sendMSTeamsMessages({
+ replyStyle: "thread",
+ adapter,
+ appId: "app123",
+ conversationRef: baseRef,
+ context: ctx,
+ messages: ["one", "two"],
+ });
+
+ expect(sent).toEqual(["one", "two"]);
+ expect(ids).toEqual(["id:one", "id:two"]);
+ });
+
+ it("sends top-level messages via continueConversation and strips activityId", async () => {
+ const seen: { reference?: unknown; texts: string[] } = { texts: [] };
+
+ const adapter: MSTeamsAdapter = {
+ continueConversation: async (_appId, reference, logic) => {
+ seen.reference = reference;
+ await logic({
+ sendActivity: async (activity: unknown) => {
+ const { text } = activity as { text?: string };
+ seen.texts.push(text ?? "");
+ return { id: `id:${text ?? ""}` };
+ },
+ });
+ },
+ };
+
+ const ids = await sendMSTeamsMessages({
+ replyStyle: "top-level",
+ adapter,
+ appId: "app123",
+ conversationRef: baseRef,
+ messages: ["hello"],
+ });
+
+ expect(seen.texts).toEqual(["hello"]);
+ expect(ids).toEqual(["id:hello"]);
+
+ const ref = seen.reference as {
+ activityId?: string;
+ conversation?: { id?: string };
+ };
+ expect(ref.activityId).toBeUndefined();
+ expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
+ });
+
+ it("retries thread sends on throttling (429)", async () => {
+ const attempts: string[] = [];
+ const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
+
+ const ctx = {
+ sendActivity: async (activity: unknown) => {
+ const { text } = activity as { text?: string };
+ attempts.push(text ?? "");
+ if (attempts.length === 1) {
+ throw Object.assign(new Error("throttled"), { statusCode: 429 });
+ }
+ return { id: `id:${text ?? ""}` };
+ },
+ };
+
+ const adapter: MSTeamsAdapter = {
+ continueConversation: async () => {},
+ };
+
+ const ids = await sendMSTeamsMessages({
+ replyStyle: "thread",
+ adapter,
+ appId: "app123",
+ conversationRef: baseRef,
+ context: ctx,
+ messages: ["one"],
+ retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
+ onRetry: (e) =>
+ retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
+ });
+
+ expect(attempts).toEqual(["one", "one"]);
+ expect(ids).toEqual(["id:one"]);
+ expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
+ });
+
+ it("does not retry thread sends on client errors (4xx)", async () => {
+ const ctx = {
+ sendActivity: async () => {
+ throw Object.assign(new Error("bad request"), { statusCode: 400 });
+ },
+ };
+
+ const adapter: MSTeamsAdapter = {
+ continueConversation: async () => {},
+ };
+
+ await expect(
+ sendMSTeamsMessages({
+ replyStyle: "thread",
+ adapter,
+ appId: "app123",
+ conversationRef: baseRef,
+ context: ctx,
+ messages: ["one"],
+ retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
+ }),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+
+ it("retries top-level sends on transient (5xx)", async () => {
+ const attempts: string[] = [];
+
+ const adapter: MSTeamsAdapter = {
+ continueConversation: async (_appId, _reference, logic) => {
+ await logic({
+ sendActivity: async (activity: unknown) => {
+ const { text } = activity as { text?: string };
+ attempts.push(text ?? "");
+ if (attempts.length === 1) {
+ throw Object.assign(new Error("server error"), {
+ statusCode: 503,
+ });
+ }
+ return { id: `id:${text ?? ""}` };
+ },
+ });
+ },
+ };
+
+ const ids = await sendMSTeamsMessages({
+ replyStyle: "top-level",
+ adapter,
+ appId: "app123",
+ conversationRef: baseRef,
+ messages: ["hello"],
+ retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
+ });
+
+ expect(attempts).toEqual(["hello", "hello"]);
+ expect(ids).toEqual(["id:hello"]);
+ });
+ });
+});
diff --git a/src/msteams/messenger.ts b/src/msteams/messenger.ts
new file mode 100644
index 000000000..aa21be60a
--- /dev/null
+++ b/src/msteams/messenger.ts
@@ -0,0 +1,294 @@
+import { chunkMarkdownText } from "../auto-reply/chunk.js";
+import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
+import type { ReplyPayload } from "../auto-reply/types.js";
+import type { MSTeamsReplyStyle } from "../config/types.js";
+import type { StoredConversationReference } from "./conversation-store.js";
+import { classifyMSTeamsSendError } from "./errors.js";
+
+type SendContext = {
+ sendActivity: (textOrActivity: string | object) => Promise;
+};
+
+type ConversationReference = {
+ activityId?: string;
+ user?: { id?: string; name?: string; aadObjectId?: string };
+ bot?: { id?: string; name?: string };
+ conversation: { id: string; conversationType?: string; tenantId?: string };
+ channelId: string;
+ serviceUrl?: string;
+ locale?: string;
+};
+
+export type MSTeamsAdapter = {
+ continueConversation: (
+ appId: string,
+ reference: ConversationReference,
+ logic: (context: SendContext) => Promise,
+ ) => Promise;
+};
+
+export type MSTeamsReplyRenderOptions = {
+ textChunkLimit: number;
+ chunkText?: boolean;
+ mediaMode?: "split" | "inline";
+};
+
+export type MSTeamsSendRetryOptions = {
+ maxAttempts?: number;
+ baseDelayMs?: number;
+ maxDelayMs?: number;
+};
+
+export type MSTeamsSendRetryEvent = {
+ messageIndex: number;
+ messageCount: number;
+ nextAttempt: number;
+ maxAttempts: number;
+ delayMs: number;
+ classification: ReturnType;
+};
+
+function normalizeConversationId(rawId: string): string {
+ return rawId.split(";")[0] ?? rawId;
+}
+
+function buildConversationReference(
+ ref: StoredConversationReference,
+): ConversationReference {
+ const conversationId = ref.conversation?.id?.trim();
+ if (!conversationId) {
+ throw new Error("Invalid stored reference: missing conversation.id");
+ }
+ return {
+ activityId: ref.activityId,
+ user: ref.user,
+ bot: ref.bot,
+ conversation: {
+ id: normalizeConversationId(conversationId),
+ conversationType: ref.conversation?.conversationType,
+ tenantId: ref.conversation?.tenantId,
+ },
+ channelId: ref.channelId ?? "msteams",
+ serviceUrl: ref.serviceUrl,
+ locale: ref.locale,
+ };
+}
+
+function extractMessageId(response: unknown): string | null {
+ if (!response || typeof response !== "object") return null;
+ if (!("id" in response)) return null;
+ const { id } = response as { id?: unknown };
+ if (typeof id !== "string" || !id) return null;
+ return id;
+}
+
+function pushTextMessages(
+ out: string[],
+ text: string,
+ opts: {
+ chunkText: boolean;
+ chunkLimit: number;
+ },
+) {
+ if (!text) return;
+ if (opts.chunkText) {
+ for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) {
+ const trimmed = chunk.trim();
+ if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
+ out.push(trimmed);
+ }
+ return;
+ }
+
+ const trimmed = text.trim();
+ if (!trimmed || trimmed === SILENT_REPLY_TOKEN) return;
+ out.push(trimmed);
+}
+
+function clampMs(value: number, maxMs: number): number {
+ if (!Number.isFinite(value) || value < 0) return 0;
+ return Math.min(value, maxMs);
+}
+
+async function sleep(ms: number): Promise {
+ const delay = Math.max(0, ms);
+ if (delay === 0) return;
+ await new Promise((resolve) => {
+ setTimeout(resolve, delay);
+ });
+}
+
+function resolveRetryOptions(
+ retry: false | MSTeamsSendRetryOptions | undefined,
+): Required & { enabled: boolean } {
+ if (!retry) {
+ return { enabled: false, maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 };
+ }
+ return {
+ enabled: true,
+ maxAttempts: Math.max(1, retry?.maxAttempts ?? 3),
+ baseDelayMs: Math.max(0, retry?.baseDelayMs ?? 250),
+ maxDelayMs: Math.max(0, retry?.maxDelayMs ?? 10_000),
+ };
+}
+
+function computeRetryDelayMs(
+ attempt: number,
+ classification: ReturnType,
+ opts: Required,
+): number {
+ if (classification.retryAfterMs != null) {
+ return clampMs(classification.retryAfterMs, opts.maxDelayMs);
+ }
+ const exponential = opts.baseDelayMs * 2 ** Math.max(0, attempt - 1);
+ return clampMs(exponential, opts.maxDelayMs);
+}
+
+function shouldRetry(
+ classification: ReturnType,
+): boolean {
+ return (
+ classification.kind === "throttled" || classification.kind === "transient"
+ );
+}
+
+export function renderReplyPayloadsToMessages(
+ replies: ReplyPayload[],
+ options: MSTeamsReplyRenderOptions,
+): string[] {
+ const out: string[] = [];
+ const chunkLimit = Math.min(options.textChunkLimit, 4000);
+ const chunkText = options.chunkText !== false;
+ const mediaMode = options.mediaMode ?? "split";
+
+ for (const payload of replies) {
+ const mediaList =
+ payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
+ const text = payload.text ?? "";
+
+ if (!text && mediaList.length === 0) continue;
+
+ if (mediaList.length === 0) {
+ pushTextMessages(out, text, { chunkText, chunkLimit });
+ continue;
+ }
+
+ if (mediaMode === "inline") {
+ const combined = text
+ ? `${text}\n\n${mediaList.join("\n")}`
+ : mediaList.join("\n");
+ pushTextMessages(out, combined, { chunkText, chunkLimit });
+ continue;
+ }
+
+ // mediaMode === "split"
+ pushTextMessages(out, text, { chunkText, chunkLimit });
+ for (const mediaUrl of mediaList) {
+ if (!mediaUrl) continue;
+ out.push(mediaUrl);
+ }
+ }
+
+ return out;
+}
+
+export async function sendMSTeamsMessages(params: {
+ replyStyle: MSTeamsReplyStyle;
+ adapter: MSTeamsAdapter;
+ appId: string;
+ conversationRef: StoredConversationReference;
+ context?: SendContext;
+ messages: string[];
+ retry?: false | MSTeamsSendRetryOptions;
+ onRetry?: (event: MSTeamsSendRetryEvent) => void;
+}): Promise {
+ const messages = params.messages
+ .map((m) => (typeof m === "string" ? m : String(m)))
+ .filter((m) => m.trim().length > 0);
+ if (messages.length === 0) return [];
+
+ const retryOptions = resolveRetryOptions(params.retry);
+
+ const sendWithRetry = async (
+ sendOnce: () => Promise,
+ meta: { messageIndex: number; messageCount: number },
+ ): Promise => {
+ if (!retryOptions.enabled) return await sendOnce();
+
+ let attempt = 1;
+ while (true) {
+ try {
+ return await sendOnce();
+ } catch (err) {
+ const classification = classifyMSTeamsSendError(err);
+ const canRetry =
+ attempt < retryOptions.maxAttempts && shouldRetry(classification);
+ if (!canRetry) throw err;
+
+ const delayMs = computeRetryDelayMs(
+ attempt,
+ classification,
+ retryOptions,
+ );
+ const nextAttempt = attempt + 1;
+ params.onRetry?.({
+ messageIndex: meta.messageIndex,
+ messageCount: meta.messageCount,
+ nextAttempt,
+ maxAttempts: retryOptions.maxAttempts,
+ delayMs,
+ classification,
+ });
+
+ await sleep(delayMs);
+ attempt = nextAttempt;
+ }
+ }
+ };
+
+ if (params.replyStyle === "thread") {
+ const ctx = params.context;
+ if (!ctx) {
+ throw new Error("Missing context for replyStyle=thread");
+ }
+ const messageIds: string[] = [];
+ for (const [idx, message] of messages.entries()) {
+ const response = await sendWithRetry(
+ async () =>
+ await ctx.sendActivity({
+ type: "message",
+ text: message,
+ }),
+ { messageIndex: idx, messageCount: messages.length },
+ );
+ messageIds.push(extractMessageId(response) ?? "unknown");
+ }
+ return messageIds;
+ }
+
+ const baseRef = buildConversationReference(params.conversationRef);
+ const proactiveRef: ConversationReference = {
+ ...baseRef,
+ activityId: undefined,
+ };
+
+ const messageIds: string[] = [];
+ await params.adapter.continueConversation(
+ params.appId,
+ proactiveRef,
+ async (ctx) => {
+ for (const [idx, message] of messages.entries()) {
+ const response = await sendWithRetry(
+ async () =>
+ await ctx.sendActivity({
+ type: "message",
+ text: message,
+ }),
+ { messageIndex: idx, messageCount: messages.length },
+ );
+ messageIds.push(extractMessageId(response) ?? "unknown");
+ }
+ },
+ );
+ return messageIds;
+}
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
index 620be86a6..5b9cebe5f 100644
--- a/src/msteams/monitor.ts
+++ b/src/msteams/monitor.ts
@@ -1,12 +1,8 @@
-import {
- chunkMarkdownText,
- resolveTextChunkLimit,
-} from "../auto-reply/chunk.js";
+import type { Request, Response } from "express";
+import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
-import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
-import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig } from "../config/types.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
@@ -17,10 +13,32 @@ import {
} from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
-import {
- saveConversationReference,
- type StoredConversationReference,
+import type {
+ MSTeamsConversationStore,
+ StoredConversationReference,
} from "./conversation-store.js";
+import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
+import {
+ classifyMSTeamsSendError,
+ formatMSTeamsSendErrorHint,
+ formatUnknownError,
+} from "./errors.js";
+import {
+ normalizeMSTeamsConversationId,
+ parseMSTeamsActivityTimestamp,
+ stripMSTeamsMentionTags,
+ wasMSTeamsBotMentioned,
+} from "./inbound.js";
+import {
+ type MSTeamsAdapter,
+ renderReplyPayloadsToMessages,
+ sendMSTeamsMessages,
+} from "./messenger.js";
+import {
+ resolveMSTeamsReplyPolicy,
+ resolveMSTeamsRouteConfig,
+} from "./policy.js";
+import type { MSTeamsTurnContext } from "./sdk-types.js";
import { resolveMSTeamsCredentials } from "./token.js";
const log = getChildLogger({ name: "msteams" });
@@ -29,6 +47,7 @@ export type MonitorMSTeamsOpts = {
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
+ conversationStore?: MSTeamsConversationStore;
};
export type MonitorMSTeamsResult = {
@@ -36,51 +55,6 @@ export type MonitorMSTeamsResult = {
shutdown: () => Promise;
};
-type TeamsActivity = {
- id?: string;
- type?: string;
- timestamp?: string | Date;
- text?: string;
- from?: { id?: string; name?: string; aadObjectId?: string };
- recipient?: { id?: string; name?: string };
- conversation?: {
- id?: string;
- conversationType?: string;
- tenantId?: string;
- isGroup?: boolean;
- };
- channelId?: string;
- serviceUrl?: string;
- membersAdded?: Array<{ id?: string; name?: string }>;
- /** Entities including mentions */
- entities?: Array<{
- type?: string;
- mentioned?: { id?: string; name?: string };
- }>;
- /** Teams-specific channel data including team info */
- channelData?: {
- team?: { id?: string; name?: string };
- channel?: { id?: string; name?: string };
- tenant?: { id?: string };
- };
-};
-
-type TeamsTurnContext = {
- activity: TeamsActivity;
- sendActivity: (textOrActivity: string | object) => Promise;
- sendActivities?: (
- activities: Array<{ type: string } & Record>,
- ) => Promise;
-};
-
-// Helper to convert timestamp to Date
-function parseTimestamp(ts?: string | Date): Date | undefined {
- if (!ts) return undefined;
- if (ts instanceof Date) return ts;
- const date = new Date(ts);
- return Number.isNaN(date.getTime()) ? undefined : date;
-}
-
export async function monitorMSTeamsProvider(
opts: MonitorMSTeamsOpts,
): Promise {
@@ -108,6 +82,8 @@ export async function monitorMSTeamsProvider(
const port = msteamsCfg.webhook?.port ?? 3978;
const textLimit = resolveTextChunkLimit(cfg, "msteams");
+ const conversationStore =
+ opts.conversationStore ?? createMSTeamsConversationStoreFs();
log.info(`starting provider (port ${port})`);
@@ -115,8 +91,12 @@ export async function monitorMSTeamsProvider(
const agentsHosting = await import("@microsoft/agents-hosting");
const express = await import("express");
- const { ActivityHandler, CloudAdapter, authorizeJWT, getAuthConfigWithDefaults } =
- agentsHosting;
+ const {
+ ActivityHandler,
+ CloudAdapter,
+ authorizeJWT,
+ getAuthConfigWithDefaults,
+ } = agentsHosting;
// Auth configuration - create early so adapter is available for deliverReplies
const authConfig = getAuthConfigWithDefaults({
@@ -126,100 +106,11 @@ export async function monitorMSTeamsProvider(
});
const adapter = new CloudAdapter(authConfig);
- // Helper to deliver replies with configurable reply style
- // - "thread": reply to the original message (for Posts layout channels)
- // - "top-level": post as a new message (for Threads layout channels)
- async function deliverReplies(params: {
- replies: ReplyPayload[];
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- context: any; // TurnContext from SDK - has activity.getConversationReference()
- adapter: InstanceType;
- appId: string;
- replyStyle: "thread" | "top-level";
- }) {
- const chunkLimit = Math.min(textLimit, 4000);
-
- // For "thread" style, use context.sendActivity directly (replies to original message)
- // For "top-level" style, use proactive messaging without activityId
- const sendMessage =
- params.replyStyle === "thread"
- ? async (message: string) => {
- await params.context.sendActivity({ type: "message", text: message });
- }
- : async (message: string) => {
- // Get conversation reference from SDK's activity (includes proper bot info)
- // Then remove activityId to avoid threading
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const fullRef = params.context.activity.getConversationReference() as any;
- const conversationRef = {
- ...fullRef,
- activityId: undefined, // Remove to post as top-level message
- };
- // Also strip the messageid suffix from conversation.id if present
- if (conversationRef.conversation?.id) {
- conversationRef.conversation = {
- ...conversationRef.conversation,
- id: conversationRef.conversation.id.split(";")[0],
- };
- }
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await (params.adapter as any).continueConversation(
- params.appId,
- conversationRef,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- async (ctx: any) => {
- await ctx.sendActivity({ type: "message", text: message });
- },
- );
- };
-
- for (const payload of params.replies) {
- const mediaList =
- payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- const text = payload.text ?? "";
- if (!text && mediaList.length === 0) continue;
-
- if (mediaList.length === 0) {
- for (const chunk of chunkMarkdownText(text, chunkLimit)) {
- const trimmed = chunk.trim();
- if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
- await sendMessage(trimmed);
- }
- } else {
- // For media, send text first then media URLs as separate messages
- if (text.trim() && text.trim() !== SILENT_REPLY_TOKEN) {
- for (const chunk of chunkMarkdownText(text, chunkLimit)) {
- await sendMessage(chunk);
- }
- }
- for (const mediaUrl of mediaList) {
- await sendMessage(mediaUrl);
- }
- }
- }
- }
-
- // Strip Teams @mention HTML tags from message text
- function stripMentionTags(text: string): string {
- // Teams wraps mentions in ... tags
- return text.replace(/.*?<\/at>/gi, "").trim();
- }
-
- // Check if the bot was mentioned in the activity
- function wasBotMentioned(activity: TeamsActivity): boolean {
- const botId = activity.recipient?.id;
- if (!botId) return false;
- const entities = activity.entities ?? [];
- return entities.some(
- (e) => e.type === "mention" && e.mentioned?.id === botId,
- );
- }
-
// Handler for incoming messages
- async function handleTeamsMessage(context: TeamsTurnContext) {
+ async function handleTeamsMessage(context: MSTeamsTurnContext) {
const activity = context.activity;
const rawText = activity.text?.trim() ?? "";
- const text = stripMentionTags(rawText);
+ const text = stripMSTeamsMentionTags(rawText);
const from = activity.from;
const conversation = activity.conversation;
@@ -241,7 +132,7 @@ export async function monitorMSTeamsProvider(
// Teams conversation.id may include ";messageid=..." suffix - strip it for session key
const rawConversationId = conversation?.id ?? "";
- const conversationId = rawConversationId.split(";")[0];
+ const conversationId = normalizeMSTeamsConversationId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const isGroupChat =
conversationType === "groupChat" || conversation?.isGroup === true;
@@ -266,8 +157,10 @@ export async function monitorMSTeamsProvider(
channelId: activity.channelId,
serviceUrl: activity.serviceUrl,
};
- saveConversationReference(conversationId, conversationRef).catch((err) => {
- log.debug("failed to save conversation reference", { error: String(err) });
+ conversationStore.upsert(conversationId, conversationRef).catch((err) => {
+ log.debug("failed to save conversation reference", {
+ error: formatUnknownError(err),
+ });
});
// Build Teams-specific identifiers
@@ -346,19 +239,21 @@ export async function monitorMSTeamsProvider(
// Resolve team/channel config for channels and group chats
const teamId = activity.channelData?.team?.id;
const channelId = conversationId;
- const teamConfig = teamId ? msteamsCfg?.teams?.[teamId] : undefined;
- const channelConfig = teamConfig?.channels?.[channelId];
+ const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({
+ cfg: msteamsCfg,
+ teamId,
+ conversationId: channelId,
+ });
+ const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
+ isDirectMessage,
+ globalConfig: msteamsCfg,
+ teamConfig,
+ channelConfig,
+ });
// Check requireMention for channels and group chats
if (!isDirectMessage) {
- // Resolution order: channel config > team config > global config > default (true)
- const requireMention =
- channelConfig?.requireMention ??
- teamConfig?.requireMention ??
- msteamsCfg?.requireMention ??
- true;
-
- const mentioned = wasBotMentioned(activity);
+ const mentioned = wasMSTeamsBotMentioned(activity);
if (requireMention && !mentioned) {
log.debug("skipping message (mention required)", {
@@ -371,26 +266,8 @@ export async function monitorMSTeamsProvider(
}
}
- // Resolve reply style for channels/groups
- // Resolution order: channel config > team config > global config > default based on requireMention
- // If requireMention is false (Threads layout), default to "top-level"
- // If requireMention is true (Posts layout), default to "thread"
- const explicitReplyStyle =
- channelConfig?.replyStyle ??
- teamConfig?.replyStyle ??
- msteamsCfg?.replyStyle;
- const effectiveRequireMention =
- channelConfig?.requireMention ??
- teamConfig?.requireMention ??
- msteamsCfg?.requireMention ??
- true;
- // For DMs, always use "thread" style (direct reply)
- const replyStyle: "thread" | "top-level" = isDirectMessage
- ? "thread"
- : explicitReplyStyle ?? (effectiveRequireMention ? "thread" : "top-level");
-
// Format the message body with envelope
- const timestamp = parseTimestamp(activity.timestamp);
+ const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
const body = formatAgentEnvelope({
provider: "Teams",
from: senderName,
@@ -413,7 +290,7 @@ export async function monitorMSTeamsProvider(
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
- WasMentioned: isDirectMessage || wasBotMentioned(activity),
+ WasMentioned: isDirectMessage || wasMSTeamsBotMentioned(activity),
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
@@ -428,9 +305,7 @@ export async function monitorMSTeamsProvider(
// Send typing indicator
const sendTypingIndicator = async () => {
try {
- if (context.sendActivities) {
- await context.sendActivities([{ type: "typing" }]);
- }
+ await context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort
}
@@ -441,25 +316,43 @@ export async function monitorMSTeamsProvider(
createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix,
deliver: async (payload) => {
- await deliverReplies({
- replies: [payload],
- context,
- adapter,
- appId,
+ const messages = renderReplyPayloadsToMessages([payload], {
+ textChunkLimit: textLimit,
+ chunkText: true,
+ mediaMode: "split",
+ });
+ await sendMSTeamsMessages({
replyStyle,
+ adapter: adapter as unknown as MSTeamsAdapter,
+ appId,
+ conversationRef,
+ context,
+ messages,
+ // Enable default retry/backoff for throttling/transient failures.
+ retry: {},
+ onRetry: (event) => {
+ log.debug("retrying send", {
+ replyStyle,
+ ...event,
+ });
+ },
});
},
onError: (err, info) => {
- const errMsg =
- err instanceof Error
- ? err.message
- : typeof err === "object"
- ? JSON.stringify(err)
- : String(err);
+ const errMsg = formatUnknownError(err);
+ const classification = classifyMSTeamsSendError(err);
+ const hint = formatMSTeamsSendErrorHint(classification);
runtime.error?.(
- danger(`msteams ${info.kind} reply failed: ${errMsg}`),
+ danger(
+ `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
+ ),
);
- log.error("reply failed", { kind: info.kind, error: err });
+ log.error("reply failed", {
+ kind: info.kind,
+ error: errMsg,
+ classification,
+ hint,
+ });
},
onReplyStart: sendTypingIndicator,
});
@@ -499,11 +392,10 @@ export async function monitorMSTeamsProvider(
}
// Create activity handler using fluent API
- // The SDK's TurnContext is compatible with our TeamsTurnContext
const handler = new ActivityHandler()
.onMessage(async (context, next) => {
try {
- await handleTeamsMessage(context as unknown as TeamsTurnContext);
+ await handleTeamsMessage(context as unknown as MSTeamsTurnContext);
} catch (err) {
runtime.error?.(danger(`msteams handler failed: ${String(err)}`));
}
@@ -527,9 +419,12 @@ export async function monitorMSTeamsProvider(
// Set up the messages endpoint - use configured path and /api/messages as fallback
const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages";
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const messageHandler = (req: any, res: any) => {
- adapter.process(req, res, (context) => handler.run(context));
+ const messageHandler = (req: Request, res: Response) => {
+ void adapter
+ .process(req, res, (context) => handler.run(context))
+ .catch((err) => {
+ log.error("msteams webhook failed", { error: formatUnknownError(err) });
+ });
};
// Listen on configured path and /api/messages (standard Bot Framework path)
diff --git a/src/msteams/policy.test.ts b/src/msteams/policy.test.ts
new file mode 100644
index 000000000..c0900ceb2
--- /dev/null
+++ b/src/msteams/policy.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+
+import type { MSTeamsConfig } from "../config/types.js";
+import {
+ resolveMSTeamsReplyPolicy,
+ resolveMSTeamsRouteConfig,
+} from "./policy.js";
+
+describe("msteams policy", () => {
+ describe("resolveMSTeamsRouteConfig", () => {
+ it("returns team and channel config when present", () => {
+ const cfg: MSTeamsConfig = {
+ teams: {
+ team123: {
+ requireMention: false,
+ channels: {
+ chan456: { requireMention: true },
+ },
+ },
+ },
+ };
+
+ const res = resolveMSTeamsRouteConfig({
+ cfg,
+ teamId: "team123",
+ conversationId: "chan456",
+ });
+
+ expect(res.teamConfig?.requireMention).toBe(false);
+ expect(res.channelConfig?.requireMention).toBe(true);
+ });
+
+ it("returns undefined configs when teamId is missing", () => {
+ const cfg: MSTeamsConfig = {
+ teams: { team123: { requireMention: false } },
+ };
+
+ const res = resolveMSTeamsRouteConfig({
+ cfg,
+ teamId: undefined,
+ conversationId: "chan",
+ });
+ expect(res.teamConfig).toBeUndefined();
+ expect(res.channelConfig).toBeUndefined();
+ });
+ });
+
+ describe("resolveMSTeamsReplyPolicy", () => {
+ it("forces thread replies for direct messages", () => {
+ const policy = resolveMSTeamsReplyPolicy({
+ isDirectMessage: true,
+ globalConfig: { replyStyle: "top-level", requireMention: false },
+ });
+ expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
+ });
+
+ it("defaults to requireMention=true and replyStyle=thread", () => {
+ const policy = resolveMSTeamsReplyPolicy({
+ isDirectMessage: false,
+ globalConfig: {},
+ });
+ expect(policy).toEqual({ requireMention: true, replyStyle: "thread" });
+ });
+
+ it("defaults replyStyle to top-level when requireMention=false", () => {
+ const policy = resolveMSTeamsReplyPolicy({
+ isDirectMessage: false,
+ globalConfig: { requireMention: false },
+ });
+ expect(policy).toEqual({
+ requireMention: false,
+ replyStyle: "top-level",
+ });
+ });
+
+ it("prefers channel overrides over team and global defaults", () => {
+ const policy = resolveMSTeamsReplyPolicy({
+ isDirectMessage: false,
+ globalConfig: { requireMention: true },
+ teamConfig: { requireMention: true },
+ channelConfig: { requireMention: false },
+ });
+
+ // requireMention from channel -> false, and replyStyle defaults from requireMention -> top-level
+ expect(policy).toEqual({
+ requireMention: false,
+ replyStyle: "top-level",
+ });
+ });
+
+ it("uses explicit replyStyle even when requireMention defaults would differ", () => {
+ const policy = resolveMSTeamsReplyPolicy({
+ isDirectMessage: false,
+ globalConfig: { requireMention: false, replyStyle: "thread" },
+ });
+ expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
+ });
+ });
+});
diff --git a/src/msteams/policy.ts b/src/msteams/policy.ts
new file mode 100644
index 000000000..b96a83205
--- /dev/null
+++ b/src/msteams/policy.ts
@@ -0,0 +1,58 @@
+import type {
+ MSTeamsChannelConfig,
+ MSTeamsConfig,
+ MSTeamsReplyStyle,
+ MSTeamsTeamConfig,
+} from "../config/types.js";
+
+export type MSTeamsResolvedRouteConfig = {
+ teamConfig?: MSTeamsTeamConfig;
+ channelConfig?: MSTeamsChannelConfig;
+};
+
+export function resolveMSTeamsRouteConfig(params: {
+ cfg?: MSTeamsConfig;
+ teamId?: string | null | undefined;
+ conversationId?: string | null | undefined;
+}): MSTeamsResolvedRouteConfig {
+ const teamId = params.teamId?.trim();
+ const conversationId = params.conversationId?.trim();
+ const teamConfig = teamId ? params.cfg?.teams?.[teamId] : undefined;
+ const channelConfig =
+ teamConfig && conversationId
+ ? teamConfig.channels?.[conversationId]
+ : undefined;
+ return { teamConfig, channelConfig };
+}
+
+export type MSTeamsReplyPolicy = {
+ requireMention: boolean;
+ replyStyle: MSTeamsReplyStyle;
+};
+
+export function resolveMSTeamsReplyPolicy(params: {
+ isDirectMessage: boolean;
+ globalConfig?: MSTeamsConfig;
+ teamConfig?: MSTeamsTeamConfig;
+ channelConfig?: MSTeamsChannelConfig;
+}): MSTeamsReplyPolicy {
+ if (params.isDirectMessage) {
+ return { requireMention: false, replyStyle: "thread" };
+ }
+
+ const requireMention =
+ params.channelConfig?.requireMention ??
+ params.teamConfig?.requireMention ??
+ params.globalConfig?.requireMention ??
+ true;
+
+ const explicitReplyStyle =
+ params.channelConfig?.replyStyle ??
+ params.teamConfig?.replyStyle ??
+ params.globalConfig?.replyStyle;
+
+ const replyStyle: MSTeamsReplyStyle =
+ explicitReplyStyle ?? (requireMention ? "thread" : "top-level");
+
+ return { requireMention, replyStyle };
+}
diff --git a/src/msteams/probe.test.ts b/src/msteams/probe.test.ts
new file mode 100644
index 000000000..1e22a42cf
--- /dev/null
+++ b/src/msteams/probe.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { MSTeamsConfig } from "../config/types.js";
+
+const hostMockState = vi.hoisted(() => ({
+ tokenError: null as Error | null,
+}));
+
+vi.mock("@microsoft/agents-hosting", () => ({
+ getAuthConfigWithDefaults: (cfg: unknown) => cfg,
+ MsalTokenProvider: class {
+ async getAccessToken() {
+ if (hostMockState.tokenError) throw hostMockState.tokenError;
+ return "token";
+ }
+ },
+}));
+
+import { probeMSTeams } from "./probe.js";
+
+describe("msteams probe", () => {
+ it("returns an error when credentials are missing", async () => {
+ const cfg = { enabled: true } as unknown as MSTeamsConfig;
+ await expect(probeMSTeams(cfg)).resolves.toMatchObject({
+ ok: false,
+ });
+ });
+
+ it("validates credentials by acquiring a token", async () => {
+ hostMockState.tokenError = null;
+ const cfg = {
+ enabled: true,
+ appId: "app",
+ appPassword: "pw",
+ tenantId: "tenant",
+ } as unknown as MSTeamsConfig;
+ await expect(probeMSTeams(cfg)).resolves.toMatchObject({
+ ok: true,
+ appId: "app",
+ });
+ });
+
+ it("returns a helpful error when token acquisition fails", async () => {
+ hostMockState.tokenError = new Error("bad creds");
+ const cfg = {
+ enabled: true,
+ appId: "app",
+ appPassword: "pw",
+ tenantId: "tenant",
+ } as unknown as MSTeamsConfig;
+ await expect(probeMSTeams(cfg)).resolves.toMatchObject({
+ ok: false,
+ appId: "app",
+ error: "bad creds",
+ });
+ });
+});
diff --git a/src/msteams/probe.ts b/src/msteams/probe.ts
index ecb4ecae1..44c36287a 100644
--- a/src/msteams/probe.ts
+++ b/src/msteams/probe.ts
@@ -1,4 +1,5 @@
import type { MSTeamsConfig } from "../config/types.js";
+import { formatUnknownError } from "./errors.js";
import { resolveMSTeamsCredentials } from "./token.js";
export type ProbeMSTeamsResult = {
@@ -18,6 +19,24 @@ export async function probeMSTeams(
};
}
- // TODO: Validate credentials by attempting to get a token
- return { ok: true, appId: creds.appId };
+ try {
+ const { MsalTokenProvider, getAuthConfigWithDefaults } = await import(
+ "@microsoft/agents-hosting"
+ );
+ const authConfig = getAuthConfigWithDefaults({
+ clientId: creds.appId,
+ clientSecret: creds.appPassword,
+ tenantId: creds.tenantId,
+ });
+
+ const tokenProvider = new MsalTokenProvider(authConfig);
+ await tokenProvider.getAccessToken("https://api.botframework.com/.default");
+ return { ok: true, appId: creds.appId };
+ } catch (err) {
+ return {
+ ok: false,
+ appId: creds.appId,
+ error: formatUnknownError(err),
+ };
+ }
}
diff --git a/src/msteams/sdk-types.ts b/src/msteams/sdk-types.ts
new file mode 100644
index 000000000..0901848a3
--- /dev/null
+++ b/src/msteams/sdk-types.ts
@@ -0,0 +1,19 @@
+import type { TurnContext } from "@microsoft/agents-hosting";
+
+/**
+ * Minimal public surface we depend on from the Microsoft SDK types.
+ *
+ * Note: we intentionally avoid coupling to SDK classes with private members
+ * (like TurnContext) in our own public signatures. The SDK's TS surface is also
+ * stricter than what the runtime accepts (e.g. it allows plain activity-like
+ * objects), so we model the minimal structural shape we rely on.
+ */
+export type MSTeamsActivity = TurnContext["activity"];
+
+export type MSTeamsTurnContext = {
+ activity: MSTeamsActivity;
+ sendActivity: (textOrActivity: string | object) => Promise;
+ sendActivities: (
+ activities: Array<{ type: string } & Record>,
+ ) => Promise;
+};
diff --git a/src/msteams/send.ts b/src/msteams/send.ts
index 0daf2a7c1..46192d913 100644
--- a/src/msteams/send.ts
+++ b/src/msteams/send.ts
@@ -1,23 +1,23 @@
import type { ClawdbotConfig } from "../config/types.js";
import type { getChildLogger as getChildLoggerFn } from "../logging.js";
-import {
- getConversationReference,
- listConversationReferences,
- type StoredConversationReference,
+import type {
+ MSTeamsConversationStore,
+ StoredConversationReference,
} from "./conversation-store.js";
+import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
+import {
+ classifyMSTeamsSendError,
+ formatMSTeamsSendErrorHint,
+ formatUnknownError,
+} from "./errors.js";
+import { type MSTeamsAdapter, sendMSTeamsMessages } from "./messenger.js";
import { resolveMSTeamsCredentials } from "./token.js";
-// Lazy logger to avoid initialization order issues in tests
let _log: ReturnType | undefined;
-const getLog = (): ReturnType => {
- if (!_log) {
- // Dynamic import to defer initialization
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const { getChildLogger } = require("../logging.js") as {
- getChildLogger: typeof getChildLoggerFn;
- };
- _log = getChildLogger({ name: "msteams:send" });
- }
+const getLog = async (): Promise> => {
+ if (_log) return _log;
+ const { getChildLogger } = await import("../logging.js");
+ _log = getChildLogger({ name: "msteams:send" });
return _log;
};
@@ -66,63 +66,23 @@ function parseRecipient(to: string): {
/**
* Find a stored conversation reference for the given recipient.
*/
-async function findConversationReference(
- recipient: { type: "conversation" | "user"; id: string },
-): Promise<{ conversationId: string; ref: StoredConversationReference } | null> {
+async function findConversationReference(recipient: {
+ type: "conversation" | "user";
+ id: string;
+ store: MSTeamsConversationStore;
+}): Promise<{
+ conversationId: string;
+ ref: StoredConversationReference;
+} | null> {
if (recipient.type === "conversation") {
- const ref = await getConversationReference(recipient.id);
+ const ref = await recipient.store.get(recipient.id);
if (ref) return { conversationId: recipient.id, ref };
return null;
}
- // Search by user AAD object ID
- const all = await listConversationReferences();
- for (const { conversationId, reference } of all) {
- if (reference.user?.aadObjectId === recipient.id) {
- return { conversationId, ref: reference };
- }
- if (reference.user?.id === recipient.id) {
- return { conversationId, ref: reference };
- }
- }
- return null;
-}
-
-// Type matching @microsoft/agents-activity ConversationReference
-type ConversationReferenceShape = {
- activityId?: string;
- user?: { id: string; name?: string };
- bot?: { id: string; name?: string };
- conversation: { id: string; conversationType?: string; tenantId?: string };
- channelId: string;
- serviceUrl?: string;
- locale?: string;
-};
-
-/**
- * Build a Bot Framework ConversationReference from our stored format.
- * Note: activityId is intentionally omitted so proactive messages post as
- * top-level messages rather than replies/threads.
- */
-function buildConversationReference(
- ref: StoredConversationReference,
-): ConversationReferenceShape {
- if (!ref.conversation?.id) {
- throw new Error("Invalid stored reference: missing conversation.id");
- }
- return {
- // activityId omitted to avoid creating reply threads
- user: ref.user?.id ? { id: ref.user.id, name: ref.user.name } : undefined,
- bot: ref.bot?.id ? { id: ref.bot.id, name: ref.bot.name } : undefined,
- conversation: {
- id: ref.conversation.id,
- conversationType: ref.conversation.conversationType,
- tenantId: ref.conversation.tenantId,
- },
- channelId: ref.channelId ?? "msteams",
- serviceUrl: ref.serviceUrl,
- locale: ref.locale,
- };
+ const found = await recipient.store.findByUserId(recipient.id);
+ if (!found) return null;
+ return { conversationId: found.conversationId, ref: found.reference };
}
/**
@@ -147,9 +107,11 @@ export async function sendMessageMSTeams(
throw new Error("msteams credentials not configured");
}
+ const store = createMSTeamsConversationStoreFs();
+
// Parse recipient and find conversation reference
const recipient = parseRecipient(to);
- const found = await findConversationReference(recipient);
+ const found = await findConversationReference({ ...recipient, store });
if (!found) {
throw new Error(
@@ -159,9 +121,10 @@ export async function sendMessageMSTeams(
}
const { conversationId, ref } = found;
- const conversationRef = buildConversationReference(ref);
- getLog().debug("sending proactive message", {
+ const log = await getLog();
+
+ log.debug("sending proactive message", {
conversationId,
textLength: text.length,
hasMedia: Boolean(mediaUrl),
@@ -179,27 +142,38 @@ export async function sendMessageMSTeams(
const adapter = new CloudAdapter(authConfig);
- let messageId = "unknown";
+ const message = mediaUrl
+ ? text
+ ? `${text}\n\n${mediaUrl}`
+ : mediaUrl
+ : text;
+ let messageIds: string[];
+ try {
+ messageIds = await sendMSTeamsMessages({
+ replyStyle: "top-level",
+ adapter: adapter as unknown as MSTeamsAdapter,
+ appId: creds.appId,
+ conversationRef: ref,
+ messages: [message],
+ // Enable default retry/backoff for throttling/transient failures.
+ retry: {},
+ onRetry: (event) => {
+ log.debug("retrying send", { conversationId, ...event });
+ },
+ });
+ } catch (err) {
+ const classification = classifyMSTeamsSendError(err);
+ const hint = formatMSTeamsSendErrorHint(classification);
+ const status = classification.statusCode
+ ? ` (HTTP ${classification.statusCode})`
+ : "";
+ throw new Error(
+ `msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
+ );
+ }
+ const messageId = messageIds[0] ?? "unknown";
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await (adapter as any).continueConversation(
- creds.appId,
- conversationRef,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- async (context: any) => {
- // Build the activity
- const activity = {
- type: "message",
- text: mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text,
- };
- const response = await context.sendActivity(activity);
- if (response?.id) {
- messageId = response.id;
- }
- },
- );
-
- getLog().info("sent proactive message", { conversationId, messageId });
+ log.info("sent proactive message", { conversationId, messageId });
return {
messageId,
@@ -217,7 +191,8 @@ export async function listMSTeamsConversations(): Promise<
conversationType?: string;
}>
> {
- const all = await listConversationReferences();
+ const store = createMSTeamsConversationStoreFs();
+ const all = await store.list();
return all.map(({ conversationId, reference }) => ({
conversationId,
userName: reference.user?.name,
diff --git a/tmp/msteams-refactor-plan.md b/tmp/msteams-refactor-plan.md
new file mode 100644
index 000000000..4296da48b
--- /dev/null
+++ b/tmp/msteams-refactor-plan.md
@@ -0,0 +1,156 @@
+# MS Teams provider refactor plan (production-ready)
+
+Goal: refactor the MS Teams provider code (`src/msteams/*`) for long-term maintainability and correctness **without changing user-facing behavior** (except incidental bug fixes discovered during refactor).
+
+Status (2026-01-08): implemented (Phases 1–3) with unit tests; `pnpm lint && pnpm build && pnpm test` pass.
+
+## Why refactor
+
+Current pain points in `src/msteams/monitor.ts` / `src/msteams/send.ts` / `src/msteams/conversation-store.ts`:
+
+- **Mixed concerns**: HTTP server wiring, SDK handler, routing, policy resolution, and outbound delivery live in one file.
+- **Duplicated outbound logic**: proactive vs in-thread sending is implemented in multiple places (monitor + send).
+- **Weak typing boundary**: custom “SDK-like” shapes + structural casts make it harder to evolve safely.
+- **Conversation store is fragile**: JSON file writes are un-locked and non-atomic; no TTL; potential corruption under concurrency.
+- **Hard to test**: key logic (policy precedence and delivery behavior) is not isolated/pure.
+
+## Non-goals
+
+- Rewriting the provider around a different SDK.
+- Introducing new configuration knobs beyond what already exists (`msteams.replyStyle`, `requireMention`, etc.).
+- Changing routing semantics, payload envelope format, or session key logic.
+- Adding new CLI commands (unless needed for validation/testing).
+
+## Target architecture (module split)
+
+### 1) Policy resolution (pure + tested)
+
+Add `src/msteams/policy.ts` (and `src/msteams/policy.test.ts`) containing pure functions:
+
+- `resolveMSTeamsRouteConfig({ cfg, teamId, conversationId }): { teamConfig?, channelConfig? }`
+- `resolveMSTeamsReplyPolicy({ isDirectMessage, cfg, teamConfig?, channelConfig? }): { requireMention: boolean; replyStyle: "thread" | "top-level" }`
+
+Acceptance: precedence is encoded and unit-tested:
+
+- Channel overrides > team defaults > global defaults > implicit defaults.
+- DM behavior: `replyStyle` is forced to `"thread"`, mention-gating is bypassed.
+- Defaulting behavior matches existing runtime logic (e.g. `requireMention -> default replyStyle` heuristic).
+
+### 2) Outbound delivery (single implementation)
+
+Add `src/msteams/messenger.ts` (and `src/msteams/messenger.test.ts`) to centralize:
+
+- chunking (`resolveTextChunkLimit`, `chunkMarkdownText`, `SILENT_REPLY_TOKEN`)
+- send mode selection (`"thread"` vs `"top-level"`)
+- media URL message splitting (same semantics as current)
+- error formatting + consistent structured logs
+
+Surface (current implementation):
+
+- `renderReplyPayloadsToMessages(replies, { textChunkLimit, chunkText, mediaMode })`
+- `sendMSTeamsMessages({ replyStyle, adapter, appId, conversationRef, context?, messages })`
+ - uses `context.sendActivity` for `"thread"`
+ - uses `adapter.continueConversation` for `"top-level"`
+
+Acceptance: `src/msteams/monitor.ts` and `src/msteams/send.ts` both use the messenger, so there’s exactly one “how do we send a message” implementation.
+
+### 3) SDK typing boundary (type-only imports; no eager runtime deps)
+
+Add `src/msteams/sdk-types.ts` exporting the minimal types we depend on:
+
+- Turn context type (`sendActivity`, `activity` with fields we read)
+- Conversation reference type for `continueConversation`
+- Adapter interface subset (`continueConversation`, `process`)
+
+Implementation note:
+
+- Use `import type …` from the Microsoft SDK packages (or fallback to minimal structural types if the SDK does not export them cleanly).
+- Keep current dynamic runtime imports (`await import("@microsoft/agents-hosting")`) intact; type-only imports compile away.
+
+Acceptance: eliminate bespoke `TeamsTurnContext` / ad-hoc casts where possible, while preserving lazy-load behavior (some casting may remain if SDK typings are stricter than runtime behavior).
+
+### 4) Conversation store interface + hardened FS implementation
+
+Introduce a store interface (e.g. `src/msteams/conversation-store.ts`) and move the current file-backed store to `src/msteams/conversation-store-fs.ts`.
+
+Store interface:
+
+- `upsert(conversationId, reference)`
+- `get(conversationId)`
+- `findByUser({ aadObjectId?, userId? })`
+- `list()`
+- `remove(conversationId)`
+
+FS implementation hardening:
+
+- **Atomic writes**: write to `*.tmp` then `rename` (or equivalent).
+- **Locking**: use `proper-lockfile` (already a dependency) to guard read-modify-write.
+- **TTL + pruning**:
+ - persist `lastSeenAt`
+ - prune on every write and/or on a timer
+ - cap size (keep existing `MAX_CONVERSATIONS` behavior, but deterministic + documented)
+- **Permissions**:
+ - dir is already `0700`; ensure file is written with `0600`
+
+Tests:
+
+- Use an in-memory store implementation for unit tests.
+- Add FS store tests only where stable (avoid flaky timing issues).
+
+Acceptance: no store corruption under concurrent writes in-process; behavior preserved for CLI `send` lookup.
+
+### 5) Monitor wiring becomes “thin”
+
+Refactor `src/msteams/monitor.ts` so it:
+
+- loads config + credentials
+- creates adapter + express routes
+- routes inbound messages to a smaller `handleInboundMessage(...)` function
+- delegates:
+ - policy decisions to `policy.ts`
+ - outbound sends to `messenger.ts`
+ - reference persistence to the store abstraction
+
+Acceptance: `monitor.ts` is mostly wiring and orchestration; logic-heavy parts are tested in isolation.
+
+## Implementation phases (incremental, safe)
+
+### Phase 1 (behavior-preserving extraction)
+
+1. Add `src/msteams/policy.ts` + `src/msteams/policy.test.ts`.
+2. Add `src/msteams/messenger.ts` + `src/msteams/messenger.test.ts` (unit test chunking + send mode selection; mock context/adapter).
+3. Refactor `src/msteams/monitor.ts` to use policy + messenger (no behavior change).
+4. Refactor `src/msteams/send.ts` to use messenger (no behavior change).
+5. Extract inbound helpers (`stripMentionTags`, mention detection, conversation ID normalization) into `src/msteams/inbound.ts` + tests.
+6. Ensure `pnpm lint && pnpm build && pnpm test` pass.
+
+### Phase 2 (store hardening)
+
+1. Introduce store interface + in-memory test store.
+2. Move FS store to its own module; add locking + atomic writes + TTL.
+3. Update `monitor.ts` + `send.ts` to depend on the interface (inject FS store from wiring).
+4. Add targeted tests.
+
+### Phase 3 (production reliability)
+
+1. Add retry/backoff around outbound sends (careful: avoid duplicate posts; only retry safe failures).
+2. Error classification helpers (auth misconfig, transient network, throttling).
+3. Improve `probeMSTeams` to validate credentials (optional; can be separate).
+
+## Done criteria / checkpoints
+
+- Phase 1 done:
+ - New policy tests cover precedence and DM behavior.
+ - `monitor.ts` + `send.ts` share outbound sending via messenger.
+ - No new runtime imports that break lazy-load behavior.
+- Phase 2 done:
+ - Store is locked + atomic + bounded.
+ - Clear migration story (keep same file format/version or bump explicitly).
+- Phase 3 done:
+ - Retries are safe and bounded; logs are structured and actionable.
+
+## Notes / edge cases to validate during refactor
+
+- “Channel config” keys: currently based on `conversation.id` (e.g. `19:…@thread.tacv2`). Preserve that.
+- `replyStyle="top-level"` correctness: ensure the conversation reference normalization is centralized and tested.
+- Mention-gating: preserve current detection behavior (`entities` mention matching `recipient.id`), but isolate it for future improvements.
From 8d096ef85de90cb651d53546ee44b0d005d70b29 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 09:49:27 +0300
Subject: [PATCH 020/152] Tests: stabilize file watchers
---
src/canvas-host/server.test.ts | 2 +-
src/canvas-host/server.ts | 1 +
src/gateway/config-reload.ts | 6 ++++++
3 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts
index 600e6df74..06132469b 100644
--- a/src/canvas-host/server.test.ts
+++ b/src/canvas-host/server.test.ts
@@ -231,7 +231,7 @@ describe("canvas host", () => {
await server.close();
await fs.rm(dir, { recursive: true, force: true });
}
- });
+ }, 10_000);
it("serves the gateway-hosted A2UI scaffold", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-"));
diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts
index 871c70e9f..85eb3bc33 100644
--- a/src/canvas-host/server.ts
+++ b/src/canvas-host/server.ts
@@ -271,6 +271,7 @@ export async function createCanvasHostHandler(
? chokidar.watch(rootReal, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
+ usePolling: opts.allowInTests === true,
ignored: [
/(^|[\\/])\../, // dotfiles
/(^|[\\/])node_modules([\\/]|$)/,
diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts
index 65303873a..f053dcd2e 100644
--- a/src/gateway/config-reload.ts
+++ b/src/gateway/config-reload.ts
@@ -360,13 +360,18 @@ export function startGatewayConfigReloader(opts: {
const watcher = chokidar.watch(opts.watchPath, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
+ usePolling: Boolean(process.env.VITEST),
});
watcher.on("add", schedule);
watcher.on("change", schedule);
watcher.on("unlink", schedule);
+ let watcherClosed = false;
watcher.on("error", (err) => {
+ if (watcherClosed) return;
+ watcherClosed = true;
opts.log.warn(`config watcher error: ${String(err)}`);
+ void watcher.close().catch(() => {});
});
return {
@@ -374,6 +379,7 @@ export function startGatewayConfigReloader(opts: {
stopped = true;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = null;
+ watcherClosed = true;
await watcher.close().catch(() => {});
},
};
From 04b1eb57eb5ec7cc477fc72aeaf7128f09f0cf39 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 10:01:05 +0300
Subject: [PATCH 021/152] MS Teams: fix top-level replies (agent reference)
---
src/msteams/conversation-store.ts | 4 +++-
src/msteams/messenger.test.ts | 2 ++
src/msteams/messenger.ts | 14 +++++++++++---
src/msteams/monitor.ts | 12 +++++++++---
4 files changed, 25 insertions(+), 7 deletions(-)
diff --git a/src/msteams/conversation-store.ts b/src/msteams/conversation-store.ts
index 75bd63c92..a76b4d3f2 100644
--- a/src/msteams/conversation-store.ts
+++ b/src/msteams/conversation-store.ts
@@ -11,7 +11,9 @@ export type StoredConversationReference = {
activityId?: string;
/** User who sent the message */
user?: { id?: string; name?: string; aadObjectId?: string };
- /** Bot that received the message */
+ /** Agent/bot that received the message */
+ agent?: { id?: string; name?: string; aadObjectId?: string } | null;
+ /** @deprecated legacy field (pre-Agents SDK). Prefer `agent`. */
bot?: { id?: string; name?: string };
/** Conversation details */
conversation?: { id?: string; conversationType?: string; tenantId?: string };
diff --git a/src/msteams/messenger.test.ts b/src/msteams/messenger.test.ts
index 0fbbdb764..2da449d4f 100644
--- a/src/msteams/messenger.test.ts
+++ b/src/msteams/messenger.test.ts
@@ -46,6 +46,8 @@ describe("msteams messenger", () => {
describe("sendMSTeamsMessages", () => {
const baseRef: StoredConversationReference = {
activityId: "activity123",
+ user: { id: "user123", name: "User" },
+ agent: { id: "bot123", name: "Bot" },
conversation: { id: "19:abc@thread.tacv2;messageid=deadbeef" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
diff --git a/src/msteams/messenger.ts b/src/msteams/messenger.ts
index aa21be60a..b33db42f2 100644
--- a/src/msteams/messenger.ts
+++ b/src/msteams/messenger.ts
@@ -12,7 +12,7 @@ type SendContext = {
type ConversationReference = {
activityId?: string;
user?: { id?: string; name?: string; aadObjectId?: string };
- bot?: { id?: string; name?: string };
+ agent?: { id?: string; name?: string; aadObjectId?: string } | null;
conversation: { id: string; conversationType?: string; tenantId?: string };
channelId: string;
serviceUrl?: string;
@@ -59,10 +59,18 @@ function buildConversationReference(
if (!conversationId) {
throw new Error("Invalid stored reference: missing conversation.id");
}
+ const agent = ref.agent ?? ref.bot ?? undefined;
+ if (agent == null || !agent.id) {
+ throw new Error("Invalid stored reference: missing agent.id");
+ }
+ const user = ref.user;
+ if (!user?.id) {
+ throw new Error("Invalid stored reference: missing user.id");
+ }
return {
activityId: ref.activityId,
- user: ref.user,
- bot: ref.bot,
+ user,
+ agent,
conversation: {
id: normalizeConversationId(conversationId),
conversationType: ref.conversation?.conversationType,
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
index 5b9cebe5f..11be94d43 100644
--- a/src/msteams/monitor.ts
+++ b/src/msteams/monitor.ts
@@ -143,12 +143,18 @@ export async function monitorMSTeamsProvider(
const senderId = from.aadObjectId ?? from.id;
// Save conversation reference for proactive messaging
+ const agent = activity.recipient
+ ? {
+ id: activity.recipient.id,
+ name: activity.recipient.name,
+ aadObjectId: activity.recipient.aadObjectId,
+ }
+ : undefined;
const conversationRef: StoredConversationReference = {
activityId: activity.id,
user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
- bot: activity.recipient
- ? { id: activity.recipient.id, name: activity.recipient.name }
- : undefined,
+ agent,
+ bot: agent ? { id: agent.id, name: agent.name } : undefined,
conversation: {
id: conversationId,
conversationType,
From d6256a388e4e31f29e7594ad93c0ca794b60fd15 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 10:49:12 +0300
Subject: [PATCH 022/152] feat(msteams): wire up proactive messaging in
routeReply for queued replies
---
src/auto-reply/reply/route-reply.ts | 20 +++++++++++++-------
1 file changed, 13 insertions(+), 7 deletions(-)
diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts
index 909407e78..32f2b220b 100644
--- a/src/auto-reply/reply/route-reply.ts
+++ b/src/auto-reply/reply/route-reply.ts
@@ -10,6 +10,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.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";
@@ -54,7 +55,8 @@ export type RouteReplyResult = {
export async function routeReply(
params: RouteReplyParams,
): Promise {
- const { payload, channel, to, accountId, threadId, abortSignal } = params;
+ const { payload, channel, to, accountId, threadId, cfg, abortSignal } =
+ params;
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
const text = payload.text ?? "";
@@ -146,11 +148,13 @@ export async function routeReply(
}
case "msteams": {
- // TODO: Implement proactive messaging for MS Teams
- return {
- ok: false,
- error: `MS Teams routing not yet supported for queued replies`,
- };
+ const result = await sendMessageMSTeams({
+ cfg,
+ to,
+ text,
+ mediaUrl,
+ });
+ return { ok: true, messageId: result.messageId };
}
default: {
@@ -203,7 +207,8 @@ export function isRoutableChannel(
| "discord"
| "signal"
| "imessage"
- | "whatsapp" {
+ | "whatsapp"
+ | "msteams" {
if (!channel) return false;
return [
"telegram",
@@ -212,5 +217,6 @@ export function isRoutableChannel(
"signal",
"imessage",
"whatsapp",
+ "msteams",
].includes(channel);
}
From 2ab5890eaba334fc6d4287439e988c8f169a0d57 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 11:08:08 +0300
Subject: [PATCH 023/152] wip
---
src/auto-reply/reply/route-reply.test.ts | 30 ++++++++++++++++++++++++
src/telegram/bot.ts | 6 ++++-
2 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts
index 9571e292f..8debc7b67 100644
--- a/src/auto-reply/reply/route-reply.test.ts
+++ b/src/auto-reply/reply/route-reply.test.ts
@@ -1,8 +1,14 @@
import { describe, expect, it, vi } from "vitest";
+import type { ClawdbotConfig } from "../../config/config.js";
+
const mocks = vi.hoisted(() => ({
sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
sendMessageIMessage: vi.fn(async () => ({ messageId: "ok" })),
+ sendMessageMSTeams: vi.fn(async () => ({
+ messageId: "m1",
+ conversationId: "c1",
+ })),
sendMessageSignal: vi.fn(async () => ({ messageId: "t1" })),
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
@@ -15,6 +21,9 @@ 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,
}));
@@ -143,4 +152,25 @@ describe("routeReply", () => {
expect.objectContaining({ accountId: "acc-1", verbose: false }),
);
});
+
+ it("routes MS Teams via proactive sender", async () => {
+ mocks.sendMessageMSTeams.mockClear();
+ const cfg = {
+ msteams: {
+ enabled: true,
+ },
+ } as unknown as ClawdbotConfig;
+ await routeReply({
+ payload: { text: "hi" },
+ channel: "msteams",
+ to: "conversation:19:abc@thread.tacv2",
+ cfg,
+ });
+ expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
+ cfg,
+ to: "conversation:19:abc@thread.tacv2",
+ text: "hi",
+ mediaUrl: undefined,
+ });
+ });
});
diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts
index 80d99028d..ac0550aeb 100644
--- a/src/telegram/bot.ts
+++ b/src/telegram/bot.ts
@@ -153,8 +153,12 @@ export function createTelegramBot(opts: TelegramBotOptions) {
},
};
const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
+ const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
+ const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun;
const client: ApiClientOptions | undefined = fetchImpl
- ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
+ ? shouldProvideFetch
+ ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
+ : undefined
: undefined;
const bot = new Bot(opts.token, client ? { client } : undefined);
From e67ca9244323fbfa8a377e7c8300bb3b140c8df6 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 11:36:26 +0300
Subject: [PATCH 024/152] MS Teams: ingest inbound image attachments
---
src/msteams/attachments.test.ts | 179 +++++++++++++++++++++
src/msteams/attachments.ts | 272 ++++++++++++++++++++++++++++++++
src/msteams/monitor.ts | 46 +++++-
3 files changed, 494 insertions(+), 3 deletions(-)
create mode 100644 src/msteams/attachments.test.ts
create mode 100644 src/msteams/attachments.ts
diff --git a/src/msteams/attachments.test.ts b/src/msteams/attachments.test.ts
new file mode 100644
index 000000000..1c690e580
--- /dev/null
+++ b/src/msteams/attachments.test.ts
@@ -0,0 +1,179 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ buildMSTeamsAttachmentPlaceholder,
+ buildMSTeamsMediaPayload,
+ downloadMSTeamsImageAttachments,
+} from "./attachments.js";
+
+const detectMimeMock = vi.fn(async () => "image/png");
+const saveMediaBufferMock = vi.fn(async () => ({
+ path: "/tmp/saved.png",
+ contentType: "image/png",
+}));
+
+vi.mock("../media/mime.js", () => ({
+ detectMime: (...args: unknown[]) => detectMimeMock(...args),
+}));
+
+vi.mock("../media/store.js", () => ({
+ saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
+}));
+
+describe("msteams attachments", () => {
+ beforeEach(() => {
+ detectMimeMock.mockClear();
+ saveMediaBufferMock.mockClear();
+ });
+
+ describe("buildMSTeamsAttachmentPlaceholder", () => {
+ it("returns empty string when no attachments", () => {
+ expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
+ expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
+ });
+
+ it("returns image placeholder for image attachments", () => {
+ expect(
+ buildMSTeamsAttachmentPlaceholder([
+ { contentType: "image/png", contentUrl: "https://x/img.png" },
+ ]),
+ ).toBe("");
+ expect(
+ buildMSTeamsAttachmentPlaceholder([
+ { contentType: "image/png", contentUrl: "https://x/1.png" },
+ { contentType: "image/jpeg", contentUrl: "https://x/2.jpg" },
+ ]),
+ ).toBe(" (2 images)");
+ });
+
+ it("treats Teams file.download.info image attachments as images", () => {
+ expect(
+ buildMSTeamsAttachmentPlaceholder([
+ {
+ contentType: "application/vnd.microsoft.teams.file.download.info",
+ content: { downloadUrl: "https://x/dl", fileType: "png" },
+ },
+ ]),
+ ).toBe("");
+ });
+
+ it("returns document placeholder for non-image attachments", () => {
+ expect(
+ buildMSTeamsAttachmentPlaceholder([
+ { contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
+ ]),
+ ).toBe("");
+ expect(
+ buildMSTeamsAttachmentPlaceholder([
+ { contentType: "application/pdf", contentUrl: "https://x/1.pdf" },
+ { contentType: "application/pdf", contentUrl: "https://x/2.pdf" },
+ ]),
+ ).toBe(" (2 files)");
+ });
+ });
+
+ describe("downloadMSTeamsImageAttachments", () => {
+ it("downloads and stores image contentUrl attachments", async () => {
+ const fetchMock = vi.fn(async () => {
+ return new Response(Buffer.from("png"), {
+ status: 200,
+ headers: { "content-type": "image/png" },
+ });
+ });
+
+ const media = await downloadMSTeamsImageAttachments({
+ attachments: [
+ { contentType: "image/png", contentUrl: "https://x/img" },
+ ],
+ maxBytes: 1024 * 1024,
+ fetchFn: fetchMock as unknown as typeof fetch,
+ });
+
+ expect(media).toHaveLength(1);
+ expect(media[0]?.path).toBe("/tmp/saved.png");
+ expect(fetchMock).toHaveBeenCalledWith("https://x/img");
+ expect(saveMediaBufferMock).toHaveBeenCalled();
+ });
+
+ it("supports Teams file.download.info downloadUrl attachments", async () => {
+ const fetchMock = vi.fn(async () => {
+ return new Response(Buffer.from("png"), {
+ status: 200,
+ headers: { "content-type": "image/png" },
+ });
+ });
+
+ const media = await downloadMSTeamsImageAttachments({
+ attachments: [
+ {
+ contentType: "application/vnd.microsoft.teams.file.download.info",
+ content: { downloadUrl: "https://x/dl", fileType: "png" },
+ },
+ ],
+ maxBytes: 1024 * 1024,
+ fetchFn: fetchMock as unknown as typeof fetch,
+ });
+
+ expect(media).toHaveLength(1);
+ expect(fetchMock).toHaveBeenCalledWith("https://x/dl");
+ });
+
+ it("retries with auth when the first request is unauthorized", async () => {
+ const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
+ const hasAuth = Boolean(
+ opts &&
+ typeof opts === "object" &&
+ "headers" in opts &&
+ (opts.headers as Record)?.Authorization,
+ );
+ if (!hasAuth) {
+ return new Response("unauthorized", { status: 401 });
+ }
+ return new Response(Buffer.from("png"), {
+ status: 200,
+ headers: { "content-type": "image/png" },
+ });
+ });
+
+ const media = await downloadMSTeamsImageAttachments({
+ attachments: [
+ { contentType: "image/png", contentUrl: "https://x/img" },
+ ],
+ maxBytes: 1024 * 1024,
+ tokenProvider: { getAccessToken: vi.fn(async () => "token") },
+ fetchFn: fetchMock as unknown as typeof fetch,
+ });
+
+ expect(media).toHaveLength(1);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("ignores non-image attachments", async () => {
+ const fetchMock = vi.fn();
+ const media = await downloadMSTeamsImageAttachments({
+ attachments: [
+ { contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
+ ],
+ maxBytes: 1024 * 1024,
+ fetchFn: fetchMock as unknown as typeof fetch,
+ });
+
+ expect(media).toHaveLength(0);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("buildMSTeamsMediaPayload", () => {
+ it("returns single and multi-file fields", () => {
+ const payload = buildMSTeamsMediaPayload([
+ { path: "/tmp/a.png", contentType: "image/png" },
+ { path: "/tmp/b.png", contentType: "image/png" },
+ ]);
+ expect(payload.MediaPath).toBe("/tmp/a.png");
+ expect(payload.MediaUrl).toBe("/tmp/a.png");
+ expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]);
+ expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]);
+ expect(payload.MediaTypes).toEqual(["image/png", "image/png"]);
+ });
+ });
+});
diff --git a/src/msteams/attachments.ts b/src/msteams/attachments.ts
new file mode 100644
index 000000000..6c136b7d3
--- /dev/null
+++ b/src/msteams/attachments.ts
@@ -0,0 +1,272 @@
+import { detectMime } from "../media/mime.js";
+import { saveMediaBuffer } from "../media/store.js";
+
+export type MSTeamsAttachmentLike = {
+ contentType?: string | null;
+ contentUrl?: string | null;
+ name?: string | null;
+ thumbnailUrl?: string | null;
+ content?: unknown;
+};
+
+export type MSTeamsAccessTokenProvider = {
+ getAccessToken: (scope: string) => Promise;
+};
+
+type DownloadCandidate = {
+ url: string;
+ fileHint?: string;
+ contentTypeHint?: string;
+ placeholder: string;
+};
+
+export type MSTeamsInboundMedia = {
+ path: string;
+ contentType?: string;
+ placeholder: string;
+};
+
+const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function normalizeContentType(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function inferPlaceholder(params: {
+ contentType?: string;
+ fileName?: string;
+ fileType?: string;
+}): string {
+ const mime = params.contentType?.toLowerCase() ?? "";
+ const name = params.fileName?.toLowerCase() ?? "";
+ const fileType = params.fileType?.toLowerCase() ?? "";
+
+ const looksLikeImage =
+ mime.startsWith("image/") ||
+ IMAGE_EXT_RE.test(name) ||
+ IMAGE_EXT_RE.test(`x.${fileType}`);
+
+ return looksLikeImage ? "" : "";
+}
+
+function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
+ const contentType = normalizeContentType(att.contentType) ?? "";
+ const name = typeof att.name === "string" ? att.name : "";
+ if (contentType.startsWith("image/")) return true;
+ if (IMAGE_EXT_RE.test(name)) return true;
+
+ if (
+ contentType === "application/vnd.microsoft.teams.file.download.info" &&
+ isRecord(att.content)
+ ) {
+ const fileType =
+ typeof att.content.fileType === "string" ? att.content.fileType : "";
+ if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) return true;
+ const fileName =
+ typeof att.content.fileName === "string" ? att.content.fileName : "";
+ if (fileName && IMAGE_EXT_RE.test(fileName)) return true;
+ }
+
+ return false;
+}
+
+export function buildMSTeamsAttachmentPlaceholder(
+ attachments: MSTeamsAttachmentLike[] | undefined,
+): string {
+ const list = Array.isArray(attachments) ? attachments : [];
+ if (list.length === 0) return "";
+ const imageCount = list.filter(isLikelyImageAttachment).length;
+ if (imageCount > 0) {
+ return `${imageCount > 1 ? ` (${imageCount} images)` : ""}`;
+ }
+ const count = list.length;
+ return `${count > 1 ? ` (${count} files)` : ""}`;
+}
+
+function resolveDownloadCandidate(
+ att: MSTeamsAttachmentLike,
+): DownloadCandidate | null {
+ const contentType = normalizeContentType(att.contentType);
+ const name = typeof att.name === "string" ? att.name.trim() : "";
+
+ if (contentType === "application/vnd.microsoft.teams.file.download.info") {
+ if (!isRecord(att.content)) return null;
+ const downloadUrl =
+ typeof att.content.downloadUrl === "string"
+ ? att.content.downloadUrl.trim()
+ : "";
+ if (!downloadUrl) return null;
+
+ const fileType =
+ typeof att.content.fileType === "string"
+ ? att.content.fileType.trim()
+ : "";
+ const uniqueId =
+ typeof att.content.uniqueId === "string"
+ ? att.content.uniqueId.trim()
+ : "";
+ const fileName =
+ typeof att.content.fileName === "string"
+ ? att.content.fileName.trim()
+ : "";
+
+ const fileHint =
+ name ||
+ fileName ||
+ (uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
+ return {
+ url: downloadUrl,
+ fileHint: fileHint || undefined,
+ contentTypeHint: undefined,
+ placeholder: inferPlaceholder({
+ contentType,
+ fileName: fileHint,
+ fileType,
+ }),
+ };
+ }
+
+ const contentUrl =
+ typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
+ if (!contentUrl) return null;
+
+ return {
+ url: contentUrl,
+ fileHint: name || undefined,
+ contentTypeHint: contentType,
+ placeholder: inferPlaceholder({ contentType, fileName: name }),
+ };
+}
+
+function scopeCandidatesForUrl(url: string): string[] {
+ try {
+ const host = new URL(url).hostname.toLowerCase();
+ const looksLikeGraph =
+ host.endsWith("graph.microsoft.com") ||
+ host.endsWith("sharepoint.com") ||
+ host.endsWith("1drv.ms") ||
+ host.includes("sharepoint");
+ return looksLikeGraph
+ ? [
+ "https://graph.microsoft.com/.default",
+ "https://api.botframework.com/.default",
+ ]
+ : [
+ "https://api.botframework.com/.default",
+ "https://graph.microsoft.com/.default",
+ ];
+ } catch {
+ return [
+ "https://api.botframework.com/.default",
+ "https://graph.microsoft.com/.default",
+ ];
+ }
+}
+
+async function fetchWithAuthFallback(params: {
+ url: string;
+ tokenProvider?: MSTeamsAccessTokenProvider;
+ fetchFn?: typeof fetch;
+}): Promise {
+ const fetchFn = params.fetchFn ?? fetch;
+ const firstAttempt = await fetchFn(params.url);
+ if (firstAttempt.ok) return firstAttempt;
+ if (!params.tokenProvider) return firstAttempt;
+ if (firstAttempt.status !== 401 && firstAttempt.status !== 403)
+ return firstAttempt;
+
+ const scopes = scopeCandidatesForUrl(params.url);
+ for (const scope of scopes) {
+ try {
+ const token = await params.tokenProvider.getAccessToken(scope);
+ const res = await fetchFn(params.url, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (res.ok) return res;
+ } catch {
+ // Try the next scope.
+ }
+ }
+
+ return firstAttempt;
+}
+
+export async function downloadMSTeamsImageAttachments(params: {
+ attachments: MSTeamsAttachmentLike[] | undefined;
+ maxBytes: number;
+ tokenProvider?: MSTeamsAccessTokenProvider;
+ fetchFn?: typeof fetch;
+}): Promise {
+ const list = Array.isArray(params.attachments) ? params.attachments : [];
+ if (list.length === 0) return [];
+
+ const candidates = list
+ .filter(isLikelyImageAttachment)
+ .map(resolveDownloadCandidate)
+ .filter(Boolean) as DownloadCandidate[];
+
+ if (candidates.length === 0) return [];
+
+ const out: MSTeamsInboundMedia[] = [];
+ for (const candidate of candidates) {
+ try {
+ const res = await fetchWithAuthFallback({
+ url: candidate.url,
+ tokenProvider: params.tokenProvider,
+ fetchFn: params.fetchFn,
+ });
+ if (!res.ok) continue;
+ const buffer = Buffer.from(await res.arrayBuffer());
+ if (buffer.byteLength > params.maxBytes) continue;
+ const mime = await detectMime({
+ buffer,
+ headerMime:
+ candidate.contentTypeHint ?? res.headers.get("content-type"),
+ filePath: candidate.fileHint ?? candidate.url,
+ });
+ const saved = await saveMediaBuffer(
+ buffer,
+ mime,
+ "inbound",
+ params.maxBytes,
+ );
+ out.push({
+ path: saved.path,
+ contentType: saved.contentType,
+ placeholder: candidate.placeholder,
+ });
+ } catch {
+ // Ignore download failures and continue.
+ }
+ }
+ return out;
+}
+
+export function buildMSTeamsMediaPayload(
+ mediaList: Array<{ path: string; contentType?: string }>,
+): {
+ MediaPath?: string;
+ MediaType?: string;
+ MediaUrl?: string;
+ MediaPaths?: string[];
+ MediaUrls?: string[];
+ MediaTypes?: string[];
+} {
+ const first = mediaList[0];
+ const mediaPaths = mediaList.map((media) => media.path);
+ const mediaTypes = mediaList.map((media) => media.contentType ?? "");
+ return {
+ MediaPath: first?.path,
+ MediaType: first?.contentType,
+ MediaUrl: first?.path,
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
+ MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined,
+ };
+}
diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts
index 11be94d43..e032f6d2e 100644
--- a/src/msteams/monitor.ts
+++ b/src/msteams/monitor.ts
@@ -13,6 +13,12 @@ import {
} from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
+import {
+ buildMSTeamsAttachmentPlaceholder,
+ buildMSTeamsMediaPayload,
+ downloadMSTeamsImageAttachments,
+ type MSTeamsAttachmentLike,
+} from "./attachments.js";
import type {
MSTeamsConversationStore,
StoredConversationReference,
@@ -82,6 +88,11 @@ export async function monitorMSTeamsProvider(
const port = msteamsCfg.webhook?.port ?? 3978;
const textLimit = resolveTextChunkLimit(cfg, "msteams");
+ const MB = 1024 * 1024;
+ const mediaMaxBytes =
+ typeof cfg.agent?.mediaMaxMb === "number" && cfg.agent.mediaMaxMb > 0
+ ? Math.floor(cfg.agent.mediaMaxMb * MB)
+ : 8 * MB;
const conversationStore =
opts.conversationStore ?? createMSTeamsConversationStoreFs();
@@ -94,6 +105,7 @@ export async function monitorMSTeamsProvider(
const {
ActivityHandler,
CloudAdapter,
+ MsalTokenProvider,
authorizeJWT,
getAuthConfigWithDefaults,
} = agentsHosting;
@@ -104,6 +116,7 @@ export async function monitorMSTeamsProvider(
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
});
+ const tokenProvider = new MsalTokenProvider(authConfig);
const adapter = new CloudAdapter(authConfig);
// Handler for incoming messages
@@ -111,17 +124,32 @@ export async function monitorMSTeamsProvider(
const activity = context.activity;
const rawText = activity.text?.trim() ?? "";
const text = stripMSTeamsMentionTags(rawText);
+ const attachments = Array.isArray(activity.attachments)
+ ? (activity.attachments as unknown as MSTeamsAttachmentLike[])
+ : [];
+ const attachmentPlaceholder =
+ buildMSTeamsAttachmentPlaceholder(attachments);
+ const rawBody = text || attachmentPlaceholder;
const from = activity.from;
const conversation = activity.conversation;
+ const attachmentTypes = attachments
+ .map((att) =>
+ typeof att.contentType === "string" ? att.contentType : undefined,
+ )
+ .filter(Boolean)
+ .slice(0, 3);
+
log.info("received message", {
rawText: rawText.slice(0, 50),
text: text.slice(0, 50),
+ attachments: attachments.length,
+ attachmentTypes,
from: from?.id,
conversation: conversation?.id,
});
- if (!text) {
+ if (!rawBody) {
log.debug("skipping empty message after stripping mentions");
return;
}
@@ -189,7 +217,7 @@ export async function monitorMSTeamsProvider(
},
});
- const preview = text.replace(/\s+/g, " ").slice(0, 160);
+ const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Teams DM from ${senderName}`
: `Teams message in ${conversationType} from ${senderName}`;
@@ -274,11 +302,22 @@ export async function monitorMSTeamsProvider(
// Format the message body with envelope
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
+ const mediaList = await downloadMSTeamsImageAttachments({
+ attachments,
+ maxBytes: mediaMaxBytes,
+ tokenProvider: {
+ getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
+ },
+ });
+ if (mediaList.length > 0) {
+ log.debug("downloaded image attachments", { count: mediaList.length });
+ }
+ const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const body = formatAgentEnvelope({
provider: "Teams",
from: senderName,
timestamp,
- body: text,
+ body: rawBody,
});
// Build context payload for agent
@@ -300,6 +339,7 @@ export async function monitorMSTeamsProvider(
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
+ ...mediaPayload,
};
if (shouldLogVerbose()) {
From 15e6761035ce9877ed98bcd4b54ec8cc08839ce9 Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 12:38:06 +0300
Subject: [PATCH 025/152] wip [skip ci]
---
tmp/msteams-implementation-guide.md | 2 ++
tmp/msteams-refactor-plan.md | 2 ++
2 files changed, 4 insertions(+)
diff --git a/tmp/msteams-implementation-guide.md b/tmp/msteams-implementation-guide.md
index d9712a605..54d787036 100644
--- a/tmp/msteams-implementation-guide.md
+++ b/tmp/msteams-implementation-guide.md
@@ -901,6 +901,8 @@ Add the `webApplicationInfo` and `authorization` sections to your `manifest.json
}
```
+**Note:** Teams clients cache app manifests. After uploading a new package or changing RSC permissions, fully quit/relaunch Teams (not just close the window) and reinstall the app to force the updated version + permissions to load.
+
**Key points:**
- `webApplicationInfo.id` must match your bot's Microsoft App ID
- `webApplicationInfo.resource` should be `https://RscPermission`
diff --git a/tmp/msteams-refactor-plan.md b/tmp/msteams-refactor-plan.md
index 4296da48b..79c207b74 100644
--- a/tmp/msteams-refactor-plan.md
+++ b/tmp/msteams-refactor-plan.md
@@ -123,6 +123,7 @@ Acceptance: `monitor.ts` is mostly wiring and orchestration; logic-heavy parts a
4. Refactor `src/msteams/send.ts` to use messenger (no behavior change).
5. Extract inbound helpers (`stripMentionTags`, mention detection, conversation ID normalization) into `src/msteams/inbound.ts` + tests.
6. Ensure `pnpm lint && pnpm build && pnpm test` pass.
+7. If testing manifest/RSC updates, fully quit/relaunch Teams and reinstall the app to flush cached app metadata.
### Phase 2 (store hardening)
@@ -154,3 +155,4 @@ Acceptance: `monitor.ts` is mostly wiring and orchestration; logic-heavy parts a
- “Channel config” keys: currently based on `conversation.id` (e.g. `19:…@thread.tacv2`). Preserve that.
- `replyStyle="top-level"` correctness: ensure the conversation reference normalization is centralized and tested.
- Mention-gating: preserve current detection behavior (`entities` mention matching `recipient.id`), but isolate it for future improvements.
+- Teams client caches app manifests; after uploading a new package or changing RSC permissions, fully quit/relaunch Teams (not just close the window) and reinstall the app to force the version + permission refresh.
From 678d704341785e1a8f3e53e126a6f4cd9e6f225a Mon Sep 17 00:00:00 2001
From: Onur
Date: Thu, 8 Jan 2026 13:58:19 +0300
Subject: [PATCH 026/152] image works in DM
---
src/cli/pairing-cli.ts | 7 +
src/msteams/attachments.test.ts | 132 +++++
src/msteams/attachments.ts | 470 +++++++++++++++++-
src/msteams/inbound.ts | 9 +
src/msteams/monitor.ts | 78 ++-
...08-msteams-permissions-and-capabilities.md | 91 ++++
6 files changed, 782 insertions(+), 5 deletions(-)
create mode 100644 tmp/2026-01-08-msteams-permissions-and-capabilities.md
diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts
index d671dc76c..be6ac1dc3 100644
--- a/src/cli/pairing-cli.ts
+++ b/src/cli/pairing-cli.ts
@@ -3,6 +3,7 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
+import { sendMessageMSTeams } from "../msteams/send.js";
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
import {
approveProviderPairingCode,
@@ -21,6 +22,7 @@ const PROVIDERS: PairingProvider[] = [
"discord",
"slack",
"whatsapp",
+ "msteams",
];
function parseProvider(raw: unknown): PairingProvider {
@@ -65,6 +67,11 @@ async function notifyApproved(provider: PairingProvider, id: string) {
await sendMessageIMessage(id, message);
return;
}
+ if (provider === "msteams") {
+ const cfg = loadConfig();
+ await sendMessageMSTeams({ cfg, to: id, text: message });
+ return;
+ }
// WhatsApp: approval still works (store); notifying requires an active web session.
}
diff --git a/src/msteams/attachments.test.ts b/src/msteams/attachments.test.ts
index 1c690e580..d1f92a33e 100644
--- a/src/msteams/attachments.test.ts
+++ b/src/msteams/attachments.test.ts
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMSTeamsAttachmentPlaceholder,
+ buildMSTeamsGraphMessageUrls,
buildMSTeamsMediaPayload,
+ downloadMSTeamsGraphMedia,
downloadMSTeamsImageAttachments,
} from "./attachments.js";
@@ -70,6 +72,26 @@ describe("msteams attachments", () => {
]),
).toBe(" (2 files)");
});
+
+ it("counts inline images in text/html attachments", () => {
+ expect(
+ buildMSTeamsAttachmentPlaceholder([
+ {
+ contentType: "text/html",
+ content: 'hi
',
+ },
+ ]),
+ ).toBe("");
+ expect(
+ buildMSTeamsAttachmentPlaceholder([
+ {
+ contentType: "text/html",
+ content:
+ '
',
+ },
+ ]),
+ ).toBe(" (2 images)");
+ });
});
describe("downloadMSTeamsImageAttachments", () => {
@@ -118,6 +140,45 @@ describe("msteams attachments", () => {
expect(fetchMock).toHaveBeenCalledWith("https://x/dl");
});
+ it("downloads inline image URLs from html attachments", async () => {
+ const fetchMock = vi.fn(async () => {
+ return new Response(Buffer.from("png"), {
+ status: 200,
+ headers: { "content-type": "image/png" },
+ });
+ });
+
+ const media = await downloadMSTeamsImageAttachments({
+ attachments: [
+ {
+ contentType: "text/html",
+ content: '
',
+ },
+ ],
+ maxBytes: 1024 * 1024,
+ fetchFn: fetchMock as unknown as typeof fetch,
+ });
+
+ expect(media).toHaveLength(1);
+ expect(fetchMock).toHaveBeenCalledWith("https://x/inline.png");
+ });
+
+ it("stores inline data:image base64 payloads", async () => {
+ const base64 = Buffer.from("png").toString("base64");
+ const media = await downloadMSTeamsImageAttachments({
+ attachments: [
+ {
+ contentType: "text/html",
+ content: `
`,
+ },
+ ],
+ maxBytes: 1024 * 1024,
+ });
+
+ expect(media).toHaveLength(1);
+ expect(saveMediaBufferMock).toHaveBeenCalled();
+ });
+
it("retries with auth when the first request is unauthorized", async () => {
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const hasAuth = Boolean(
@@ -163,6 +224,77 @@ describe("msteams attachments", () => {
});
});
+ describe("buildMSTeamsGraphMessageUrls", () => {
+ it("builds channel message urls", () => {
+ const urls = buildMSTeamsGraphMessageUrls({
+ conversationType: "channel",
+ conversationId: "19:thread@thread.tacv2",
+ messageId: "123",
+ channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
+ });
+ expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123");
+ });
+
+ it("builds channel reply urls when replyToId is present", () => {
+ const urls = buildMSTeamsGraphMessageUrls({
+ conversationType: "channel",
+ messageId: "reply-id",
+ replyToId: "root-id",
+ channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
+ });
+ expect(urls[0]).toContain(
+ "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id",
+ );
+ });
+
+ it("builds chat message urls", () => {
+ const urls = buildMSTeamsGraphMessageUrls({
+ conversationType: "groupChat",
+ conversationId: "19:chat@thread.v2",
+ messageId: "456",
+ });
+ expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456");
+ });
+ });
+
+ describe("downloadMSTeamsGraphMedia", () => {
+ it("downloads hostedContents images", async () => {
+ const base64 = Buffer.from("png").toString("base64");
+ const fetchMock = vi.fn(async (url: string) => {
+ if (url.endsWith("/hostedContents")) {
+ return new Response(
+ JSON.stringify({
+ value: [
+ {
+ id: "1",
+ contentType: "image/png",
+ contentBytes: base64,
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/attachments")) {
+ return new Response(JSON.stringify({ value: [] }), { status: 200 });
+ }
+ return new Response("not found", { status: 404 });
+ });
+
+ const media = await downloadMSTeamsGraphMedia({
+ messageUrl:
+ "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
+ tokenProvider: { getAccessToken: vi.fn(async () => "token") },
+ maxBytes: 1024 * 1024,
+ fetchFn: fetchMock as unknown as typeof fetch,
+ });
+
+ expect(media.media).toHaveLength(1);
+ expect(fetchMock).toHaveBeenCalled();
+ expect(saveMediaBufferMock).toHaveBeenCalled();
+ });
+ });
+
describe("buildMSTeamsMediaPayload", () => {
it("returns single and multi-file fields", () => {
const payload = buildMSTeamsMediaPayload([
diff --git a/src/msteams/attachments.ts b/src/msteams/attachments.ts
index 6c136b7d3..e426899a9 100644
--- a/src/msteams/attachments.ts
+++ b/src/msteams/attachments.ts
@@ -26,8 +26,63 @@ export type MSTeamsInboundMedia = {
placeholder: string;
};
+type InlineImageCandidate =
+ | {
+ kind: "data";
+ data: Buffer;
+ contentType?: string;
+ placeholder: string;
+ }
+ | {
+ kind: "url";
+ url: string;
+ contentType?: string;
+ fileHint?: string;
+ placeholder: string;
+ };
+
const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
+const IMG_SRC_RE = /
]+src=["']([^"']+)["'][^>]*>/gi;
+const ATTACHMENT_TAG_RE = /]+id=["']([^"']+)["'][^>]*>/gi;
+
+export type MSTeamsHtmlAttachmentSummary = {
+ htmlAttachments: number;
+ imgTags: number;
+ dataImages: number;
+ cidImages: number;
+ srcHosts: string[];
+ attachmentTags: number;
+ attachmentIds: string[];
+};
+
+export type MSTeamsGraphMediaResult = {
+ media: MSTeamsInboundMedia[];
+ hostedCount?: number;
+ attachmentCount?: number;
+ hostedStatus?: number;
+ attachmentStatus?: number;
+ messageUrl?: string;
+ tokenError?: boolean;
+};
+
+type GraphHostedContent = {
+ id?: string | null;
+ contentType?: string | null;
+ contentBytes?: string | null;
+};
+
+type GraphAttachment = {
+ id?: string | null;
+ contentType?: string | null;
+ contentUrl?: string | null;
+ name?: string | null;
+ thumbnailUrl?: string | null;
+ content?: unknown;
+};
+
+const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
+
function isRecord(value: unknown): value is Record {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -76,14 +131,387 @@ function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
return false;
}
+function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
+ const contentType = normalizeContentType(att.contentType) ?? "";
+ return contentType.startsWith("text/html");
+}
+
+function extractHtmlFromAttachment(
+ att: MSTeamsAttachmentLike,
+): string | undefined {
+ if (!isHtmlAttachment(att)) return undefined;
+ if (typeof att.content === "string") return att.content;
+ if (!isRecord(att.content)) return undefined;
+ const text =
+ typeof att.content.text === "string"
+ ? att.content.text
+ : typeof att.content.body === "string"
+ ? att.content.body
+ : typeof att.content.content === "string"
+ ? att.content.content
+ : undefined;
+ return text;
+}
+
+function decodeDataImage(src: string): InlineImageCandidate | null {
+ const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
+ if (!match) return null;
+ const contentType = match[1]?.toLowerCase();
+ const isBase64 = Boolean(match[2]);
+ if (!isBase64) return null;
+ const payload = match[3] ?? "";
+ if (!payload) return null;
+ try {
+ const data = Buffer.from(payload, "base64");
+ return {
+ kind: "data",
+ data,
+ contentType,
+ placeholder: "