Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# OpenClaw Microsoft Teams
Official OpenClaw channel plugin for Microsoft Teams bot conversations.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/msteams
```
Configure the Teams bot credentials and trusted service URLs in OpenClaw, then connect the bot to the teams or chats where agents should operate.

View File

@@ -0,0 +1,4 @@
// Msteams API module exposes the plugin public contract.
export { msteamsPlugin } from "./src/channel.js";
export { createMSTeamsSetupWizardBase, msteamsSetupAdapter } from "./src/setup-core.js";
export { msteamsSetupWizard, openDelegatedOAuthUrl } from "./src/setup-surface.js";

View File

@@ -0,0 +1,2 @@
// Msteams API module exposes the plugin public contract.
export { MSTeamsChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -0,0 +1,3 @@
// Msteams API module exposes the plugin public contract.
export { msteamsPlugin } from "./src/channel.js";
export type { ChannelPlugin } from "./src/channel-api.js";

View File

@@ -0,0 +1,5 @@
// Msteams API module exposes the plugin public contract.
export {
buildChannelConfigSchema,
MSTeamsConfigSchema,
} from "openclaw/plugin-sdk/bundled-channel-config-schema";

View File

@@ -0,0 +1,46 @@
// Msteams API module exposes the plugin public contract.
import type { ChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-contract";
import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime";
import { normalizeMSTeamsMessagingTarget } from "./src/resolve-allowlist.js";
import { resolveMSTeamsCredentials } from "./src/token.js";
const msteamsDirectoryContractAdapter: ChannelDirectoryAdapter = {
self: async ({ cfg }) => {
const creds = resolveMSTeamsCredentials(cfg.channels?.msteams);
return creds ? { kind: "user" as const, id: creds.appId, name: creds.appId } : null;
},
listPeers: async ({ cfg, query, limit }) =>
listDirectoryEntriesFromSources({
kind: "user",
sources: [
cfg.channels?.msteams?.allowFrom ?? [],
Object.keys(cfg.channels?.msteams?.dms ?? {}),
],
query,
limit,
normalizeId: (raw) => {
const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw;
const lowered = normalized.toLowerCase();
return lowered.startsWith("user:") || lowered.startsWith("conversation:")
? normalized
: `user:${normalized}`;
},
}),
listGroups: async ({ cfg, query, limit }) =>
listDirectoryEntriesFromSources({
kind: "group",
sources: [
Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) =>
Object.keys(team.channels ?? {}),
),
],
query,
limit,
normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`,
}),
};
export const msteamsDirectoryContractPlugin = {
id: "msteams",
directory: msteamsDirectoryContractAdapter,
};

View File

@@ -0,0 +1,350 @@
// Msteams tests cover doctor contract api plugin behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
import {
buildMSTeamsConversationStateKey,
MSTEAMS_CONVERSATIONS_NAMESPACE,
type MSTeamsLegacyConversationStoreData,
} from "./src/conversation-store-state.js";
import type { StoredConversationReference } from "./src/conversation-store.js";
import {
buildMSTeamsPollStateKey,
buildMSTeamsPollVoteBucketKey,
MSTEAMS_POLL_VOTE_BUCKETS_NAMESPACE,
MSTEAMS_POLLS_NAMESPACE,
selectMSTeamsPollVoteBucket,
type MSTeamsPoll,
type StoredMSTeamsPoll,
type StoredMSTeamsPollVoteBucket,
} from "./src/polls.js";
import {
makeMSTeamsSsoTokenStoreKey,
MSTEAMS_SSO_TOKENS_NAMESPACE,
type MSTeamsSsoStoredToken,
} from "./src/sso-token-store.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("msteams", {
...options,
env: options.env ?? env,
});
},
};
}
function encodeSessionKey(sessionKey: string): string {
return Buffer.from(sessionKey, "utf8").toString("base64url");
}
function learningStoreKey(storePath: string, sessionKey: string): string {
return createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
}
function migrationById(id: string) {
const migration = stateMigrations.find((entry) => entry.id === id);
if (!migration) {
throw new Error(`missing migration ${id}`);
}
return migration;
}
describe("msteams doctor state migration", () => {
let stateDir = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-doctor-"));
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("imports legacy conversations into plugin state", async () => {
const filePath = path.join(stateDir, "msteams-conversations.json");
const ref: StoredConversationReference = {
conversation: { id: "19:conv@thread.tacv2" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-1" },
};
await fs.writeFile(
filePath,
`${JSON.stringify({
version: 1,
conversations: {
"19:conv@thread.tacv2": ref,
},
} satisfies MSTeamsLegacyConversationStoreData)}\n`,
);
const migration = migrationById("msteams-conversations-json-to-plugin-state");
const context = createDoctorContext(env);
await expect(
migration.detectLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
}),
).resolves.toMatchObject({
preview: [expect.stringContaining("Microsoft Teams conversations")],
});
const result = await migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
expect.stringContaining("Migrated 1 Microsoft Teams conversation entry"),
expect.stringContaining("Archived Microsoft Teams conversation legacy source"),
]);
await expect(fs.access(filePath)).rejects.toThrow();
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
const store = context.openPluginStateKeyedStore<StoredConversationReference>({
namespace: MSTEAMS_CONVERSATIONS_NAMESPACE,
maxEntries: 2000,
});
await expect(
store.lookup(buildMSTeamsConversationStateKey("19:conv@thread.tacv2")),
).resolves.toMatchObject({
conversation: { id: "19:conv@thread.tacv2" },
user: { id: "user-1" },
});
});
it("imports legacy polls and vote buckets into plugin state", async () => {
const filePath = path.join(stateDir, "msteams-polls.json");
const poll: MSTeamsPoll = {
id: "poll-legacy",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
createdAt: new Date().toISOString(),
votes: {
"user-legacy": ["0"],
"user-new": ["1"],
},
};
await fs.writeFile(
filePath,
`${JSON.stringify({
version: 1,
polls: {
"poll-legacy": poll,
},
})}\n`,
);
const context = createDoctorContext(env);
const voteBucketStore = context.openPluginStateKeyedStore<StoredMSTeamsPollVoteBucket>({
namespace: MSTEAMS_POLL_VOTE_BUCKETS_NAMESPACE,
maxEntries: 32_032,
});
const legacyBucket = selectMSTeamsPollVoteBucket("poll-legacy", "user-legacy");
await voteBucketStore.register(buildMSTeamsPollVoteBucketKey("poll-legacy", legacyBucket), {
pollId: "poll-legacy",
bucket: legacyBucket,
votes: { "user-legacy": ["1"] },
updatedAt: poll.createdAt,
});
const migration = migrationById("msteams-polls-json-to-plugin-state");
const result = await migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
expect.stringContaining("Migrated 1 Microsoft Teams poll entry"),
expect.stringContaining("Archived Microsoft Teams poll legacy source"),
]);
const pollStore = context.openPluginStateKeyedStore<StoredMSTeamsPoll>({
namespace: MSTEAMS_POLLS_NAMESPACE,
maxEntries: 2000,
});
await expect(pollStore.lookup(buildMSTeamsPollStateKey("poll-legacy"))).resolves.toMatchObject({
id: "poll-legacy",
question: "Lunch?",
});
const newBucket = selectMSTeamsPollVoteBucket("poll-legacy", "user-new");
await expect(
voteBucketStore.lookup(buildMSTeamsPollVoteBucketKey("poll-legacy", legacyBucket)),
).resolves.toMatchObject({
votes: { "user-legacy": ["1"] },
});
await expect(
voteBucketStore.lookup(buildMSTeamsPollVoteBucketKey("poll-legacy", newBucket)),
).resolves.toMatchObject({
votes: { "user-new": ["1"] },
});
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
});
it("imports legacy SSO tokens into the existing plugin-state token namespace", async () => {
const filePath = path.join(stateDir, "msteams-sso-tokens.json");
const token: MSTeamsSsoStoredToken = {
connectionName: "conn::alpha",
userId: "user::one",
token: "test-token-value",
updatedAt: "2026-04-10T00:00:00.000Z",
};
await fs.writeFile(
filePath,
`${JSON.stringify({
version: 1,
tokens: {
"legacy::wrong-key": token,
},
})}\n`,
);
const migration = migrationById("msteams-sso-tokens-json-to-plugin-state");
const context = createDoctorContext(env);
const result = await migration.migrateLegacyState({
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
});
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
expect.stringContaining("Migrated 1 Microsoft Teams SSO token entry"),
expect.stringContaining("Archived Microsoft Teams SSO-token legacy source"),
]);
const store = context.openPluginStateKeyedStore<MSTeamsSsoStoredToken>({
namespace: MSTEAMS_SSO_TOKENS_NAMESPACE,
maxEntries: 5000,
});
await expect(
store.lookup(makeMSTeamsSsoTokenStoreKey("conn::alpha", "user::one")),
).resolves.toEqual(token);
expect(result.changes.join("\n")).not.toContain(token.token);
expect(result.warnings.join("\n")).not.toContain(token.token);
await expect(fs.access(`${filePath}.migrated`)).resolves.toBeUndefined();
});
it("does not register a doctor migration for pending-upload cache files", () => {
expect(stateMigrations.map((migration) => migration.id)).not.toContain(
"msteams-pending-uploads-json-to-plugin-state",
);
});
it("imports legacy feedback learnings into plugin state", async () => {
const agentStoreTemplate = path.join(stateDir, "agents", "{agentId}", "sessions");
const mainStorePath = path.join(stateDir, "agents", "main", "sessions");
const workStorePath = path.join(stateDir, "agents", "work", "sessions");
const encodedSessionKey = "msteams:user1";
const encodedSourcePath = path.join(
mainStorePath,
`${encodeSessionKey(encodedSessionKey)}.learnings.json`,
);
const sanitizedSessionKey = "msteams:channel:19:abc@thread.tacv2";
const sanitizedSourcePath = path.join(
workStorePath,
"msteams_channel_19_abc_thread_tacv2.learnings.json",
);
await fs.mkdir(mainStorePath, { recursive: true });
await fs.mkdir(workStorePath, { recursive: true });
await fs.writeFile(
path.join(workStorePath, "sessions.json"),
JSON.stringify({ sessions: { [sanitizedSessionKey]: {} } }),
);
await fs.writeFile(encodedSourcePath, JSON.stringify(["Be concise", "Use examples"]));
await fs.writeFile(sanitizedSourcePath, JSON.stringify(["Prefer cards for channel feedback"]));
const migration = migrationById("msteams-feedback-learnings-json-to-plugin-state");
const context = createDoctorContext(env);
await context
.openPluginStateKeyedStore({
namespace: "feedback-learnings",
maxEntries: 10_000,
})
.register(learningStoreKey(mainStorePath, encodedSessionKey), {
sessionKey: encodedSessionKey,
learnings: ["Use examples", "New runtime note"],
updatedAt: 1900,
});
await expect(
migration.detectLegacyState({
config: {
session: { store: agentStoreTemplate },
agents: { list: [{ id: "work" }] },
},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
}),
).resolves.toMatchObject({
preview: [expect.stringContaining("2 files")],
});
const result = await migration.migrateLegacyState({
config: {
session: { store: agentStoreTemplate },
agents: { list: [{ id: "work" }] },
},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context,
});
expect(result.changes).toEqual([
expect.stringContaining("Migrated 2 Microsoft Teams feedback-learning entries"),
expect.stringContaining("Archived Microsoft Teams feedback-learning legacy source"),
expect.stringContaining("Archived Microsoft Teams feedback-learning legacy source"),
]);
expect(result.warnings).toEqual([]);
await expect(fs.access(encodedSourcePath)).rejects.toThrow();
await expect(fs.access(sanitizedSourcePath)).rejects.toThrow();
await expect(fs.access(`${encodedSourcePath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(`${sanitizedSourcePath}.migrated`)).resolves.toBeUndefined();
const store = context.openPluginStateKeyedStore({
namespace: "feedback-learnings",
maxEntries: 10_000,
});
await expect(
store.lookup(learningStoreKey(mainStorePath, encodedSessionKey)),
).resolves.toMatchObject({
sessionKey: encodedSessionKey,
learnings: ["Be concise", "Use examples", "New runtime note"],
});
await expect(
store.lookup(learningStoreKey(workStorePath, sanitizedSessionKey)),
).resolves.toMatchObject({
sessionKey: sanitizedSessionKey,
learnings: ["Prefer cards for channel feedback"],
});
});
});

View File

@@ -0,0 +1,545 @@
// Msteams API module exposes the plugin public contract.
import crypto from "node:crypto";
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import {
archiveLegacyStateSource,
type PluginDoctorStateMigration,
} from "openclaw/plugin-sdk/runtime-doctor";
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeStoredConversationId } from "./src/conversation-store-helpers.js";
import {
buildMSTeamsConversationStateKey,
MSTEAMS_CONVERSATIONS_LEGACY_FILENAME,
MSTEAMS_CONVERSATIONS_NAMESPACE,
MSTEAMS_SQLITE_MAX_CONVERSATION_ROWS,
normalizeMSTeamsLegacyConversationStore,
prepareMSTeamsConversationReferenceForStorage,
selectRetainedMSTeamsConversations,
type MSTeamsLegacyConversationStoreData,
} from "./src/conversation-store-state.js";
import type { StoredConversationReference } from "./src/conversation-store.js";
import {
buildMSTeamsPollStateKey,
buildMSTeamsPollVoteBucketKey,
MSTEAMS_MAX_POLL_VOTE_BUCKET_ROWS,
MSTEAMS_POLL_VOTE_BUCKETS_NAMESPACE,
MSTEAMS_POLLS_LEGACY_FILENAME,
MSTEAMS_POLLS_NAMESPACE,
MSTEAMS_SQLITE_MAX_POLL_ROWS,
selectMSTeamsPollVoteBucket,
selectRetainedMSTeamsPolls,
splitMSTeamsPoll,
type MSTeamsPoll,
type MSTeamsPollStoreData,
type StoredMSTeamsPoll,
type StoredMSTeamsPollVoteBucket,
} from "./src/polls.js";
import {
isMSTeamsSsoStoreData,
makeMSTeamsSsoTokenStoreKey,
MSTEAMS_MAX_SSO_TOKENS,
MSTEAMS_SSO_TOKENS_LEGACY_FILENAME,
MSTEAMS_SSO_TOKENS_NAMESPACE,
normalizeMSTeamsSsoStoredToken,
type MSTeamsSsoStoredToken,
} from "./src/sso-token-store.js";
type FeedbackLearningEntry = {
sessionKey: string;
learnings: string[];
updatedAt: number;
};
const LEARNINGS_NAMESPACE = "feedback-learnings";
const MAX_LEARNING_ENTRIES = 10_000;
const MSTEAMS_PLUGIN_ID = "Microsoft Teams";
function encodeSessionKey(sessionKey: string): string {
return Buffer.from(sessionKey, "utf8").toString("base64url");
}
function learningStoreKey(storePath: string, sessionKey: string): string {
return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
}
function decodeSessionKey(fileStem: string): string | null {
try {
const decoded = Buffer.from(fileStem, "base64url").toString("utf8");
return encodeSessionKey(decoded) === fileStem && decoded.trim() ? decoded : null;
} catch {
return null;
}
}
function resolveLearningSessionKey(fileStem: string): string | null {
return decodeSessionKey(fileStem);
}
function legacySanitizeSessionKey(sessionKey: string): string {
return sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
}
async function listKnownSessionKeys(storePath: string): Promise<string[]> {
const candidates = [storePath, path.join(storePath, "sessions.json")];
for (const candidate of candidates) {
try {
const parsed = JSON.parse(await fs.readFile(candidate, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
continue;
}
const sessions =
(parsed as { sessions?: unknown }).sessions &&
typeof (parsed as { sessions?: unknown }).sessions === "object" &&
!Array.isArray((parsed as { sessions?: unknown }).sessions)
? (parsed as { sessions: Record<string, unknown> }).sessions
: (parsed as Record<string, unknown>);
return Object.keys(sessions).filter((key) => key.trim());
} catch {
// Try the next known session index shape/location.
}
}
return [];
}
function resolveLegacySanitizedSessionKey(
fileStem: string,
knownSessionKeys: string[],
): string | null {
const matches = knownSessionKeys.filter(
(sessionKey) => legacySanitizeSessionKey(sessionKey) === fileStem,
);
return matches.length === 1 ? matches[0] : null;
}
function listAgentIds(config: { agents?: { list?: Array<{ id?: unknown }> } }): string[] {
const ids = new Set<string>(["main"]);
for (const agent of config.agents?.list ?? []) {
if (typeof agent.id === "string" && agent.id.trim()) {
ids.add(agent.id.trim());
}
}
return [...ids];
}
function listCandidateStorePaths(params: {
config: Parameters<PluginDoctorStateMigration["migrateLegacyState"]>[0]["config"];
env: NodeJS.ProcessEnv;
}): string[] {
const paths = new Set<string>();
paths.add(resolveStorePath(params.config.session?.store, { env: params.env }));
for (const agentId of listAgentIds(params.config)) {
paths.add(resolveStorePath(params.config.session?.store, { agentId, env: params.env }));
}
return [...paths];
}
function resolveStateFilePath(stateDir: string, filename: string): string {
return path.join(stateDir, filename);
}
async function readLegacyJsonFile<T>(
filePath: string,
parse: (value: unknown) => T | null,
): Promise<T | null> {
try {
return parse(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown);
} catch {
return null;
}
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}
function parseLegacyConversationStore(value: unknown): MSTeamsLegacyConversationStoreData | null {
if (!isRecord(value) || value.version !== 1 || !isRecord(value.conversations)) {
return null;
}
return normalizeMSTeamsLegacyConversationStore({
version: 1,
conversations: value.conversations as Record<string, StoredConversationReference>,
});
}
function parseLegacyPoll(value: unknown): MSTeamsPoll | null {
if (!isRecord(value)) {
return null;
}
const votes = isRecord(value.votes) ? value.votes : null;
if (
typeof value.id !== "string" ||
!value.id ||
typeof value.question !== "string" ||
!value.question ||
!isStringArray(value.options) ||
typeof value.maxSelections !== "number" ||
!Number.isFinite(value.maxSelections) ||
typeof value.createdAt !== "string" ||
!votes
) {
return null;
}
const normalizedVotes: Record<string, string[]> = {};
for (const [voterId, selections] of Object.entries(votes)) {
if (typeof voterId === "string" && isStringArray(selections)) {
normalizedVotes[voterId] = selections;
}
}
return {
id: value.id,
question: value.question,
options: value.options,
maxSelections: value.maxSelections,
createdAt: value.createdAt,
...(typeof value.updatedAt === "string" ? { updatedAt: value.updatedAt } : {}),
...(typeof value.conversationId === "string" ? { conversationId: value.conversationId } : {}),
...(typeof value.messageId === "string" ? { messageId: value.messageId } : {}),
votes: normalizedVotes,
};
}
function parseLegacyPollStore(value: unknown): MSTeamsPollStoreData | null {
if (!isRecord(value) || value.version !== 1 || !isRecord(value.polls)) {
return null;
}
const polls: Record<string, MSTeamsPoll> = {};
for (const [pollId, poll] of Object.entries(value.polls)) {
const parsed = parseLegacyPoll(poll);
if (parsed) {
polls[pollId] = parsed;
}
}
return { version: 1, polls };
}
async function listLegacyLearningFiles(
storePath: string,
): Promise<
Array<{ storePath: string; sessionKey: string | null; filePath: string; learnings: string[] }>
> {
let entries: Dirent[];
try {
entries = await fs.readdir(storePath, { withFileTypes: true });
} catch {
return [];
}
const suffix = ".learnings.json";
const knownSessionKeys = await listKnownSessionKeys(storePath);
const files: Array<{
storePath: string;
sessionKey: string | null;
filePath: string;
learnings: string[];
}> = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(suffix)) {
continue;
}
const fileStem = entry.name.slice(0, -suffix.length);
const sessionKey =
resolveLearningSessionKey(fileStem) ??
resolveLegacySanitizedSessionKey(fileStem, knownSessionKeys);
const filePath = path.join(storePath, entry.name);
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
if (Array.isArray(parsed)) {
const learnings = parsed.filter((item): item is string => typeof item === "string");
if (learnings.length > 0) {
files.push({ storePath, sessionKey, filePath, learnings: learnings.slice(-10) });
}
}
} catch {
// Malformed legacy feedback notes are ignored by migration.
}
}
return files;
}
function mergeLearnings(legacy: string[], existing?: FeedbackLearningEntry): string[] {
const seen = new Set<string>();
const merged: string[] = [];
for (const learning of [...legacy, ...(existing?.learnings ?? [])]) {
if (seen.has(learning)) {
continue;
}
seen.add(learning);
merged.push(learning);
}
return merged.slice(-10);
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "msteams-conversations-json-to-plugin-state",
label: "Microsoft Teams conversations",
async detectLegacyState(params) {
const filePath = resolveStateFilePath(params.stateDir, MSTEAMS_CONVERSATIONS_LEGACY_FILENAME);
const state = await readLegacyJsonFile(filePath, parseLegacyConversationStore);
if (!state || Object.keys(state.conversations).length === 0) {
return null;
}
return {
preview: [
`- ${MSTEAMS_PLUGIN_ID} conversations: ${Object.keys(state.conversations).length} entries -> plugin state (${MSTEAMS_CONVERSATIONS_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = resolveStateFilePath(params.stateDir, MSTEAMS_CONVERSATIONS_LEGACY_FILENAME);
const state = await readLegacyJsonFile(filePath, parseLegacyConversationStore);
if (!state) {
return { changes, warnings };
}
const store = params.context.openPluginStateKeyedStore<StoredConversationReference>({
namespace: MSTEAMS_CONVERSATIONS_NAMESPACE,
maxEntries: MSTEAMS_SQLITE_MAX_CONVERSATION_ROWS,
});
let imported = 0;
for (const [rawConversationId, reference] of selectRetainedMSTeamsConversations(
state.conversations,
)) {
const conversationId = normalizeStoredConversationId(rawConversationId);
if (!conversationId) {
continue;
}
const didImport = await store.registerIfAbsent(
buildMSTeamsConversationStateKey(conversationId),
prepareMSTeamsConversationReferenceForStorage(conversationId, reference),
);
if (didImport) {
imported++;
}
}
changes.push(
`Migrated ${imported} ${MSTEAMS_PLUGIN_ID} conversation ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
await archiveLegacyStateSource({
filePath,
label: `${MSTEAMS_PLUGIN_ID} conversation`,
changes,
warnings,
});
return { changes, warnings };
},
},
{
id: "msteams-polls-json-to-plugin-state",
label: "Microsoft Teams polls",
async detectLegacyState(params) {
const filePath = resolveStateFilePath(params.stateDir, MSTEAMS_POLLS_LEGACY_FILENAME);
const state = await readLegacyJsonFile(filePath, parseLegacyPollStore);
if (!state || Object.keys(state.polls).length === 0) {
return null;
}
return {
preview: [
`- ${MSTEAMS_PLUGIN_ID} polls: ${Object.keys(state.polls).length} entries -> plugin state (${MSTEAMS_POLLS_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = resolveStateFilePath(params.stateDir, MSTEAMS_POLLS_LEGACY_FILENAME);
const state = await readLegacyJsonFile(filePath, parseLegacyPollStore);
if (!state) {
return { changes, warnings };
}
const pollStore = params.context.openPluginStateKeyedStore<StoredMSTeamsPoll>({
namespace: MSTEAMS_POLLS_NAMESPACE,
maxEntries: MSTEAMS_SQLITE_MAX_POLL_ROWS,
});
const voteBucketStore = params.context.openPluginStateKeyedStore<StoredMSTeamsPollVoteBucket>(
{
namespace: MSTEAMS_POLL_VOTE_BUCKETS_NAMESPACE,
maxEntries: MSTEAMS_MAX_POLL_VOTE_BUCKET_ROWS,
},
);
let imported = 0;
for (const [pollId, poll] of selectRetainedMSTeamsPolls(state.polls)) {
const { metadata, votes } = splitMSTeamsPoll(poll);
const didImportPoll = await pollStore.registerIfAbsent(
buildMSTeamsPollStateKey(pollId),
metadata,
);
const buckets = new Map<string, Record<string, string[]>>();
for (const [voterId, selections] of Object.entries(votes)) {
const bucket = selectMSTeamsPollVoteBucket(pollId, voterId);
const bucketVotes = buckets.get(bucket) ?? {};
bucketVotes[voterId] = selections;
buckets.set(bucket, bucketVotes);
}
let importedVoteBucket = false;
for (const [bucket, bucketVotes] of buckets) {
const key = buildMSTeamsPollVoteBucketKey(pollId, bucket);
const existing = await voteBucketStore.lookup(key);
await voteBucketStore.register(key, {
pollId,
bucket,
votes: { ...bucketVotes, ...existing?.votes },
updatedAt: poll.updatedAt ?? poll.createdAt,
});
importedVoteBucket = true;
}
if (didImportPoll || importedVoteBucket) {
imported++;
}
}
changes.push(
`Migrated ${imported} ${MSTEAMS_PLUGIN_ID} poll ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
await archiveLegacyStateSource({
filePath,
label: `${MSTEAMS_PLUGIN_ID} poll`,
changes,
warnings,
});
return { changes, warnings };
},
},
{
id: "msteams-sso-tokens-json-to-plugin-state",
label: "Microsoft Teams SSO tokens",
async detectLegacyState(params) {
const filePath = resolveStateFilePath(params.stateDir, MSTEAMS_SSO_TOKENS_LEGACY_FILENAME);
const state = await readLegacyJsonFile(filePath, (value) =>
isMSTeamsSsoStoreData(value) ? value : null,
);
if (!state || Object.keys(state.tokens).length === 0) {
return null;
}
return {
preview: [
`- ${MSTEAMS_PLUGIN_ID} SSO tokens: ${Object.keys(state.tokens).length} entries -> plugin state (${MSTEAMS_SSO_TOKENS_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = resolveStateFilePath(params.stateDir, MSTEAMS_SSO_TOKENS_LEGACY_FILENAME);
const state = await readLegacyJsonFile(filePath, (value) =>
isMSTeamsSsoStoreData(value) ? value : null,
);
if (!state) {
return { changes, warnings };
}
const store = params.context.openPluginStateKeyedStore<MSTeamsSsoStoredToken>({
namespace: MSTEAMS_SSO_TOKENS_NAMESPACE,
maxEntries: MSTEAMS_MAX_SSO_TOKENS,
});
let imported = 0;
let skipped = 0;
for (const token of Object.values(state.tokens)) {
const normalized = normalizeMSTeamsSsoStoredToken(token);
if (!normalized) {
skipped++;
continue;
}
const didImport = await store.registerIfAbsent(
makeMSTeamsSsoTokenStoreKey(normalized.connectionName, normalized.userId),
normalized,
);
if (didImport) {
imported++;
}
}
changes.push(
`Migrated ${imported} ${MSTEAMS_PLUGIN_ID} SSO token ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
if (skipped > 0) {
warnings.push(
`Skipped ${skipped} malformed ${MSTEAMS_PLUGIN_ID} SSO token ${skipped === 1 ? "entry" : "entries"} during migration`,
);
}
await archiveLegacyStateSource({
filePath,
label: `${MSTEAMS_PLUGIN_ID} SSO-token`,
changes,
warnings,
});
return { changes, warnings };
},
},
{
id: "msteams-feedback-learnings-json-to-plugin-state",
label: "Microsoft Teams feedback learnings",
async detectLegacyState(params) {
const files = (
await Promise.all(
listCandidateStorePaths(params).map((storePath) => listLegacyLearningFiles(storePath)),
)
).flat();
if (files.length === 0) {
return null;
}
return {
preview: [
`- Microsoft Teams feedback learnings: ${files.length} ${files.length === 1 ? "file" : "files"} -> plugin state (${LEARNINGS_NAMESPACE})`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const files = (
await Promise.all(
listCandidateStorePaths(params).map((storePath) => listLegacyLearningFiles(storePath)),
)
).flat();
const store = params.context.openPluginStateKeyedStore<FeedbackLearningEntry>({
namespace: LEARNINGS_NAMESPACE,
maxEntries: MAX_LEARNING_ENTRIES,
});
const existingEntries = await store.entries();
const existingKeys = new Set(existingEntries.map((entry) => entry.key));
const importableFiles = files.filter((file) => file.sessionKey);
const missingKeys = new Set(
importableFiles
.map((file) => learningStoreKey(file.storePath, file.sessionKey ?? ""))
.filter((key) => !existingKeys.has(key)),
);
if (missingKeys.size > MAX_LEARNING_ENTRIES - existingKeys.size) {
warnings.push(
`Skipped Microsoft Teams feedback-learning migration because plugin state has room for ${MAX_LEARNING_ENTRIES - existingKeys.size} of ${missingKeys.size} missing entries; left legacy sources in place`,
);
return { changes, warnings };
}
let imported = 0;
for (const file of files) {
if (!file.sessionKey) {
warnings.push(
`Left Microsoft Teams feedback-learning source in place because its legacy filename cannot be mapped to a session key: ${file.filePath}`,
);
continue;
}
const key = learningStoreKey(file.storePath, file.sessionKey);
const existing = await store.lookup(key);
await store.register(key, {
sessionKey: existing?.sessionKey ?? file.sessionKey,
learnings: mergeLearnings(file.learnings, existing),
updatedAt: Date.now(),
});
imported++;
await archiveLegacyStateSource({
filePath: file.filePath,
label: "Microsoft Teams feedback-learning",
changes,
warnings,
});
}
if (imported > 0) {
changes.unshift(
`Migrated ${imported} Microsoft Teams feedback-learning ${imported === 1 ? "entry" : "entries"} -> plugin state`,
);
}
return { changes, warnings };
},
},
];

View File

@@ -0,0 +1,21 @@
// Msteams plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "msteams",
name: "Microsoft Teams",
description: "Microsoft Teams channel plugin (Bot Framework)",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "msteamsPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setMSTeamsRuntime",
},
});

1711
extensions/msteams/npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"id": "msteams",
"name": "Microsoft Teams",
"description": "OpenClaw Microsoft Teams channel plugin for bot conversations.",
"activation": {
"onStartup": false
},
"channels": ["msteams"],
"channelEnvVars": {
"msteams": ["MSTEAMS_APP_ID", "MSTEAMS_APP_PASSWORD", "MSTEAMS_TENANT_ID"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,69 @@
{
"name": "@openclaw/msteams",
"version": "2026.6.11",
"description": "OpenClaw Microsoft Teams channel plugin for bot conversations.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@azure/identity": "4.13.1",
"@microsoft/teams.api": "2.0.13",
"@microsoft/teams.apps": "2.0.13",
"express": "5.2.1",
"typebox": "1.3.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"jose": "6.2.3",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "msteams",
"label": "Microsoft Teams",
"selectionLabel": "Microsoft Teams (Teams SDK)",
"docsPath": "/channels/msteams",
"docsLabel": "msteams",
"blurb": "Teams SDK; enterprise support.",
"aliases": [
"teams"
],
"order": 60,
"doctorCapabilities": {
"dmAllowFromMode": "topOnly",
"groupModel": "hybrid",
"groupAllowFromFallbackToAllowFrom": true,
"warnOnEmptyGroupSenderAllowlist": true
}
},
"install": {
"npmSpec": "@openclaw/msteams",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,67 @@
// Private runtime barrel for the bundled Microsoft Teams extension.
// Keep this barrel thin and aligned with the local extension surface.
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export type { AllowlistMatch } from "openclaw/plugin-sdk/allow-from";
export {
mergeAllowlist,
resolveAllowlistMatchSimple,
summarizeMapping,
} from "openclaw/plugin-sdk/allow-from";
export type {
BaseProbeResult,
ChannelDirectoryEntry,
ChannelGroupContext,
ChannelMessageActionName,
ChannelOutboundAdapter,
} from "openclaw/plugin-sdk/channel-contract";
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-outbound";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { resolveToolsBySender } from "openclaw/plugin-sdk/channel-policy";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
export {
PAIRING_APPROVED_MESSAGE,
buildProbeChannelStatusSummary,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/channel-status";
export {
buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
} from "openclaw/plugin-sdk/channel-targets";
export type {
GroupPolicy,
GroupToolPolicyConfig,
MSTeamsChannelConfig,
MSTeamsCloudName,
MSTeamsConfig,
MSTeamsReplyStyle,
MSTeamsTeamConfig,
MarkdownTableMode,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-contracts";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export { resolveDefaultGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
export { withFileLock } from "openclaw/plugin-sdk/file-lock";
export { keepHttpServerTaskAlive } from "openclaw/plugin-sdk/channel-outbound";
export {
detectMime,
extensionForMime,
extractOriginalFilename,
getFileExtension,
resolveChannelMediaMaxBytes,
} from "openclaw/plugin-sdk/media-runtime";
export { dispatchReplyFromConfigWithSettledDispatcher } from "openclaw/plugin-sdk/channel-inbound";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
export { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
export { normalizeStringEntries } from "openclaw/plugin-sdk/string-normalization-runtime";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export { DEFAULT_WEBHOOK_MAX_BODY_BYTES } from "openclaw/plugin-sdk/webhook-ingress";
export { setMSTeamsRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,6 @@
// Msteams API module exposes the plugin public contract.
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,14 @@
// Msteams plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "msteamsSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});

View File

@@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Teams channel plugin surface.
export { msteamsSetupPlugin } from "./src/channel.setup.js";

View File

@@ -0,0 +1,52 @@
// Msteams helper module supports Adaptive Card submit payload behavior.
import {
isRecord,
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
function extractAdaptiveCardSubmittedData(value: unknown): unknown {
if (!isRecord(value)) {
return value;
}
const action = isRecord(value.action) ? value.action : undefined;
if (
action &&
normalizeOptionalLowercaseString(action.type) === "action.submit" &&
"data" in action
) {
return action.data;
}
return value;
}
function readMSTeamsImBackValue(value: unknown): string | null {
if (!isRecord(value)) {
return null;
}
const msteams = isRecord(value.msteams) ? value.msteams : undefined;
if (!msteams || normalizeOptionalLowercaseString(msteams.type) !== "imback") {
return null;
}
return normalizeOptionalString(msteams.value) ?? null;
}
export function serializeMSTeamsAdaptiveCardActionValue(value: unknown): string | null {
const submittedValue = extractAdaptiveCardSubmittedData(value);
if (typeof submittedValue === "string") {
const trimmed = submittedValue.trim();
return trimmed ? trimmed : null;
}
const imBackValue = readMSTeamsImBackValue(submittedValue);
if (imBackValue) {
return imBackValue;
}
if (submittedValue == null) {
return null;
}
try {
return JSON.stringify(submittedValue);
} catch {
return null;
}
}

View File

@@ -0,0 +1,7 @@
/** AI-generated content entity added to every outbound AI message. */
export const AI_GENERATED_ENTITY = {
type: "https://schema.org/Message",
"@type": "Message",
"@id": "",
additionalType: ["AIGeneratedContent"],
};

View File

@@ -0,0 +1,45 @@
// Msteams plugin module implements approval auth behavior.
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { OpenClawConfig } from "../runtime-api.js";
import { normalizeMSTeamsMessagingTarget } from "./resolve-allowlist.js";
const MSTEAMS_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function normalizeMSTeamsApproverId(value: string | number): string | undefined {
const normalized = normalizeMSTeamsMessagingTarget(String(value));
if (!normalized?.startsWith("user:")) {
return undefined;
}
const id = normalizeOptionalLowercaseString(normalized.slice("user:".length));
if (!id) {
return undefined;
}
return MSTEAMS_ID_RE.test(id) ? id : undefined;
}
function resolveMSTeamsChannelConfig(cfg: OpenClawConfig) {
return cfg.channels?.msteams;
}
export const msTeamsApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Microsoft Teams",
resolveApprovers: ({ cfg }) => {
const channel = resolveMSTeamsChannelConfig(cfg);
return resolveApprovalApprovers({
allowFrom: channel?.allowFrom,
defaultTo: channel?.defaultTo,
normalizeApprover: normalizeMSTeamsApproverId,
});
},
normalizeSenderId: (value) => {
const trimmed = normalizeOptionalLowercaseString(value);
if (!trimmed) {
return undefined;
}
return MSTEAMS_ID_RE.test(trimmed) ? trimmed : undefined;
},
});

View File

@@ -0,0 +1,415 @@
// Msteams tests cover attachments.graph plugin behavior.
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
import { readRemoteMediaResponse } from "./attachments.test-helpers.js";
import { downloadMSTeamsGraphMedia } from "./attachments/graph.js";
import { encodeGraphShareId, resolveRequestUrl } from "./attachments/shared.js";
import { setMSTeamsRuntime } from "./runtime.js";
const GRAPH_HOST = "graph.microsoft.com";
const SHAREPOINT_HOST = "contoso.sharepoint.com";
const DEFAULT_MESSAGE_URL = `https://${GRAPH_HOST}/v1.0/chats/19%3Achat/messages/123`;
const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
const DEFAULT_MAX_BYTES = 1024 * 1024;
const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST];
const DEFAULT_SHARE_REFERENCE_URL = `https://${SHAREPOINT_HOST}/site/file`;
const CONTENT_TYPE_IMAGE_PNG = "image/png";
const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
const PNG_BUFFER = Buffer.from("png");
const detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
const saveMediaBufferMock = vi.fn(
async (
_buffer: Buffer,
contentType?: string,
_subdir?: string,
_maxBytes?: number,
_originalFilename?: string,
) => ({
id: "saved.png",
path: "/tmp/saved.png",
size: Buffer.byteLength(PNG_BUFFER),
contentType: contentType ?? CONTENT_TYPE_IMAGE_PNG,
}),
);
const readRemoteMediaBufferMock = vi.fn(
async (params: {
url: string;
maxBytes?: number;
filePathHint?: string;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetchFn = params.fetchImpl ?? fetch;
const res = await fetchFn(params.url, { redirect: "manual" });
return readRemoteMediaResponse(res, params);
},
);
const saveRemoteMediaMock = vi.fn(
async (params: {
url: string;
maxBytes?: number;
filePathHint?: string;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetched = await readRemoteMediaBufferMock(params);
return await saveMediaBufferMock(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
params.filePathHint,
);
},
);
const saveResponseMediaMock = vi.fn(
async (
res: Response,
options: {
maxBytes?: number;
fallbackContentType?: string;
subdir?: string;
originalFilename?: string;
},
) => {
const buffer = Buffer.from(await res.arrayBuffer());
return await saveMediaBufferMock(
buffer,
options.fallbackContentType,
options.subdir ?? "inbound",
options.maxBytes,
options.originalFilename,
);
},
);
const runtimeStub = {
media: {
detectMime: detectMimeMock,
},
channel: {
media: {
readRemoteMediaBuffer: readRemoteMediaBufferMock,
saveRemoteMedia: saveRemoteMediaMock,
saveResponseMedia: saveResponseMediaMock,
saveMediaBuffer: saveMediaBufferMock,
},
},
} as unknown as PluginRuntime;
type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
type DownloadGraphMediaOverrides = Partial<
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider">
>;
type FetchFn = typeof fetch;
type LabeledCase = { label: string };
type GraphFetchMockOptions = {
hostedContents?: unknown[];
attachments?: unknown[];
messageAttachments?: unknown[];
onShareRequest?: (url: string) => Response | Promise<Response>;
onUnhandled?: (url: string) => Response | Promise<Response> | undefined;
};
type GraphMediaDownloadResult = {
fetchMock: ReturnType<typeof createGraphFetchMock>;
media: Awaited<ReturnType<typeof downloadMSTeamsGraphMedia>>;
};
type GraphMediaSuccessCase = LabeledCase & {
buildOptions: () => GraphFetchMockOptions;
expectedLength: number;
assert?: (params: GraphMediaDownloadResult) => void;
};
const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
label,
...fields,
});
const createTokenProvider = (
tokenOrResolver: string | ((scope: string) => string | Promise<string>) = "token",
) => ({
getAccessToken: vi.fn(async (scope: string) =>
typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver,
),
});
const resolvePublicHost = async (): Promise<{ address: string }> => ({ address: "93.184.216.34" });
const createBufferResponse = (payload: Buffer | string, contentType: string, status = 200) => {
const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
return new Response(new Uint8Array(raw), {
status,
headers: { "content-type": contentType },
});
};
const createPdfResponse = (payload: Buffer | string = Buffer.from("pdf")) =>
createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF);
const createJsonResponse = (payload: unknown, status = 200) =>
new Response(JSON.stringify(payload), { status });
const createGraphCollectionResponse = (value: unknown[]) => createJsonResponse({ value });
const createNotFoundResponse = () => new Response("not found", { status: 404 });
const createRedirectResponse = (location: string, status = 302) =>
new Response(null, { status, headers: { location } });
const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn;
const expectAttachmentMediaLength = (
media: Awaited<ReturnType<typeof downloadMSTeamsGraphMedia>>["media"],
expectedLength: number,
) => {
expect(media).toHaveLength(expectedLength);
};
const expectMediaBufferSaved = () => {
expect(saveMediaBufferMock).toHaveBeenCalled();
};
const createHostedContentsWithType = (contentType: string, ...ids: string[]) =>
ids.map((id) => ({ id, contentType, contentBytes: PNG_BUFFER.toString("base64") }));
const createHostedImageContents = (...ids: string[]) =>
createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids);
const createReferenceAttachment = (shareUrl = DEFAULT_SHARE_REFERENCE_URL) => ({
id: "ref-1",
contentType: "reference",
contentUrl: shareUrl,
name: "report.pdf",
});
const buildShareReferenceGraphFetchOptions = (params: {
referenceAttachment: ReturnType<typeof createReferenceAttachment>;
onShareRequest?: GraphFetchMockOptions["onShareRequest"];
onUnhandled?: GraphFetchMockOptions["onUnhandled"];
}) => ({
attachments: [params.referenceAttachment],
messageAttachments: [params.referenceAttachment],
...(params.onShareRequest ? { onShareRequest: params.onShareRequest } : {}),
...(params.onUnhandled ? { onUnhandled: params.onUnhandled } : {}),
});
const buildDefaultShareReferenceGraphFetchOptions = (
params: Omit<Parameters<typeof buildShareReferenceGraphFetchOptions>[0], "referenceAttachment">,
) =>
buildShareReferenceGraphFetchOptions({
referenceAttachment: createReferenceAttachment(),
...params,
});
type GraphEndpointResponseHandler = {
suffix: string;
buildResponse: () => Response;
};
const createGraphEndpointResponseHandlers = (params: {
hostedContents: unknown[];
attachments: unknown[];
messageAttachments: unknown[];
}): GraphEndpointResponseHandler[] => [
{
suffix: "/hostedContents",
buildResponse: () => createGraphCollectionResponse(params.hostedContents),
},
{
suffix: "/attachments",
buildResponse: () => createGraphCollectionResponse(params.attachments),
},
{
suffix: "/messages/123",
buildResponse: () => createJsonResponse({ attachments: params.messageAttachments }),
},
];
const resolveGraphEndpointResponse = (
url: string,
handlers: GraphEndpointResponseHandler[],
): Response | undefined => {
const handler = handlers.find((entry) => url.endsWith(entry.suffix));
return handler ? handler.buildResponse() : undefined;
};
const createGraphFetchMock = (options: GraphFetchMockOptions = {}) => {
const hostedContents = options.hostedContents ?? [];
const attachments = options.attachments ?? [];
const messageAttachments = options.messageAttachments ?? [];
const endpointHandlers = createGraphEndpointResponseHandlers({
hostedContents,
attachments,
messageAttachments,
});
return vi.fn(async (url: string) => {
const endpointResponse = resolveGraphEndpointResponse(url, endpointHandlers);
if (endpointResponse) {
return endpointResponse;
}
if (url.startsWith(GRAPH_SHARES_URL_PREFIX) && options.onShareRequest) {
return options.onShareRequest(url);
}
const unhandled = options.onUnhandled ? await options.onUnhandled(url) : undefined;
return unhandled ?? createNotFoundResponse();
});
};
const downloadGraphMediaWithMockOptions = async (
options: GraphFetchMockOptions = {},
overrides: DownloadGraphMediaOverrides = {},
): Promise<GraphMediaDownloadResult> => {
const fetchMock = createGraphFetchMock(options);
const media = await downloadMSTeamsGraphMedia({
messageUrl: DEFAULT_MESSAGE_URL,
tokenProvider: createTokenProvider(),
maxBytes: DEFAULT_MAX_BYTES,
fetchFn: asFetchFn(fetchMock),
resolveFn: resolvePublicHost,
...overrides,
});
return { fetchMock, media };
};
const runGraphMediaSuccessCase = async ({
buildOptions,
expectedLength,
assert,
}: GraphMediaSuccessCase) => {
const { fetchMock, media } = await downloadGraphMediaWithMockOptions(buildOptions());
expectAttachmentMediaLength(media.media, expectedLength);
assert?.({ fetchMock, media });
};
const GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [
withLabel("downloads hostedContents images", {
buildOptions: () => ({ hostedContents: createHostedImageContents("1") }),
expectedLength: 1,
assert: ({ fetchMock }) => {
expect(fetchMock).toHaveBeenCalled();
expectMediaBufferSaved();
},
}),
withLabel("streams hostedContent value responses through shared response saver", {
buildOptions: () => ({
hostedContents: [{ id: "hosted-1", contentType: CONTENT_TYPE_APPLICATION_PDF }],
onUnhandled: (url) =>
url.endsWith("/hostedContents/hosted-1/$value") ? createPdfResponse() : undefined,
}),
expectedLength: 1,
assert: () => {
expect(saveResponseMediaMock).toHaveBeenCalledTimes(1);
expectMediaBufferSaved();
},
}),
withLabel("merges SharePoint reference attachments with hosted content", {
buildOptions: () => {
return {
hostedContents: createHostedImageContents("hosted-1"),
...buildDefaultShareReferenceGraphFetchOptions({
onShareRequest: () => createPdfResponse(),
}),
};
},
expectedLength: 2,
}),
];
describe("msteams graph attachments", () => {
let ssrfMock: { mockRestore: () => void } | undefined;
beforeEach(() => {
ssrfMock?.mockRestore();
ssrfMock = mockPinnedHostnameResolution();
detectMimeMock.mockClear();
readRemoteMediaBufferMock.mockClear();
saveRemoteMediaMock.mockClear();
saveResponseMediaMock.mockClear();
saveMediaBufferMock.mockClear();
setMSTeamsRuntime(runtimeStub);
});
it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
it("does not forward Authorization for SharePoint redirects outside auth allowlist", async () => {
const tokenProvider = createTokenProvider("top-secret-token");
const escapedUrl = "https://example.com/collect";
const seen: Array<{ url: string; auth: string }> = [];
const referenceAttachment = createReferenceAttachment();
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = resolveRequestUrl(input);
const auth = new Headers(init?.headers).get("Authorization") ?? "";
seen.push({ url, auth });
if (url === DEFAULT_MESSAGE_URL) {
return createJsonResponse({ attachments: [referenceAttachment] });
}
if (url === `${DEFAULT_MESSAGE_URL}/hostedContents`) {
return createGraphCollectionResponse([]);
}
if (url === `${DEFAULT_MESSAGE_URL}/attachments`) {
return createGraphCollectionResponse([referenceAttachment]);
}
if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
return createRedirectResponse(escapedUrl);
}
if (url === escapedUrl) {
return createPdfResponse();
}
return createNotFoundResponse();
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: DEFAULT_MESSAGE_URL,
tokenProvider,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: [...DEFAULT_SHAREPOINT_ALLOW_HOSTS, "example.com"],
authAllowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
fetchFn: asFetchFn(fetchMock),
resolveFn: resolvePublicHost,
});
expectAttachmentMediaLength(media.media, 1);
const redirected = seen.find((entry) => entry.url === escapedUrl);
if (!redirected) {
throw new Error("expected SharePoint redirect request to be observed");
}
expect(redirected.auth).toBe("");
});
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {
const escapedUrl = "https://evil.example/internal.pdf";
const { fetchMock, media } = await downloadGraphMediaWithMockOptions(
{
...buildDefaultShareReferenceGraphFetchOptions({
onShareRequest: () => createRedirectResponse(escapedUrl),
onUnhandled: (url) => {
if (url === escapedUrl) {
return createPdfResponse("should-not-be-fetched");
}
return undefined;
},
}),
},
{
allowHosts: DEFAULT_SHAREPOINT_ALLOW_HOSTS,
},
);
expectAttachmentMediaLength(media.media, 0);
const calledUrls = fetchMock.mock.calls.map((call) => call[0]);
const expectedSharesUrl = `${GRAPH_SHARES_URL_PREFIX}${encodeGraphShareId(DEFAULT_SHARE_REFERENCE_URL)}/driveItem/content`;
expect(calledUrls).toEqual([
DEFAULT_MESSAGE_URL,
expectedSharesUrl,
`${DEFAULT_MESSAGE_URL}/hostedContents`,
expectedSharesUrl,
]);
expect(calledUrls).not.toContain(escapedUrl);
});
it("skips inline hosted content when estimated decoded bytes exceed maxBytes", async () => {
const oversizedBase64 = "A".repeat(16);
const bufferFromSpy = vi.spyOn(Buffer, "from");
try {
const { media } = await downloadGraphMediaWithMockOptions(
{
hostedContents: [
{
id: "hosted-oversized",
contentType: CONTENT_TYPE_IMAGE_PNG,
contentBytes: oversizedBase64,
},
],
},
{ maxBytes: 4 },
);
expect(media.media).toStrictEqual([]);
expect(bufferFromSpy).not.toHaveBeenCalledWith(oversizedBase64, "base64");
} finally {
bufferFromSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,306 @@
// Msteams tests cover attachments.helpers plugin behavior.
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
import {
buildMSTeamsAttachmentPlaceholder,
buildMSTeamsGraphMessageUrls,
buildMSTeamsMediaPayload,
resolveMSTeamsInboundAttachmentPresentation,
} from "./attachments.js";
import { setMSTeamsRuntime } from "./runtime.js";
const SHAREPOINT_HOST = "contoso.sharepoint.com";
const TEST_HOST = "x";
const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`;
const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathSegment);
const TEST_URL_IMAGE = createTestUrl("img");
const TEST_URL_IMAGE_PNG = createTestUrl("img.png");
const TEST_URL_IMAGE_1_PNG = createTestUrl("1.png");
const TEST_URL_IMAGE_2_JPG = createTestUrl("2.jpg");
const TEST_URL_PDF = createTestUrl("x.pdf");
const TEST_URL_PDF_1 = createTestUrl("1.pdf");
const TEST_URL_PDF_2 = createTestUrl("2.pdf");
const TEST_URL_HTML_A = createTestUrl("a.png");
const TEST_URL_HTML_B = createTestUrl("b.png");
const CONTENT_TYPE_IMAGE_PNG = "image/png";
const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
const CONTENT_TYPE_TEXT_HTML = "text/html";
const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
type AttachmentPlaceholderInput = Parameters<typeof buildMSTeamsAttachmentPlaceholder>[0];
type GraphMessageUrlParams = Parameters<typeof buildMSTeamsGraphMessageUrls>[0];
type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>;
const runtimeStub = {
channel: {
text: {
chunkText: (text: string) => (text ? [text] : []),
},
},
} as unknown as PluginRuntime;
const MEDIA_PLACEHOLDER_IMAGE = "<media:image>";
const MEDIA_PLACEHOLDER_DOCUMENT = "<media:document>";
const formatImagePlaceholder = (count: number) =>
count > 1 ? `${MEDIA_PLACEHOLDER_IMAGE} (${count} images)` : MEDIA_PLACEHOLDER_IMAGE;
const formatDocumentPlaceholder = (count: number) =>
count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT;
const withLabel = <T extends object>(label: string, fields: T): T & { label: string } => ({
label,
...fields,
});
const buildAttachment = <T extends Record<string, unknown>>(contentType: string, props: T) => ({
contentType,
...props,
});
const createHtmlAttachment = (content: string) =>
buildAttachment(CONTENT_TYPE_TEXT_HTML, { content });
const buildHtmlImageTag = (src: string) => `<img src="${src}" />`;
const createHtmlImageAttachments = (sources: string[], prefix = "") => [
createHtmlAttachment(`${prefix}${sources.map(buildHtmlImageTag).join("")}`),
];
const createContentUrlAttachments = (contentType: string, ...contentUrls: string[]) =>
contentUrls.map((contentUrl) => buildAttachment(contentType, { contentUrl }));
const createImageAttachments = (...contentUrls: string[]) =>
createContentUrlAttachments(CONTENT_TYPE_IMAGE_PNG, ...contentUrls);
const createPdfAttachments = (...contentUrls: string[]) =>
createContentUrlAttachments(CONTENT_TYPE_APPLICATION_PDF, ...contentUrls);
const createTeamsFileDownloadInfoAttachments = (
downloadUrl = createTestUrl("dl"),
fileType = "png",
) => [
buildAttachment(CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO, {
content: { downloadUrl, fileType },
}),
];
const createMediaEntriesWithType = (contentType: string, ...paths: string[]) =>
paths.map((path) => ({ path, contentType }));
const createImageMediaEntries = (...paths: string[]) =>
createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths);
const DEFAULT_CHANNEL_TEAM_ID = "team-id";
const DEFAULT_CHANNEL_ID = "chan-id";
const createChannelGraphMessageUrlParams = (params: {
messageId: string;
replyToId?: string;
conversationId?: string;
}) => ({
conversationType: "channel" as const,
...params,
channelData: {
team: { id: DEFAULT_CHANNEL_TEAM_ID },
channel: { id: DEFAULT_CHANNEL_ID },
},
});
const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) =>
params.replyToId
? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}`
: `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`;
const expectMSTeamsMediaPayload = (
payload: MSTeamsMediaPayload,
expected: { firstPath: string; paths: string[]; types: string[] },
) => {
expect(payload.MediaPath).toBe(expected.firstPath);
expect(payload.MediaUrl).toBe(expected.firstPath);
expect(payload.MediaPaths).toEqual(expected.paths);
expect(payload.MediaUrls).toEqual(expected.paths);
expect(payload.MediaTypes).toEqual(expected.types);
};
const ATTACHMENT_PLACEHOLDER_CASES = [
withLabel("returns empty string when no attachments", {
attachments: undefined as AttachmentPlaceholderInput,
expected: "",
}),
withLabel("returns empty string when attachments are empty", {
attachments: [],
expected: "",
}),
withLabel("returns image placeholder for one image attachment", {
attachments: createImageAttachments(TEST_URL_IMAGE_PNG),
expected: formatImagePlaceholder(1),
}),
withLabel("returns image placeholder with count for many image attachments", {
attachments: [
...createImageAttachments(TEST_URL_IMAGE_1_PNG),
{ contentType: "image/jpeg", contentUrl: TEST_URL_IMAGE_2_JPG },
],
expected: formatImagePlaceholder(2),
}),
withLabel("treats Teams file.download.info image attachments as images", {
attachments: createTeamsFileDownloadInfoAttachments(),
expected: formatImagePlaceholder(1),
}),
withLabel("returns document placeholder for non-image attachments", {
attachments: createPdfAttachments(TEST_URL_PDF),
expected: formatDocumentPlaceholder(1),
}),
withLabel("returns document placeholder with count for many non-image attachments", {
attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2),
expected: formatDocumentPlaceholder(2),
}),
withLabel("counts one inline image in html attachments", {
attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "<p>hi</p>"),
expected: formatImagePlaceholder(1),
}),
withLabel("counts many inline images in html attachments", {
attachments: createHtmlImageAttachments([TEST_URL_HTML_A, TEST_URL_HTML_B]),
expected: formatImagePlaceholder(2),
}),
];
const GRAPH_URL_EXPECTATION_CASES = [
withLabel("builds channel message urls", {
params: createChannelGraphMessageUrlParams({
conversationId: "19:thread@thread.tacv2",
messageId: "123",
}),
expectedPath: buildExpectedChannelMessagePath({ messageId: "123" }),
}),
withLabel("builds channel reply urls when replyToId is present", {
params: createChannelGraphMessageUrlParams({
messageId: "reply-id",
replyToId: "root-id",
}),
expectedPath: buildExpectedChannelMessagePath({
messageId: "reply-id",
replyToId: "root-id",
}),
}),
withLabel("builds chat message urls", {
params: {
conversationType: "groupChat" as const,
conversationId: "19:chat@thread.v2",
messageId: "456",
} satisfies GraphMessageUrlParams,
expectedPath: "/chats/19%3Achat%40thread.v2/messages/456",
}),
];
describe("msteams attachment helpers", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
});
describe("buildMSTeamsAttachmentPlaceholder", () => {
it.each(ATTACHMENT_PLACEHOLDER_CASES)("$label", ({ attachments, expected }) => {
expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected);
});
it("respects inline image limits when counting placeholder images", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="data:image/png;base64,${"A".repeat(16)}" />`,
},
];
expect(
buildMSTeamsAttachmentPlaceholder(attachments, {
maxInlineBytes: 4,
maxInlineTotalBytes: 4,
}),
).toBe("<media:document>");
});
it("counts advertised files without URLs and ignores mention-only HTML", () => {
expect(
resolveMSTeamsInboundAttachmentPresentation([
{ contentType: "application/pdf", name: "report.pdf" },
]),
).toEqual({ placeholder: "<media:document>", expectedMediaCount: 1 });
expect(
resolveMSTeamsInboundAttachmentPresentation([
{ contentType: "text/html", content: "<div><at>Bot</at> hello</div>" },
]),
).toEqual({ placeholder: "", expectedMediaCount: 0 });
});
it("does not count HTML references separately from files or cards", () => {
expect(
resolveMSTeamsInboundAttachmentPresentation([
createHtmlAttachment('<attachment id="file-1"></attachment>'),
{
id: "file-1",
contentType: CONTENT_TYPE_APPLICATION_PDF,
contentUrl: TEST_URL_PDF,
},
]),
).toEqual({ placeholder: "<media:document>", expectedMediaCount: 1 });
expect(
resolveMSTeamsInboundAttachmentPresentation([
createHtmlAttachment('<attachment id="card-1"></attachment>'),
{
id: "card-1",
contentType: "application/vnd.microsoft.card.adaptive",
content: { type: "AdaptiveCard" },
},
]),
).toEqual({ placeholder: "", expectedMediaCount: 0 });
});
it("counts repeated inline URLs once while keeping data images per occurrence", () => {
const repeatedUrl = "https://example.com/repeated.png";
expect(
resolveMSTeamsInboundAttachmentPresentation([
{
contentType: "text/html",
content: `<img src="${repeatedUrl}"><img src="${repeatedUrl}">`,
},
]),
).toEqual({ placeholder: "<media:image>", expectedMediaCount: 1 });
const dataUrl = "data:image/png;base64,AQ==";
expect(
resolveMSTeamsInboundAttachmentPresentation([
{
contentType: "text/html",
content: `<img src="${dataUrl}"><img src="${dataUrl}">`,
},
]),
).toEqual({ placeholder: "<media:image> (2 images)", expectedMediaCount: 2 });
});
});
describe("buildMSTeamsGraphMessageUrls", () => {
it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => {
const urls = buildMSTeamsGraphMessageUrls(params);
expect(urls[0]).toContain(expectedPath);
});
it("uses resolved Graph chat ID for personal DMs instead of Bot Framework a: ID", () => {
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "personal",
conversationId: "19:real-graph-chat-id@unq.gbl.spaces",
messageId: "msg-1",
});
expect(urls).toHaveLength(1);
expect(urls[0]).toContain("/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1");
});
it("still builds URLs when a: conversation ID is passed (caller did not resolve)", () => {
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "personal",
conversationId: "a:1dRsHCobZ1AxURzY",
messageId: "msg-1",
});
expect(urls).toHaveLength(1);
expect(urls[0]).toContain("/chats/a%3A1dRsHCobZ1AxURzY/messages/msg-1");
});
});
describe("buildMSTeamsMediaPayload", () => {
it("returns single and multi-file fields", () => {
const payload = buildMSTeamsMediaPayload(createImageMediaEntries("/tmp/a.png", "/tmp/b.png"));
expectMSTeamsMediaPayload(payload, {
firstPath: "/tmp/a.png",
paths: ["/tmp/a.png", "/tmp/b.png"],
types: [CONTENT_TYPE_IMAGE_PNG, CONTENT_TYPE_IMAGE_PNG],
});
});
});
it("retains the expected sharepoint host fixture", () => {
expect(SHAREPOINT_HOST).toBe("contoso.sharepoint.com");
expect(TEST_URL_IMAGE).toContain(TEST_HOST);
});
});

View File

@@ -0,0 +1,18 @@
// Msteams helper module supports attachments helpers behavior.
export async function readRemoteMediaResponse(
res: Response,
params: { maxBytes?: number; filePathHint?: string },
) {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
throw new Error(`payload exceeds maxBytes ${params.maxBytes}`);
}
return {
buffer,
contentType: res.headers.get("content-type") ?? undefined,
fileName: params.filePathHint,
};
}

View File

@@ -0,0 +1,740 @@
// Msteams tests cover attachments plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime, SsrFPolicy } from "../runtime-api.js";
import { readRemoteMediaResponse } from "./attachments.test-helpers.js";
import { downloadMSTeamsAttachments } from "./attachments/download.js";
import { resolveRequestUrl } from "./attachments/shared.js";
import { setMSTeamsRuntime } from "./runtime.js";
const saveResponseMediaMock = vi.hoisted(() =>
vi.fn(async (response: Response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "image/png";
return {
id: contentType === "application/pdf" ? "saved.pdf" : "saved.png",
path: contentType === "application/pdf" ? "/tmp/saved.pdf" : "/tmp/saved.png",
size: 42,
contentType,
};
}),
);
vi.mock("openclaw/plugin-sdk/media-runtime", async () => ({
saveResponseMedia: saveResponseMediaMock,
}));
const GRAPH_HOST = "graph.microsoft.com";
const AZUREEDGE_HOST = "azureedge.net";
const TEST_HOST = "x";
const createUrlForHost = (host: string, pathSegment: string) => `https://${host}/${pathSegment}`;
const createTestUrl = (pathSegment: string) => createUrlForHost(TEST_HOST, pathSegment);
const SAVED_PNG_PATH = "/tmp/saved.png";
const SAVED_PDF_PATH = "/tmp/saved.pdf";
const TEST_URL_IMAGE = createTestUrl("img");
const TEST_URL_INLINE_IMAGE = createTestUrl("inline.png");
const TEST_URL_DOC_PDF = createTestUrl("doc.pdf");
const TEST_URL_FILE_DOWNLOAD = createTestUrl("dl");
const TEST_URL_OUTSIDE_ALLOWLIST = "https://evil.test/img";
const CONTENT_TYPE_IMAGE_PNG = "image/png";
const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
const CONTENT_TYPE_APPLICATION_ZIP = "application/zip";
const CONTENT_TYPE_TEXT_HTML = "text/html";
const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
const MAX_REDIRECT_HOPS = 5;
type RemoteMediaFetchParams = {
url: string;
maxBytes?: number;
filePathHint?: string;
ssrfPolicy?: SsrFPolicy;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
const detectMimeDefault = async () => CONTENT_TYPE_IMAGE_PNG;
const saveMediaBufferDefault = async (
_buffer: Buffer,
contentType?: string,
_subdir?: string,
_maxBytes?: number,
_originalFilename?: string,
) => ({
id: "saved.png",
path: contentType === CONTENT_TYPE_APPLICATION_PDF ? SAVED_PDF_PATH : SAVED_PNG_PATH,
size: Buffer.byteLength(PNG_BUFFER),
contentType: contentType ?? CONTENT_TYPE_IMAGE_PNG,
});
const detectMimeMock = vi.fn(detectMimeDefault);
const saveMediaBufferMock = vi.fn(saveMediaBufferDefault);
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(2);
return suffix.length > 0 && hostname !== suffix && hostname.endsWith(`.${suffix}`);
}
return hostname === pattern;
}
function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean {
if (!policy?.hostnameAllowlist || policy.hostnameAllowlist.length === 0) {
return true;
}
const hostname = new URL(url).hostname.toLowerCase();
return policy.hostnameAllowlist.some((pattern) =>
isHostnameAllowedByPattern(hostname, pattern.toLowerCase()),
);
}
async function readRemoteMediaBufferWithRedirects(
params: RemoteMediaFetchParams,
requestInit?: RequestInit,
) {
const fetchFn = params.fetchImpl ?? fetch;
let currentUrl = params.url;
for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) {
if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) {
throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`);
}
const res = await fetchFn(currentUrl, { redirect: "manual", ...requestInit });
if (REDIRECT_STATUS_CODES.has(res.status)) {
const location = res.headers.get("location");
if (!location) {
throw new Error("redirect missing location");
}
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return readRemoteMediaResponse(res, params);
}
throw new Error("too many redirects");
}
const readRemoteMediaBufferMock = vi.fn(async (params: RemoteMediaFetchParams) => {
return await readRemoteMediaBufferWithRedirects(params);
});
const saveRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
const fetched = await readRemoteMediaBufferWithRedirects(params);
return await saveMediaBufferMock(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
params.filePathHint,
);
});
const runtimeStub = {
media: {
detectMime: detectMimeMock,
},
channel: {
media: {
readRemoteMediaBuffer: readRemoteMediaBufferMock,
saveRemoteMedia: saveRemoteMediaMock,
saveResponseMedia: saveResponseMediaMock,
saveMediaBuffer: saveMediaBufferMock,
},
},
} as unknown as PluginRuntime;
type DownloadAttachmentsParams = Parameters<typeof downloadMSTeamsAttachments>[0];
type DownloadedMedia = Awaited<ReturnType<typeof downloadMSTeamsAttachments>>;
type DownloadAttachmentsBuildOverrides = Partial<
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts">
> &
Pick<DownloadAttachmentsParams, "allowHosts">;
type DownloadAttachmentsNoFetchOverrides = Partial<
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "fetchFn">
> &
Pick<DownloadAttachmentsParams, "allowHosts">;
type FetchFn = typeof fetch;
type MSTeamsAttachments = DownloadAttachmentsParams["attachments"];
type LabeledCase = { label: string };
type FetchCallExpectation = { expectFetchCalled?: boolean };
type DownloadedMediaExpectation = { path?: string; placeholder?: string };
const DEFAULT_MAX_BYTES = 1024 * 1024;
const DEFAULT_ALLOW_HOSTS = [TEST_HOST];
const MEDIA_PLACEHOLDER_DOCUMENT = "<media:document>";
const formatDocumentPlaceholder = (count: number) =>
count > 1 ? `${MEDIA_PLACEHOLDER_DOCUMENT} (${count} files)` : MEDIA_PLACEHOLDER_DOCUMENT;
const IMAGE_ATTACHMENT = { contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: TEST_URL_IMAGE };
const PNG_BUFFER = Buffer.from("png");
const PNG_BASE64 = PNG_BUFFER.toString("base64");
const PDF_BUFFER = Buffer.from("pdf");
const createTokenProvider = (
tokenOrResolver: string | ((scope: string) => string | Promise<string>) = "token",
) => ({
getAccessToken: vi.fn(async (scope: string) =>
typeof tokenOrResolver === "function" ? await tokenOrResolver(scope) : tokenOrResolver,
),
});
const asSingleItemArray = <T>(value: T) => [value];
const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
label,
...fields,
});
const buildAttachment = <T extends Record<string, unknown>>(contentType: string, props: T) => ({
contentType,
...props,
});
const createHtmlAttachment = (content: string) =>
buildAttachment(CONTENT_TYPE_TEXT_HTML, { content });
const buildHtmlImageTag = (src: string) => `<img src="${src}" />`;
const createHtmlImageAttachments = (sources: string[], prefix = "") =>
asSingleItemArray(createHtmlAttachment(`${prefix}${sources.map(buildHtmlImageTag).join("")}`));
const createContentUrlAttachments = (contentType: string, ...contentUrls: string[]) =>
contentUrls.map((contentUrl) => buildAttachment(contentType, { contentUrl }));
const createImageAttachments = (...contentUrls: string[]) =>
createContentUrlAttachments(CONTENT_TYPE_IMAGE_PNG, ...contentUrls);
const createPdfAttachments = (...contentUrls: string[]) =>
createContentUrlAttachments(CONTENT_TYPE_APPLICATION_PDF, ...contentUrls);
const createTeamsFileDownloadInfoAttachments = (
downloadUrl = TEST_URL_FILE_DOWNLOAD,
fileType = "png",
) =>
asSingleItemArray(
buildAttachment(CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO, {
content: { downloadUrl, fileType },
}),
);
type BinaryPayload = Uint8Array | string;
const createBufferResponse = (payload: BinaryPayload, contentType: string, status = 200) => {
const raw = typeof payload === "string" ? Buffer.from(payload) : payload;
return new Response(new Uint8Array(raw), {
status,
headers: { "content-type": contentType },
});
};
const createTextResponse = (body: string, status = 200) => new Response(body, { status });
const createNotFoundResponse = () => new Response("not found", { status: 404 });
const createRedirectResponse = (location: string, status = 302) =>
new Response(null, { status, headers: { location } });
const publicResolve = async () => ({ address: "13.107.136.10" });
const createOkFetchMock = (contentType: string, payload = "png") =>
vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
createBufferResponse(payload, contentType),
);
const asFetchFn = (fetchFn: unknown): FetchFn => fetchFn as FetchFn;
const buildDownloadParams = (
attachments: MSTeamsAttachments,
overrides: DownloadAttachmentsBuildOverrides = {},
): DownloadAttachmentsParams => {
return {
attachments,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: DEFAULT_ALLOW_HOSTS,
resolveFn: publicResolve,
...overrides,
};
};
const downloadAttachmentsWithFetch = async (
attachments: MSTeamsAttachments,
fetchFn: unknown,
overrides: DownloadAttachmentsNoFetchOverrides = {},
options: FetchCallExpectation = {},
) => {
const media = await downloadMSTeamsAttachments(
buildDownloadParams(attachments, {
...overrides,
fetchFn: asFetchFn(fetchFn),
}),
);
expectMockCallState(fetchFn, options.expectFetchCalled ?? true);
return media;
};
const createAuthAwareImageFetchMock = (params: { unauthStatus: number; unauthBody: string }) =>
vi.fn(async (_url: string, opts?: RequestInit) => {
const headers = new Headers(opts?.headers);
const hasAuth = Boolean(headers.get("Authorization"));
if (!hasAuth) {
return createTextResponse(params.unauthBody, params.unauthStatus);
}
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
});
const expectMockCallState = (mockFn: unknown, shouldCall: boolean) => {
if (shouldCall) {
expect(mockFn).toHaveBeenCalled();
} else {
expect(mockFn).not.toHaveBeenCalled();
}
};
const expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => {
expect(media).toHaveLength(expectedLength);
};
const expectSingleMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation = {}) => {
expectAttachmentMediaLength(media, 1);
expectFirstMedia(media, expected);
};
const expectMediaBufferSaved = () => {
expect(
saveResponseMediaMock.mock.calls.length + saveMediaBufferMock.mock.calls.length,
).toBeGreaterThan(0);
};
const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => {
const first = media[0];
if (expected.path !== undefined) {
expect(first?.path).toBe(expected.path);
}
if (expected.placeholder !== undefined) {
expect(first?.placeholder).toBe(expected.placeholder);
}
};
type AttachmentDownloadSuccessCase = LabeledCase & {
attachments: MSTeamsAttachments;
buildFetchFn?: () => unknown;
beforeDownload?: () => void;
assert?: (media: DownloadedMedia) => void;
};
type AttachmentAuthRetryScenario = {
attachmentUrl: string;
unauthStatus: number;
unauthBody: string;
overrides?: Omit<DownloadAttachmentsNoFetchOverrides, "tokenProvider">;
};
type AttachmentAuthRetryCase = LabeledCase & {
scenario: AttachmentAuthRetryScenario;
expectedMediaLength: number;
expectTokenFetch: boolean;
};
const ATTACHMENT_DOWNLOAD_SUCCESS_CASES: AttachmentDownloadSuccessCase[] = [
withLabel("downloads and stores image contentUrl attachments", {
attachments: asSingleItemArray(IMAGE_ATTACHMENT),
assert: (media) => {
expectFirstMedia(media, { path: SAVED_PNG_PATH });
expectMediaBufferSaved();
},
}),
withLabel("supports Teams file.download.info downloadUrl attachments", {
attachments: createTeamsFileDownloadInfoAttachments(),
}),
withLabel("downloads inline image URLs from html attachments", {
attachments: createHtmlImageAttachments([TEST_URL_INLINE_IMAGE]),
}),
withLabel("downloads non-image file attachments (PDF)", {
attachments: createPdfAttachments(TEST_URL_DOC_PDF),
buildFetchFn: () => createOkFetchMock(CONTENT_TYPE_APPLICATION_PDF, "pdf"),
beforeDownload: () => {
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
saveMediaBufferMock.mockResolvedValueOnce({
id: "saved.pdf",
path: SAVED_PDF_PATH,
size: Buffer.byteLength(PDF_BUFFER),
contentType: CONTENT_TYPE_APPLICATION_PDF,
});
},
assert: (media) => {
expectSingleMedia(media, {
path: SAVED_PDF_PATH,
placeholder: formatDocumentPlaceholder(1),
});
},
}),
];
const ATTACHMENT_AUTH_RETRY_CASES: AttachmentAuthRetryCase[] = [
withLabel("retries with auth when the first request is unauthorized", {
scenario: {
attachmentUrl: IMAGE_ATTACHMENT.contentUrl,
unauthStatus: 401,
unauthBody: "unauthorized",
overrides: { authAllowHosts: [TEST_HOST] },
},
expectedMediaLength: 1,
expectTokenFetch: true,
}),
withLabel("skips auth retries when the host is not in auth allowlist", {
scenario: {
attachmentUrl: createUrlForHost(AZUREEDGE_HOST, "img"),
unauthStatus: 403,
unauthBody: "forbidden",
overrides: {
allowHosts: [AZUREEDGE_HOST],
authAllowHosts: [GRAPH_HOST],
},
},
expectedMediaLength: 0,
expectTokenFetch: false,
}),
];
const runAttachmentDownloadSuccessCase = async ({
attachments,
buildFetchFn,
beforeDownload,
assert,
}: AttachmentDownloadSuccessCase) => {
const fetchFn = (buildFetchFn ?? (() => createOkFetchMock(CONTENT_TYPE_IMAGE_PNG)))();
beforeDownload?.();
const media = await downloadAttachmentsWithFetch(attachments, fetchFn);
expectSingleMedia(media);
assert?.(media);
};
const runAttachmentAuthRetryCase = async ({
scenario,
expectedMediaLength,
expectTokenFetch,
}: AttachmentAuthRetryCase) => {
const tokenProvider = createTokenProvider();
const fetchMock = createAuthAwareImageFetchMock({
unauthStatus: scenario.unauthStatus,
unauthBody: scenario.unauthBody,
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(scenario.attachmentUrl),
fetchMock,
{ tokenProvider, ...scenario.overrides },
);
expectAttachmentMediaLength(media, expectedMediaLength);
expectMockCallState(tokenProvider.getAccessToken, expectTokenFetch);
};
describe("msteams attachments", () => {
beforeEach(() => {
detectMimeMock.mockReset();
detectMimeMock.mockImplementation(detectMimeDefault);
saveMediaBufferMock.mockReset();
saveMediaBufferMock.mockImplementation(saveMediaBufferDefault);
readRemoteMediaBufferMock.mockClear();
saveRemoteMediaMock.mockClear();
saveResponseMediaMock.mockClear();
setMSTeamsRuntime(runtimeStub);
});
describe("downloadMSTeamsAttachments", () => {
it.each<AttachmentDownloadSuccessCase>(ATTACHMENT_DOWNLOAD_SUCCESS_CASES)(
"$label",
runAttachmentDownloadSuccessCase,
);
it("stores inline data:image base64 payloads", async () => {
const media = await downloadMSTeamsAttachments(
buildDownloadParams([
...createHtmlImageAttachments([`data:image/png;base64,${PNG_BASE64}`]),
]),
);
expectSingleMedia(media);
expectMediaBufferSaved();
});
it("stores every inline data:image base64 payload", async () => {
const media = await downloadMSTeamsAttachments(
buildDownloadParams([
...createHtmlImageAttachments([
`data:image/png;base64,${PNG_BASE64}`,
`data:image/png;base64,${PNG_BASE64}`,
]),
]),
);
expectAttachmentMediaLength(media, 2);
expect(saveMediaBufferMock).toHaveBeenCalledTimes(2);
});
it("skips inline data:image payloads whose bytes sniff as non-image", async () => {
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_ZIP);
const media = await downloadMSTeamsAttachments(
buildDownloadParams([
...createHtmlImageAttachments([`data:image/png;base64,${PNG_BASE64}`]),
]),
);
expectAttachmentMediaLength(media, 0);
expect(saveMediaBufferMock).not.toHaveBeenCalled();
});
it.each<AttachmentAuthRetryCase>(ATTACHMENT_AUTH_RETRY_CASES)(
"$label",
runAttachmentAuthRetryCase,
);
it("preserves auth fallback when dispatcher-mode fetch returns a redirect", async () => {
const redirectedUrl = createTestUrl("redirected.png");
const tokenProvider = createTokenProvider();
const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
const hasAuth = Boolean(new Headers(opts?.headers).get("Authorization"));
if (url === TEST_URL_IMAGE) {
return hasAuth
? createRedirectResponse(redirectedUrl)
: createTextResponse("unauthorized", 401);
}
if (url === redirectedUrl) {
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
}
return createNotFoundResponse();
});
readRemoteMediaBufferMock.mockImplementationOnce(async (params) => {
return await readRemoteMediaBufferWithRedirects(params, {
dispatcher: {},
} as RequestInit);
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_IMAGE),
fetchMock,
{ tokenProvider, authAllowHosts: [TEST_HOST] },
);
expectAttachmentMediaLength(media, 1);
expect(tokenProvider.getAccessToken).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls.map(([calledUrl]) => calledUrl)).toContain(redirectedUrl);
});
it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
let authAttempt = 0;
const tokenProvider = createTokenProvider((scope) => `token:${scope}`);
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const auth = new Headers(opts?.headers).get("Authorization");
if (!auth) {
return createTextResponse("unauthorized", 401);
}
authAttempt += 1;
if (authAttempt === 1) {
return createTextResponse("upstream transient", 500);
}
return createBufferResponse(PNG_BUFFER, CONTENT_TYPE_IMAGE_PNG);
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_IMAGE),
fetchMock,
{ tokenProvider, authAllowHosts: [TEST_HOST] },
);
expectAttachmentMediaLength(media, 1);
expect(tokenProvider.getAccessToken).toHaveBeenCalledTimes(2);
});
it("does not forward Authorization to redirects outside auth allowlist", async () => {
const tokenProvider = createTokenProvider("top-secret-token");
const graphFileUrl = createUrlForHost(GRAPH_HOST, "file");
const seen: Array<{ url: string; auth: string }> = [];
const fetchMock = vi.fn(async (url: string, opts?: RequestInit) => {
const auth = new Headers(opts?.headers).get("Authorization") ?? "";
seen.push({ url, auth });
if (url === graphFileUrl && !auth) {
return new Response("unauthorized", { status: 401 });
}
if (url === graphFileUrl && auth) {
return new Response("", {
status: 302,
headers: { location: "https://attacker.azureedge.net/collect" },
});
}
if (url === "https://attacker.azureedge.net/collect") {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": CONTENT_TYPE_IMAGE_PNG },
});
}
return createNotFoundResponse();
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams([{ contentType: CONTENT_TYPE_IMAGE_PNG, contentUrl: graphFileUrl }], {
tokenProvider,
allowHosts: [GRAPH_HOST, AZUREEDGE_HOST],
authAllowHosts: [GRAPH_HOST],
fetchFn: asFetchFn(fetchMock),
}),
);
expectSingleMedia(media);
const redirected = seen.find(
(entry) => entry.url === "https://attacker.azureedge.net/collect",
);
if (!redirected) {
throw new Error("expected Azure CDN redirect request to be observed");
}
expect(redirected.auth).toBe("");
});
it("skips urls outside the allowlist", async () => {
const fetchMock = vi.fn();
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_OUTSIDE_ALLOWLIST),
fetchMock,
{
allowHosts: [GRAPH_HOST],
},
{ expectFetchCalled: false },
);
expectAttachmentMediaLength(media, 0);
});
it("blocks redirects to non-https URLs", async () => {
const insecureUrl = "http://x/insecure.png";
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = resolveRequestUrl(input);
if (url === TEST_URL_IMAGE) {
return createRedirectResponse(insecureUrl);
}
if (url === insecureUrl) {
return createBufferResponse("insecure", CONTENT_TYPE_IMAGE_PNG);
}
return createNotFoundResponse();
});
const media = await downloadAttachmentsWithFetch(
createImageAttachments(TEST_URL_IMAGE),
fetchMock,
{
allowHosts: [TEST_HOST],
},
);
expectAttachmentMediaLength(media, 0);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
describe("OneDrive/SharePoint shared links", () => {
const GRAPH_SHARES_URL_PREFIX = `https://${GRAPH_HOST}/v1.0/shares/`;
const DEFAULT_GRAPH_ALLOW_HOSTS = [GRAPH_HOST];
const PDF_PAYLOAD = Buffer.from("pdf-bytes");
const createGraphSharesFetchMock = () =>
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = resolveRequestUrl(input);
const auth = new Headers(init?.headers).get("Authorization");
if (url.startsWith(GRAPH_SHARES_URL_PREFIX)) {
if (!auth) {
return createTextResponse("unauthorized", 401);
}
return createBufferResponse(PDF_PAYLOAD, CONTENT_TYPE_APPLICATION_PDF);
}
return createNotFoundResponse();
});
it.each([
{
label: "SharePoint URL",
contentUrl: "https://contoso.sharepoint.com/personal/user/Documents/report.pdf",
},
{
label: "OneDrive 1drv.ms URL",
contentUrl: "https://1drv.ms/b/s!AkxYabcdefg",
},
{
label: "OneDrive onedrive.live.com URL",
contentUrl: "https://onedrive.live.com/share/file",
},
])("routes $label through Graph shares endpoint", async ({ contentUrl }) => {
const tokenProvider = createTokenProvider();
const fetchMock = createGraphSharesFetchMock();
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
saveMediaBufferMock.mockResolvedValueOnce({
id: "saved.pdf",
path: SAVED_PDF_PATH,
size: Buffer.byteLength(PDF_PAYLOAD),
contentType: CONTENT_TYPE_APPLICATION_PDF,
});
const media = await downloadMSTeamsAttachments(
buildDownloadParams(
[
{
contentType: "reference",
contentUrl,
name: "report.pdf",
},
],
{
tokenProvider,
allowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
authAllowHosts: DEFAULT_GRAPH_ALLOW_HOSTS,
fetchFn: asFetchFn(fetchMock),
},
),
);
expectAttachmentMediaLength(media, 1);
expect(media[0]?.path).toBe(SAVED_PDF_PATH);
// The only host that should be fetched is graph.microsoft.com.
const calledUrls = (fetchMock.mock.calls as Array<[RequestInfo | URL, RequestInit?]>).map(
([input]) => resolveRequestUrl(input),
);
expect(calledUrls.length).toBeGreaterThan(0);
for (const url of calledUrls) {
expect(url.startsWith(GRAPH_SHARES_URL_PREFIX)).toBe(true);
}
// Graph scope token was acquired for the shares fetch.
expect(tokenProvider.getAccessToken).toHaveBeenCalled();
});
it("falls through to direct fetch for non-shared-link URLs", async () => {
const directUrl = createTestUrl("direct.pdf");
const fetchMock = createOkFetchMock(CONTENT_TYPE_APPLICATION_PDF, "pdf");
detectMimeMock.mockResolvedValueOnce(CONTENT_TYPE_APPLICATION_PDF);
saveMediaBufferMock.mockResolvedValueOnce({
id: "saved.pdf",
path: SAVED_PDF_PATH,
size: Buffer.byteLength(PDF_BUFFER),
contentType: CONTENT_TYPE_APPLICATION_PDF,
});
const media = await downloadAttachmentsWithFetch(
createPdfAttachments(directUrl),
fetchMock,
);
expectAttachmentMediaLength(media, 1);
const calledUrls = (fetchMock.mock.calls as unknown[]).map((call) => {
const input = (call as [RequestInfo | URL])[0];
return resolveRequestUrl(input);
});
// Should have hit the original host, NOT graph shares.
expect(calledUrls).toContain(directUrl);
expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(false);
});
});
describe("error logging (issue #63396)", () => {
// Before this fix, fetch failures were swallowed by empty `catch {}`
// blocks, leaving operators with no signal that SharePoint downloads
// were silently failing on Node 24+. These tests pin the logger contract
// so the regression cannot return.
it("invokes logger.warn when a remote media download fails", async () => {
const logger = { warn: vi.fn(), error: vi.fn() };
const fetchMock = vi.fn(async () => createTextResponse("server error", 500));
const media = await downloadMSTeamsAttachments(
buildDownloadParams(createImageAttachments(TEST_URL_IMAGE), {
fetchFn: asFetchFn(fetchMock),
logger,
}),
);
expectAttachmentMediaLength(media, 0);
// Migration inlines host + error into the message text — the structured
// meta object was being dropped by the logger formatter pre-migration.
expect(logger.warn).toHaveBeenCalledWith(
expect.stringMatching(/msteams attachment download failed.*host=.*error=.*HTTP 500/),
);
});
it("does not log when downloads succeed", async () => {
const logger = { warn: vi.fn(), error: vi.fn() };
const fetchMock = createOkFetchMock(CONTENT_TYPE_IMAGE_PNG);
const media = await downloadMSTeamsAttachments(
buildDownloadParams(createImageAttachments(TEST_URL_IMAGE), {
fetchFn: asFetchFn(fetchMock),
logger,
}),
);
expectAttachmentMediaLength(media, 1);
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,20 @@
// Msteams plugin module implements attachments behavior.
export {
downloadMSTeamsBotFrameworkAttachments,
isBotFrameworkPersonalChatId,
} from "./attachments/bot-framework.js";
export { downloadMSTeamsAttachments } from "./attachments/download.js";
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
export {
buildMSTeamsAttachmentPlaceholder,
extractMSTeamsHtmlAttachmentIds,
resolveMSTeamsInboundAttachmentPresentation,
summarizeMSTeamsHtmlAttachments,
} from "./attachments/html.js";
export { buildMSTeamsMediaPayload } from "./attachments/payload.js";
export type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsHtmlAttachmentSummary,
MSTeamsInboundMedia,
} from "./attachments/types.js";

View File

@@ -0,0 +1,609 @@
// Msteams tests cover bot framework plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMSTeamsRuntime } from "../runtime.js";
import {
downloadMSTeamsBotFrameworkAttachment,
downloadMSTeamsBotFrameworkAttachments,
isBotFrameworkPersonalChatId,
} from "./bot-framework.js";
import type { MSTeamsAccessTokenProvider } from "./types.js";
type SavedCall = {
buffer: Buffer;
contentType?: string;
direction: string;
maxBytes: number;
originalFilename?: string;
};
type MockRuntime = {
saveCalls: SavedCall[];
savePath: string;
savedContentType: string;
};
function installRuntime(): MockRuntime {
const state: MockRuntime = {
saveCalls: [],
savePath: "/tmp/bf-attachment.bin",
savedContentType: "application/pdf",
};
setMSTeamsRuntime({
media: {
detectMime: async ({ headerMime }: { headerMime?: string }) =>
headerMime ?? "application/pdf",
},
channel: {
media: {
saveMediaBuffer: async (
buffer: Buffer,
contentType: string | undefined,
direction: string,
maxBytes: number,
originalFilename?: string,
) => {
state.saveCalls.push({
buffer,
contentType,
direction,
maxBytes,
originalFilename,
});
return { path: state.savePath, contentType: state.savedContentType };
},
readRemoteMediaBuffer: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
saveRemoteMedia: async () => ({
path: state.savePath,
contentType: state.savedContentType,
}),
saveResponseMedia: async (
response: Response,
options: {
fallbackContentType?: string;
subdir?: string;
maxBytes?: number;
originalFilename?: string;
},
) => {
const buffer = Buffer.from(await response.arrayBuffer());
state.saveCalls.push({
buffer,
contentType: options.fallbackContentType,
direction: options.subdir ?? "inbound",
maxBytes: options.maxBytes ?? 0,
originalFilename: options.originalFilename,
});
return { path: state.savePath, contentType: state.savedContentType };
},
},
},
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
return state;
}
function createMockFetch(entries: Array<{ match: RegExp; response: Response }>): typeof fetch {
return vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const entry = entries.find((e) => e.match.test(url));
if (!entry) {
return new Response("not found", { status: 404 });
}
return entry.response.clone();
}) as typeof fetch;
}
function buildTokenProvider(): MSTeamsAccessTokenProvider {
return {
getAccessToken: vi.fn(async (scope: string) => {
if (scope.includes("botframework.com")) {
return "bf-token";
}
return "graph-token";
}),
};
}
function firstMockCall(mock: ReturnType<typeof vi.fn>, label: string): unknown[] {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
async function resolvePublicHost(): Promise<{ address: string }> {
return { address: "93.184.216.34" };
}
describe("isBotFrameworkPersonalChatId", () => {
it("detects a: prefix personal chat IDs", () => {
expect(isBotFrameworkPersonalChatId("a:1dRsHCobZ1AxURzY05Dc")).toBe(true);
});
it("detects 8:orgid: prefix chat IDs", () => {
expect(isBotFrameworkPersonalChatId("8:orgid:12345678-1234-1234-1234-123456789abc")).toBe(true);
});
it("returns false for Graph-compatible 19: thread IDs", () => {
expect(isBotFrameworkPersonalChatId("19:abc@thread.tacv2")).toBe(false);
});
it("returns false for synthetic DM Graph IDs", () => {
expect(isBotFrameworkPersonalChatId("19:aad-user-id_bot-app-id@unq.gbl.spaces")).toBe(false);
});
it("returns false for null/undefined/empty", () => {
expect(isBotFrameworkPersonalChatId(null)).toBe(false);
expect(isBotFrameworkPersonalChatId(undefined)).toBe(false);
expect(isBotFrameworkPersonalChatId("")).toBe(false);
});
});
describe("downloadMSTeamsBotFrameworkAttachment", () => {
let runtime: MockRuntime;
beforeEach(() => {
runtime = installRuntime();
});
it("fetches attachment info then view and saves media", async () => {
const info = {
name: "report.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 1024 }],
};
const fileBytes = Buffer.from("PDFBYTES", "utf-8");
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response(JSON.stringify(info), {
status: 200,
headers: { "content-type": "application/json" },
}),
},
{
match: /\/v3\/attachments\/att-1\/views\/original$/,
response: new Response(fileBytes, {
status: 200,
headers: { "content-length": String(fileBytes.byteLength) },
}),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer/",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media?.path).toBe(runtime.savePath);
expect(media?.contentType).toBe(runtime.savedContentType);
expect(runtime.saveCalls).toHaveLength(1);
expect(runtime.saveCalls[0].buffer.toString("utf-8")).toBe("PDFBYTES");
});
it("skips malformed attachment view content-length before saving media", async () => {
const info = {
name: "report.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 3 }],
};
const warn = vi.fn();
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response(JSON.stringify(info), {
status: 200,
headers: { "content-type": "application/json" },
}),
},
{
match: /\/v3\/attachments\/att-1\/views\/original$/,
response: new Response("PDFBYTES", {
status: 200,
headers: { "content-length": "0x3" },
}),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer/",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
logger: { warn },
});
expect(media).toBeUndefined();
expect(runtime.saveCalls).toHaveLength(0);
expect(warn).toHaveBeenCalledWith(
"msteams botFramework attachmentView invalid content-length",
{ error: "invalid content-length header: 0x3" },
);
});
it("returns undefined when attachment info fetch fails", async () => {
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\//,
response: new Response("unauthorized", { status: 401 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media).toBeUndefined();
expect(runtime.saveCalls).toHaveLength(0);
});
it("does not send Bot Framework service tokens to non-auth-allowlisted media hosts", async () => {
const seenAuth: Array<string | null> = [];
const fetchFn: typeof fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
seenAuth.push(new Headers(init?.headers).get("authorization"));
return new Response("unauthorized", { status: 401 });
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://attacker.trafficmanager.net",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media).toBeUndefined();
expect(seenAuth).toEqual([null]);
expect(runtime.saveCalls).toHaveLength(0);
});
it("sends Bot Framework service tokens to auth-allowlisted service hosts", async () => {
const seenAuth: Array<string | null> = [];
const fileBytes = Buffer.from("BFBYTES", "utf-8");
const fetchFn: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
seenAuth.push(new Headers(init?.headers).get("authorization"));
if (url.endsWith("/v3/attachments/att-1")) {
return new Response(
JSON.stringify({
name: "doc.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: fileBytes.byteLength }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url.endsWith("/v3/attachments/att-1/views/original")) {
return new Response(fileBytes, {
status: 200,
headers: { "content-length": String(fileBytes.byteLength) },
});
}
return new Response("not found", { status: 404 });
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media?.path).toBe(runtime.savePath);
expect(seenAuth).toEqual(["Bearer bf-token", "Bearer bf-token"]);
});
it("skips when attachment view size exceeds maxBytes", async () => {
const info = {
name: "huge.bin",
type: "application/octet-stream",
views: [{ viewId: "original", size: 50_000_000 }],
};
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/big-1$/,
response: new Response(JSON.stringify(info), { status: 200 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "big-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
resolveFn: resolvePublicHost,
});
expect(media).toBeUndefined();
expect(runtime.saveCalls).toHaveLength(0);
});
it("returns undefined when no views are returned", async () => {
const info = { name: "nothing", type: "application/pdf", views: [] };
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/empty-1$/,
response: new Response(JSON.stringify(info), { status: 200 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "empty-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
resolveFn: resolvePublicHost,
});
expect(media).toBeUndefined();
});
it("returns undefined without a tokenProvider", async () => {
const fetchFn = vi.fn();
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: undefined,
maxBytes: 10_000_000,
fetchFn: fetchFn as unknown as typeof fetch,
});
expect(media).toBeUndefined();
expect(fetchFn).not.toHaveBeenCalled();
});
describe("guarded attachment fetches", () => {
it("drives dispatcher-aware caller fetchFn hooks through a pinned dispatcher", async () => {
const fileBytes = Buffer.from("BFBYTES", "utf-8");
const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
fetchCalls.push({ url, init });
if (url.endsWith("/v3/attachments/att-1")) {
return new Response(
JSON.stringify({
name: "doc.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: fileBytes.byteLength }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url.endsWith("/v3/attachments/att-1/views/original")) {
return new Response(fileBytes, {
status: 200,
headers: { "content-length": String(fileBytes.byteLength) },
});
}
return new Response("not found", { status: 404 });
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
});
expect(media?.path).toBe(runtime.savePath);
expect(media?.contentType).toBe(runtime.savedContentType);
// Both the attachment info call and the view call should be observed,
// confirming the guarded fetch path still preserves caller fetch hooks.
expect(fetchCalls).toHaveLength(2);
expect(fetchCalls[0].url.endsWith("/v3/attachments/att-1")).toBe(true);
expect(fetchCalls[1].url.endsWith("/v3/attachments/att-1/views/original")).toBe(true);
for (const call of fetchCalls) {
const init = call.init as RequestInit & { dispatcher?: unknown };
expect(init?.dispatcher).toBeDefined();
}
});
it("logs a warning when the attachmentInfo fetch throws (no longer silently swallowed)", async () => {
const warn = vi.fn();
const logger = { warn };
const error = new TypeError("fetch failed | invalid onRequestStart method");
const fetchFn: typeof fetch = (async () => {
throw error;
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
logger,
});
expect(media).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
"msteams botFramework attachmentInfo fetch failed",
{ error: "fetch failed | invalid onRequestStart method" },
]);
});
it("logs a warning when the attachmentView fetch throws", async () => {
const warn = vi.fn();
const logger = { warn };
const fetchFn: typeof fetch = (async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.endsWith("/v3/attachments/att-1")) {
return new Response(
JSON.stringify({
name: "doc.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 10 }],
}),
{ status: 200 },
);
}
throw new TypeError("fetch failed");
}) as typeof fetch;
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
fetchFnSupportsDispatcher: true,
resolveFn: resolvePublicHost,
logger,
});
expect(media).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
"msteams botFramework attachmentView fetch failed",
{ error: "fetch failed" },
]);
});
it("logs a warning on non-ok attachmentInfo response", async () => {
const warn = vi.fn();
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response("server error", { status: 500 }),
},
]);
const media = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentId: "att-1",
tokenProvider: buildTokenProvider(),
maxBytes: 10_000_000,
fetchFn,
resolveFn: resolvePublicHost,
logger: { warn },
});
expect(media).toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
"msteams botFramework attachmentInfo non-ok",
{ status: 500 },
]);
});
});
});
describe("downloadMSTeamsBotFrameworkAttachments", () => {
beforeEach(() => {
installRuntime();
});
it("fetches every unique attachment id and returns combined media", async () => {
const mkInfo = (viewId: string) => ({
name: `file-${viewId}.pdf`,
type: "application/pdf",
views: [{ viewId, size: 10 }],
});
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/att-1$/,
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-1\/views\/original$/,
response: new Response(Buffer.from("A"), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-2$/,
response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }),
},
{
match: /\/v3\/attachments\/att-2\/views\/original$/,
response: new Response(Buffer.from("B"), { status: 200 }),
},
]);
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: ["att-1", "att-2", "att-1"],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn,
resolveFn: resolvePublicHost,
});
expect(result.media).toHaveLength(2);
expect(result.attachmentCount).toBe(2);
});
it("returns empty when no valid attachment ids", async () => {
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: [],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn: vi.fn() as unknown as typeof fetch,
});
expect(result.media).toStrictEqual([]);
});
it("continues past a per-attachment failure", async () => {
const fetchFn = createMockFetch([
{
match: /\/v3\/attachments\/ok$/,
response: new Response(
JSON.stringify({
name: "ok.pdf",
type: "application/pdf",
views: [{ viewId: "original", size: 1 }],
}),
{ status: 200 },
),
},
{
match: /\/v3\/attachments\/ok\/views\/original$/,
response: new Response(Buffer.from("OK"), { status: 200 }),
},
{
match: /\/v3\/attachments\/bad$/,
response: new Response("nope", { status: 500 }),
},
]);
const result = await downloadMSTeamsBotFrameworkAttachments({
serviceUrl: "https://smba.trafficmanager.net/amer",
attachmentIds: ["bad", "ok"],
tokenProvider: buildTokenProvider(),
maxBytes: 10_000,
fetchFn,
resolveFn: resolvePublicHost,
});
expect(result.media).toHaveLength(1);
expect(result.attachmentCount).toBe(2);
});
});

View File

@@ -0,0 +1,386 @@
// Msteams plugin module implements bot framework behavior.
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
import { getMSTeamsRuntime } from "../runtime.js";
import { ensureUserAgentHeader } from "../user-agent.js";
import {
applyAuthorizationHeaderForUrl,
inferPlaceholder,
isUrlAllowed,
type MSTeamsAttachmentDownloadLogger,
type MSTeamsAttachmentFetchPolicy,
type MSTeamsAttachmentResolveFn,
resolveAttachmentFetchPolicy,
safeFetchWithPolicy,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsGraphMediaResult,
MSTeamsInboundMedia,
} from "./types.js";
/**
* Bot Framework Service token scope for requesting a token used against
* the Bot Connector (v3) REST endpoints such as `/v3/attachments/{id}`.
*/
const BOT_FRAMEWORK_SCOPE = "https://api.botframework.com";
/**
* Detect Bot Framework personal chat ("a:") and MSA orgid ("8:orgid:") conversation
* IDs. These identifiers are not recognized by Graph's `/chats/{id}` endpoint, so we
* must fetch media via the Bot Framework v3 attachments endpoint instead.
*
* Graph-compatible IDs start with `19:` and are left untouched by this detector.
*/
export function isBotFrameworkPersonalChatId(conversationId: string | null | undefined): boolean {
if (typeof conversationId !== "string") {
return false;
}
const trimmed = conversationId.trim();
return trimmed.startsWith("a:") || trimmed.startsWith("8:orgid:");
}
type BotFrameworkView = {
viewId?: string | null;
size?: number | null;
};
type BotFrameworkAttachmentInfo = {
name?: string | null;
type?: string | null;
views?: BotFrameworkView[] | null;
};
function normalizeServiceUrl(serviceUrl: string): string {
// Bot Framework service URLs sometimes carry a trailing slash; normalize so
// we can safely append `/v3/attachments/...` below.
return serviceUrl.replace(/\/+$/, "");
}
function buildBotFrameworkAttachmentHeaders(params: {
url: string;
accessToken: string;
policy: MSTeamsAttachmentFetchPolicy;
}): Headers {
const headers = ensureUserAgentHeader();
applyAuthorizationHeaderForUrl({
headers,
url: params.url,
authAllowHosts: params.policy.authAllowHosts,
bearerToken: params.accessToken,
});
return headers;
}
async function fetchBotFrameworkAttachmentInfo(params: {
serviceUrl: string;
attachmentId: string;
accessToken: string;
policy: MSTeamsAttachmentFetchPolicy;
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<BotFrameworkAttachmentInfo | undefined> {
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
let response: Response;
try {
response = await safeFetchWithPolicy({
url,
policy: params.policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
requestInit: {
headers: buildBotFrameworkAttachmentHeaders({
url,
accessToken: params.accessToken,
policy: params.policy,
}),
},
});
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentInfo fetch failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
if (!response.ok) {
await response.body?.cancel();
params.logger?.warn?.("msteams botFramework attachmentInfo non-ok", {
status: response.status,
});
return undefined;
}
try {
return (await response.json()) as BotFrameworkAttachmentInfo;
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentInfo parse failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
}
async function saveBotFrameworkAttachmentView(params: {
serviceUrl: string;
attachmentId: string;
viewId: string;
accessToken: string;
maxBytes: number;
fileNameHint?: string;
contentTypeHint?: string;
preserveFilenames?: boolean;
policy: MSTeamsAttachmentFetchPolicy;
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<{ path: string; contentType?: string } | undefined> {
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
let response: Response;
try {
response = await safeFetchWithPolicy({
url,
policy: params.policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
requestInit: {
headers: buildBotFrameworkAttachmentHeaders({
url,
accessToken: params.accessToken,
policy: params.policy,
}),
},
});
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentView fetch failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
if (!response.ok) {
await response.body?.cancel();
params.logger?.warn?.("msteams botFramework attachmentView non-ok", {
status: response.status,
});
return undefined;
}
let contentLength: number | null;
try {
contentLength = parseMediaContentLength(response.headers.get("content-length"));
} catch (err) {
await response.body?.cancel();
params.logger?.warn?.("msteams botFramework attachmentView invalid content-length", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
if (contentLength !== null && contentLength > params.maxBytes) {
await response.body?.cancel();
return undefined;
}
try {
return await getMSTeamsRuntime().channel.media.saveResponseMedia(response, {
sourceUrl: url,
filePathHint: params.fileNameHint,
maxBytes: params.maxBytes,
fallbackContentType: params.contentTypeHint,
subdir: "inbound",
originalFilename: params.preserveFilenames ? params.fileNameHint : undefined,
});
} catch (err) {
params.logger?.warn?.("msteams botFramework attachmentView save failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
}
/**
* Download media for a single attachment via the Bot Framework v3 attachments
* endpoint. Used for personal DM conversations where the Graph `/chats/{id}`
* path is not usable because the Bot Framework conversation ID (`a:...`) is
* not a valid Graph chat identifier.
*/
export async function downloadMSTeamsBotFrameworkAttachment(params: {
serviceUrl: string;
attachmentId: string;
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
fileNameHint?: string | null;
contentTypeHint?: string | null;
preserveFilenames?: boolean;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<MSTeamsInboundMedia | undefined> {
if (!params.serviceUrl || !params.attachmentId || !params.tokenProvider) {
return undefined;
}
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const baseUrl = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
if (!isUrlAllowed(baseUrl, policy.allowHosts)) {
return undefined;
}
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE);
} catch (err) {
params.logger?.warn?.("msteams botFramework token acquisition failed", {
error: err instanceof Error ? err.message : String(err),
});
return undefined;
}
if (!accessToken) {
return undefined;
}
const info = await fetchBotFrameworkAttachmentInfo({
serviceUrl: params.serviceUrl,
attachmentId: params.attachmentId,
accessToken,
policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
logger: params.logger,
});
if (!info) {
return undefined;
}
const views = Array.isArray(info.views) ? info.views : [];
// Prefer the "original" view when present, otherwise fall back to the first
// view the Bot Framework service returned.
const original = views.find((view) => view?.viewId === "original");
const candidateView = original ?? views.find((view) => typeof view?.viewId === "string");
const viewId =
typeof candidateView?.viewId === "string" && candidateView.viewId
? candidateView.viewId
: undefined;
if (!viewId) {
return undefined;
}
if (
typeof candidateView?.size === "number" &&
candidateView.size > 0 &&
candidateView.size > params.maxBytes
) {
return undefined;
}
const fileNameHint =
(typeof params.fileNameHint === "string" && params.fileNameHint) ||
(typeof info.name === "string" && info.name) ||
undefined;
const contentTypeHint =
(typeof params.contentTypeHint === "string" && params.contentTypeHint) ||
(typeof info.type === "string" && info.type) ||
undefined;
const saved = await saveBotFrameworkAttachmentView({
serviceUrl: params.serviceUrl,
attachmentId: params.attachmentId,
viewId,
accessToken,
maxBytes: params.maxBytes,
fileNameHint,
contentTypeHint,
preserveFilenames: params.preserveFilenames,
policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
logger: params.logger,
});
if (!saved) {
return undefined;
}
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }),
};
}
/**
* Download media for every attachment referenced by a Bot Framework personal
* chat activity. Returns all successfully fetched media along with diagnostics
* compatible with `downloadMSTeamsGraphMedia`'s result shape so callers can
* reuse the existing logging path.
*/
export async function downloadMSTeamsBotFrameworkAttachments(params: {
serviceUrl: string;
attachmentIds: string[];
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
fileNameHint?: string | null;
contentTypeHint?: string | null;
preserveFilenames?: boolean;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<MSTeamsGraphMediaResult> {
const seen = new Set<string>();
const unique: string[] = [];
for (const id of params.attachmentIds ?? []) {
if (typeof id !== "string") {
continue;
}
const trimmed = id.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
unique.push(trimmed);
}
if (unique.length === 0 || !params.serviceUrl || !params.tokenProvider) {
return { media: [], attachmentCount: unique.length };
}
const media: MSTeamsInboundMedia[] = [];
for (const attachmentId of unique) {
try {
const item = await downloadMSTeamsBotFrameworkAttachment({
serviceUrl: params.serviceUrl,
attachmentId,
tokenProvider: params.tokenProvider,
maxBytes: params.maxBytes,
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
fileNameHint: params.fileNameHint,
contentTypeHint: params.contentTypeHint,
preserveFilenames: params.preserveFilenames,
logger: params.logger,
});
if (item) {
media.push(item);
}
} catch (err) {
params.logger?.warn?.("msteams botFramework attachment download failed", {
error: err instanceof Error ? err.message : String(err),
attachmentId,
});
}
}
return {
media,
attachmentCount: unique.length,
};
}

View File

@@ -0,0 +1,335 @@
// Msteams plugin module implements download behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
extractInlineImageCandidates,
inferPlaceholder,
isDownloadableAttachment,
isRecord,
isUrlAllowed,
type MSTeamsAttachmentDownloadLogger,
type MSTeamsAttachmentFetchPolicy,
type MSTeamsAttachmentResolveFn,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveAttachmentFetchPolicy,
resolveRequestUrl,
safeFetchWithPolicy,
tryBuildGraphSharesUrlForSharedLink,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsInboundMedia,
} from "./types.js";
type DownloadCandidate = {
url: string;
fileHint?: string;
contentTypeHint?: string;
placeholder: string;
};
function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate | null {
const contentType = normalizeContentType(att.contentType);
const name = normalizeOptionalString(att.name) ?? "";
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
if (!isRecord(att.content)) {
return null;
}
const downloadUrl = normalizeOptionalString(att.content.downloadUrl) ?? "";
if (!downloadUrl) {
return null;
}
const fileType = normalizeOptionalString(att.content.fileType) ?? "";
const uniqueId = normalizeOptionalString(att.content.uniqueId) ?? "";
const fileName = normalizeOptionalString(att.content.fileName) ?? "";
const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
return {
url: downloadUrl,
fileHint: fileHint || undefined,
contentTypeHint: undefined,
placeholder: inferPlaceholder({
contentType,
fileName: fileHint,
fileType,
}),
};
}
const contentUrl = normalizeOptionalString(att.contentUrl) ?? "";
if (!contentUrl) {
return null;
}
// OneDrive/SharePoint shared links (delivered in 1:1 DMs when the user
// picks "Attach > OneDrive") cannot be fetched directly — the URL returns
// an HTML landing page rather than the file bytes. Rewrite them to the
// Graph shares endpoint so the auth fallback attaches a Graph-scoped token
// and the response is the real file content.
const sharesUrl = tryBuildGraphSharesUrlForSharedLink(contentUrl);
const resolvedUrl = sharesUrl ?? contentUrl;
// Graph shares returns raw bytes without a declared content type we can
// trust for routing — let the downloader infer MIME from the buffer.
const resolvedContentTypeHint = sharesUrl ? undefined : contentType;
return {
url: resolvedUrl,
fileHint: name || undefined,
contentTypeHint: resolvedContentTypeHint,
placeholder: inferPlaceholder({ contentType, fileName: name }),
};
}
function scopeCandidatesForUrl(url: string): string[] {
try {
const host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
const looksLikeGraph =
host.endsWith("graph.microsoft.com") ||
host.endsWith("sharepoint.com") ||
host.endsWith("1drv.ms") ||
host.includes("sharepoint");
return looksLikeGraph
? ["https://graph.microsoft.com", "https://api.botframework.com"]
: ["https://api.botframework.com", "https://graph.microsoft.com"];
} catch {
return ["https://api.botframework.com", "https://graph.microsoft.com"];
}
}
function isRedirectStatus(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
async function resolveInlineDataImageMime(inline: {
data: Buffer;
contentType?: string;
}): Promise<string | undefined> {
const detectedMime = await getMSTeamsRuntime().media.detectMime({
buffer: inline.data,
headerMime: inline.contentType,
});
const mime = normalizeOptionalLowercaseString(detectedMime ?? inline.contentType);
return mime?.startsWith("image/") ? mime : undefined;
}
async function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
requestInit?: RequestInit;
resolveFn?: MSTeamsAttachmentResolveFn;
policy: MSTeamsAttachmentFetchPolicy;
}): Promise<Response> {
const firstAttempt = await safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: params.requestInit,
resolveFn: params.resolveFn,
});
if (firstAttempt.ok) {
return firstAttempt;
}
if (!params.tokenProvider) {
return firstAttempt;
}
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
return firstAttempt;
}
if (!isUrlAllowed(params.url, params.policy.authAllowHosts)) {
return firstAttempt;
}
await firstAttempt.body?.cancel();
const scopes = scopeCandidatesForUrl(params.url);
const fetchFn = params.fetchFn ?? fetch;
for (const scope of scopes) {
try {
const token = await params.tokenProvider.getAccessToken(scope);
const authHeaders = new Headers(params.requestInit?.headers);
authHeaders.set("Authorization", `Bearer ${token}`);
const authAttempt = await safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
resolveFn: params.resolveFn,
});
if (authAttempt.ok) {
return authAttempt;
}
if (isRedirectStatus(authAttempt.status)) {
// Redirects in guarded fetch mode must propagate to the outer guard.
return authAttempt;
}
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
// Preserve scope fallback semantics for non-auth failures.
await authAttempt.body?.cancel();
continue;
}
await authAttempt.body?.cancel();
} catch {
// Try the next scope.
}
}
return firstAttempt;
}
/**
* Download all file attachments from a Teams message (images, documents, etc.).
* Renamed from downloadMSTeamsImageAttachments to support all file types.
*/
export async function downloadMSTeamsAttachments(params: {
attachments: MSTeamsAttachmentLike[] | undefined;
maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
/**
* Optional logger used to surface inline data decode failures and remote
* media download errors. Errors that are not logged here are invisible at
* INFO level and block diagnosis of issues like #63396.
*/
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) {
return [];
}
const policy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const allowHosts = policy.allowHosts;
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
// Download ANY downloadable attachment (not just images)
const downloadable = list.filter(isDownloadableAttachment);
const candidates: DownloadCandidate[] = downloadable
.map(resolveDownloadCandidate)
.filter(Boolean) as DownloadCandidate[];
const inlineCandidates = extractInlineImageCandidates(list, {
maxInlineBytes: params.maxBytes,
maxInlineTotalBytes: params.maxBytes,
});
const seenUrls = new Set<string>();
for (const inline of inlineCandidates) {
if (inline.kind === "url") {
if (!isUrlAllowed(inline.url, allowHosts)) {
continue;
}
if (seenUrls.has(inline.url)) {
continue;
}
seenUrls.add(inline.url);
candidates.push({
url: inline.url,
fileHint: inline.fileHint,
contentTypeHint: inline.contentType,
placeholder: inline.placeholder,
});
}
}
if (candidates.length === 0 && inlineCandidates.length === 0) {
return [];
}
const out: MSTeamsInboundMedia[] = [];
for (const inline of inlineCandidates) {
if (inline.kind !== "data") {
continue;
}
if (inline.data.byteLength > params.maxBytes) {
continue;
}
try {
const contentType = await resolveInlineDataImageMime(inline);
if (!contentType) {
continue;
}
// Data inline candidates (base64 data URLs) don't have original filenames
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
inline.data,
contentType,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType ?? contentType }),
});
} catch (err) {
params.logger?.warn?.("msteams inline attachment decode failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
for (const candidate of candidates) {
if (!isUrlAllowed(candidate.url, allowHosts)) {
continue;
}
try {
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: candidate.url,
filePathHint: candidate.fileHint ?? candidate.url,
maxBytes: params.maxBytes,
contentTypeHint: candidate.contentTypeHint,
placeholder: candidate.placeholder,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
// `fetchImpl` below owns Teams auth fallback and enforces the
// attachment fetch policy through `safeFetchWithPolicy`.
useDirectFetch: true,
fetchImpl: (input, init) =>
fetchWithAuthFallback({
url: resolveRequestUrl(input),
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: init,
resolveFn: params.resolveFn,
policy,
}),
});
out.push(media);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
params.logger?.warn?.(
`msteams attachment download failed host=${safeHostForLog(candidate.url)} error=${msg}`,
);
}
}
return out;
}
function safeHostForLog(url: string): string {
try {
return new URL(url).host;
} catch {
return "invalid-url";
}
}

View File

@@ -0,0 +1,444 @@
// Msteams tests cover graph plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock shared.js to avoid transitive runtime-api imports that pull in uninstalled packages.
vi.mock("./shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./shared.js")>();
return {
...actual,
applyAuthorizationHeaderForUrl: vi.fn(),
GRAPH_ROOT: "https://graph.microsoft.com/v1.0",
inferPlaceholder: vi.fn(({ contentType }: { contentType?: string }) =>
contentType?.startsWith("image/") ? "[image]" : "[file]",
),
isRecord: (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v),
isUrlAllowed: vi.fn(() => true),
normalizeContentType: vi.fn((ct: string | null | undefined) => ct ?? undefined),
resolveMediaSsrfPolicy: vi.fn(() => undefined),
resolveAttachmentFetchPolicy: vi.fn(() => ({ allowHosts: ["*"], authAllowHosts: ["*"] })),
resolveRequestUrl: vi.fn((input: string) => input),
safeFetchWithPolicy: vi.fn(),
};
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("../runtime.js", () => ({
getMSTeamsRuntime: vi.fn(() => ({
media: {
detectMime: vi.fn(async () => "image/png"),
},
channel: {
media: {
saveResponseMedia: vi.fn(
async (
response: Response,
options?: { fallbackContentType?: string; maxBytes?: number },
) => {
const length = Number(response.headers.get("content-length"));
if (
Number.isFinite(length) &&
options?.maxBytes !== undefined &&
length > options.maxBytes
) {
throw new Error("content length exceeds maxBytes");
}
return {
path: "/tmp/saved.png",
contentType: options?.fallbackContentType ?? "image/png",
};
},
),
saveMediaBuffer: vi.fn(async (_buf: Buffer, ct: string) => ({
path: "/tmp/saved.png",
contentType: ct ?? "image/png",
})),
},
},
})),
}));
vi.mock("./download.js", () => ({
downloadMSTeamsAttachments: vi.fn(async () => []),
}));
vi.mock("./remote-media.js", () => ({
downloadAndStoreMSTeamsRemoteMedia: vi.fn(),
}));
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { downloadMSTeamsGraphMedia } from "./graph.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import { safeFetchWithPolicy } from "./shared.js";
function mockFetchResponse(body: unknown, status = 200) {
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
return new Response(bodyStr, { status, headers: { "content-type": "application/json" } });
}
function mockBinaryResponse(data: Uint8Array, status = 200) {
return new Response(Buffer.from(data) as BodyInit, { status });
}
type GuardedFetchParams = { url: string; init?: RequestInit };
function guardedFetchResult(params: GuardedFetchParams, response: Response) {
return {
response,
release: async () => {},
finalUrl: params.url,
};
}
function requireFirstMockCall<TArgs extends unknown[]>(
mock: { mock: { calls: TArgs[] } },
label: string,
): TArgs {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`expected ${label}`);
}
return call;
}
function mockGraphMediaFetch(options: {
messageId: string;
messageResponse?: unknown;
hostedContents?: unknown[];
valueResponses?: Record<string, Response>;
fetchCalls?: string[];
}) {
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
options.fetchCalls?.push(params.url);
const url = params.url;
if (url.endsWith(`/messages/${options.messageId}`) && !url.includes("hostedContents")) {
return guardedFetchResult(
params,
mockFetchResponse(options.messageResponse ?? { body: {}, attachments: [] }),
);
}
if (url.endsWith("/hostedContents")) {
return guardedFetchResult(params, mockFetchResponse({ value: options.hostedContents ?? [] }));
}
for (const [fragment, response] of Object.entries(options.valueResponses ?? {})) {
if (url.includes(fragment)) {
return guardedFetchResult(params, response);
}
}
return guardedFetchResult(params, mockFetchResponse({}, 404));
});
}
describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches $value endpoint when contentBytes is null but item.id exists", async () => {
const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
const fetchCalls: string[] = [];
mockGraphMediaFetch({
messageId: "msg-1",
hostedContents: [{ id: "hosted-123", contentType: "image/png", contentBytes: null }],
valueResponses: {
"/hostedContents/hosted-123/$value": mockBinaryResponse(imageBytes),
},
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-1",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
// Verify the $value endpoint was fetched
expect(fetchCalls).toContain(
"https://graph.microsoft.com/v1.0/chats/c/messages/msg-1/hostedContents/hosted-123/$value",
);
expect(result.media.length).toBeGreaterThan(0);
expect(result.hostedCount).toBe(1);
});
it("skips hosted content when contentBytes is null and id is missing", async () => {
mockGraphMediaFetch({
messageId: "msg-2",
hostedContents: [{ contentType: "image/png", contentBytes: null }],
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-2",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
// No media because there's no id to fetch $value from and no contentBytes
expect(result.media).toHaveLength(0);
});
it("skips $value content when Content-Length exceeds maxBytes", async () => {
const fetchCalls: string[] = [];
mockGraphMediaFetch({
messageId: "msg-cl",
hostedContents: [{ id: "hosted-big", contentType: "image/png", contentBytes: null }],
valueResponses: {
"/hostedContents/hosted-big/$value": new Response(
Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])) as BodyInit,
{
status: 200,
headers: { "content-length": "999999999" },
},
),
},
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-cl",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 1024, // 1 KB limit
});
// $value was fetched but skipped due to Content-Length exceeding maxBytes
expect(fetchCalls).toContain(
"https://graph.microsoft.com/v1.0/chats/c/messages/msg-cl/hostedContents/hosted-big/$value",
);
expect(result.media).toHaveLength(0);
});
it("uses inline contentBytes when available instead of $value", async () => {
const fetchCalls: string[] = [];
const base64Png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64");
mockGraphMediaFetch({
messageId: "msg-3",
hostedContents: [{ id: "hosted-456", contentType: "image/png", contentBytes: base64Png }],
fetchCalls,
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-3",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
// Should NOT have fetched $value since contentBytes was available
const valueCall = fetchCalls.find((u) => u.includes("/$value"));
expect(valueCall).toBeUndefined();
expect(result.media.length).toBeGreaterThan(0);
});
it("adds the OpenClaw User-Agent to guarded Graph attachment fetches", async () => {
mockGraphMediaFetch({ messageId: "msg-ua" });
await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-ua",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
const guardCalls = vi.mocked(fetchWithSsrFGuard).mock.calls;
for (const [call] of guardCalls) {
const headers = call.init?.headers;
expect(headers).toBeInstanceOf(Headers);
expect((headers as Headers).get("Authorization")).toBe("Bearer test-token");
expect((headers as Headers).get("User-Agent")).toMatch(
/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/,
);
}
});
it("adds the OpenClaw User-Agent to Graph shares downloads for reference attachments", async () => {
mockGraphMediaFetch({
messageId: "msg-share",
messageResponse: {
body: {},
attachments: [
{
contentType: "reference",
contentUrl: "https://tenant.sharepoint.com/file.docx",
name: "file.docx",
},
],
},
});
vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockImplementation(async (params) => {
if (params.fetchImpl) {
await params.fetchImpl(params.url, {});
}
return {
path: "/tmp/file.docx",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
placeholder: "[file]",
};
});
await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-share",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
const [fetchParams] = requireFirstMockCall(
vi.mocked(safeFetchWithPolicy),
"safeFetchWithPolicy call",
);
expect(fetchParams.requestInit?.headers).toBeInstanceOf(Headers);
const requestInit = fetchParams.requestInit;
const headers = requestInit?.headers as Headers;
expect(headers.get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/);
});
});
describe("downloadMSTeamsGraphMedia attachment sourcing and error logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does NOT call the nonexistent ${messageUrl}/attachments sub-resource", async () => {
// The Graph v1.0 API does not expose a `/attachments` sub-resource on
// channel or chat messages. Issue #58617 documented that the old code
// path called this endpoint and recorded a 404 in diagnostics. After
// this fix, the helper must source attachments from the main message
// resource's inline `attachments` array instead.
const fetchCalls: string[] = [];
mockGraphMediaFetch({
messageId: "msg-no-sub",
messageResponse: {
body: { content: "hi" },
attachments: [],
},
fetchCalls,
});
await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-no-sub",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
const calledSubResource = fetchCalls.some((u) =>
u.endsWith("/messages/msg-no-sub/attachments"),
);
expect(calledSubResource).toBe(false);
});
it("sources reference attachments from the message body's attachments array", async () => {
// Before the fix, the helper fetched `/attachments` and used that list.
// After the fix, it must use `msgData.attachments` from the main fetch.
mockGraphMediaFetch({
messageId: "msg-inline",
messageResponse: {
body: {},
attachments: [
{
contentType: "reference",
contentUrl: "https://tenant.sharepoint.com/inline.pdf",
name: "inline.pdf",
},
],
},
});
vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockResolvedValue({
path: "/tmp/inline.pdf",
contentType: "application/pdf",
placeholder: "[file]",
});
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-inline",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
});
expect(result.media).toHaveLength(1);
expect(result.media[0]?.path).toBe("/tmp/inline.pdf");
// Regression guard: attachmentCount now reflects real inline attachments,
// not the imaginary `/attachments` sub-resource count.
expect(result.attachmentCount).toBe(1);
});
it("logs a debug event when the message fetch throws instead of swallowing it", async () => {
// Regression test for #51749: empty `catch {}` blocks used to hide the
// real error, producing misleading `graph media fetch empty` diagnostics
// without surfacing the underlying cause.
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
if (params.url.endsWith("/messages/msg-err")) {
throw new Error("network boom");
}
// hostedContents and any other paths succeed so the error branch under
// test is the only one that fires.
return guardedFetchResult(params, mockFetchResponse({ value: [] }));
});
const logger = { warn: vi.fn() };
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-err",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
logger,
});
expect(result.media).toHaveLength(0);
const [message, context] = requireFirstMockCall(logger.warn, "message fetch warning");
expect(message).toBe("msteams graph message fetch failed");
expect((context as { error?: unknown }).error).toBe("network boom");
});
it("logs a debug event when the message fetch returns non-ok", async () => {
// If the message endpoint returns 403/404, we want that recorded so
// operators can distinguish auth issues from empty result sets.
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
const url = params.url;
if (url.endsWith("/hostedContents")) {
return guardedFetchResult(params, mockFetchResponse({ value: [] }));
}
return guardedFetchResult(params, mockFetchResponse({ error: "forbidden" }, 403));
});
const log = { debug: vi.fn() };
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-403",
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
maxBytes: 10 * 1024 * 1024,
log,
});
expect(result.media).toHaveLength(0);
expect(result.attachmentStatus).toBe(403);
const [message, context] = requireFirstMockCall(log.debug, "message fetch debug event");
expect(message).toBe("graph media message fetch not ok");
expect((context as { status?: unknown }).status).toBe(403);
});
it("logs a debug event when token acquisition fails", async () => {
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) =>
guardedFetchResult(params, mockFetchResponse({})),
);
const logger = { warn: vi.fn() };
const result = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-token",
tokenProvider: {
getAccessToken: vi.fn(async () => {
throw new Error("token expired");
}),
},
maxBytes: 10 * 1024 * 1024,
logger,
});
expect(result.tokenError).toBe(true);
const [message, context] = requireFirstMockCall(logger.warn, "token acquisition warning");
expect(message).toBe("msteams graph token acquisition failed");
expect((context as { error?: unknown }).error).toBe("token expired");
});
});

View File

@@ -0,0 +1,494 @@
// Msteams plugin module implements graph behavior.
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMSTeamsRuntime } from "../runtime.js";
import { ensureUserAgentHeader } from "../user-agent.js";
import { downloadMSTeamsAttachments } from "./download.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
applyAuthorizationHeaderForUrl,
encodeGraphShareId,
GRAPH_ROOT,
estimateBase64DecodedBytes,
inferPlaceholder,
readNestedString,
isUrlAllowed,
type MSTeamsAttachmentDownloadLogger,
type MSTeamsAttachmentFetchPolicy,
type MSTeamsAttachmentResolveFn,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveAttachmentFetchPolicy,
resolveRequestUrl,
safeFetchWithPolicy,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsGraphMediaLogger,
MSTeamsGraphMediaResult,
MSTeamsInboundMedia,
} from "./types.js";
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;
};
export function buildMSTeamsGraphMessageUrls(params: {
conversationType?: string | null;
conversationId?: string | null;
messageId?: string | null;
replyToId?: string | null;
conversationMessageId?: string | null;
channelData?: unknown;
}): string[] {
const conversationType = normalizeLowercaseStringOrEmpty(params.conversationType ?? "");
const messageIdCandidates = new Set<string>();
const pushCandidate = (value: string | null | undefined) => {
const trimmed = normalizeOptionalString(value) ?? "";
if (trimmed) {
messageIdCandidates.add(trimmed);
}
};
pushCandidate(params.messageId);
pushCandidate(params.conversationMessageId);
pushCandidate(readNestedString(params.channelData, ["messageId"]));
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
const replyToId = normalizeOptionalString(params.replyToId) ?? "";
if (conversationType === "channel") {
const teamId =
readNestedString(params.channelData, ["team", "id"]) ??
readNestedString(params.channelData, ["teamId"]);
const channelId =
readNestedString(params.channelData, ["channel", "id"]) ??
readNestedString(params.channelData, ["channelId"]) ??
readNestedString(params.channelData, ["teamsChannelId"]);
if (!teamId || !channelId) {
return [];
}
const urls: string[] = [];
if (replyToId) {
for (const candidate of messageIdCandidates) {
if (candidate === replyToId) {
continue;
}
urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
);
}
}
if (messageIdCandidates.size === 0 && replyToId) {
messageIdCandidates.add(replyToId);
}
for (const candidate of messageIdCandidates) {
urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
);
}
return uniqueStrings(urls);
}
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
if (!chatId) {
return [];
}
if (messageIdCandidates.size === 0 && replyToId) {
messageIdCandidates.add(replyToId);
}
const urls = Array.from(messageIdCandidates).map(
(candidate) =>
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
);
return uniqueStrings(urls);
}
async function fetchGraphCollection(params: {
url: string;
accessToken: string;
fetchFn?: typeof fetch;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ status: number; items: unknown[] }> {
const fetchFn = params.fetchFn ?? fetch;
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
fetchImpl: fetchFn,
init: {
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
},
policy: params.ssrfPolicy,
auditContext: "msteams.graph.collection",
});
try {
const status = response.status;
if (!response.ok) {
return { status, items: [] };
}
try {
const data = (await response.json()) as { value?: unknown[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
}
} finally {
await release();
}
}
function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
let content: unknown = att.content;
if (typeof content === "string") {
try {
content = JSON.parse(content);
} catch {
// Keep as raw string if it's not JSON.
}
}
return {
contentType: normalizeContentType(att.contentType) ?? undefined,
contentUrl: att.contentUrl ?? undefined,
name: att.name ?? undefined,
thumbnailUrl: att.thumbnailUrl ?? undefined,
content,
};
}
/**
* Download all hosted content from a Teams message (images, documents, etc.).
* Renamed from downloadGraphHostedImages to support all file types.
*/
async function downloadGraphHostedContent(params: {
accessToken: string;
messageUrl: string;
maxBytes: number;
fetchFn?: typeof fetch;
preserveFilenames?: boolean;
ssrfPolicy?: SsrFPolicy;
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = (await fetchGraphCollection({
url: `${params.messageUrl}/hostedContents`,
accessToken: params.accessToken,
fetchFn: params.fetchFn,
ssrfPolicy: params.ssrfPolicy,
})) as { status: number; items: GraphHostedContent[] };
if (hosted.items.length === 0) {
return { media: [], status: hosted.status, count: 0 };
}
const out: MSTeamsInboundMedia[] = [];
for (const item of hosted.items) {
const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
let buffer: Buffer;
if (contentBytes) {
if (estimateBase64DecodedBytes(contentBytes) > params.maxBytes) {
continue;
}
try {
buffer = Buffer.from(contentBytes, "base64");
} catch (err) {
params.logger?.warn?.("msteams graph hostedContent base64 decode failed", {
error: err instanceof Error ? err.message : String(err),
});
continue;
}
} else if (item.id) {
// contentBytes not inline — fetch from the individual $value endpoint.
try {
const valueUrl = `${params.messageUrl}/hostedContents/${encodeURIComponent(item.id)}/$value`;
const { response: valRes, release } = await fetchWithSsrFGuard({
url: valueUrl,
fetchImpl: params.fetchFn ?? fetch,
init: {
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
},
policy: params.ssrfPolicy,
auditContext: "msteams.graph.hostedContent.value",
});
try {
if (!valRes.ok) {
continue;
}
const saved = await getMSTeamsRuntime().channel.media.saveResponseMedia(valRes, {
sourceUrl: valueUrl,
maxBytes: params.maxBytes,
fallbackContentType: item.contentType ?? undefined,
subdir: "inbound",
});
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType }),
});
} finally {
await release();
}
} catch (err) {
params.logger?.warn?.("msteams graph hostedContent value fetch failed", {
error: err instanceof Error ? err.message : String(err),
});
continue;
}
continue;
} else {
continue;
}
if (buffer.byteLength > params.maxBytes) {
continue;
}
const mime = await getMSTeamsRuntime().media.detectMime({
buffer,
headerMime: item.contentType ?? undefined,
});
// Download any file type, not just images
try {
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer,
mime ?? item.contentType ?? undefined,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType }),
});
} catch (err) {
params.logger?.warn?.("msteams graph hostedContent save failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
return { media: out, status: hosted.status, count: hosted.items.length };
}
export async function downloadMSTeamsGraphMedia(params: {
messageUrl?: string | null;
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
resolveFn?: MSTeamsAttachmentResolveFn;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
/** Optional logger used to surface Graph/SharePoint fetch errors. */
logger?: MSTeamsAttachmentDownloadLogger;
/** Back-compat diagnostic logger used by older tests/callers. */
log?: MSTeamsGraphMediaLogger;
}): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) {
return { media: [] };
}
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
const messageUrl = params.messageUrl;
const debugLog =
params.log ?? (params.logger as MSTeamsGraphMediaLogger | undefined) ?? undefined;
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
} catch (err) {
debugLog?.debug?.("graph media token acquisition failed", {
messageUrl,
error: err instanceof Error ? err.message : String(err),
});
params.logger?.warn?.("msteams graph token acquisition failed", {
error: err instanceof Error ? err.message : String(err),
});
return { media: [], messageUrl, tokenError: true };
}
const fetchFn = params.fetchFn ?? fetch;
const sharePointMedia: MSTeamsInboundMedia[] = [];
const downloadedReferenceUrls = new Set<string>();
let messageAttachments: GraphAttachment[] = [];
let messageStatus: number | undefined;
try {
const { response: msgRes, release } = await fetchWithSsrFGuard({
url: messageUrl,
fetchImpl: fetchFn,
init: {
headers: ensureUserAgentHeader({ Authorization: `Bearer ${accessToken}` }),
},
policy: ssrfPolicy,
auditContext: "msteams.graph.message",
});
try {
messageStatus = msgRes.status;
if (msgRes.ok) {
let msgData: {
body?: { content?: string; contentType?: string };
attachments?: GraphAttachment[];
};
try {
msgData = (await msgRes.json()) as typeof msgData;
} catch (err) {
debugLog?.debug?.("graph media message parse failed", {
messageUrl,
error: err instanceof Error ? err.message : String(err),
});
params.logger?.warn?.("msteams graph message parse failed", {
error: err instanceof Error ? err.message : String(err),
messageUrl,
});
msgData = {};
}
messageAttachments = Array.isArray(msgData.attachments) ? msgData.attachments : [];
const spAttachments = messageAttachments.filter(
(a) => a.contentType === "reference" && a.contentUrl && a.name,
);
for (const att of spAttachments) {
const name = att.name ?? "file";
const shareUrl = att.contentUrl ?? "";
if (!shareUrl) {
continue;
}
try {
const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
debugLog?.debug?.("graph media sharepoint url not in allowHosts", {
messageUrl,
sharesUrl,
});
continue;
}
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: sharesUrl,
filePathHint: name,
maxBytes: params.maxBytes,
contentTypeHint: "application/octet-stream",
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
useDirectFetch: true,
fetchImpl: async (input, init) => {
const requestUrl = resolveRequestUrl(input);
const headers = ensureUserAgentHeader(init?.headers);
applyAuthorizationHeaderForUrl({
headers,
url: requestUrl,
authAllowHosts: policy.authAllowHosts,
bearerToken: accessToken,
});
return await safeFetchWithPolicy({
url: requestUrl,
policy,
fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: {
...init,
headers,
},
resolveFn: params.resolveFn,
});
},
});
sharePointMedia.push(media);
downloadedReferenceUrls.add(shareUrl);
} catch (err) {
params.logger?.warn?.("msteams SharePoint reference download failed", {
error: err instanceof Error ? err.message : String(err),
name,
});
}
}
} else {
debugLog?.debug?.("graph media message fetch not ok", {
messageUrl,
status: messageStatus,
});
}
} finally {
await release();
}
} catch (err) {
debugLog?.debug?.("graph media message fetch failed", {
messageUrl,
error: err instanceof Error ? err.message : String(err),
});
params.logger?.warn?.("msteams graph message fetch failed", {
error: err instanceof Error ? err.message : String(err),
});
}
const hosted = await downloadGraphHostedContent({
accessToken,
messageUrl,
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
logger: params.logger,
});
const normalizedAttachments = messageAttachments.map(normalizeGraphAttachment);
const filteredAttachments =
sharePointMedia.length > 0
? normalizedAttachments.filter((att) => {
const contentType = normalizeOptionalLowercaseString(att.contentType);
if (contentType !== "reference") {
return true;
}
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
if (!url) {
return true;
}
return !downloadedReferenceUrls.has(url);
})
: normalizedAttachments;
let attachmentMedia: MSTeamsInboundMedia[] = [];
try {
attachmentMedia = await downloadMSTeamsAttachments({
attachments: filteredAttachments,
maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider,
allowHosts: policy.allowHosts,
authAllowHosts: policy.authAllowHosts,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
resolveFn: params.resolveFn,
preserveFilenames: params.preserveFilenames,
logger: params.logger,
});
} catch (err) {
params.logger?.warn?.("msteams graph attachment download failed", {
error: err instanceof Error ? err.message : String(err),
messageUrl,
});
}
return {
media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
hostedCount: hosted.count,
attachmentCount: filteredAttachments.length + sharePointMedia.length,
hostedStatus: hosted.status,
attachmentStatus: messageStatus,
messageUrl,
};
}

View File

@@ -0,0 +1,220 @@
// Msteams plugin module implements html behavior.
import {
ATTACHMENT_TAG_RE,
extractHtmlFromAttachment,
extractInlineImageCandidates,
IMG_SRC_RE,
isDownloadableAttachment,
isLikelyImageAttachment,
normalizeContentType,
safeHostForUrl,
} from "./shared.js";
import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
/**
* Extract every `<attachment id="...">` reference from the HTML attachments in
* the inbound activity. Returns the complete (non-sliced) list; callers that
* need a capped diagnostic summary can truncate after calling this helper.
*/
export function extractMSTeamsHtmlAttachmentIds(
attachments: MSTeamsAttachmentLike[] | undefined,
): string[] {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) {
return [];
}
const ids = new Set<string>();
for (const att of list) {
const html = extractHtmlFromAttachment(att);
if (!html) {
continue;
}
ATTACHMENT_TAG_RE.lastIndex = 0;
let match: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
while (match) {
const id = match[1]?.trim();
if (id) {
ids.add(id);
}
match = ATTACHMENT_TAG_RE.exec(html);
}
}
return Array.from(ids);
}
export function summarizeMSTeamsHtmlAttachments(
attachments: MSTeamsAttachmentLike[] | undefined,
): MSTeamsHtmlAttachmentSummary | undefined {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) {
return undefined;
}
let htmlAttachments = 0;
let imgTags = 0;
let dataImages = 0;
let cidImages = 0;
const srcHosts = new Set<string>();
let attachmentTags = 0;
const attachmentIds = new Set<string>();
for (const att of list) {
const html = extractHtmlFromAttachment(att);
if (!html) {
continue;
}
htmlAttachments += 1;
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
imgTags += 1;
const src = match[1]?.trim();
if (src) {
if (src.startsWith("data:")) {
dataImages += 1;
} else if (src.startsWith("cid:")) {
cidImages += 1;
} else {
srcHosts.add(safeHostForUrl(src));
}
}
match = IMG_SRC_RE.exec(html);
}
ATTACHMENT_TAG_RE.lastIndex = 0;
let attachmentMatch: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
while (attachmentMatch) {
attachmentTags += 1;
const id = attachmentMatch[1]?.trim();
if (id) {
attachmentIds.add(id);
}
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
}
}
if (htmlAttachments === 0) {
return undefined;
}
return {
htmlAttachments,
imgTags,
dataImages,
cidImages,
srcHosts: Array.from(srcHosts).slice(0, 5),
attachmentTags,
attachmentIds: Array.from(attachmentIds).slice(0, 5),
};
}
export function buildMSTeamsAttachmentPlaceholder(
attachments: MSTeamsAttachmentLike[] | undefined,
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
): string {
return resolveMSTeamsInboundAttachmentPresentation(attachments, limits).placeholder;
}
function isAdvertisedFileAttachment(attachment: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(attachment.contentType) ?? "";
if (
contentType.startsWith("text/html") ||
contentType.startsWith("application/vnd.microsoft.card.") ||
contentType.startsWith("application/vnd.microsoft.teams.card.")
) {
return false;
}
return Boolean(
isDownloadableAttachment(attachment) ||
isLikelyImageAttachment(attachment) ||
attachment.name?.trim() ||
contentType,
);
}
function countDistinctInlineImages(attachments: MSTeamsAttachmentLike[]): number {
const seenReferences = new Set<string>();
let count = 0;
for (const attachment of attachments) {
const html = extractHtmlFromAttachment(attachment);
if (!html) {
continue;
}
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
const src = match[1]?.trim();
if (src?.startsWith("data:")) {
count += 1;
} else if (src && !seenReferences.has(src)) {
seenReferences.add(src);
count += 1;
}
match = IMG_SRC_RE.exec(html);
}
}
return count;
}
function countDistinctInlineCandidates(
attachments: MSTeamsAttachmentLike[],
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
): number {
const seenUrls = new Set<string>();
let dataCount = 0;
for (const candidate of extractInlineImageCandidates(attachments, limits)) {
if (candidate.kind === "data") {
dataCount += 1;
} else {
seenUrls.add(candidate.url);
}
}
return dataCount + seenUrls.size;
}
function countUnrepresentedHtmlAttachmentIds(attachments: MSTeamsAttachmentLike[]): number {
const representedIds = new Set<string>();
for (const attachment of attachments) {
const contentType = normalizeContentType(attachment.contentType) ?? "";
if (contentType.startsWith("text/html")) {
continue;
}
const id = attachment.id?.trim();
if (id) {
representedIds.add(id);
}
}
return extractMSTeamsHtmlAttachmentIds(attachments).filter((id) => !representedIds.has(id))
.length;
}
export function resolveMSTeamsInboundAttachmentPresentation(
attachments: MSTeamsAttachmentLike[] | undefined,
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
): { placeholder: string; expectedMediaCount: number } {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) {
return { placeholder: "", expectedMediaCount: 0 };
}
const fileAttachments = list.filter(isAdvertisedFileAttachment);
const inlinePlaceholderCount = countDistinctInlineCandidates(list, limits);
const inlineExpectedCount = countDistinctInlineImages(list);
// Teams HTML uses <attachment> tags as references. A matching attachment
// entry is the same resource (and may be a card), so count only unmatched
// IDs that need the Graph/Bot Framework hosted-content fallback.
const htmlAttachmentCount = countUnrepresentedHtmlAttachmentIds(list);
const expectedMediaCount = fileAttachments.length + inlineExpectedCount + htmlAttachmentCount;
if (expectedMediaCount === 0) {
return { placeholder: "", expectedMediaCount: 0 };
}
const totalImages =
fileAttachments.filter(isLikelyImageAttachment).length + inlinePlaceholderCount;
if (totalImages > 0) {
return {
placeholder: `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`,
expectedMediaCount,
};
}
return {
placeholder: `<media:document>${expectedMediaCount > 1 ? ` (${expectedMediaCount} files)` : ""}`,
expectedMediaCount,
};
}

View File

@@ -0,0 +1,15 @@
// Msteams plugin module implements payload behavior.
import { buildMediaPayload } from "../../runtime-api.js";
export function buildMSTeamsMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
return buildMediaPayload(mediaList, { preserveMediaTypeCardinality: true });
}

View File

@@ -0,0 +1,188 @@
// Msteams tests cover remote media plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the runtime so we can assert whether the strict-dispatcher path
// (`saveRemoteMedia`) was invoked versus the new direct-fetch path added
// for issue #63396 (Node 24+ / undici v7 compat).
const runtimeSaveRemoteMediaMock = vi.fn(
async (
_params: unknown,
): Promise<{
id: string;
path: string;
size: number;
contentType?: string;
fileName?: string;
}> => ({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
}),
);
const runtimeDetectMimeMock = vi.fn(async () => "image/png");
const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: string) => ({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: contentType ?? "image/png",
}));
const saveResponseMediaMock = vi.hoisted(() =>
vi.fn(async (response: Response, options: { maxBytes?: number }) => {
if (!response.ok) {
const statusText = response.statusText ? ` ${response.statusText}` : "";
throw new Error(`HTTP ${response.status}${statusText}`);
}
const contentLength = Number(response.headers.get("content-length"));
if (Number.isFinite(contentLength) && options.maxBytes && contentLength > options.maxBytes) {
throw new Error(`content length ${contentLength} exceeds maxBytes ${options.maxBytes}`);
}
return {
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: response.headers.get("content-type") ?? "image/png",
};
}),
);
vi.mock("openclaw/plugin-sdk/media-runtime", async () => ({
saveResponseMedia: saveResponseMediaMock,
}));
vi.mock("../runtime.js", () => ({
getMSTeamsRuntime: () => ({
media: { detectMime: runtimeDetectMimeMock },
channel: {
media: {
saveRemoteMedia: runtimeSaveRemoteMediaMock,
saveMediaBuffer: runtimeSaveMediaBufferMock,
},
},
}),
}));
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function jsonResponse(body: BodyInit, init?: ResponseInit): Response {
return new Response(body, init);
}
function requireFirstFetchUrl(mock: ReturnType<typeof vi.fn>): unknown {
const [call] = mock.mock.calls;
if (!call) {
throw new Error("expected direct fetch call");
}
return call[0];
}
describe("downloadAndStoreMSTeamsRemoteMedia", () => {
beforeEach(() => {
runtimeSaveRemoteMediaMock.mockClear();
saveResponseMediaMock.mockClear();
runtimeDetectMimeMock.mockClear();
runtimeSaveMediaBufferMock.mockClear();
});
describe("useDirectFetch: true (Node 24+ / undici v7 path for issue #63396)", () => {
it("bypasses readRemoteMediaBuffer and calls the supplied fetchImpl directly", async () => {
// `fetchImpl` here simulates the "pre-validated hostname" contract from
// `safeFetchWithPolicy`: the caller has already enforced the allowlist,
// so the strict SSRF dispatcher is not needed.
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
jsonResponse(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } }),
);
const result = await downloadAndStoreMSTeamsRemoteMedia({
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
filePathHint: "file.png",
maxBytes: 1024,
useDirectFetch: true,
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const calledUrl = requireFirstFetchUrl(fetchImpl);
expect(calledUrl).toBe("https://graph.microsoft.com/v1.0/shares/abc/driveItem/content");
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
expect(result.path).toBe("/tmp/saved.png");
});
it("surfaces HTTP errors as exceptions (no silent drop)", async () => {
const fetchImpl = vi.fn(async () => jsonResponse("nope", { status: 403 }));
await expect(
downloadAndStoreMSTeamsRemoteMedia({
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
filePathHint: "file.png",
maxBytes: 1024,
useDirectFetch: true,
fetchImpl,
}),
).rejects.toThrow(/HTTP 403/);
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
});
it("rejects a response whose Content-Length exceeds maxBytes", async () => {
const fetchImpl = vi.fn(async () =>
jsonResponse(PNG_BYTES, {
status: 200,
headers: { "content-length": "999999" },
}),
);
await expect(
downloadAndStoreMSTeamsRemoteMedia({
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
filePathHint: "file.png",
maxBytes: 1024,
useDirectFetch: true,
fetchImpl,
}),
).rejects.toThrow(/exceeds maxBytes/);
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
});
it("falls back to the runtime saveRemoteMedia path when useDirectFetch is omitted", async () => {
// Non-SharePoint caller, no pre-validated fetchImpl: make sure the strict
// SSRF dispatcher path is still used.
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
fileName: "file.png",
});
await downloadAndStoreMSTeamsRemoteMedia({
url: "https://tenant.sharepoint.com/file.png",
filePathHint: "file.png",
maxBytes: 1024,
});
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
});
it("does not use the direct path when useDirectFetch is true but fetchImpl is missing", async () => {
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
id: "saved",
path: "/tmp/saved.png",
size: 42,
contentType: "image/png",
});
await downloadAndStoreMSTeamsRemoteMedia({
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
filePathHint: "file.png",
maxBytes: 1024,
useDirectFetch: true,
});
// Without a fetchImpl to delegate to, we must fall back to the runtime
// path rather than crashing.
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,77 @@
// Msteams plugin module implements remote media behavior.
import { saveResponseMedia, type SavedRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import type { SsrFPolicy } from "../../runtime-api.js";
import { getMSTeamsRuntime } from "../runtime.js";
import { inferPlaceholder } from "./shared.js";
import type { MSTeamsInboundMedia } from "./types.js";
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/**
* Direct save path used when the caller supplies the already-guarded fetch
* implementation. This lets Teams-specific auth fallback own the request
* sequence while keeping redirect and DNS pinning inside `safeFetchWithPolicy`.
*/
async function saveRemoteMediaDirect(params: {
url: string;
filePathHint: string;
fetchImpl: FetchLike;
maxBytes: number;
contentTypeHint?: string;
originalFilename?: string;
}): Promise<SavedRemoteMedia> {
const response = await params.fetchImpl(params.url, { redirect: "follow" });
return await saveResponseMedia(response, {
sourceUrl: params.url,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
fallbackContentType: params.contentTypeHint,
originalFilename: params.originalFilename,
});
}
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
url: string;
filePathHint: string;
maxBytes: number;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
contentTypeHint?: string;
placeholder?: string;
preserveFilenames?: boolean;
/**
* Opt into the Teams-specific guarded fetch path. Only safe when the
* supplied `fetchImpl` enforces the attachment fetch policy itself.
*/
useDirectFetch?: boolean;
}): Promise<MSTeamsInboundMedia> {
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
let saved: SavedRemoteMedia;
if (params.useDirectFetch && params.fetchImpl) {
saved = await saveRemoteMediaDirect({
url: params.url,
filePathHint: params.filePathHint,
fetchImpl: params.fetchImpl,
maxBytes: params.maxBytes,
contentTypeHint: params.contentTypeHint,
originalFilename,
});
} else {
saved = await getMSTeamsRuntime().channel.media.saveRemoteMedia({
url: params.url,
fetchImpl: params.fetchImpl,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
ssrfPolicy: params.ssrfPolicy,
fallbackContentType: params.contentTypeHint,
originalFilename,
});
}
return {
path: saved.path,
contentType: saved.contentType,
placeholder:
params.placeholder ??
inferPlaceholder({ contentType: saved.contentType, fileName: params.filePathHint }),
};
}

View File

@@ -0,0 +1,680 @@
// Msteams tests cover shared plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
applyAuthorizationHeaderForUrl,
encodeGraphShareId,
extractInlineImageCandidates,
isGraphSharedLinkUrl,
isPrivateOrReservedIP,
isUrlAllowed,
resolveAndValidateIP,
resolveAttachmentFetchPolicy,
resolveAllowedHosts,
resolveAuthAllowedHosts,
resolveMediaSsrfPolicy,
safeFetch,
safeFetchWithPolicy,
tryBuildGraphSharesUrlForSharedLink,
} from "./shared.js";
const publicResolve = async () => ({ address: "13.107.136.10" });
const privateResolve = (ip: string) => async () => ({ address: ip });
const failingResolve = async () => {
throw new Error("DNS failure");
};
function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody = "ok") {
return vi.fn(async (url: string, init?: RequestInit) => {
const target = redirectMap[url];
if (target && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: target },
});
}
return new Response(finalBody, { status: 200 });
});
}
function fetchInitAt(fetchMock: ReturnType<typeof vi.fn>, index: number): unknown {
const call = fetchMock.mock.calls[index];
if (!call) {
throw new Error(`expected fetch call ${index}`);
}
return call[1];
}
async function expectSafeFetchStatus(params: {
fetchMock: ReturnType<typeof vi.fn>;
url: string;
allowHosts: string[];
expectedStatus: number;
resolveFn?: typeof publicResolve;
}) {
const res = await safeFetch({
url: params.url,
allowHosts: params.allowHosts,
fetchFn: params.fetchMock as unknown as typeof fetch,
resolveFn: params.resolveFn ?? publicResolve,
});
expect(res.status).toBe(params.expectedStatus);
await res.body?.cancel();
return res;
}
describe("msteams attachment allowlists", () => {
it("normalizes wildcard host lists", () => {
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
});
it("resolves a normalized attachment fetch policy", () => {
expect(
resolveAttachmentFetchPolicy({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
}),
).toEqual({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
});
});
it("allows Azure China Bot Framework attachment URLs with auth by default", () => {
const policy = resolveAttachmentFetchPolicy();
const url = "https://msteams.botframework.azure.cn/teams/v3/attachments/att-1/views/original";
const headers = new Headers();
expect(isUrlAllowed(url, policy.allowHosts)).toBe(true);
applyAuthorizationHeaderForUrl({
headers,
url,
authAllowHosts: policy.authAllowHosts,
bearerToken: "token-1",
});
expect(headers.get("Authorization")).toBe("Bearer token-1");
});
it("requires https and host suffix match", () => {
const allowHosts = resolveAllowedHosts(["sharepoint.com"]);
expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true);
expect(isUrlAllowed("http://contoso.sharepoint.com/file.png", allowHosts)).toBe(false);
expect(isUrlAllowed("https://evil.example.com/file.png", allowHosts)).toBe(false);
});
it("builds shared SSRF policy from suffix allowlist", () => {
expect(resolveMediaSsrfPolicy(["sharepoint.com"])).toEqual({
hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"],
});
expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined();
});
it.each([
["999.999.999.999", true],
["256.0.0.1", true],
["10.0.0.256", true],
["-1.0.0.1", false],
["1.2.3.4.5", false],
["0:0:0:0:0:0:0:1", true],
] as const)("malformed/expanded %s → %s (SDK fails closed)", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
});
// ─── resolveAndValidateIP ────────────────────────────────────────────────────
describe("resolveAndValidateIP", () => {
it("accepts a hostname resolving to a public IP", async () => {
const ip = await resolveAndValidateIP("teams.sharepoint.com", publicResolve);
expect(ip).toBe("13.107.136.10");
});
it("rejects a hostname resolving to 10.x.x.x", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("10.0.0.1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("rejects a hostname resolving to 169.254.169.254", async () => {
await expect(
resolveAndValidateIP("evil.test", privateResolve("169.254.169.254")),
).rejects.toThrow("private/reserved IP");
});
it("rejects a hostname resolving to loopback", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("127.0.0.1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("rejects a hostname resolving to IPv6 loopback", async () => {
await expect(resolveAndValidateIP("evil.test", privateResolve("::1"))).rejects.toThrow(
"private/reserved IP",
);
});
it("throws on DNS resolution failure", async () => {
await expect(resolveAndValidateIP("nonexistent.test", failingResolve)).rejects.toThrow(
"DNS resolution failed",
);
});
});
// ─── safeFetch ───────────────────────────────────────────────────────────────
describe("safeFetch", () => {
it("fetches a URL directly when no redirect occurs", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
await expectSafeFetchStatus({
fetchMock,
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
expectedStatus: 200,
});
expect(fetchMock).toHaveBeenCalledOnce();
// Should have used redirect: "manual"
expect(fetchInitAt(fetchMock, 0)).toHaveProperty("redirect", "manual");
});
it("pins the validated DNS result into the request dispatcher", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
await expectSafeFetchStatus({
fetchMock,
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
expectedStatus: 200,
});
expect(fetchInitAt(fetchMock, 0)).toHaveProperty("dispatcher");
});
it("follows a redirect to an allowlisted host with public IP", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
});
await expectSafeFetchStatus({
fetchMock,
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
expectedStatus: 200,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("fails explicitly for custom fetch functions that cannot receive the pinned dispatcher", async () => {
let called = false;
const customFetch = async () => {
called = true;
return new Response("ok", { status: 200 });
};
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: customFetch as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("fetchFnSupportsDispatcher");
expect(called).toBe(false);
});
it("returns the redirect response when dispatcher is provided by an outer guard", async () => {
const redirectedTo = "https://cdn.sharepoint.com/storage/file.pdf";
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": redirectedTo,
});
const res = await safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { dispatcher: {} } as RequestInit,
resolveFn: publicResolve,
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe(redirectedTo);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("still enforces allowlist checks before returning dispatcher-mode redirects", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { dispatcher: {} } as RequestInit,
resolveFn: publicResolve,
}),
).rejects.toThrow("blocked by allowlist");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("blocks a redirect to a non-allowlisted host", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("allowlist");
// Should not have fetched the evil URL
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("blocks a redirect to an allowlisted host that resolves to a private IP (DNS rebinding)", async () => {
let callCount = 0;
const rebindingResolve = async () => {
callCount++;
// First call (initial URL) resolves to public IP
if (callCount === 1) {
return { address: "13.107.136.10" };
}
// Second call (redirect target) resolves to private IP
return { address: "169.254.169.254" };
};
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file.pdf": "https://evil.trafficmanager.net/metadata",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com", "trafficmanager.net"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: rebindingResolve,
}),
).rejects.toThrow("private/internal");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("blocks when the initial URL resolves to a private IP", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://evil.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: privateResolve("10.0.0.1"),
}),
).rejects.toThrow("private/internal");
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks private hosts with the default resolver", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://localhost/file.pdf",
allowHosts: ["localhost"],
fetchFn: fetchMock as unknown as typeof fetch,
}),
).rejects.toThrow("private/internal");
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks when initial URL DNS resolution fails", async () => {
const fetchMock = vi.fn();
await expect(
safeFetch({
url: "https://nonexistent.sharepoint.com/file.pdf",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: failingResolve,
}),
).rejects.toThrow("DNS failure");
expect(fetchMock).not.toHaveBeenCalled();
});
it("follows multiple redirects when all are valid", async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === "https://a.sharepoint.com/1" && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: "https://b.sharepoint.com/2" },
});
}
if (url === "https://b.sharepoint.com/2" && init?.redirect === "manual") {
return new Response(null, {
status: 302,
headers: { location: "https://c.sharepoint.com/3" },
});
}
return new Response("final", { status: 200 });
});
const res = await safeFetch({
url: "https://a.sharepoint.com/1",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("throws on too many redirects", async () => {
let counter = 0;
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.redirect === "manual") {
counter++;
return new Response(null, {
status: 302,
headers: { location: `https://loop${counter}.sharepoint.com/x` },
});
}
return new Response("ok", { status: 200 });
});
await expect(
safeFetch({
url: "https://start.sharepoint.com/x",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("Too many redirects");
});
it("blocks redirect to HTTP (non-HTTPS)", async () => {
const fetchMock = mockFetchWithRedirect({
"https://teams.sharepoint.com/file": "http://internal.sharepoint.com/file",
});
await expect(
safeFetch({
url: "https://teams.sharepoint.com/file",
allowHosts: ["sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
}),
).rejects.toThrow("https");
});
it("strips authorization across redirects outside auth allowlist", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const auth = new Headers(init?.headers).get("authorization") ?? "";
seenAuth.push(`${url}|${auth}`);
if (url === "https://graph.microsoft.com/v1.0/me/photo") {
return new Response(null, {
status: 302,
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
});
}
return new Response("ok", { status: 200 });
});
const headers = new Headers({ Authorization: "Bearer secret" });
const res = await safeFetch({
url: "https://graph.microsoft.com/v1.0/me/photo",
allowHosts: ["graph.microsoft.com", "sharepoint.com"],
authorizationAllowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(seenAuth[0]).toContain("Bearer secret");
expect(seenAuth[1]).toMatch(/\|$/);
});
it("keeps authorization across redirects inside auth allowlist", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const auth = new Headers(init?.headers).get("authorization") ?? "";
seenAuth.push(`${url}|${auth}`);
if (url === "https://graph.microsoft.com/file.pdf") {
return new Response(null, {
status: 302,
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
});
}
return new Response("ok", { status: 200 });
});
const headers = new Headers({ Authorization: "Bearer secret" });
const res = await safeFetch({
url: "https://graph.microsoft.com/file.pdf",
allowHosts: ["graph.microsoft.com", "sharepoint.com"],
authorizationAllowHosts: ["graph.microsoft.com", "sharepoint.com"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(seenAuth[0]).toContain("Bearer secret");
expect(seenAuth[1]).toContain("Bearer secret");
});
it("keeps authorization across HTTPS redirects when auth allowlist is wildcard", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const auth = new Headers(init?.headers).get("authorization") ?? "";
seenAuth.push(`${url}|${auth}`);
if (url === "https://graph.microsoft.com/file.pdf") {
return new Response(null, {
status: 302,
headers: { location: "https://cdn.example.com/storage/file.pdf" },
});
}
return new Response("ok", { status: 200 });
});
const headers = new Headers({ Authorization: "Bearer secret" });
const res = await safeFetch({
url: "https://graph.microsoft.com/file.pdf",
allowHosts: ["*"],
authorizationAllowHosts: ["*"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(seenAuth[0]).toContain("Bearer secret");
expect(seenAuth[1]).toContain("Bearer secret");
});
it("strips authorization from the initial fetch outside auth allowlist", async () => {
const seenAuth: string[] = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
seenAuth.push(new Headers(init?.headers).get("authorization") ?? "");
expect(url).toBe("https://attacker.trafficmanager.net/v3/attachments/att-1");
return new Response("ok", { status: 200 });
});
const res = await safeFetch({
url: "https://attacker.trafficmanager.net/v3/attachments/att-1",
allowHosts: ["trafficmanager.net"],
authorizationAllowHosts: ["smba.trafficmanager.net"],
fetchFn: fetchMock as unknown as typeof fetch,
requestInit: { headers: { Authorization: "Bearer secret" } },
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
expect(seenAuth).toEqual([""]);
});
});
describe("attachment fetch auth helpers", () => {
it("sets and clears authorization header by auth allowlist", () => {
const headers = new Headers();
applyAuthorizationHeaderForUrl({
headers,
url: "https://graph.microsoft.com/v1.0/me",
authAllowHosts: ["graph.microsoft.com"],
bearerToken: "token-1",
});
expect(headers.get("authorization")).toBe("Bearer token-1");
applyAuthorizationHeaderForUrl({
headers,
url: "https://evil.example.com/collect",
authAllowHosts: ["graph.microsoft.com"],
bearerToken: "token-1",
});
expect(headers.get("authorization")).toBeNull();
});
it("safeFetchWithPolicy forwards policy allowlists", async () => {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
return new Response("ok", { status: 200 });
});
const res = await safeFetchWithPolicy({
url: "https://teams.sharepoint.com/file.pdf",
policy: resolveAttachmentFetchPolicy({
allowHosts: ["sharepoint.com"],
authAllowHosts: ["graph.microsoft.com"],
}),
fetchFn: fetchMock as unknown as typeof fetch,
resolveFn: publicResolve,
});
expect(res.status).toBe(200);
await res.body?.cancel();
expect(fetchMock).toHaveBeenCalledOnce();
});
});
describe("Graph shared-link helpers", () => {
it.each([
["https://contoso.sharepoint.com/personal/user/Documents/report.pdf", true],
["https://contoso.sharepoint.us/sites/team/file.docx", true],
["https://contoso.sharepoint.cn/file", true],
["https://tenant-my.sharepoint.com/:b:/g/personal/file", true],
["https://1drv.ms/b/s!AkxYabc", true],
["https://onedrive.live.com/view.aspx?resid=ABC", true],
["https://onedrive.com/share/abc", true],
["https://graph.microsoft.com/v1.0/me", false],
["https://smba.trafficmanager.net/amer/v3", false],
["https://example.com/file.pdf", false],
["not-a-url", false],
])("isGraphSharedLinkUrl(%s) === %s", (url, expected) => {
expect(isGraphSharedLinkUrl(url)).toBe(expected);
});
it("encodeGraphShareId uses u! + base64url without padding", () => {
// Graph docs example: encoding "https://onedrive.live.com/redir?resid=..."
// should yield u!aHR0cHM6... (base64url, no '+', '/', or trailing '=').
const url = "https://contoso.sharepoint.com/sites/a/Shared Documents/file.pdf";
const shareId = encodeGraphShareId(url);
expect(shareId.startsWith("u!")).toBe(true);
const encoded = shareId.slice(2);
// base64url alphabet is A-Z, a-z, 0-9, '-', '_' (no padding).
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
// Round-trip check: decoding yields the original URL.
const decoded = Buffer.from(encoded, "base64url").toString("utf8");
expect(decoded).toBe(url);
});
it("encodeGraphShareId swaps '+' and '/' for '-' and '_'", () => {
// A URL whose standard base64 contains '+' and '/' chars.
// Choose an input that base64 encodes with those characters.
const url = "https://host.sharepoint.com/sites/path?x=???";
const shareId = encodeGraphShareId(url);
const encoded = shareId.slice(2);
expect(encoded).not.toContain("+");
expect(encoded).not.toContain("/");
expect(encoded).not.toContain("=");
});
it("tryBuildGraphSharesUrlForSharedLink rewrites SharePoint URLs", () => {
const url = "https://contoso.sharepoint.com/personal/user/Documents/report.pdf";
const result = tryBuildGraphSharesUrlForSharedLink(url);
expect(result).toBe(
`https://graph.microsoft.com/v1.0/shares/${encodeGraphShareId(url)}/driveItem/content`,
);
});
it("tryBuildGraphSharesUrlForSharedLink rewrites OneDrive URLs", () => {
const url = "https://1drv.ms/b/s!AkxYabcdefg";
const result = tryBuildGraphSharesUrlForSharedLink(url);
expect(result).toBe(
`https://graph.microsoft.com/v1.0/shares/${encodeGraphShareId(url)}/driveItem/content`,
);
});
it("tryBuildGraphSharesUrlForSharedLink returns undefined for non-shared URLs", () => {
expect(
tryBuildGraphSharesUrlForSharedLink("https://graph.microsoft.com/v1.0/me"),
).toBeUndefined();
expect(tryBuildGraphSharesUrlForSharedLink("https://example.com/file.pdf")).toBeUndefined();
expect(tryBuildGraphSharesUrlForSharedLink("not-a-url")).toBeUndefined();
});
});
describe("msteams inline image limits", () => {
const smallPngDataUrl = "data:image/png;base64,aGVsbG8="; // "hello" (5 bytes)
it("rejects inline data images above per-image limit", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="${smallPngDataUrl}" />`,
},
];
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 4 });
expect(out).toStrictEqual([]);
});
it("accepts inline data images within limit", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="${smallPngDataUrl}" />`,
},
];
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 });
expect(out.length).toBe(1);
expect(out[0]?.kind).toBe("data");
if (out[0]?.kind === "data") {
expect(out[0].data.byteLength).toBeGreaterThan(0);
expect(out[0].contentType).toBe("image/png");
}
});
it("rejects inline data images with malformed base64 padding", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="data:image/png;base64,aGV=sbG8=" />`,
},
];
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 });
expect(out).toStrictEqual([]);
});
it("enforces cumulative inline size limit across attachments", () => {
const attachments = [
{
contentType: "text/html",
content: `<img src="${smallPngDataUrl}" />`,
},
{
contentType: "text/html",
content: `<img src="${smallPngDataUrl}" />`,
},
];
const out = extractInlineImageCandidates(attachments, {
maxInlineBytes: 10,
maxInlineTotalBytes: 6,
});
expect(out.length).toBe(1);
expect(out[0]?.kind).toBe("data");
});
});

View File

@@ -0,0 +1,730 @@
// Msteams plugin module implements shared behavior.
import { Buffer } from "node:buffer";
import { lookup } from "node:dns/promises";
import {
buildHostnameAllowlistPolicyFromSuffixAllowlist,
isHttpsUrlAllowedByHostnameSuffixAllowlist,
isPrivateIpAddress,
normalizeHostnameSuffixAllowlist,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-policy";
import { fetchWithSsrFGuard, type LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
import {
isRecord,
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { responseWithRelease } from "../response-with-release.js";
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
| {
kind: "data";
data: Buffer;
contentType?: string;
placeholder: string;
}
| {
kind: "url";
url: string;
contentType?: string;
fileHint?: string;
placeholder: string;
};
type InlineImageLimitOptions = {
maxInlineBytes?: number;
maxInlineTotalBytes?: number;
};
const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
export const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
export const ATTACHMENT_TAG_RE = /<attachment[^>]+id=["']([^"']+)["'][^>]*>/gi;
const DEFAULT_MEDIA_HOST_ALLOWLIST = [
"graph.microsoft.com",
"graph.microsoft.us",
"graph.microsoft.de",
"graph.microsoft.cn",
"sharepoint.com",
"sharepoint.us",
"sharepoint.de",
"sharepoint.cn",
"sharepoint-df.com",
"1drv.ms",
"onedrive.com",
"teams.microsoft.com",
"teams.cdn.office.net",
"statics.teams.cdn.office.net",
"office.com",
"office.net",
// Azure Media Services / Skype CDN for clipboard-pasted images
"asm.skype.com",
"ams.skype.com",
"media.ams.skype.com",
// Bot Framework attachment URLs
"trafficmanager.net",
"botframework.azure.cn",
"blob.core.windows.net",
"azureedge.net",
"microsoft.com",
] as const;
const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
"api.botframework.com",
"botframework.com",
// Bot Framework Service URL (smba.trafficmanager.net) used for outbound
// replies and inbound attachment downloads (clipboard-pasted images).
"smba.trafficmanager.net",
"botframework.azure.cn",
"graph.microsoft.com",
"graph.microsoft.us",
"graph.microsoft.de",
"graph.microsoft.cn",
] as const;
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
export { isRecord };
// Keep this local; importing the broad media-runtime SDK barrel pulls image/audio runtimes into
// hot MSTeams attachment tests for one tiny estimator.
export function estimateBase64DecodedBytes(base64: string): number {
let effectiveLen = 0;
for (let i = 0; i < base64.length; i += 1) {
const code = base64.charCodeAt(i);
if (code <= 0x20) {
continue;
}
effectiveLen += 1;
}
if (effectiveLen === 0) {
return 0;
}
let padding = 0;
let end = base64.length - 1;
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
end -= 1;
}
if (end >= 0 && base64[end] === "=") {
padding = 1;
end -= 1;
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
end -= 1;
}
if (end >= 0 && base64[end] === "=") {
padding = 2;
}
}
const estimated = Math.floor((effectiveLen * 3) / 4) - padding;
return Math.max(0, estimated);
}
/**
* Host suffixes for SharePoint/OneDrive shared links that must be fetched via
* the Graph `/shares/{shareId}/driveItem/content` endpoint instead of directly.
*
* Direct fetches of SharePoint/OneDrive shared URLs return empty/HTML landing
* pages unless encoded as a Graph share id. See
* https://learn.microsoft.com/en-us/graph/api/shares-get for the encoding.
*/
const GRAPH_SHARED_LINK_HOST_SUFFIXES = [
".sharepoint.com",
".sharepoint.us",
".sharepoint.de",
".sharepoint.cn",
".sharepoint-df.com",
"1drv.ms",
"onedrive.live.com",
"onedrive.com",
] as const;
/**
* Returns true when the URL points at a SharePoint or OneDrive host whose
* shared-link content must be fetched through the Graph shares API rather
* than directly.
*/
export function isGraphSharedLinkUrl(url: string): boolean {
let host: string;
try {
host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
} catch {
return false;
}
if (!host) {
return false;
}
return GRAPH_SHARED_LINK_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(suffix));
}
/**
* Encode a SharePoint/OneDrive URL as a Graph shareId using the documented
* `u!` + base64url (no padding) scheme:
* https://learn.microsoft.com/en-us/graph/api/shares-get#encoding-sharing-urls
*/
export function encodeGraphShareId(url: string): string {
// Buffer.from(...).toString("base64url") already returns base64url without
// padding, matching the Graph spec exactly.
return `u!${Buffer.from(url, "utf8").toString("base64url")}`;
}
/**
* When `url` is a SharePoint/OneDrive shared link, return the matching
* `GET /shares/{shareId}/driveItem/content` URL that actually yields the file
* bytes. Returns `undefined` for non-shared-link URLs so callers can fall
* through to the existing fetch path.
*/
export function tryBuildGraphSharesUrlForSharedLink(url: string): string | undefined {
if (!isGraphSharedLinkUrl(url)) {
return undefined;
}
return `${GRAPH_ROOT}/shares/${encodeGraphShareId(url)}/driveItem/content`;
}
export function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
let current: unknown = value;
for (const key of keys) {
if (!isRecord(current)) {
return undefined;
}
current = current[key as keyof typeof current];
}
return normalizeOptionalString(current);
}
export function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
return input.url;
}
try {
return JSON.stringify(input);
} catch {
return "";
}
}
export function normalizeContentType(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function inferPlaceholder(params: {
contentType?: string;
fileName?: string;
fileType?: string;
}): string {
const mime = normalizeLowercaseStringOrEmpty(params.contentType ?? "");
const name = normalizeLowercaseStringOrEmpty(params.fileName ?? "");
const fileType = normalizeLowercaseStringOrEmpty(params.fileType ?? "");
const looksLikeImage =
mime.startsWith("image/") || IMAGE_EXT_RE.test(name) || IMAGE_EXT_RE.test(`x.${fileType}`);
return looksLikeImage ? "<media:image>" : "<media:document>";
}
export 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;
}
/**
* Returns true if the attachment can be downloaded (any file type).
* Used when downloading all files, not just images.
*/
export function isDownloadableAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
// Teams file download info always has a downloadUrl
if (
contentType === "application/vnd.microsoft.teams.file.download.info" &&
isRecord(att.content) &&
typeof att.content.downloadUrl === "string"
) {
return true;
}
// Any attachment with a contentUrl can be downloaded
if (typeof att.contentUrl === "string" && att.contentUrl.trim()) {
return true;
}
return false;
}
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
return contentType.startsWith("text/html");
}
export 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 canonicalizeInlineBase64Payload(value: string): string | undefined {
let cleaned = "";
let padding = 0;
let sawPadding = false;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x20) {
continue;
}
if (code === 0x3d) {
padding += 1;
if (padding > 2) {
return undefined;
}
sawPadding = true;
cleaned += "=";
continue;
}
const isDataChar =
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a) ||
(code >= 0x30 && code <= 0x39) ||
code === 0x2b ||
code === 0x2f;
if (sawPadding || !isDataChar) {
return undefined;
}
cleaned += value[index];
}
return cleaned && cleaned.length % 4 === 0 ? cleaned : undefined;
}
function decodeDataImageWithLimits(
src: string,
opts: { maxInlineBytes?: number },
): { candidate: InlineImageCandidate | null; estimatedBytes: number } {
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
if (!match) {
return { candidate: null, estimatedBytes: 0 };
}
const contentType = normalizeLowercaseStringOrEmpty(match[1] ?? "");
const isBase64 = Boolean(match[2]);
if (!isBase64) {
return { candidate: null, estimatedBytes: 0 };
}
const payload = match[3] ?? "";
const canonicalPayload = canonicalizeInlineBase64Payload(payload);
if (!canonicalPayload) {
return { candidate: null, estimatedBytes: 0 };
}
const estimatedBytes = estimateBase64DecodedBytes(canonicalPayload);
if (estimatedBytes <= 0) {
return { candidate: null, estimatedBytes: 0 };
}
if (typeof opts.maxInlineBytes === "number" && estimatedBytes > opts.maxInlineBytes) {
return { candidate: null, estimatedBytes };
}
try {
const data = Buffer.from(canonicalPayload, "base64");
return {
candidate: { kind: "data", data, contentType, placeholder: "<media:image>" },
estimatedBytes,
};
} catch {
return { candidate: null, estimatedBytes: 0 };
}
}
function fileHintFromUrl(src: string): string | undefined {
try {
const url = new URL(src);
const name = url.pathname.split("/").pop();
return name || undefined;
} catch {
return undefined;
}
}
export function extractInlineImageCandidates(
attachments: MSTeamsAttachmentLike[],
limits?: InlineImageLimitOptions,
): InlineImageCandidate[] {
const out: InlineImageCandidate[] = [];
let totalEstimatedInlineBytes = 0;
outerLoop: for (const att of attachments) {
const html = extractHtmlFromAttachment(att);
if (!html) {
continue;
}
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
const src = match[1]?.trim();
if (src && !src.startsWith("cid:")) {
if (src.startsWith("data:")) {
const { candidate: decoded, estimatedBytes } = decodeDataImageWithLimits(src, {
maxInlineBytes: limits?.maxInlineBytes,
});
if (decoded) {
const nextTotal = totalEstimatedInlineBytes + estimatedBytes;
if (
typeof limits?.maxInlineTotalBytes === "number" &&
nextTotal > limits.maxInlineTotalBytes
) {
break outerLoop;
}
totalEstimatedInlineBytes = nextTotal;
out.push(decoded);
}
} else {
out.push({
kind: "url",
url: src,
fileHint: fileHintFromUrl(src),
placeholder: "<media:image>",
});
}
}
match = IMG_SRC_RE.exec(html);
}
}
return out;
}
export function safeHostForUrl(url: string): string {
try {
return normalizeLowercaseStringOrEmpty(new URL(url).hostname);
} catch {
return "invalid-url";
}
}
export function resolveAllowedHosts(input?: string[]): string[] {
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_HOST_ALLOWLIST);
}
export function resolveAuthAllowedHosts(input?: string[]): string[] {
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
}
export type MSTeamsAttachmentFetchPolicy = {
allowHosts: string[];
authAllowHosts: string[];
};
/**
* Logger surface for attachment download errors. Structured so callers can
* pass `MSTeamsMonitorLogger` directly without adapters. Optional `warn`/
* `error` methods prevent silent swallowing of fetch failures — see issue
* #63396 where empty `catch {}` blocks hid a Node 24+ undici incompatibility.
*/
export type MSTeamsAttachmentDownloadLogger = {
warn?: (message: string, meta?: Record<string, unknown>) => void;
error?: (message: string, meta?: Record<string, unknown>) => void;
};
export type MSTeamsAttachmentResolveFn = (hostname: string) => Promise<{ address: string }>;
function isMockFetchFn(fetchFn: typeof fetch): boolean {
const candidate = fetchFn as unknown as { mock?: unknown };
return Boolean(candidate.mock || Object.hasOwn(candidate, "_isMockFunction"));
}
function resolveGuardedFetchImpl(params: {
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
}): typeof fetch | undefined {
if (!params.fetchFn) {
return undefined;
}
if (
params.fetchFnSupportsDispatcher === true ||
params.fetchFn === fetch ||
params.fetchFn === globalThis.fetch ||
isMockFetchFn(params.fetchFn)
) {
return params.fetchFn;
}
throw new Error(
"MSTeams attachment fetchFn must set fetchFnSupportsDispatcher to use guarded DNS pinning",
);
}
function resolveRetainedAuthorizationRedirectHostnameAllowlist(
input?: string[],
): string[] | undefined {
if (!input) {
return undefined;
}
if (input.includes("*")) {
return ["*"];
}
return resolveMediaSsrfPolicy(input)?.hostnameAllowlist;
}
export function resolveAttachmentFetchPolicy(params?: {
allowHosts?: string[];
authAllowHosts?: string[];
}): MSTeamsAttachmentFetchPolicy {
return {
allowHosts: resolveAllowedHosts(params?.allowHosts),
authAllowHosts: resolveAuthAllowedHosts(params?.authAllowHosts),
};
}
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
}
export function applyAuthorizationHeaderForUrl(params: {
headers: Headers;
url: string;
authAllowHosts: string[];
bearerToken?: string;
}): void {
if (!params.bearerToken) {
params.headers.delete("Authorization");
return;
}
if (isUrlAllowed(params.url, params.authAllowHosts)) {
params.headers.set("Authorization", `Bearer ${params.bearerToken}`);
return;
}
params.headers.delete("Authorization");
}
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
}
/**
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
* or link-local range that must never be reached from media downloads.
*
* Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
* expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
* parse errors.
*/
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
/**
* Resolve a hostname via DNS and reject private/reserved IPs.
* Throws if the resolved IP is private or resolution fails.
*/
export async function resolveAndValidateIP(
hostname: string,
resolveFn?: MSTeamsAttachmentResolveFn,
): Promise<string> {
const resolve = resolveFn ?? lookup;
let resolved: { address: string };
try {
resolved = await resolve(hostname);
} catch {
throw new Error(`DNS resolution failed for "${hostname}"`);
}
if (isPrivateOrReservedIP(resolved.address)) {
throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
}
return resolved.address;
}
/** Maximum number of redirects to follow in safeFetch. */
const MAX_SAFE_REDIRECTS = 5;
/**
* Fetch a URL with redirect: "manual", validating each redirect target
* against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).
*
* This prevents:
* - Auto-following redirects to non-allowlisted hosts
* - DNS rebinding attacks when a lookup function is provided
*/
export async function safeFetch(params: {
url: string;
allowHosts: string[];
/**
* Optional allowlist for forwarding Authorization across redirects.
* When set, Authorization is stripped before following redirects to hosts
* outside this list.
*/
authorizationAllowHosts?: string[];
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
requestInit?: RequestInit;
resolveFn?: MSTeamsAttachmentResolveFn;
}): Promise<Response> {
const resolveFn = params.resolveFn ?? lookup;
const hasDispatcher = Boolean(
params.requestInit &&
typeof params.requestInit === "object" &&
"dispatcher" in (params.requestInit as Record<string, unknown>),
);
const currentHeaders = new Headers(params.requestInit?.headers);
let currentUrl = params.url;
if (!isUrlAllowed(currentUrl, params.allowHosts)) {
throw new Error(`Initial download URL blocked: ${currentUrl}`);
}
// Authorization is only allowed on explicitly auth-allowlisted hosts, including
// the first hop. Redirect hops apply the same rule below or in fetchWithSsrFGuard.
if (
currentHeaders.has("authorization") &&
params.authorizationAllowHosts &&
!isUrlAllowed(currentUrl, params.authorizationAllowHosts)
) {
currentHeaders.delete("authorization");
}
if (!hasDispatcher) {
const guarded = await fetchWithSsrFGuard({
url: currentUrl,
fetchImpl: resolveGuardedFetchImpl({
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
}),
init: {
...params.requestInit,
headers: currentHeaders,
},
maxRedirects: MAX_SAFE_REDIRECTS,
requireHttps: true,
policy: resolveMediaSsrfPolicy(params.allowHosts),
lookupFn: resolveFn as LookupFn,
retainAuthorizationRedirectHostnameAllowlist:
resolveRetainedAuthorizationRedirectHostnameAllowlist(params.authorizationAllowHosts),
auditContext: "msteams.attachment",
});
return responseWithRelease(guarded.response, guarded.release);
}
if (resolveFn) {
try {
const initialHost = new URL(currentUrl).hostname;
await resolveAndValidateIP(initialHost, resolveFn);
} catch {
throw new Error(`Initial download URL blocked: ${currentUrl}`);
}
}
for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
const res = await (params.fetchFn ?? fetch)(currentUrl, {
...params.requestInit,
headers: currentHeaders,
redirect: "manual",
});
if (![301, 302, 303, 307, 308].includes(res.status)) {
return res;
}
const location = res.headers.get("location");
if (!location) {
return res;
}
let redirectUrl: string;
try {
redirectUrl = new URL(location, currentUrl).toString();
} catch {
throw new Error(`Invalid redirect URL: ${location}`);
}
// Validate redirect target against hostname allowlist
if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
}
// Prevent credential bleed: only keep Authorization on redirect hops that
// are explicitly auth-allowlisted.
if (
currentHeaders.has("authorization") &&
params.authorizationAllowHosts &&
!isUrlAllowed(redirectUrl, params.authorizationAllowHosts)
) {
currentHeaders.delete("authorization");
}
// When a pinned dispatcher is already injected by an upstream guard
// (for example fetchWithSsrFGuard), let that guard own redirect handling
// after this allowlist validation step.
if (hasDispatcher) {
return res;
}
// Validate redirect target's resolved IP
if (resolveFn) {
const redirectHost = new URL(redirectUrl).hostname;
await resolveAndValidateIP(redirectHost, resolveFn);
}
currentUrl = redirectUrl;
}
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
}
export async function safeFetchWithPolicy(params: {
url: string;
policy: MSTeamsAttachmentFetchPolicy;
fetchFn?: typeof fetch;
fetchFnSupportsDispatcher?: boolean;
requestInit?: RequestInit;
resolveFn?: MSTeamsAttachmentResolveFn;
}): Promise<Response> {
return await safeFetch({
url: params.url,
allowHosts: params.policy.allowHosts,
authorizationAllowHosts: params.policy.authAllowHosts,
fetchFn: params.fetchFn,
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
requestInit: params.requestInit,
resolveFn: params.resolveFn,
});
}

View File

@@ -0,0 +1,49 @@
// Msteams type declarations define plugin contracts.
export type MSTeamsAttachmentLike = {
id?: string | null;
contentType?: string | null;
contentUrl?: string | null;
name?: string | null;
thumbnailUrl?: string | null;
content?: unknown;
};
export type MSTeamsAccessTokenProvider = {
getAccessToken: (scope: string) => Promise<string>;
};
export type MSTeamsInboundMedia = {
path: string;
contentType?: string;
placeholder: string;
};
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;
};
/**
* Narrow logger surface used by `downloadMSTeamsGraphMedia` for diagnostic
* events. Accepting an optional callback keeps the helper testable without
* pulling in the full channel logger type, while still allowing the monitor
* handler to forward its plugin logger.
*/
export type MSTeamsGraphMediaLogger = {
debug?: (message: string, meta?: Record<string, unknown>) => void;
};

View File

@@ -0,0 +1,165 @@
/**
* Auth coverage tests for the SDK migration (#76262 reviewer ask from
* @BradGroux). Locks in three contract guarantees that the SDK's built-in
* JWT validation must satisfy:
*
* 1. Inbound Bot Framework tokens with `aud=<bot app id>` are accepted.
* 2. Inbound tokens with `aud=https://api.botframework.com` are rejected,
* even when the `appid` claim matches the bot. That audience belongs to
* the SMBA/ABS Connector resource (token issued *for* the Connector);
* accepting it inbound on the bot would be a confused-deputy that
* contradicts the Entra audience-validation guidance.
* 3. The 2.0.10 SDK bump's v1-issuer support is exercised: Entra tokens
* issued by the legacy `https://sts.windows.net/{tenantId}/` endpoint
* are accepted alongside the v2 `https://login.microsoftonline.com/...`
* endpoint when `allowedTenantIds` is configured.
*
* The tests reach into `@microsoft/teams.apps`'s internal middleware/auth
* subpath to drive `ServiceTokenValidator` and `createEntraTokenValidator`
* directly. Those aren't part of the SDK's public barrel today; if they
* shift in a future SDK release this file lights up clearly. We chose this
* over standing up an Express + supertest harness because the contract being
* tested is purely the validator's accept/reject behavior — the surrounding
* HTTP plumbing is a separate concern covered by `monitor.lifecycle.test.ts`.
*
* `JwksClient.prototype.getSigningKey` is patched to return a single
* in-memory test public key so we don't hit `login.botframework.com` /
* `login.microsoftonline.com` during the test. `jose` (devDep) mints RS256
* tokens against the matching private key.
*/
// Internal subpath imports. See file header for the rationale.
import { createEntraTokenValidator } from "@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js";
import { ServiceTokenValidator } from "@microsoft/teams.apps/dist/middleware/auth/service-token-validator.js";
import type { ILogger } from "@microsoft/teams.common";
import { exportSPKI, generateKeyPair, SignJWT } from "jose";
import { JwksClient, type SigningKey } from "jwks-rsa";
import { beforeAll, describe, expect, it, vi } from "vitest";
const APP_ID = "test-app-id";
const TENANT_ID = "test-tenant-id";
const TEST_KID = "test-key-id";
let privateKey: CryptoKey;
let publicPem: string;
async function mintToken(claims: Record<string, unknown>): Promise<string> {
return await new SignJWT(claims)
.setProtectedHeader({ alg: "RS256", kid: TEST_KID })
.setIssuedAt()
.setExpirationTime("1h")
.sign(privateKey);
}
beforeAll(async () => {
const { publicKey, privateKey: priv } = await generateKeyPair("RS256", {
modulusLength: 2048,
});
privateKey = priv;
publicPem = await exportSPKI(publicKey);
// Patch `JwksClient.prototype.getSigningKeys` so every JWKS lookup the SDK
// performs returns our in-memory test key instead of fetching from
// `login.botframework.com` / `login.microsoftonline.com` while preserving
// the package's callback/promise getSigningKey wrapper behavior.
vi.spyOn(JwksClient.prototype, "getSigningKeys").mockResolvedValue([
{
kid: TEST_KID,
alg: "RS256",
getPublicKey: () => publicPem,
rsaPublicKey: publicPem,
} as SigningKey,
]);
});
// Logger that surfaces SDK validation failures so we can see *why* a token
// was rejected when the test fails. `error` is what the SDK uses for
// rejection reasons; the rest are no-ops to keep the test output clean.
const debugLogger: ILogger = {
child: () => debugLogger,
debug: () => {},
info: () => {},
error: (...args: unknown[]) => console.error("[sdk]", ...args),
warn: () => {},
log: () => {},
trace: () => {},
};
describe("ServiceTokenValidator (inbound Bot Framework)", () => {
it("accepts a token whose audience matches the bot app id", async () => {
const validator = new ServiceTokenValidator(APP_ID, undefined, undefined, debugLogger);
const token = await mintToken({
aud: APP_ID,
iss: "https://api.botframework.com",
});
const result = await validator.check(`Bearer ${token}`, { id: "activity-1" });
expect(result.appId).toBe(APP_ID);
});
it("rejects a token with aud=api.botframework.com even when the appid claim matches the bot", async () => {
const validator = new ServiceTokenValidator(APP_ID);
// This is the confused-deputy shape: the token was issued *for* the
// Connector resource (`aud=https://api.botframework.com`) and happens to
// carry the bot's app id in `appid`. The SDK must reject it on the
// audience check before any appid/azp logic runs.
const token = await mintToken({
aud: "https://api.botframework.com",
iss: "https://api.botframework.com",
appid: APP_ID,
azp: APP_ID,
});
await expect(validator.check(`Bearer ${token}`, { id: "activity-2" })).rejects.toThrow();
});
});
describe("createEntraTokenValidator (Entra access tokens — SDK 2.0.10 v1 issuer fix)", () => {
it("accepts the v1 sts.windows.net issuer for an allowed tenant", async () => {
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
allowedTenantIds: [TENANT_ID],
});
const token = await mintToken({
aud: APP_ID,
iss: `https://sts.windows.net/${TENANT_ID}/`,
});
const payload = await validator.validateAccessToken(token);
expect(payload).not.toBeNull();
expect(payload?.iss).toBe(`https://sts.windows.net/${TENANT_ID}/`);
});
it("accepts the v2 login.microsoftonline.com issuer for an allowed tenant", async () => {
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
allowedTenantIds: [TENANT_ID],
});
const token = await mintToken({
aud: APP_ID,
iss: `https://login.microsoftonline.com/${TENANT_ID}/v2.0`,
});
const payload = await validator.validateAccessToken(token);
expect(payload).not.toBeNull();
});
it("rejects an issuer for a tenant that is not allowed", async () => {
const validator = createEntraTokenValidator(TENANT_ID, APP_ID, {
allowedTenantIds: [TENANT_ID],
});
const token = await mintToken({
aud: APP_ID,
iss: `https://sts.windows.net/some-other-tenant-id/`,
});
// The SDK's `validateAccessToken` resolves to `null` (rather than
// throwing) when issuer/audience/signature checks fail. The contract we
// care about is "this token does not yield a payload" — both shapes are
// valid rejections; we just want to be sure a non-allowed tenant does
// not produce a usable payload.
const payload = await validator.validateAccessToken(token);
expect(payload).toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
// Msteams tests cover block streaming config plugin behavior.
import { describe, expect, it } from "vitest";
import { MSTeamsConfigSchema } from "../config-api.js";
describe("MSTeamsConfigSchema blockStreaming", () => {
const baseConfig = {
enabled: true,
dmPolicy: "open" as const,
allowFrom: ["*"],
};
it("accepts blockStreaming: true", () => {
const result = MSTeamsConfigSchema.safeParse({
...baseConfig,
blockStreaming: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.blockStreaming).toBe(true);
}
});
it("accepts blockStreaming: false", () => {
const result = MSTeamsConfigSchema.safeParse({
...baseConfig,
blockStreaming: false,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.blockStreaming).toBe(false);
}
});
it("accepts config without blockStreaming (optional)", () => {
const result = MSTeamsConfigSchema.safeParse(baseConfig);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.blockStreaming).toBeUndefined();
}
});
it("accepts blockStreaming alongside blockStreamingCoalesce", () => {
const result = MSTeamsConfigSchema.safeParse({
...baseConfig,
blockStreaming: true,
blockStreamingCoalesce: { minChars: 100, idleMs: 500 },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.blockStreaming).toBe(true);
expect(result.data.blockStreamingCoalesce).toEqual({ minChars: 100, idleMs: 500 });
}
});
it("rejects non-boolean blockStreaming", () => {
const result = MSTeamsConfigSchema.safeParse({
...baseConfig,
blockStreaming: "yes",
});
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,58 @@
// Msteams plugin module implements bot framework service url behavior.
import {
isHttpsUrlAllowedByHostnameSuffixAllowlist,
normalizeHostnameSuffixAllowlist,
} from "openclaw/plugin-sdk/ssrf-policy";
const DEFAULT_BOT_FRAMEWORK_SERVICE_URL_HOST_ALLOWLIST = [
// Microsoft Teams Bot Framework serviceUrl endpoints documented for
// commercial, GCC, GCC High, and DOD clouds. Azure China Bot Framework
// documents *.botframework.azure.cn as the channel boundary for 21Vianet.
// These are the only hosts that may receive Bot Framework service tokens.
"smba.trafficmanager.net",
"smba.infra.gcc.teams.microsoft.com",
"smba.infra.gov.teams.microsoft.us",
"smba.infra.dod.teams.microsoft.us",
"botframework.azure.cn",
] as const;
export const BOT_FRAMEWORK_SERVICE_URL_HOST_ALLOWLIST = normalizeHostnameSuffixAllowlist(
DEFAULT_BOT_FRAMEWORK_SERVICE_URL_HOST_ALLOWLIST,
);
export function describeBotFrameworkServiceUrlHost(serviceUrl: string): string {
try {
const parsed = new URL(serviceUrl.trim());
return parsed.hostname || "invalid-url";
} catch {
return "invalid-url";
}
}
export function isAllowedBotFrameworkServiceUrl(serviceUrl: unknown): serviceUrl is string {
if (typeof serviceUrl !== "string") {
return false;
}
const trimmed = serviceUrl.trim();
return Boolean(
trimmed &&
isHttpsUrlAllowedByHostnameSuffixAllowlist(trimmed, BOT_FRAMEWORK_SERVICE_URL_HOST_ALLOWLIST),
);
}
export function tryNormalizeBotFrameworkServiceUrl(serviceUrl: unknown): string | undefined {
if (!isAllowedBotFrameworkServiceUrl(serviceUrl)) {
return undefined;
}
return serviceUrl.trim().replace(/\/+$/, "");
}
export function normalizeBotFrameworkServiceUrl(serviceUrl: string): string {
const normalized = tryNormalizeBotFrameworkServiceUrl(serviceUrl);
if (normalized) {
return normalized;
}
throw new Error(
`Blocked Microsoft Teams serviceUrl host: ${describeBotFrameworkServiceUrlHost(serviceUrl)}`,
);
}

View File

@@ -0,0 +1,2 @@
// Msteams API module exposes the plugin public contract.
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";

View File

@@ -0,0 +1,965 @@
// Msteams tests cover channel.actions plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { msteamsPlugin } from "./channel.js";
const {
addParticipantMSTeamsMock,
editMessageMSTeamsMock,
deleteMessageMSTeamsMock,
getChannelInfoMSTeamsMock,
getMemberInfoMSTeamsMock,
getMessageMSTeamsMock,
listChannelsMSTeamsMock,
listReactionsMSTeamsMock,
pinMessageMSTeamsMock,
reactMessageMSTeamsMock,
removeParticipantMSTeamsMock,
renameGroupMSTeamsMock,
searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeamsMock,
sendMessageMSTeamsMock,
unpinMessageMSTeamsMock,
} = vi.hoisted(() => ({
addParticipantMSTeamsMock: vi.fn(),
editMessageMSTeamsMock: vi.fn(),
deleteMessageMSTeamsMock: vi.fn(),
getChannelInfoMSTeamsMock: vi.fn(),
getMemberInfoMSTeamsMock: vi.fn(),
getMessageMSTeamsMock: vi.fn(),
listChannelsMSTeamsMock: vi.fn(),
listReactionsMSTeamsMock: vi.fn(),
pinMessageMSTeamsMock: vi.fn(),
reactMessageMSTeamsMock: vi.fn(),
removeParticipantMSTeamsMock: vi.fn(),
renameGroupMSTeamsMock: vi.fn(),
searchMessagesMSTeamsMock: vi.fn(),
sendAdaptiveCardMSTeamsMock: vi.fn(),
sendMessageMSTeamsMock: vi.fn(),
unpinMessageMSTeamsMock: vi.fn(),
}));
vi.mock("./channel.runtime.js", () => ({
msTeamsChannelRuntime: {
addParticipantMSTeams: addParticipantMSTeamsMock,
editMessageMSTeams: editMessageMSTeamsMock,
deleteMessageMSTeams: deleteMessageMSTeamsMock,
getChannelInfoMSTeams: getChannelInfoMSTeamsMock,
getMemberInfoMSTeams: getMemberInfoMSTeamsMock,
getMessageMSTeams: getMessageMSTeamsMock,
listChannelsMSTeams: listChannelsMSTeamsMock,
listReactionsMSTeams: listReactionsMSTeamsMock,
pinMessageMSTeams: pinMessageMSTeamsMock,
reactMessageMSTeams: reactMessageMSTeamsMock,
removeParticipantMSTeams: removeParticipantMSTeamsMock,
renameGroupMSTeams: renameGroupMSTeamsMock,
searchMessagesMSTeams: searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeams: sendAdaptiveCardMSTeamsMock,
sendMessageMSTeams: sendMessageMSTeamsMock,
unpinMessageMSTeams: unpinMessageMSTeamsMock,
},
}));
const actionMocks = [
addParticipantMSTeamsMock,
editMessageMSTeamsMock,
deleteMessageMSTeamsMock,
getChannelInfoMSTeamsMock,
getMemberInfoMSTeamsMock,
getMessageMSTeamsMock,
listChannelsMSTeamsMock,
listReactionsMSTeamsMock,
pinMessageMSTeamsMock,
reactMessageMSTeamsMock,
removeParticipantMSTeamsMock,
renameGroupMSTeamsMock,
searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeamsMock,
sendMessageMSTeamsMock,
unpinMessageMSTeamsMock,
];
const currentChannelId = "conversation:19:ctx@thread.tacv2";
const reactChannelId = "conversation:19:react@thread.tacv2";
const targetChannelId = "conversation:19:target@thread.tacv2";
const editedConversationId = "19:edited@thread.tacv2";
const editedMessageId = "msg-edit-1";
const readMessage = { id: "msg-1", text: "hello" };
const reactionType = "like";
const updatedText = "updated text";
const reactionTypes = ["like", "heart", "laugh", "surprised", "sad", "angry"];
const deleteMissingTargetError = "Delete requires a target (to) and messageId.";
const reactionsMissingTargetError = "Reactions requires a target (to) and messageId.";
const presentationSendMissingTargetError = "Card send requires a target (to).";
const reactMissingEmojiError =
"React requires an emoji (reaction type). Valid types: like, heart, laugh, surprised, sad, angry.";
const reactMissingEmojiDetail = "React requires an emoji (reaction type).";
const searchMissingQueryError = "Search requires a target (to) and query.";
const groupManagementAuthError =
"Microsoft Teams group management requires an owner or operator.admin requester.";
function padded(value: string) {
return ` ${value} `;
}
function msteamsActionDetails(action: string, details?: Record<string, unknown>) {
return {
channel: "msteams",
action,
...details,
};
}
function okMSTeamsActionDetails(action: string, details?: Record<string, unknown>) {
return msteamsActionDetails(action, { ok: true, ...details });
}
function requireMSTeamsHandleAction() {
const handleAction = msteamsPlugin.actions?.handleAction;
if (!handleAction) {
throw new Error("msteams actions.handleAction unavailable");
}
return handleAction;
}
async function runAction(params: {
action: string;
cfg?: Record<string, unknown>;
params?: Record<string, unknown>;
toolContext?: Record<string, unknown>;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
requesterSenderId?: string | null;
senderIsOwner?: boolean;
gatewayClientScopes?: readonly string[];
}) {
const handleAction = requireMSTeamsHandleAction();
return await handleAction({
channel: "msteams",
action: params.action,
cfg: params.cfg ?? {},
params: params.params ?? {},
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
toolContext: params.toolContext,
requesterSenderId: params.requesterSenderId,
senderIsOwner: params.senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
} as Parameters<ReturnType<typeof requireMSTeamsHandleAction>>[0]);
}
async function expectActionError(
params: Parameters<typeof runAction>[0],
expectedMessage: string,
expectedDetails?: Record<string, unknown>,
) {
await expect(runAction(params)).resolves.toEqual({
isError: true,
content: [{ type: "text", text: expectedMessage }],
details: expectedDetails ?? { error: expectedMessage },
});
}
async function expectActionParamError(
action: Parameters<typeof runAction>[0]["action"],
params: Record<string, unknown>,
expectedMessage: string,
expectedDetails?: Record<string, unknown>,
) {
await expectActionError({ action, params }, expectedMessage, expectedDetails);
}
function expectActionSuccess(
result: Awaited<ReturnType<typeof runAction>>,
details: Record<string, unknown>,
contentDetails: Record<string, unknown> = details,
) {
expect(result).toEqual({
content: [
{
type: "text",
text: JSON.stringify(contentDetails),
},
],
details,
});
}
function expectActionRuntimeCall(
mockFn: ReturnType<typeof vi.fn>,
params: Record<string, unknown>,
) {
expect(mockFn).toHaveBeenCalledWith({
cfg: {},
...params,
});
}
async function expectSuccessfulAction(params: {
mockFn: ReturnType<typeof vi.fn>;
mockResult: unknown;
action: Parameters<typeof runAction>[0]["action"];
actionParams?: Parameters<typeof runAction>[0]["params"];
toolContext?: Parameters<typeof runAction>[0]["toolContext"];
mediaLocalRoots?: Parameters<typeof runAction>[0]["mediaLocalRoots"];
mediaReadFile?: Parameters<typeof runAction>[0]["mediaReadFile"];
requesterSenderId?: Parameters<typeof runAction>[0]["requesterSenderId"];
senderIsOwner?: Parameters<typeof runAction>[0]["senderIsOwner"];
gatewayClientScopes?: Parameters<typeof runAction>[0]["gatewayClientScopes"];
runtimeParams: Record<string, unknown>;
details: Record<string, unknown>;
contentDetails?: Record<string, unknown>;
}) {
params.mockFn.mockResolvedValue(params.mockResult);
const result = await runAction({
action: params.action,
params: params.actionParams,
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
toolContext: params.toolContext,
requesterSenderId: params.requesterSenderId,
senderIsOwner: params.senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
});
expectActionRuntimeCall(params.mockFn, params.runtimeParams);
expectActionSuccess(result, params.details, params.contentDetails);
}
describe("msteamsPlugin message actions", () => {
beforeEach(() => {
for (const mockFn of actionMocks) {
mockFn.mockReset();
}
});
it("falls back to toolContext.currentChannelId for read actions", async () => {
await expectSuccessfulAction({
mockFn: getMessageMSTeamsMock,
mockResult: readMessage,
action: "read",
actionParams: {
messageId: padded("msg-1"),
},
toolContext: {
currentChannelId: padded(currentChannelId),
},
runtimeParams: {
to: currentChannelId,
messageId: "msg-1",
},
details: okMSTeamsActionDetails("read", {
message: readMessage,
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "read",
message: readMessage,
},
});
});
it("advertises upload-file in the message tool surface", () => {
expect(
msteamsPlugin.actions?.describeMessageTool?.({
cfg: {
channels: {
msteams: {
appId: "app-id",
appPassword: "secret",
tenantId: "tenant-id",
},
},
} as OpenClawConfig,
})?.actions,
).toContain("upload-file");
});
it("routes upload-file through sendMessageMSTeams with filename override", async () => {
const mediaReadFile = vi.fn(async () => Buffer.from("pdf"));
await expectSuccessfulAction({
mockFn: sendMessageMSTeamsMock,
mockResult: {
messageId: "msg-upload-1",
conversationId: "conv-upload-1",
},
action: "upload-file",
actionParams: {
target: padded(targetChannelId),
path: " /tmp/report.pdf ",
message: "Quarterly report",
filename: "Q1-report.pdf",
},
mediaLocalRoots: ["/tmp"],
mediaReadFile,
runtimeParams: {
to: targetChannelId,
text: "Quarterly report",
mediaUrl: " /tmp/report.pdf ",
filename: "Q1-report.pdf",
mediaLocalRoots: ["/tmp"],
mediaReadFile,
},
details: {
ok: true,
channel: "msteams",
messageId: "msg-upload-1",
},
contentDetails: {
ok: true,
channel: "msteams",
action: "upload-file",
messageId: "msg-upload-1",
conversationId: "conv-upload-1",
},
});
});
it("routes member-info through the Teams runtime", async () => {
await expectSuccessfulAction({
mockFn: getMemberInfoMSTeamsMock,
mockResult: { member: { id: "user-1" } },
action: "member-info",
actionParams: { userId: " user-1 " },
runtimeParams: { userId: "user-1" },
details: okMSTeamsActionDetails("member-info", {
member: { id: "user-1" },
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "member-info",
member: { id: "user-1" },
},
});
});
it("routes channel-list through the Teams runtime", async () => {
await expectSuccessfulAction({
mockFn: listChannelsMSTeamsMock,
mockResult: { channels: [{ id: "channel-1" }] },
action: "channel-list",
actionParams: { teamId: " team-1 " },
runtimeParams: { teamId: "team-1" },
details: okMSTeamsActionDetails("channel-list", {
channels: [{ id: "channel-1" }],
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "channel-list",
channels: [{ id: "channel-1" }],
},
});
});
it("routes channel-info through the Teams runtime", async () => {
await expectSuccessfulAction({
mockFn: getChannelInfoMSTeamsMock,
mockResult: { channel: { id: "channel-1" } },
action: "channel-info",
actionParams: {
teamId: " team-1 ",
channelId: " channel-1 ",
},
runtimeParams: {
teamId: "team-1",
channelId: "channel-1",
},
details: okMSTeamsActionDetails("channel-info", {
channelInfo: { id: "channel-1" },
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "channel-info",
channelInfo: { id: "channel-1" },
},
});
});
it("requires trusted requester sender for Teams group-management actions from Teams turns", () => {
const requiresTrustedRequesterSender = msteamsPlugin.actions?.requiresTrustedRequesterSender;
if (!requiresTrustedRequesterSender) {
throw new Error("msteams actions.requiresTrustedRequesterSender unavailable");
}
for (const action of ["addParticipant", "removeParticipant", "renameGroup"] as const) {
expect(
requiresTrustedRequesterSender({
action,
toolContext: { currentChannelProvider: "msteams" },
}),
).toBe(true);
}
expect(
requiresTrustedRequesterSender({
action: "addParticipant",
toolContext: { currentChannelProvider: "discord" },
}),
).toBe(false);
expect(
requiresTrustedRequesterSender({
action: "read",
toolContext: { currentChannelProvider: "msteams" },
}),
).toBe(false);
});
it("rejects group-management actions from non-owner non-admin callers", async () => {
const cases = [
{
action: "addParticipant",
mockFn: addParticipantMSTeamsMock,
params: { target: targetChannelId, userId: "user-1" },
},
{
action: "removeParticipant",
mockFn: removeParticipantMSTeamsMock,
params: { target: targetChannelId, userId: "user-1" },
},
{
action: "renameGroup",
mockFn: renameGroupMSTeamsMock,
params: { target: targetChannelId, name: "Renamed group" },
},
] as const;
for (const testCase of cases) {
await expectActionError(
{
action: testCase.action,
params: testCase.params,
senderIsOwner: false,
gatewayClientScopes: ["operator.write"],
},
groupManagementAuthError,
);
expect(testCase.mockFn).not.toHaveBeenCalled();
}
});
it("allows owner-authorized group-management actions", async () => {
await expectSuccessfulAction({
mockFn: addParticipantMSTeamsMock,
mockResult: { added: { userId: "user-1", chatId: targetChannelId } },
action: "addParticipant",
actionParams: {
target: targetChannelId,
userId: " user-1 ",
role: " owner ",
},
senderIsOwner: true,
runtimeParams: {
to: targetChannelId,
userId: "user-1",
role: "owner",
},
details: okMSTeamsActionDetails("addParticipant", {
added: { userId: "user-1", chatId: targetChannelId },
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "addParticipant",
added: { userId: "user-1", chatId: targetChannelId },
},
});
});
it("allows operator.admin group-management actions without owner sender status", async () => {
await expectSuccessfulAction({
mockFn: removeParticipantMSTeamsMock,
mockResult: { removed: { userId: "user-1", chatId: targetChannelId } },
action: "removeParticipant",
actionParams: {
target: targetChannelId,
userId: " user-1 ",
},
senderIsOwner: false,
gatewayClientScopes: ["operator.admin"],
runtimeParams: {
to: targetChannelId,
userId: "user-1",
},
details: okMSTeamsActionDetails("removeParticipant", {
removed: { userId: "user-1", chatId: targetChannelId },
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "removeParticipant",
removed: { userId: "user-1", chatId: targetChannelId },
},
});
await expectSuccessfulAction({
mockFn: renameGroupMSTeamsMock,
mockResult: { renamed: { chatId: targetChannelId, newName: "Renamed group" } },
action: "renameGroup",
actionParams: {
target: targetChannelId,
name: " Renamed group ",
},
senderIsOwner: false,
gatewayClientScopes: ["operator.admin"],
runtimeParams: {
to: targetChannelId,
name: "Renamed group",
},
details: okMSTeamsActionDetails("renameGroup", {
renamed: { chatId: targetChannelId, newName: "Renamed group" },
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "renameGroup",
renamed: { chatId: targetChannelId, newName: "Renamed group" },
},
});
});
it("accepts target as an alias for pin actions", async () => {
await expectSuccessfulAction({
mockFn: pinMessageMSTeamsMock,
mockResult: { ok: true, pinnedMessageId: "pin-1" },
action: "pin",
actionParams: {
target: padded(targetChannelId),
messageId: padded("msg-2"),
},
runtimeParams: {
to: targetChannelId,
messageId: "msg-2",
},
details: okMSTeamsActionDetails("pin", {
pinnedMessageId: "pin-1",
}),
});
});
it("falls back from content to message fields for edit actions", async () => {
await expectSuccessfulAction({
mockFn: editMessageMSTeamsMock,
mockResult: { conversationId: editedConversationId },
action: "edit",
actionParams: {
to: targetChannelId,
messageId: editedMessageId,
content: updatedText,
},
runtimeParams: {
to: targetChannelId,
activityId: editedMessageId,
text: updatedText,
},
details: {
ok: true,
channel: "msteams",
},
contentDetails: {
ok: true,
channel: "msteams",
conversationId: editedConversationId,
},
});
});
it("falls back from pinnedMessageId to messageId for unpin actions", async () => {
await expectSuccessfulAction({
mockFn: unpinMessageMSTeamsMock,
mockResult: { ok: true },
action: "unpin",
actionParams: {
target: padded(targetChannelId),
messageId: padded("pin-2"),
},
runtimeParams: {
to: targetChannelId,
pinnedMessageId: "pin-2",
},
details: okMSTeamsActionDetails("unpin"),
});
});
it("uses explicit pinnedMessageId over messageId for unpin actions", async () => {
await expectSuccessfulAction({
mockFn: unpinMessageMSTeamsMock,
mockResult: { ok: true },
action: "unpin",
actionParams: {
target: padded(targetChannelId),
pinnedMessageId: padded("pinned-resource-99"),
messageId: padded("msg-99"),
},
runtimeParams: {
to: targetChannelId,
pinnedMessageId: "pinned-resource-99",
},
details: okMSTeamsActionDetails("unpin"),
});
});
it("returns an error when unpin is called without pinnedMessageId or messageId", async () => {
await expectActionParamError(
"unpin",
{ target: targetChannelId },
"Unpin requires a target (to) and pinnedMessageId.",
);
});
it("exposes pinnedMessageId in the tool schema", () => {
const discovery = msteamsPlugin.actions?.describeMessageTool?.({
cfg: {
channels: {
msteams: {
appId: "app-id",
appPassword: "secret",
tenantId: "tenant-id",
},
},
} as OpenClawConfig,
});
const schema = discovery?.schema;
if (!schema) {
throw new Error("expected msteams message tool schema");
}
const properties = Array.isArray(schema)
? schema[0]?.properties
: (schema as { properties: Record<string, unknown> })?.properties;
expect(properties).toHaveProperty("pinnedMessageId");
});
it("reuses currentChannelId fallback for react actions", async () => {
await expectSuccessfulAction({
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
actionParams: {
messageId: padded("msg-3"),
emoji: padded(reactionType),
},
toolContext: {
currentChannelId: padded(reactChannelId),
},
runtimeParams: {
to: reactChannelId,
messageId: "msg-3",
reactionType,
},
details: okMSTeamsActionDetails("react", {
reactionType,
}),
contentDetails: {
channel: "msteams",
action: "react",
reactionType,
ok: true,
},
});
});
it("shares the missing target and messageId validation across actions", async () => {
await expectActionParamError("delete", {}, deleteMissingTargetError);
await expectActionParamError("reactions", { to: targetChannelId }, reactionsMissingTargetError);
});
it("keeps presentation-card target validation shared", async () => {
await expectActionParamError(
"send",
{ presentation: { blocks: [{ type: "text", text: "hello" }] } },
presentationSendMissingTargetError,
);
});
it("preserves message text when sending presentation cards", async () => {
await expectSuccessfulAction({
mockFn: sendAdaptiveCardMSTeamsMock,
mockResult: {
messageId: "msg-card-1",
conversationId: "conv-card-1",
},
action: "send",
actionParams: {
to: targetChannelId,
message: "Deploy finished",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Open", value: "open" }],
},
],
},
},
runtimeParams: {
to: targetChannelId,
card: {
type: "AdaptiveCard",
version: "1.4",
body: [{ type: "TextBlock", text: "Deploy finished", wrap: true }],
actions: [
{ type: "Action.Submit", title: "Open", data: { value: "open", label: "Open" } },
],
},
},
details: {
ok: true,
channel: "msteams",
messageId: "msg-card-1",
},
contentDetails: {
ok: true,
channel: "msteams",
messageId: "msg-card-1",
conversationId: "conv-card-1",
},
});
});
it("downgrades select blocks when sending presentation cards", async () => {
await expectSuccessfulAction({
mockFn: sendAdaptiveCardMSTeamsMock,
mockResult: {
messageId: "msg-card-select-1",
conversationId: "conv-card-select-1",
},
action: "send",
actionParams: {
to: targetChannelId,
presentation: {
blocks: [
{
type: "select",
placeholder: "Pick a lane",
options: [
{ label: "Canary", value: "canary" },
{ label: "Stable", value: "stable" },
],
},
],
},
},
runtimeParams: {
to: targetChannelId,
card: {
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "Pick a lane:\n- Canary\n- Stable",
wrap: true,
isSubtle: true,
size: "Small",
},
],
},
},
details: {
ok: true,
channel: "msteams",
messageId: "msg-card-select-1",
},
contentDetails: {
ok: true,
channel: "msteams",
messageId: "msg-card-select-1",
conversationId: "conv-card-select-1",
},
});
});
it("reports the allowed reaction types when emoji is missing", async () => {
await expectActionParamError(
"react",
{
to: targetChannelId,
messageId: "msg-4",
},
reactMissingEmojiError,
{
error: reactMissingEmojiDetail,
validTypes: reactionTypes,
},
);
});
it("requires a non-empty search query after trimming", async () => {
await expectActionParamError(
"search",
{
to: targetChannelId,
query: " ",
},
searchMissingQueryError,
);
});
it("routes channel fallback targets via teamId/channelId for react actions", async () => {
// When an action is invoked in a Teams channel context and `target` is
// omitted, the action handler falls back to `toolContext.currentChannelId`.
// For channel turns, buildToolContext populates that field with the
// compound `teamId/channelId` form (see buildToolContext below), so the
// runtime call must receive that compound form — NOT a bare
// `conversation:<id>` — so Graph API routes through
// `/teams/{teamId}/channels/{channelId}` rather than `/chats/{id}`.
const teamChannelTarget = "team-1/19:channel-abc@thread.tacv2";
await expectSuccessfulAction({
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
actionParams: {
messageId: "msg-channel-react",
emoji: reactionType,
},
toolContext: {
currentChannelId: "conversation:19:channel-abc@thread.tacv2",
currentGraphChannelId: teamChannelTarget,
},
runtimeParams: {
to: teamChannelTarget,
messageId: "msg-channel-react",
reactionType,
},
details: okMSTeamsActionDetails("react", {
reactionType,
}),
contentDetails: {
channel: "msteams",
action: "react",
reactionType,
ok: true,
},
});
});
it("preserves explicit teamId/channelId target over toolContext fallback", async () => {
// Even in a channel context with a compound currentChannelId, an
// explicit `target` param must take precedence.
const teamChannelTarget = "team-2/19:channel-def@thread.tacv2";
const explicitTarget = "team-explicit/19:other@thread.tacv2";
await expectSuccessfulAction({
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
actionParams: {
target: explicitTarget,
messageId: "msg-explicit",
emoji: reactionType,
},
toolContext: {
currentChannelId: teamChannelTarget,
currentGraphChannelId: teamChannelTarget,
},
runtimeParams: {
to: explicitTarget,
messageId: "msg-explicit",
reactionType,
},
details: okMSTeamsActionDetails("react", {
reactionType,
}),
contentDetails: {
channel: "msteams",
action: "react",
reactionType,
ok: true,
},
});
});
it("keeps chat conversation fallback targets as-is for DM react actions", async () => {
// DM/group-chat turns continue to set currentChannelId to a
// `conversation:<id>` string (no `teamId/` prefix), which the runtime
// will resolve through `/chats/{id}`.
const dmFallback = "conversation:19:chat-dm@thread.skype";
await expectSuccessfulAction({
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
actionParams: {
messageId: "msg-dm-react",
emoji: reactionType,
},
toolContext: {
currentChannelId: dmFallback,
},
runtimeParams: {
to: dmFallback,
messageId: "msg-dm-react",
reactionType,
},
details: okMSTeamsActionDetails("react", {
reactionType,
}),
contentDetails: {
channel: "msteams",
action: "react",
reactionType,
ok: true,
},
});
});
});
describe("msteamsPlugin.threading.buildToolContext", () => {
function callBuildToolContext(context: {
To?: string;
NativeChannelId?: string;
ReplyToId?: string;
}) {
const build = msteamsPlugin.threading?.buildToolContext;
if (!build) {
throw new Error("msteams threading.buildToolContext unavailable");
}
return build({
cfg: {} as OpenClawConfig,
accountId: undefined,
context,
});
}
it("uses NativeChannelId for channel turns so actions route via teamId/channelId", () => {
// Teams channel inbound messages carry the compound `teamId/channelId`
// on NativeChannelId. buildToolContext must prefer it over the bare
// `conversation:<id>` in To so action fallbacks route via
// `/teams/{teamId}/channels/{channelId}`.
const result = callBuildToolContext({
To: "conversation:19:channel-abc@thread.tacv2",
NativeChannelId: "team-1/19:channel-abc@thread.tacv2",
ReplyToId: "reply-1",
});
expect(result?.currentChannelId).toBe("conversation:19:channel-abc@thread.tacv2");
expect(result?.currentGraphChannelId).toBe("team-1/19:channel-abc@thread.tacv2");
expect(result?.currentThreadTs).toBe("reply-1");
});
it("falls back to To for DM turns (no NativeChannelId)", () => {
const result = callBuildToolContext({
To: "user:aad-user-1",
});
expect(result?.currentChannelId).toBe("user:aad-user-1");
expect(result?.currentGraphChannelId).toBeUndefined();
});
it("falls back to To for group chat turns (no NativeChannelId)", () => {
const result = callBuildToolContext({
To: "conversation:19:groupchat@thread.v2",
});
expect(result?.currentChannelId).toBe("conversation:19:groupchat@thread.v2");
expect(result?.currentGraphChannelId).toBeUndefined();
});
it("ignores NativeChannelId that does not encode a teamId/channelId pair", () => {
// Safety: only compound forms (with "/") should preempt the To fallback.
// A bare native id without a team prefix must not accidentally route
// through channel Graph paths.
const result = callBuildToolContext({
To: "conversation:19:chat@thread.v2",
NativeChannelId: "19:chat@thread.v2",
});
expect(result?.currentChannelId).toBe("conversation:19:chat@thread.v2");
expect(result?.currentGraphChannelId).toBeUndefined();
});
});

View File

@@ -0,0 +1,177 @@
// Msteams tests cover channelirectory plugin behavior.
import {
createDirectoryTestRuntime,
expectDirectorySurface,
} from "openclaw/plugin-sdk/channel-test-helpers";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import { msteamsPlugin } from "./channel.js";
import { resolveMSTeamsOutboundSessionRoute } from "./session-route.js";
const msteamsDirectoryAdapter = msteamsPlugin.directory;
function requireDirectorySelf(): NonNullable<NonNullable<typeof msteamsDirectoryAdapter>["self"]> {
const directorySelf = msteamsDirectoryAdapter?.self;
if (!directorySelf) {
throw new Error("expected msteams directory.self");
}
return directorySelf;
}
describe("msteams directory", () => {
const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
const directorySelf = requireDirectorySelf();
afterEach(() => {
vi.unstubAllEnvs();
});
describe("self()", () => {
it("returns bot identity when credentials are configured", async () => {
const cfg = {
channels: {
msteams: {
appId: "test-app-id-1234",
appPassword: "secret",
tenantId: "tenant-id-5678",
},
},
} as unknown as OpenClawConfig;
const result = await directorySelf({ cfg, runtime: runtimeEnv });
expect(result).toEqual({ kind: "user", id: "test-app-id-1234", name: "test-app-id-1234" });
});
it("returns null when credentials are not configured", async () => {
vi.stubEnv("MSTEAMS_APP_ID", "");
vi.stubEnv("MSTEAMS_APP_PASSWORD", "");
vi.stubEnv("MSTEAMS_TENANT_ID", "");
const cfg = { channels: {} } as unknown as OpenClawConfig;
const result = await directorySelf({ cfg, runtime: runtimeEnv });
expect(result).toBeNull();
});
});
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
msteams: {
allowFrom: ["alice", "user:Bob"],
dms: { carol: {}, bob: {} },
teams: {
team1: {
channels: {
"conversation:chan1": {},
chan2: {},
},
},
},
},
},
} as unknown as OpenClawConfig;
const directory = expectDirectorySurface(msteamsDirectoryAdapter);
const peers = await directory.listPeers({
cfg,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(peers).toStrictEqual([
{ kind: "user", id: "user:alice" },
{ kind: "user", id: "user:Bob" },
{ kind: "user", id: "user:carol" },
{ kind: "user", id: "user:bob" },
]);
const groups = await directory.listGroups({
cfg,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(groups).toStrictEqual([
{ kind: "group", id: "conversation:chan1" },
{ kind: "group", id: "conversation:chan2" },
]);
});
it("normalizes spaced allowlist and dm entries", async () => {
const cfg = {
channels: {
msteams: {
allowFrom: [" user:Bob ", " Alice "],
dms: { " Carol ": {}, "user:Dave": {} },
},
},
} as unknown as OpenClawConfig;
const directory = expectDirectorySurface(msteamsDirectoryAdapter);
const peers = await directory.listPeers({
cfg,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
});
expect(peers).toStrictEqual([
{ kind: "user", id: "user:Bob" },
{ kind: "user", id: "user:Alice" },
{ kind: "user", id: "user:Carol" },
{ kind: "user", id: "user:Dave" },
]);
});
});
describe("msteams session route", () => {
it("builds direct routes for explicit user targets", () => {
const route = resolveMSTeamsOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "default",
target: "msteams:user:alice-id",
});
expect(route?.peer).toEqual({ kind: "direct", id: "alice-id" });
expect(route?.from).toBe("msteams:alice-id");
expect(route?.to).toBe("user:alice-id");
});
it("builds channel routes for thread conversations and strips suffix metadata", () => {
const route = resolveMSTeamsOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "default",
target: "teams:19:abc123@thread.tacv2;messageid=42",
});
expect(route?.peer).toEqual({ kind: "channel", id: "19:abc123@thread.tacv2" });
expect(route?.from).toBe("msteams:channel:19:abc123@thread.tacv2");
expect(route?.to).toBe("conversation:19:abc123@thread.tacv2");
});
it("returns group routes for non-user, non-channel conversations", () => {
const route = resolveMSTeamsOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "default",
target: "msteams:conversation:19:groupchat",
});
expect(route?.peer).toEqual({ kind: "group", id: "19:groupchat" });
expect(route?.from).toBe("msteams:group:19:groupchat");
expect(route?.to).toBe("conversation:19:groupchat");
});
it("returns null when the target cannot be normalized", () => {
expect(
resolveMSTeamsOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "default",
target: "msteams:",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,228 @@
// Msteams tests cover channel.message adapter plugin behavior.
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
const mocks = vi.hoisted(() => ({
sendText: vi.fn(),
sendMedia: vi.fn(),
sendPayload: vi.fn(),
sendPoll: vi.fn(),
}));
vi.mock("./channel.runtime.js", () => ({
msTeamsChannelRuntime: {
msteamsOutbound: {
sendText: mocks.sendText,
sendMedia: mocks.sendMedia,
sendPayload: mocks.sendPayload,
sendPoll: mocks.sendPoll,
},
},
}));
import { msteamsPlugin } from "./channel.js";
type MSTeamsMessageAdapter = NonNullable<typeof msteamsPlugin.message>;
type MSTeamsMessageSender = NonNullable<MSTeamsMessageAdapter["send"]>;
function requireMSTeamsMessageAdapter(): MSTeamsMessageAdapter {
const adapter = msteamsPlugin.message;
if (!adapter) {
throw new Error("Expected msteams channel message adapter");
}
return adapter;
}
function requireTextSender(
adapter: MSTeamsMessageAdapter,
): NonNullable<MSTeamsMessageSender["text"]> {
const text = adapter.send?.text;
if (!text) {
throw new Error("Expected msteams message adapter text sender");
}
return text;
}
function requireMediaSender(
adapter: MSTeamsMessageAdapter,
): NonNullable<MSTeamsMessageSender["media"]> {
const media = adapter.send?.media;
if (!media) {
throw new Error("Expected msteams message adapter media sender");
}
return media;
}
function requirePayloadSender(
adapter: MSTeamsMessageAdapter,
): NonNullable<MSTeamsMessageSender["payload"]> {
const payload = adapter.send?.payload;
if (!payload) {
throw new Error("Expected msteams message adapter payload sender");
}
return payload;
}
const cfg = {
channels: {
msteams: {
appId: "resolved-app-id",
},
},
} as OpenClawConfig;
describe("msteams channel message adapter", () => {
beforeEach(() => {
mocks.sendText.mockReset();
mocks.sendMedia.mockReset();
mocks.sendPayload.mockReset();
mocks.sendPoll.mockReset();
mocks.sendText.mockResolvedValue({
channel: "msteams",
messageId: "msg-1",
conversationId: "conv-1",
});
mocks.sendMedia.mockResolvedValue({
channel: "msteams",
messageId: "msg-media-1",
conversationId: "conv-1",
});
mocks.sendPayload.mockResolvedValue({
channel: "msteams",
messageId: "msg-payload-1",
conversationId: "conv-1",
});
});
it("backs declared durable-final capabilities with outbound send proofs", async () => {
const adapter = requireMSTeamsMessageAdapter();
const sendText = requireTextSender(adapter);
const sendMedia = requireMediaSender(adapter);
const sendPayload = requirePayloadSender(adapter);
expect(adapter.durableFinal?.capabilities?.replyTo).toBeUndefined();
expect(adapter.durableFinal?.capabilities?.thread).toBeUndefined();
const proveText = async () => {
mocks.sendText.mockClear();
const result = await sendText({
cfg,
to: "conversation:abc",
text: "hello",
accountId: "default",
});
expect(mocks.sendText).toHaveBeenLastCalledWith({
cfg,
to: "conversation:abc",
text: "hello",
accountId: "default",
});
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
mocks.sendMedia.mockClear();
const result = await sendMedia({
cfg,
to: "conversation:abc",
text: "photo",
mediaUrl: "file:///tmp/photo.png",
mediaLocalRoots: ["/tmp"],
accountId: "default",
});
expect(mocks.sendMedia).toHaveBeenLastCalledWith({
cfg,
to: "conversation:abc",
text: "photo",
mediaUrl: "file:///tmp/photo.png",
mediaLocalRoots: ["/tmp"],
accountId: "default",
});
expect(result.receipt.platformMessageIds).toEqual(["msg-media-1"]);
expect(result.receipt.parts[0]?.kind).toBe("media");
};
const provePayload = async () => {
mocks.sendPayload.mockClear();
const payload = {
presentation: {
blocks: [{ type: "text" as const, text: "card body" }],
},
};
const result = await sendPayload({
cfg,
to: "conversation:abc",
text: "",
payload,
accountId: "default",
});
expect(mocks.sendPayload).toHaveBeenLastCalledWith({
cfg,
to: "conversation:abc",
text: "",
payload,
accountId: "default",
});
expect(result.receipt.platformMessageIds).toEqual(["msg-payload-1"]);
expect(result.receipt.parts[0]?.kind).toBe("card");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "msteamsMessageAdapter",
adapter,
proofs: {
text: proveText,
media: proveMedia,
payload: provePayload,
messageSendingHooks: () => {
expect(sendText).toBeTypeOf("function");
},
},
});
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = requireMSTeamsMessageAdapter();
const sendText = requireTextSender(adapter);
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "msteamsMessageAdapter",
adapter,
proofs: {
draftPreview: () => {
expect(adapter.live?.capabilities?.nativeStreaming).toBe(true);
},
previewFinalization: () => {
expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
},
nativeStreaming: () => {
expect(adapter.live?.finalizer?.capabilities?.previewReceipt).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "msteamsMessageAdapter",
adapter,
proofs: {
finalEdit: () => {
expect(adapter.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(sendText).toBeTypeOf("function");
},
previewReceipt: () => {
expect(adapter.live?.capabilities?.nativeStreaming).toBe(true);
},
},
});
});
});

View File

@@ -0,0 +1,57 @@
// Msteams plugin module implements channel behavior.
import {
listMSTeamsDirectoryGroupsLive as listMSTeamsDirectoryGroupsLiveImpl,
listMSTeamsDirectoryPeersLive as listMSTeamsDirectoryPeersLiveImpl,
} from "./directory-live.js";
import {
addParticipantMSTeams as addParticipantMSTeamsImpl,
removeParticipantMSTeams as removeParticipantMSTeamsImpl,
renameGroupMSTeams as renameGroupMSTeamsImpl,
} from "./graph-group-management.js";
import { getMemberInfoMSTeams as getMemberInfoMSTeamsImpl } from "./graph-members.js";
import {
getMessageMSTeams as getMessageMSTeamsImpl,
listPinsMSTeams as listPinsMSTeamsImpl,
listReactionsMSTeams as listReactionsMSTeamsImpl,
pinMessageMSTeams as pinMessageMSTeamsImpl,
reactMessageMSTeams as reactMessageMSTeamsImpl,
searchMessagesMSTeams as searchMessagesMSTeamsImpl,
unpinMessageMSTeams as unpinMessageMSTeamsImpl,
unreactMessageMSTeams as unreactMessageMSTeamsImpl,
} from "./graph-messages.js";
import {
listChannelsMSTeams as listChannelsMSTeamsImpl,
getChannelInfoMSTeams as getChannelInfoMSTeamsImpl,
} from "./graph-teams.js";
import { msteamsOutbound as msteamsOutboundImpl } from "./outbound.js";
import { probeMSTeams as probeMSTeamsImpl } from "./probe.js";
import {
deleteMessageMSTeams as deleteMessageMSTeamsImpl,
editMessageMSTeams as editMessageMSTeamsImpl,
sendAdaptiveCardMSTeams as sendAdaptiveCardMSTeamsImpl,
sendMessageMSTeams as sendMessageMSTeamsImpl,
} from "./send.js";
export const msTeamsChannelRuntime = {
addParticipantMSTeams: addParticipantMSTeamsImpl,
deleteMessageMSTeams: deleteMessageMSTeamsImpl,
editMessageMSTeams: editMessageMSTeamsImpl,
getChannelInfoMSTeams: getChannelInfoMSTeamsImpl,
getMemberInfoMSTeams: getMemberInfoMSTeamsImpl,
getMessageMSTeams: getMessageMSTeamsImpl,
listChannelsMSTeams: listChannelsMSTeamsImpl,
listPinsMSTeams: listPinsMSTeamsImpl,
listReactionsMSTeams: listReactionsMSTeamsImpl,
pinMessageMSTeams: pinMessageMSTeamsImpl,
reactMessageMSTeams: reactMessageMSTeamsImpl,
removeParticipantMSTeams: removeParticipantMSTeamsImpl,
renameGroupMSTeams: renameGroupMSTeamsImpl,
searchMessagesMSTeams: searchMessagesMSTeamsImpl,
unpinMessageMSTeams: unpinMessageMSTeamsImpl,
unreactMessageMSTeams: unreactMessageMSTeamsImpl,
listMSTeamsDirectoryGroupsLive: listMSTeamsDirectoryGroupsLiveImpl,
listMSTeamsDirectoryPeersLive: listMSTeamsDirectoryPeersLiveImpl,
msteamsOutbound: { ...msteamsOutboundImpl },
probeMSTeams: probeMSTeamsImpl,
sendAdaptiveCardMSTeams: sendAdaptiveCardMSTeamsImpl,
sendMessageMSTeams: sendMessageMSTeamsImpl,
};

View File

@@ -0,0 +1,78 @@
// Msteams plugin module implements channel.setup behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { MSTeamsChannelConfigSchema } from "./config-schema.js";
import { msteamsSetupAdapter } from "./setup-core.js";
import { msteamsSetupWizard } from "./setup-surface.js";
import { resolveMSTeamsCredentials } from "./token.js";
type ResolvedMSTeamsAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
};
const meta = {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams (Bot Framework)",
docsPath: "/channels/msteams",
docsLabel: "msteams",
blurb: "Teams SDK; enterprise support.",
aliases: ["teams"],
order: 60,
} as const;
const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({
allowFrom: cfg.channels?.msteams?.allowFrom,
defaultTo: cfg.channels?.msteams?.defaultTo,
});
const msteamsConfigAdapter = createTopLevelChannelConfigAdapter<
ResolvedMSTeamsAccount,
{
allowFrom?: Array<string | number>;
defaultTo?: string;
}
>({
sectionKey: "msteams",
resolveAccount: (cfg) => ({
accountId: "default",
enabled: cfg.channels?.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
}),
resolveAccessorAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg),
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account) => account.defaultTo,
});
export const msteamsSetupPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
...meta,
aliases: [...meta.aliases],
},
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.msteams"] },
configSchema: MSTeamsChannelConfigSchema,
config: {
...msteamsConfigAdapter,
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
}),
},
setupWizard: msteamsSetupWizard,
setup: msteamsSetupAdapter,
};

View File

@@ -0,0 +1,201 @@
// Msteams tests cover channel plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { MSTeamsConfigSchema } from "../config-api.js";
import { msTeamsApprovalAuth } from "./approval-auth.js";
import { msteamsPlugin } from "./channel.js";
function createConfiguredMSTeamsCfg(): OpenClawConfig {
return {
channels: {
msteams: {
appId: "app-id",
appPassword: "secret",
tenantId: "tenant-id",
},
},
};
}
describe("msteamsPlugin", () => {
it("exposes approval auth through approvalCapability", () => {
expect(msteamsPlugin.approvalCapability).toBe(msTeamsApprovalAuth);
});
it("advertises legacy and group-management message-tool actions together", () => {
const actions = msteamsPlugin.actions?.describeMessageTool?.({
cfg: createConfiguredMSTeamsCfg(),
})?.actions;
expect(actions).toEqual([
"upload-file",
"poll",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"read",
"react",
"reactions",
"search",
"member-info",
"channel-list",
"channel-info",
"addParticipant",
"removeParticipant",
"renameGroup",
]);
});
it("reuses the shared Teams target-id matcher for explicit targets", () => {
const looksLikeId = msteamsPlugin.messaging?.targetResolver?.looksLikeId;
expect(looksLikeId?.("29:1a2b3c4d5e6f")).toBe(true);
expect(looksLikeId?.("a:1bfPersonalChat")).toBe(true);
expect(looksLikeId?.("user:Jane Doe")).toBe(false);
});
});
describe("msteams config schema", () => {
it("defaults groupPolicy to allowlist", () => {
const res = MSTeamsConfigSchema.safeParse({});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.groupPolicy).toBe("allowlist");
}
});
it("accepts historyLimit", () => {
const res = MSTeamsConfigSchema.safeParse({ historyLimit: 4 });
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.historyLimit).toBe(4);
}
});
it("accepts replyStyle at global/team/channel levels", () => {
const res = MSTeamsConfigSchema.safeParse({
replyStyle: "top-level",
teams: {
team123: {
replyStyle: "thread",
channels: {
chan456: { replyStyle: "top-level" },
},
},
},
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.replyStyle).toBe("top-level");
expect(res.data.teams?.team123?.replyStyle).toBe("thread");
expect(res.data.teams?.team123?.channels?.chan456?.replyStyle).toBe("top-level");
}
});
it("accepts Teams SDK cloud and serviceUrl configuration", () => {
const res = MSTeamsConfigSchema.safeParse({
cloud: "USGovDoD",
serviceUrl: "https://smba.infra.dod.teams.microsoft.us/teams",
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.cloud).toBe("USGovDoD");
expect(res.data.serviceUrl).toBe("https://smba.infra.dod.teams.microsoft.us/teams");
}
});
it("rejects unsupported Teams serviceUrl hosts", () => {
const res = MSTeamsConfigSchema.safeParse({
cloud: "USGovDoD",
serviceUrl: "https://dod.example.mil/teams",
});
expect(res.success).toBe(false);
});
it("accepts China cloud without a configured global serviceUrl", () => {
const res = MSTeamsConfigSchema.safeParse({
cloud: "China",
});
expect(res.success).toBe(true);
});
it("accepts Azure China Bot Framework serviceUrl hosts", () => {
const res = MSTeamsConfigSchema.safeParse({
cloud: "China",
serviceUrl: "https://msteams.botframework.azure.cn/teams",
});
expect(res.success).toBe(true);
});
it("rejects non-China serviceUrl hosts when China cloud is configured", () => {
const res = MSTeamsConfigSchema.safeParse({
cloud: "China",
serviceUrl: "https://smba.trafficmanager.net/teams",
});
expect(res.success).toBe(false);
});
it("rejects Azure China Bot Framework serviceUrl hosts without China cloud", () => {
const res = MSTeamsConfigSchema.safeParse({
serviceUrl: "https://msteams.botframework.azure.cn/teams",
});
expect(res.success).toBe(false);
});
it("requires serviceUrl with non-public Teams clouds", () => {
const res = MSTeamsConfigSchema.safeParse({
cloud: "USGov",
});
expect(res.success).toBe(false);
});
it("rejects invalid replyStyle", () => {
const res = MSTeamsConfigSchema.safeParse({
replyStyle: "nope",
});
expect(res.success).toBe(false);
});
});
describe("msTeamsApprovalAuth", () => {
it("authorizes stable Teams user ids and ignores display-name allowlists", () => {
expect(
msTeamsApprovalAuth.authorizeActorAction({
cfg: {
channels: {
msteams: {
allowFrom: ["user:123e4567-e89b-12d3-a456-426614174000"],
},
},
},
senderId: "123e4567-e89b-12d3-a456-426614174000",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
expect(
msTeamsApprovalAuth.authorizeActorAction({
cfg: {
channels: { msteams: { allowFrom: ["Owner Display"] } },
},
senderId: "attacker-aad",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
// Msteams tests cover cloud plugin behavior.
import { describe, expect, it } from "vitest";
import {
resolveMSTeamsSdkCloudOptions,
validateMSTeamsProactiveServiceUrlBoundary,
} from "./cloud.js";
describe("resolveMSTeamsSdkCloudOptions", () => {
it("defaults to public cloud without an explicit serviceUrl", () => {
expect(resolveMSTeamsSdkCloudOptions({})).toEqual({ cloud: "Public" });
});
it("passes serviceUrl override through with default public cloud", () => {
expect(
resolveMSTeamsSdkCloudOptions({
serviceUrl: " https://smba.infra.gcc.teams.microsoft.com/teams ",
}),
).toEqual({
cloud: "Public",
serviceUrl: "https://smba.infra.gcc.teams.microsoft.com/teams",
});
});
it("requires serviceUrl when US government cloud is configured", () => {
expect(() => resolveMSTeamsSdkCloudOptions({ cloud: "USGov" })).toThrow(
/channels\.msteams\.cloud=USGov requires channels\.msteams\.serviceUrl/,
);
});
it("allows China cloud without a configured global serviceUrl", () => {
expect(resolveMSTeamsSdkCloudOptions({ cloud: "China" })).toEqual({
cloud: "China",
});
});
it("passes configured cloud and serviceUrl through to the SDK", () => {
expect(
resolveMSTeamsSdkCloudOptions({
cloud: "USGovDoD",
serviceUrl: " https://smba.infra.dod.teams.microsoft.us/teams ",
}),
).toEqual({
cloud: "USGovDoD",
serviceUrl: "https://smba.infra.dod.teams.microsoft.us/teams",
});
});
});
describe("validateMSTeamsProactiveServiceUrlBoundary", () => {
it("allows public-cloud stored serviceUrls with the default public cloud", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "Public",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://smba.trafficmanager.net/amer/",
}),
).not.toThrow();
});
it("blocks non-public stored serviceUrls when public cloud is configured", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "Public",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://smba.infra.gcc.example/teams",
}),
).toThrow(/not a Microsoft Teams public-cloud Bot Connector endpoint/);
});
it("allows China cloud stored serviceUrls on the Azure China Bot Framework boundary", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "China",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://msteams.botframework.azure.cn/teams/",
}),
).not.toThrow();
});
it("blocks non-China serviceUrls when China cloud is configured without a serviceUrl", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "China",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://smba.trafficmanager.net/teams/",
}),
).toThrow(/not a Microsoft Teams China Bot Framework channel endpoint/);
});
it("blocks configured non-China serviceUrls when China cloud is configured", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "China",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://smba.trafficmanager.net/teams/",
configuredServiceUrl: "https://smba.trafficmanager.net/teams",
}),
).toThrow(/configured Teams serviceUrl .*not a Microsoft Teams China Bot Framework/);
});
it("blocks configured China serviceUrls unless China cloud is configured", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "Public",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://msteams.botframework.azure.cn/teams/",
configuredServiceUrl: "https://msteams.botframework.azure.cn/teams",
}),
).toThrow(/requires channels\.msteams\.cloud=China/);
});
it("requires serviceUrl when non-public cloud is configured", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "USGov",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://gov.example.us/teams",
}),
).toThrow(/cloud=USGov requires channels\.msteams\.serviceUrl/);
});
it("blocks configured serviceUrl host mismatches", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "USGovDoD",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://dod-a.example.mil/teams",
configuredServiceUrl: "https://dod-b.example.mil/teams",
}),
).toThrow(/does not match configured Teams SDK serviceUrl host/);
});
it("allows configured serviceUrl host matches with different paths", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "USGov",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://connector.example.cn/teams-region/",
configuredServiceUrl: "https://connector.example.cn/teams",
}),
).not.toThrow();
});
it("allows configured China serviceUrl host matches with different paths", () => {
expect(() =>
validateMSTeamsProactiveServiceUrlBoundary({
cloud: "China",
conversationId: "19:conversation@thread.tacv2",
storedServiceUrl: "https://msteams.botframework.azure.cn/teams-region/",
configuredServiceUrl: "https://msteams.botframework.azure.cn/teams",
}),
).not.toThrow();
});
});

View File

@@ -0,0 +1,145 @@
// Msteams plugin module implements cloud behavior.
import type { MSTeamsConfig } from "../runtime-api.js";
export type MSTeamsCloudName = "Public" | "USGov" | "USGovDoD" | "China";
export const DEFAULT_MSTEAMS_CLOUD: MSTeamsCloudName = "Public";
const PUBLIC_MSTEAMS_SERVICE_HOST = "smba.trafficmanager.net";
const CHINA_BOT_FRAMEWORK_SERVICE_HOST = "botframework.azure.cn";
export type MSTeamsSdkCloudOptions = {
cloud: MSTeamsCloudName;
serviceUrl?: string;
};
type NormalizedServiceUrl = {
value: string;
host: string;
};
function normalizeOptionalServiceUrl(value: string | undefined): NormalizedServiceUrl | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
try {
const parsed = new URL(trimmed);
parsed.hash = "";
parsed.search = "";
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
return {
value: parsed.toString().replace(/\/+$/, ""),
host: parsed.hostname.toLowerCase(),
};
} catch {
return null;
}
}
export function resolveMSTeamsSdkCloudOptions(cfg?: MSTeamsConfig): MSTeamsSdkCloudOptions {
const cloud = cfg?.cloud ?? DEFAULT_MSTEAMS_CLOUD;
const serviceUrl = cfg?.serviceUrl?.trim();
if (cloud !== "Public" && cloud !== "China" && !serviceUrl) {
throw new Error(
`channels.msteams.cloud=${cloud} requires channels.msteams.serviceUrl so SDK proactive operations use the matching Teams Bot Connector endpoint.`,
);
}
return {
cloud,
...(serviceUrl ? { serviceUrl } : {}),
};
}
function isChinaBotFrameworkServiceHost(host: string): boolean {
return (
host === CHINA_BOT_FRAMEWORK_SERVICE_HOST ||
host.endsWith(`.${CHINA_BOT_FRAMEWORK_SERVICE_HOST}`)
);
}
function isChinaBotFrameworkServiceUrl(value: string): boolean {
const parsed = normalizeOptionalServiceUrl(value);
return Boolean(parsed && isChinaBotFrameworkServiceHost(parsed.host));
}
export function validateMSTeamsProactiveServiceUrlBoundary(params: {
cloud: MSTeamsCloudName;
conversationId: string;
storedServiceUrl?: string;
configuredServiceUrl?: string;
}) {
const configured = normalizeOptionalServiceUrl(params.configuredServiceUrl);
if (params.cloud !== "Public" && params.cloud !== "China" && !configured) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: channels.msteams.cloud=${params.cloud} requires ` +
"channels.msteams.serviceUrl so SDK proactive operations use the matching Teams Bot Connector endpoint.",
);
}
if (params.cloud === "China" && configured && !isChinaBotFrameworkServiceHost(configured.host)) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: configured Teams serviceUrl (${configured.value}) ` +
"is not a Microsoft Teams China Bot Framework channel endpoint.",
);
}
if (params.cloud !== "China" && configured && isChinaBotFrameworkServiceHost(configured.host)) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: configured Teams serviceUrl (${configured.value}) ` +
"requires channels.msteams.cloud=China.",
);
}
if (configured) {
const stored = normalizeOptionalServiceUrl(params.storedServiceUrl);
if (!stored) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: stored conversation reference is missing a valid serviceUrl. ` +
"Ask the bot to receive a new Teams message in this conversation, then retry.",
);
}
if (stored.host !== configured.host) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: stored conversation serviceUrl (${stored.value}) ` +
`does not match configured Teams SDK serviceUrl host (${configured.host}). ` +
"Set channels.msteams.cloud/channels.msteams.serviceUrl for the Teams cloud that owns this conversation, or refresh the stored conversation by receiving a new message.",
);
}
return;
}
const stored = normalizeOptionalServiceUrl(params.storedServiceUrl);
if (!stored) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: stored conversation reference is missing a valid serviceUrl. ` +
"Ask the bot to receive a new Teams message in this conversation, then retry.",
);
}
if (params.cloud === "China") {
if (!isChinaBotFrameworkServiceHost(stored.host)) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: stored conversation serviceUrl (${stored.value}) ` +
"is not a Microsoft Teams China Bot Framework channel endpoint. " +
"Use a conversation reference received from the China/21Vianet Teams cloud.",
);
}
return;
}
if (isChinaBotFrameworkServiceUrl(stored.value)) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: stored conversation serviceUrl (${stored.value}) ` +
"requires channels.msteams.cloud=China.",
);
}
if (stored.host !== PUBLIC_MSTEAMS_SERVICE_HOST) {
throw new Error(
`msteams proactive send blocked for ${params.conversationId}: stored conversation serviceUrl (${stored.value}) ` +
"is not a Microsoft Teams public-cloud Bot Connector endpoint. " +
"Set channels.msteams.cloud and channels.msteams.serviceUrl for the supported Teams cloud that owns this conversation.",
);
}
}

View File

@@ -0,0 +1,7 @@
// Msteams helper module supports config schema behavior.
import { buildChannelConfigSchema, MSTeamsConfigSchema } from "../config-api.js";
import { msTeamsChannelConfigUiHints } from "./config-ui-hints.js";
export const MSTeamsChannelConfigSchema = buildChannelConfigSchema(MSTeamsConfigSchema, {
uiHints: msTeamsChannelConfigUiHints,
});

View File

@@ -0,0 +1,49 @@
// Msteams helper module supports config ui hints behavior.
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
export const msTeamsChannelConfigUiHints = {
"": {
label: "MS Teams",
help: "Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers.",
},
configWrites: {
label: "MS Teams Config Writes",
help: "Allow Microsoft Teams to write config in response to channel events/commands (default: true).",
},
cloud: {
label: "MS Teams Cloud",
help: 'Teams SDK cloud environment for auth, token validation, and token services: "Public", "USGov", "USGovDoD", or "China" (default: Public).',
},
serviceUrl: {
label: "MS Teams Service URL",
help: "Bot Connector service URL for SDK proactive sends/edits/deletes. Set with cloud for USGov/DoD; set alone for GCC.",
},
streaming: {
label: "MS Teams Streaming",
help: 'Microsoft Teams preview/progress streaming mode: "off" | "partial" | "block" | "progress". Personal chats use Teams native streaminfo progress when available.',
},
"streaming.progress.label": {
label: "MS Teams Progress Label",
help: 'Initial progress title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
},
"streaming.progress.labels": {
label: "MS Teams Progress Label Pool",
help: 'Candidate labels for streaming.progress.label="auto". Leave unset to use OpenClaw built-in progress labels.',
},
"streaming.progress.maxLines": {
label: "MS Teams Progress Max Lines",
help: "Maximum number of compact progress lines to keep below the progress title (default: 8).",
},
"streaming.progress.maxLineChars": {
label: "MS Teams Progress Max Line Chars",
help: "Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes.",
},
"streaming.progress.toolProgress": {
label: "MS Teams Progress Tool Lines",
help: "Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery.",
},
"streaming.progress.commandText": {
label: "MS Teams Progress Command Text",
help: 'Command/exec detail in progress lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,203 @@
// Msteams tests cover conversation store helpers plugin behavior.
import { describe, expect, it } from "vitest";
import { findPreferredDmConversationByUserId } from "./conversation-store-helpers.js";
import type { MSTeamsConversationStoreEntry } from "./conversation-store.js";
function entry(params: {
conversationId: string;
userId?: string;
aadObjectId?: string;
conversationType?: string;
lastSeenAt?: string;
}): MSTeamsConversationStoreEntry {
return {
conversationId: params.conversationId,
reference: {
user: {
id: params.userId ?? "user-1",
aadObjectId: params.aadObjectId ?? "aad-1",
},
conversation: {
id: params.conversationId,
conversationType: params.conversationType,
},
lastSeenAt: params.lastSeenAt,
},
};
}
describe("findPreferredDmConversationByUserId", () => {
it("returns null for empty id", () => {
expect(findPreferredDmConversationByUserId([], " ")).toBeNull();
});
it("returns null when no entries match", () => {
const entries = [entry({ conversationId: "conv-1", aadObjectId: "other-user" })];
expect(findPreferredDmConversationByUserId(entries, "aad-1")).toBeNull();
});
it("returns a personal DM conversation by aadObjectId", () => {
const entries = [
entry({
conversationId: "dm-conv",
aadObjectId: "aad-target",
conversationType: "personal",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("returns a personal DM conversation by user.id", () => {
const entries = [
entry({
conversationId: "dm-conv",
userId: "user-target",
aadObjectId: "other",
conversationType: "personal",
}),
];
const result = findPreferredDmConversationByUserId(entries, "user-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("does NOT return a channel conversation for a user lookup (#54520)", () => {
// This is the core bug: user sends messages in both a DM and a channel.
// The channel conversation also carries the user's aadObjectId.
// findPreferredDmByUserId must NOT return the channel conversation.
const entries = [
entry({
conversationId: "19:channel@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "channel",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result).toBeNull();
});
it("does NOT return a groupChat conversation for a user lookup (#54520)", () => {
const entries = [
entry({
conversationId: "19:group@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "groupChat",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result).toBeNull();
});
it("prefers personal DM over channel even when channel is more recent (#54520)", () => {
// Reproduces the exact race: channel message arrives after DM, but the
// DM conversation should still be returned.
const entries = [
entry({
conversationId: "dm-conv",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
entry({
conversationId: "19:channel@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "channel",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("prefers personal DM over groupChat even when groupChat is more recent", () => {
const entries = [
entry({
conversationId: "dm-conv",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
entry({
conversationId: "19:group@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "groupChat",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("prefers the freshest personal DM when multiple exist", () => {
const entries = [
entry({
conversationId: "dm-old",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
entry({
conversationId: "dm-new",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-new");
});
it("falls back to unknown-type entries when no personal conversations exist", () => {
// Legacy entries without conversationType should still be usable as a
// fallback to avoid breaking existing deployments.
const entries = [
entry({
conversationId: "legacy-conv",
aadObjectId: "aad-target",
// No conversationType set (legacy entry)
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("legacy-conv");
});
it("prefers personal over unknown-type entries", () => {
const entries = [
entry({
conversationId: "legacy-conv",
aadObjectId: "aad-target",
lastSeenAt: "2026-03-25T21:00:00.000Z",
// No conversationType
}),
entry({
conversationId: "dm-conv",
aadObjectId: "aad-target",
conversationType: "personal",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result?.conversationId).toBe("dm-conv");
});
it("does NOT fall back to channel/group when no personal or unknown entries exist", () => {
const entries = [
entry({
conversationId: "19:channel@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "channel",
lastSeenAt: "2026-03-25T21:00:00.000Z",
}),
entry({
conversationId: "19:group@thread.tacv2",
aadObjectId: "aad-target",
conversationType: "groupChat",
lastSeenAt: "2026-03-25T20:00:00.000Z",
}),
];
const result = findPreferredDmConversationByUserId(entries, "aad-target");
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,106 @@
// Msteams helper module supports conversation store helpers behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
MSTeamsConversationStoreEntry,
StoredConversationReference,
} from "./conversation-store.js";
export function normalizeStoredConversationId(raw: string): string {
return raw.split(";")[0] ?? raw;
}
export function parseStoredConversationTimestamp(value: string | undefined): number | null {
if (!value) {
return null;
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
return null;
}
return parsed;
}
export function toConversationStoreEntries(
entries: Iterable<[string, StoredConversationReference]>,
): MSTeamsConversationStoreEntry[] {
return Array.from(entries, ([conversationId, reference]) => ({
conversationId,
reference,
}));
}
export function mergeStoredConversationReference(
existing: StoredConversationReference | undefined,
incoming: StoredConversationReference,
nowIso: string,
): StoredConversationReference {
return {
// Preserve fields from the previous entry that may not be present on every
// inbound activity. Without this, sparse activities (e.g. conversationUpdate,
// reactions) would clear previously captured values. Some fields are only
// populated opportunistically, such as timezone from clientInfo entities and
// graphChatId from Graph lookups used for DM media downloads.
...(existing?.timezone && !incoming.timezone ? { timezone: existing.timezone } : {}),
...(existing?.graphChatId && !incoming.graphChatId
? { graphChatId: existing.graphChatId }
: {}),
...(existing?.tenantId && !incoming.tenantId ? { tenantId: existing.tenantId } : {}),
...(existing?.aadObjectId && !incoming.aadObjectId
? { aadObjectId: existing.aadObjectId }
: {}),
...incoming,
lastSeenAt: nowIso,
};
}
export function findPreferredDmConversationByUserId(
entries: Iterable<MSTeamsConversationStoreEntry>,
id: string,
): MSTeamsConversationStoreEntry | null {
const target = id.trim();
if (!target) {
return null;
}
// Partition user matches into DM-safe and non-DM buckets.
// Channel and group conversations also carry the sender's aadObjectId, but
// returning one of those when the caller asked for a user-targeted DM would
// leak the reply into a shared channel -- the root cause of #54520.
const personalMatches: MSTeamsConversationStoreEntry[] = [];
const unknownTypeMatches: MSTeamsConversationStoreEntry[] = [];
for (const entry of entries) {
if (entry.reference.user?.aadObjectId !== target && entry.reference.user?.id !== target) {
continue;
}
const convType = normalizeLowercaseStringOrEmpty(
entry.reference.conversation?.conversationType ?? "",
);
if (convType === "personal") {
personalMatches.push(entry);
} else if (convType === "channel" || convType === "groupchat") {
// Explicitly skip channel/group conversations -- these must never be
// returned for a user-targeted DM lookup.
} else {
// Legacy entries without conversationType are ambiguous. Include them
// as a fallback but rank below confirmed personal conversations.
unknownTypeMatches.push(entry);
}
}
// Prefer confirmed personal DMs, fall back to unknown-type entries.
const candidates = personalMatches.length > 0 ? personalMatches : unknownTypeMatches;
if (candidates.length === 0) {
return null;
}
// When multiple candidates exist, prefer the most recently seen one.
if (candidates.length > 1) {
candidates.sort(
(a, b) =>
(parseStoredConversationTimestamp(b.reference.lastSeenAt) ?? 0) -
(parseStoredConversationTimestamp(a.reference.lastSeenAt) ?? 0),
);
}
return candidates[0] ?? null;
}

View File

@@ -0,0 +1,250 @@
// Msteams tests cover conversation store state plugin behavior.
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { beforeEach, describe, expect, it } from "vitest";
import { createMSTeamsConversationStoreState } from "./conversation-store-state.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-support/runtime.js";
function conversationStateKey(conversationId: string): string {
return crypto.createHash("sha256").update(conversationId).digest("hex");
}
describe("msteams conversation store (plugin state)", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
setMSTeamsRuntime(msteamsRuntimeStub);
});
it("filters expired SQLite entries while preserving entries without lastSeenAt", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
const env: NodeJS.ProcessEnv = {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
};
const ref: StoredConversationReference = {
conversation: { id: "19:active@thread.tacv2" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1", aadObjectId: "aad1" },
};
const sqliteStore = createPluginStateKeyedStoreForTests<StoredConversationReference>(
"msteams",
{
namespace: "conversations",
maxEntries: 2000,
env,
},
);
await sqliteStore.register(conversationStateKey("19:active@thread.tacv2"), ref);
await sqliteStore.register(conversationStateKey("19:old@thread.tacv2"), {
...ref,
conversation: { id: "19:old@thread.tacv2" },
lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
});
await sqliteStore.register(conversationStateKey("19:legacy@thread.tacv2"), {
...ref,
conversation: { id: "19:legacy@thread.tacv2" },
});
const store = createMSTeamsConversationStoreState({ env, ttlMs: 1_000 });
const ids = (await store.list()).map((entry) => entry.conversationId).toSorted();
expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
expect(await store.get("19:old@thread.tacv2")).toBeNull();
const legacyConversation = await store.get("19:legacy@thread.tacv2");
if (!legacyConversation?.conversation) {
throw new Error("expected migrated legacy Teams conversation payload");
}
expect(legacyConversation.conversation.id).toBe("19:legacy@thread.tacv2");
await store.upsert("19:new@thread.tacv2", {
...ref,
conversation: { id: "19:new@thread.tacv2" },
});
const idsAfter = (await store.list()).map((entry) => entry.conversationId).toSorted();
expect(idsAfter).toEqual([
"19:active@thread.tacv2",
"19:legacy@thread.tacv2",
"19:new@thread.tacv2",
]);
await expect(
fs.promises.access(path.join(stateDir, "state", "openclaw.sqlite")),
).resolves.toBeUndefined();
});
it("ignores a stale legacy JSON file at runtime", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
const env: NodeJS.ProcessEnv = {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
};
const ref: StoredConversationReference = {
conversation: { id: "conv-current" },
channelId: "msteams",
serviceUrl: "https://service.example.com/current",
user: { id: "current-user" },
};
const filePath = path.join(stateDir, "msteams-conversations.json");
await fs.promises.writeFile(
filePath,
`${JSON.stringify({
version: 1,
conversations: {
"conv-current": {
...ref,
serviceUrl: "https://service.example.com/stale",
user: { id: "stale-user" },
},
},
})}\n`,
);
const sqliteStore = createPluginStateKeyedStoreForTests<StoredConversationReference>(
"msteams",
{
namespace: "conversations",
maxEntries: 2000,
env,
},
);
await sqliteStore.register(conversationStateKey("conv-current"), ref);
const store = createMSTeamsConversationStoreState({ env });
await expect(store.get("conv-current")).resolves.toEqual(ref);
await expect(fs.promises.access(filePath)).resolves.toBeUndefined();
});
it("hashes external conversation ids before using plugin-state keys", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
const longConversationId = `a:${"x".repeat(900)}`;
const store = createMSTeamsConversationStoreState({ stateDir });
await store.upsert(longConversationId, {
conversation: { conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "long-user" },
});
await expect(store.get(longConversationId)).resolves.toMatchObject({
conversation: { id: longConversationId },
user: { id: "long-user" },
});
});
it("serializes concurrent upserts so sparse activities do not drop preserved fields", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
const store = createMSTeamsConversationStoreState({ stateDir });
await store.upsert("conv-race", {
conversation: { id: "conv-race", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
graphChatId: "19:resolved@unq.gbl.spaces",
});
await Promise.all([
store.upsert("conv-race", {
conversation: { id: "conv-race", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
timezone: "Europe/London",
}),
store.upsert("conv-race", {
conversation: { id: "conv-race", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
tenantId: "tenant-1",
}),
]);
await expect(store.get("conv-race")).resolves.toMatchObject({
graphChatId: "19:resolved@unq.gbl.spaces",
timezone: "Europe/London",
tenantId: "tenant-1",
});
});
it("keeps newest conversations by lastSeenAt at the row cap", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
const env: NodeJS.ProcessEnv = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sqliteStore = createPluginStateKeyedStoreForTests<StoredConversationReference>(
"msteams",
{
namespace: "conversations",
maxEntries: 2000,
env,
},
);
for (let index = 0; index < 1000; index += 1) {
const id = `conv-${String(index).padStart(4, "0")}`;
await sqliteStore.register(conversationStateKey(id), {
conversation: { id },
channelId: "msteams",
serviceUrl: "https://service.example.com",
lastSeenAt: new Date(Date.UTC(2026, 1, 1, 0, 0, index)).toISOString(),
});
}
const store = createMSTeamsConversationStoreState({ env });
await store.upsert("conv-recent", {
conversation: { id: "conv-recent" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
});
const ids = (await store.list()).map((entry) => entry.conversationId);
expect(ids).toHaveLength(1000);
expect(ids).toContain("conv-recent");
expect(ids).not.toContain("conv-0000");
});
it("treats timestamp-less conversations as oldest during later cap pruning", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
const env: NodeJS.ProcessEnv = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sqliteStore = createPluginStateKeyedStoreForTests<StoredConversationReference>(
"msteams",
{
namespace: "conversations",
maxEntries: 2000,
env,
},
);
await sqliteStore.register(conversationStateKey("conv-legacy"), {
conversation: { id: "conv-legacy" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
});
for (let index = 0; index < 999; index += 1) {
const id = `conv-seen-${String(index).padStart(4, "0")}`;
await sqliteStore.register(conversationStateKey(id), {
conversation: { id },
channelId: "msteams",
serviceUrl: "https://service.example.com",
lastSeenAt: new Date(Date.UTC(2026, 1, 1, 0, 0, index)).toISOString(),
});
}
const store = createMSTeamsConversationStoreState({ env });
await store.upsert("conv-new", {
conversation: { id: "conv-new" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
});
const ids = (await store.list()).map((entry) => entry.conversationId);
expect(ids).toHaveLength(1000);
expect(ids).toContain("conv-new");
expect(ids).not.toContain("conv-legacy");
});
});

View File

@@ -0,0 +1,229 @@
// Msteams plugin module implements conversation store state behavior.
import crypto from "node:crypto";
import {
findPreferredDmConversationByUserId,
mergeStoredConversationReference,
normalizeStoredConversationId,
parseStoredConversationTimestamp,
toConversationStoreEntries,
} from "./conversation-store-helpers.js";
import type {
MSTeamsConversationStore,
MSTeamsConversationStoreEntry,
StoredConversationReference,
} from "./conversation-store.js";
import { getMSTeamsRuntime } from "./runtime.js";
import {
resolveMSTeamsSqliteStateEnv,
toPluginJsonValue,
withMSTeamsSqliteMutationLock,
} from "./sqlite-state.js";
export type MSTeamsLegacyConversationStoreData = {
version: 1;
conversations: Record<string, StoredConversationReference>;
};
export const MSTEAMS_CONVERSATIONS_LEGACY_FILENAME = "msteams-conversations.json";
export const MSTEAMS_CONVERSATIONS_NAMESPACE = "conversations";
export const MSTEAMS_MAX_CONVERSATIONS = 1000;
export const MSTEAMS_SQLITE_MAX_CONVERSATION_ROWS = MSTEAMS_MAX_CONVERSATIONS + 1000;
export const MSTEAMS_CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
const CONVERSATION_LOCK_FILENAME = "msteams-conversations.sqlite.lock";
type MSTeamsConversationStoreStateOptions = {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
ttlMs?: number;
stateDir?: string;
storePath?: string;
};
function createConversationStateStore(params?: MSTeamsConversationStoreStateOptions) {
return getMSTeamsRuntime().state.openKeyedStore<StoredConversationReference>({
namespace: MSTEAMS_CONVERSATIONS_NAMESPACE,
maxEntries: MSTEAMS_SQLITE_MAX_CONVERSATION_ROWS,
env: resolveMSTeamsSqliteStateEnv(params),
});
}
export function normalizeMSTeamsLegacyConversationStore(
value: MSTeamsLegacyConversationStoreData,
): MSTeamsLegacyConversationStoreData {
if (
value.version !== 1 ||
!value.conversations ||
typeof value.conversations !== "object" ||
Array.isArray(value.conversations)
) {
return { version: 1, conversations: {} };
}
return value;
}
export function buildMSTeamsConversationStateKey(conversationId: string): string {
return crypto.createHash("sha256").update(conversationId).digest("hex");
}
export function prepareMSTeamsConversationReferenceForStorage(
conversationId: string,
reference: StoredConversationReference,
): StoredConversationReference {
return {
...reference,
conversation: {
...reference.conversation,
id: conversationId,
},
};
}
function getStoredConversationId(reference: StoredConversationReference): string | null {
const rawId = reference.conversation?.id;
return rawId ? normalizeStoredConversationId(rawId) : null;
}
export function selectRetainedMSTeamsConversations(
conversations: Record<string, StoredConversationReference>,
ttlMs = MSTEAMS_CONVERSATION_TTL_MS,
): Array<[string, StoredConversationReference]> {
const retained = Object.entries(conversations).filter(([, reference]) => {
const lastSeenAt = parseStoredConversationTimestamp(reference.lastSeenAt);
return lastSeenAt == null || Date.now() - lastSeenAt <= ttlMs;
});
if (retained.length <= MSTEAMS_MAX_CONVERSATIONS) {
return retained;
}
retained.sort((a, b) => {
const aTs = parseStoredConversationTimestamp(a[1].lastSeenAt) ?? 0;
const bTs = parseStoredConversationTimestamp(b[1].lastSeenAt) ?? 0;
return aTs - bTs || a[0].localeCompare(b[0]);
});
return retained.slice(retained.length - MSTEAMS_MAX_CONVERSATIONS);
}
export function createMSTeamsConversationStoreState(
params?: MSTeamsConversationStoreStateOptions,
): MSTeamsConversationStore {
const ttlMs = params?.ttlMs ?? MSTEAMS_CONVERSATION_TTL_MS;
const conversationStore = createConversationStateStore(params);
const isExpired = (reference: StoredConversationReference): boolean => {
const lastSeenAt = parseStoredConversationTimestamp(reference.lastSeenAt);
// Preserve migrated legacy entries that have no lastSeenAt until they're seen again.
return lastSeenAt != null && Date.now() - lastSeenAt > ttlMs;
};
const lookupStored = async (
conversationId: string,
): Promise<StoredConversationReference | null> => {
const normalizedId = normalizeStoredConversationId(conversationId);
const value = await conversationStore.lookup(buildMSTeamsConversationStateKey(normalizedId));
if (!value) {
return null;
}
if (isExpired(value)) {
return null;
}
return value;
};
const entries = async (): Promise<Array<[string, StoredConversationReference]>> => {
const rows = await conversationStore.entries();
const kept: Array<[string, StoredConversationReference]> = [];
for (const row of rows) {
if (isExpired(row.value)) {
continue;
}
const conversationId = getStoredConversationId(row.value);
if (conversationId) {
kept.push([conversationId, row.value]);
}
}
return kept;
};
const lookup = async (conversationId: string): Promise<StoredConversationReference | null> => {
return await lookupStored(conversationId);
};
const register = async (
conversationId: string,
reference: StoredConversationReference,
): Promise<void> => {
const normalizedId = normalizeStoredConversationId(conversationId);
await conversationStore.register(
buildMSTeamsConversationStateKey(normalizedId),
toPluginJsonValue(prepareMSTeamsConversationReferenceForStorage(normalizedId, reference)),
);
const rows = [];
for (const row of await conversationStore.entries()) {
if (isExpired(row.value)) {
await conversationStore.delete(row.key);
continue;
}
rows.push(row);
}
if (rows.length <= MSTEAMS_MAX_CONVERSATIONS) {
return;
}
const sorted = rows.toSorted((a, b) => {
const aTs = parseStoredConversationTimestamp(a.value.lastSeenAt) ?? 0;
const bTs = parseStoredConversationTimestamp(b.value.lastSeenAt) ?? 0;
const aId = getStoredConversationId(a.value) ?? a.key;
const bId = getStoredConversationId(b.value) ?? b.key;
return aTs - bTs || aId.localeCompare(bId);
});
for (const row of sorted.slice(0, rows.length - MSTEAMS_MAX_CONVERSATIONS)) {
await conversationStore.delete(row.key);
}
};
const list = async (): Promise<MSTeamsConversationStoreEntry[]> => {
return toConversationStoreEntries(await entries());
};
const get = async (conversationId: string): Promise<StoredConversationReference | null> => {
return await lookup(conversationId);
};
const findPreferredDmByUserId = async (
id: string,
): Promise<MSTeamsConversationStoreEntry | null> => {
return findPreferredDmConversationByUserId(await list(), id);
};
const upsert = async (
conversationId: string,
reference: StoredConversationReference,
): Promise<void> => {
const normalizedId = normalizeStoredConversationId(conversationId);
await withMSTeamsSqliteMutationLock(params, CONVERSATION_LOCK_FILENAME, async () => {
const existing = await lookupStored(normalizedId);
await register(
normalizedId,
mergeStoredConversationReference(
existing ?? undefined,
reference,
new Date().toISOString(),
),
);
});
};
const remove = async (conversationId: string): Promise<boolean> => {
const normalizedId = normalizeStoredConversationId(conversationId);
return await withMSTeamsSqliteMutationLock(params, CONVERSATION_LOCK_FILENAME, async () => {
return await conversationStore.delete(buildMSTeamsConversationStateKey(normalizedId));
});
};
return {
upsert,
get,
list,
remove,
findPreferredDmByUserId,
findByUserId: findPreferredDmByUserId,
};
}

View File

@@ -0,0 +1,305 @@
// Msteams tests cover conversation store.shared plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
findPreferredDmConversationByUserId,
mergeStoredConversationReference,
normalizeStoredConversationId,
toConversationStoreEntries,
} from "./conversation-store-helpers.js";
import { createMSTeamsConversationStoreState } from "./conversation-store-state.js";
import type {
MSTeamsConversationStore,
MSTeamsConversationStoreEntry,
StoredConversationReference,
} from "./conversation-store.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-support/runtime.js";
type StoreFactory = {
name: string;
createStore: () => Promise<MSTeamsConversationStore>;
};
function createMemoryConversationStore(
initial: MSTeamsConversationStoreEntry[] = [],
): MSTeamsConversationStore {
const map = new Map<string, StoredConversationReference>();
for (const { conversationId, reference } of initial) {
map.set(normalizeStoredConversationId(conversationId), reference);
}
const findPreferredDmByUserId = async (
id: string,
): Promise<MSTeamsConversationStoreEntry | null> =>
findPreferredDmConversationByUserId(toConversationStoreEntries(map.entries()), id);
return {
upsert: async (conversationId, reference) => {
const normalizedId = normalizeStoredConversationId(conversationId);
map.set(
normalizedId,
mergeStoredConversationReference(
map.get(normalizedId),
reference,
new Date().toISOString(),
),
);
},
get: async (conversationId) => map.get(normalizeStoredConversationId(conversationId)) ?? null,
list: async () => toConversationStoreEntries(map.entries()),
remove: async (conversationId) => map.delete(normalizeStoredConversationId(conversationId)),
findPreferredDmByUserId,
findByUserId: findPreferredDmByUserId,
};
}
const storeFactories: StoreFactory[] = [
{
name: "state",
createStore: async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
return createMSTeamsConversationStoreState({
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
ttlMs: 60_000,
});
},
},
{
name: "memory",
createStore: async () => createMemoryConversationStore(),
},
];
describe.each(storeFactories)("msteams conversation store ($name)", ({ createStore }) => {
beforeEach(() => {
resetPluginStateStoreForTests();
setMSTeamsRuntime(msteamsRuntimeStub);
});
it("normalizes conversation ids consistently", async () => {
const store = await createStore();
await store.upsert("conv-norm;messageid=123", {
conversation: { id: "conv-norm" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
});
const normalized = await store.get("conv-norm");
expect(normalized).toEqual({
conversation: { id: "conv-norm" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
lastSeenAt: normalized?.lastSeenAt,
});
expect(typeof normalized?.lastSeenAt).toBe("string");
await expect(store.remove("conv-norm")).resolves.toBe(true);
await expect(store.get("conv-norm;messageid=123")).resolves.toBeNull();
});
it("upserts, lists, removes, and resolves users by both AAD and Bot Framework ids", async () => {
const store = await createStore();
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-03-25T20:00:00.000Z"));
await store.upsert("conv-a", {
conversation: { id: "conv-a" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
});
vi.setSystemTime(new Date("2026-03-25T20:00:30.000Z"));
await store.upsert("conv-b", {
conversation: { id: "conv-b" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
});
await expect(store.get("conv-a")).resolves.toEqual({
conversation: { id: "conv-a" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
lastSeenAt: "2026-03-25T20:00:00.000Z",
});
await expect(store.list()).resolves.toEqual([
{
conversationId: "conv-a",
reference: {
conversation: { id: "conv-a" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
lastSeenAt: "2026-03-25T20:00:00.000Z",
},
},
{
conversationId: "conv-b",
reference: {
conversation: { id: "conv-b" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
lastSeenAt: "2026-03-25T20:00:30.000Z",
},
},
]);
await expect(store.findPreferredDmByUserId(" aad-b ")).resolves.toEqual({
conversationId: "conv-b",
reference: {
conversation: { id: "conv-b" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-b", aadObjectId: "aad-b", name: "Bob" },
lastSeenAt: "2026-03-25T20:00:30.000Z",
},
});
await expect(store.findPreferredDmByUserId("user-a")).resolves.toEqual({
conversationId: "conv-a",
reference: {
conversation: { id: "conv-a" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-a", aadObjectId: "aad-a", name: "Alice" },
lastSeenAt: "2026-03-25T20:00:00.000Z",
},
});
await expect(store.findByUserId("user-a")).resolves.toEqual(
await store.findPreferredDmByUserId("user-a"),
);
await expect(store.findPreferredDmByUserId(" ")).resolves.toBeNull();
await expect(store.remove("conv-a")).resolves.toBe(true);
await expect(store.get("conv-a")).resolves.toBeNull();
await expect(store.remove("missing")).resolves.toBe(false);
} finally {
vi.useRealTimers();
}
});
it("preserves existing timezone when upsert omits timezone", async () => {
const store = await createStore();
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-03-25T20:00:00.000Z"));
await store.upsert("conv-tz", {
conversation: { id: "conv-tz" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
timezone: "Europe/London",
});
vi.setSystemTime(new Date("2026-03-25T20:01:00.000Z"));
await store.upsert("conv-tz", {
conversation: { id: "conv-tz" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
});
await expect(store.get("conv-tz")).resolves.toEqual({
conversation: { id: "conv-tz" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
timezone: "Europe/London",
lastSeenAt: "2026-03-25T20:01:00.000Z",
});
} finally {
vi.useRealTimers();
}
});
it("preserves graphChatId across upserts that omit it", async () => {
const store = await createStore();
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-03-25T20:00:00.000Z"));
await store.upsert("conv-graph", {
conversation: { id: "conv-graph", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
});
vi.setSystemTime(new Date("2026-03-25T20:01:00.000Z"));
// Second upsert without graphChatId (normal activity-based upsert)
await store.upsert("conv-graph", {
conversation: { id: "conv-graph", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
});
await expect(store.get("conv-graph")).resolves.toEqual({
conversation: { id: "conv-graph", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1" },
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
lastSeenAt: "2026-03-25T20:01:00.000Z",
});
} finally {
vi.useRealTimers();
}
});
it("prefers the freshest personal conversation for repeated upserts of the same user", async () => {
const store = await createStore();
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-03-25T20:00:00.000Z"));
await store.upsert("dm-old", {
conversation: { id: "dm-old", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-shared-old", aadObjectId: "aad-shared", name: "Old DM" },
});
vi.setSystemTime(new Date("2026-03-25T20:30:00.000Z"));
await store.upsert("group-shared", {
conversation: { id: "group-shared", conversationType: "groupChat" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-shared-group", aadObjectId: "aad-shared", name: "Group" },
});
vi.setSystemTime(new Date("2026-03-25T21:00:00.000Z"));
await store.upsert("dm-new", {
conversation: { id: "dm-new", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-shared-new", aadObjectId: "aad-shared", name: "New DM" },
});
await expect(store.findPreferredDmByUserId("aad-shared")).resolves.toEqual({
conversationId: "dm-new",
reference: {
conversation: { id: "dm-new", conversationType: "personal" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "user-shared-new", aadObjectId: "aad-shared", name: "New DM" },
lastSeenAt: "2026-03-25T21:00:00.000Z",
},
});
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,71 @@
/**
* Conversation store for MS Teams proactive messaging.
*
* Stores ConversationReference-like objects keyed by conversation ID so we can
* send proactive messages later (after the webhook turn has completed).
*/
/** Minimal ConversationReference shape for proactive messaging */
export type StoredConversationReference = {
/** Timestamp when this reference was last seen/updated. */
lastSeenAt?: string;
/** Activity ID from the last message */
activityId?: string;
/** Channel thread root activity ID for threaded replies. */
threadId?: string;
/** User who sent the message */
user?: { id?: string; name?: string; aadObjectId?: string };
/** 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 };
/**
* Tenant ID sourced from `activity.channelData.tenant.id` at inbound time.
* Bot Framework requires this on outbound proactive messages so the connector
* can route them to the correct Azure AD tenant; without it, the connector
* rejects the request with HTTP 403. For channel activities, `conversation.tenantId`
* is often unset, making `channelData.tenant.id` the reliable source.
*/
tenantId?: string;
/**
* Azure AD object ID of the user who sent the last inbound activity,
* mirrored from `activity.from.aadObjectId` so outbound proactive sends
* can include it on the connector request (required for personal DMs).
*/
aadObjectId?: string;
/** Team ID for channel messages (when available). */
teamId?: string;
/** Channel ID (usually "msteams") */
channelId?: string;
/** Service URL for sending messages back */
serviceUrl?: string;
/** Locale */
locale?: string;
/**
* Cached Graph API chat ID (format: `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces`).
* Bot Framework conversation IDs for personal DMs use a different format (`a:1xxx` or
* `8:orgid:xxx`) that the Graph API does not accept. This field caches the resolved
* Graph-native chat ID so we don't need to re-query the API on every send.
*/
graphChatId?: string;
/** IANA timezone from Teams clientInfo entity (e.g. "America/New_York") */
timezone?: string;
};
export type MSTeamsConversationStoreEntry = {
conversationId: string;
reference: StoredConversationReference;
};
export type MSTeamsConversationStore = {
upsert: (conversationId: string, reference: StoredConversationReference) => Promise<void>;
get: (conversationId: string) => Promise<StoredConversationReference | null>;
list: () => Promise<MSTeamsConversationStoreEntry[]>;
remove: (conversationId: string) => Promise<boolean>;
/** Person-targeted proactive lookup: prefer the freshest personal DM reference. */
findPreferredDmByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
/** @deprecated Use `findPreferredDmByUserId` for proactive user-targeted sends. */
findByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
};

View File

@@ -0,0 +1,157 @@
// Msteams tests cover directory live plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
searchGraphUsersMock,
listTeamsByNameMock,
listChannelsForTeamMock,
normalizeQueryMock,
resolveGraphTokenMock,
} = vi.hoisted(() => {
return {
searchGraphUsersMock: vi.fn(),
listTeamsByNameMock: vi.fn(),
listChannelsForTeamMock: vi.fn(),
normalizeQueryMock: vi.fn((value?: string | null) => value?.trim() ?? ""),
resolveGraphTokenMock: vi.fn(),
};
});
vi.mock("./graph-users.js", () => {
return { searchGraphUsers: searchGraphUsersMock };
});
vi.mock("./graph.js", () => {
return {
listTeamsByName: listTeamsByNameMock,
listChannelsForTeam: listChannelsForTeamMock,
normalizeQuery: normalizeQueryMock,
resolveGraphToken: resolveGraphTokenMock,
};
});
import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js";
describe("msteams directory live", () => {
beforeEach(() => {
vi.clearAllMocks();
normalizeQueryMock.mockImplementation((value?: string | null) => value?.trim() ?? "");
});
it("returns normalized peer entries and skips users without ids", async () => {
resolveGraphTokenMock.mockResolvedValue("graph-token");
searchGraphUsersMock.mockResolvedValue([
{
id: "user-1",
displayName: "Alice",
userPrincipalName: "alice@example.com",
},
{
id: "user-2",
displayName: "Bob",
mail: "bob@example.com",
},
{
displayName: "Missing Id",
},
]);
await expect(
listMSTeamsDirectoryPeersLive({
cfg: {},
query: " ali ",
}),
).resolves.toEqual([
{
kind: "user",
id: "user:user-1",
name: "Alice",
handle: "@alice@example.com",
raw: {
id: "user-1",
displayName: "Alice",
userPrincipalName: "alice@example.com",
},
},
{
kind: "user",
id: "user:user-2",
name: "Bob",
handle: "@bob@example.com",
raw: {
id: "user-2",
displayName: "Bob",
mail: "bob@example.com",
},
},
]);
expect(searchGraphUsersMock).toHaveBeenCalledWith({
token: "graph-token",
query: "ali",
top: 20,
});
});
it("returns team entries without channel queries and honors limits", async () => {
resolveGraphTokenMock.mockResolvedValue("graph-token");
listTeamsByNameMock.mockResolvedValue([
{ id: "team-1", displayName: "Platform" },
{ id: "team-2", displayName: "Infra" },
]);
await expect(
listMSTeamsDirectoryGroupsLive({
cfg: {},
query: "platform",
limit: 1,
}),
).resolves.toEqual([
{
kind: "group",
id: "team:team-1",
name: "Platform",
handle: "#Platform",
raw: { id: "team-1", displayName: "Platform" },
},
]);
});
it("searches channels within matching teams when a team/channel query is used", async () => {
resolveGraphTokenMock.mockResolvedValue("graph-token");
listTeamsByNameMock.mockResolvedValue([
{ id: "team-1", displayName: "Platform" },
{ id: "team-2", displayName: "Infra" },
]);
listChannelsForTeamMock
.mockResolvedValueOnce([
{ id: "chan-1", displayName: "Deployments" },
{ id: "chan-2", displayName: "General" },
])
.mockResolvedValueOnce([{ id: "chan-3", displayName: "Deployments-West" }]);
await expect(
listMSTeamsDirectoryGroupsLive({
cfg: {},
query: "plat / deploy",
}),
).resolves.toEqual([
{
kind: "group",
id: "conversation:chan-1",
name: "Platform/Deployments",
handle: "#Deployments",
raw: { id: "chan-1", displayName: "Deployments" },
},
{
kind: "group",
id: "conversation:chan-3",
name: "Infra/Deployments-West",
handle: "#Deployments-West",
raw: { id: "chan-3", displayName: "Deployments-West" },
},
]);
expect(listTeamsByNameMock).toHaveBeenCalledWith("graph-token", "plat");
});
});

View File

@@ -0,0 +1,112 @@
// Msteams plugin module implements directory live behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ChannelDirectoryEntry } from "../runtime-api.js";
import { searchGraphUsers } from "./graph-users.js";
import {
listChannelsForTeam,
listTeamsByName,
normalizeQuery,
resolveGraphToken,
} from "./graph.js";
export async function listMSTeamsDirectoryPeersLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) {
return [];
}
const token = await resolveGraphToken(params.cfg);
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
const users = await searchGraphUsers({ token, query, top: limit });
return users
.map((user) => {
const id = user.id?.trim();
if (!id) {
return null;
}
const name = user.displayName?.trim();
const handle = user.userPrincipalName?.trim() || user.mail?.trim();
return {
kind: "user",
id: `user:${id}`,
name: name || undefined,
handle: handle ? `@${handle}` : undefined,
raw: user,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
export async function listMSTeamsDirectoryGroupsLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const rawQuery = normalizeQuery(params.query);
if (!rawQuery) {
return [];
}
const token = await resolveGraphToken(params.cfg);
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
const [teamQuery, channelQuery] = rawQuery.includes("/")
? normalizeStringEntries(rawQuery.split("/", 2))
: [rawQuery, null];
const teams = await listTeamsByName(token, teamQuery);
const results: ChannelDirectoryEntry[] = [];
for (const team of teams) {
const teamId = team.id?.trim();
if (!teamId) {
continue;
}
const teamName = team.displayName?.trim() || teamQuery;
if (!channelQuery) {
results.push({
kind: "group",
id: `team:${teamId}`,
name: teamName,
handle: teamName ? `#${teamName}` : undefined,
raw: team,
});
if (results.length >= limit) {
return results;
}
continue;
}
const channels = await listChannelsForTeam(token, teamId);
for (const channel of channels) {
const name = channel.displayName?.trim();
if (!name) {
continue;
}
if (
!normalizeLowercaseStringOrEmpty(name).includes(
normalizeLowercaseStringOrEmpty(channelQuery),
)
) {
continue;
}
results.push({
kind: "group",
id: `conversation:${channel.id}`,
name: `${teamName}/${name}`,
handle: `#${name}`,
raw: channel,
});
if (results.length >= limit) {
return results;
}
}
}
return results;
}

View File

@@ -0,0 +1,28 @@
// Msteams plugin module implements doctor behavior.
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
function isMSTeamsMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim();
return /\s/.test(withoutPrefix) || withoutPrefix.includes("@");
}
export const collectMSTeamsMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "msteams",
detector: isMSTeamsMutableAllowEntry,
collectLists: (scope) => [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
],
});

View File

@@ -0,0 +1,214 @@
// Msteams tests cover errors plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
classifyMSTeamsSendError,
formatMSTeamsSendErrorHint,
formatUnknownError,
isRevokedProxyError,
} from "./errors.js";
import { withRevokedProxyFallback } from "./revoked-context.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 ContentStreamNotAllowed as permanent instead of auth", () => {
const result = classifyMSTeamsSendError({
statusCode: 403,
response: {
body: {
error: {
code: "ContentStreamNotAllowed",
},
},
},
});
expect(result.kind).toBe("permanent");
expect(result.statusCode).toBe(403);
expect(result.errorCode).toBe("ContentStreamNotAllowed");
});
it("classifies throttling errors and parses retry-after", () => {
const result = classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5" });
expect(result.kind).toBe("throttled");
expect(result.statusCode).toBe(429);
expect(result.retryAfterMs).toBe(1500);
});
it("does not parse partial retry-after values", () => {
expect(
classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5s" }).retryAfterMs,
).toBeUndefined();
expect(
classifyMSTeamsSendError({
statusCode: 429,
response: { headers: { "retry-after": "2 seconds" } },
}).retryAfterMs,
).toBeUndefined();
expect(
classifyMSTeamsSendError({
statusCode: 429,
response: { headers: new Headers({ "retry-after": "3 seconds" }) },
}).retryAfterMs,
).toBeUndefined();
});
it("ignores unsafe retry-after magnitudes", () => {
expect(
classifyMSTeamsSendError({
statusCode: 429,
retryAfterMs: Number.MAX_SAFE_INTEGER + 1,
}).retryAfterMs,
).toBeUndefined();
expect(
classifyMSTeamsSendError({
statusCode: 429,
retryAfter: Number.MAX_SAFE_INTEGER,
}).retryAfterMs,
).toBeUndefined();
expect(
classifyMSTeamsSendError({
statusCode: 429,
retryAfter: "9007199254741",
}).retryAfterMs,
).toBeUndefined();
expect(
classifyMSTeamsSendError({
statusCode: 429,
response: { headers: { "retry-after": "9007199254741" } },
}).retryAfterMs,
).toBeUndefined();
expect(
classifyMSTeamsSendError({
statusCode: 429,
response: { headers: new Headers({ "retry-after": "9007199254741" }) },
}).retryAfterMs,
).toBeUndefined();
});
it("does not parse partial or fractional status codes", () => {
expect(classifyMSTeamsSendError({ statusCode: "429oops" }).kind).toBe("unknown");
expect(classifyMSTeamsSendError({ statusCode: 429.5 }).kind).toBe("unknown");
expect(
classifyMSTeamsSendError({ response: { status: "503 temporarily unavailable" } }).kind,
).toBe("unknown");
});
it("classifies transient errors", () => {
const result = classifyMSTeamsSendError({ statusCode: 503 });
expect(result.kind).toBe("transient");
expect(result.statusCode).toBe(503);
});
it("classifies permanent 4xx errors", () => {
const result = classifyMSTeamsSendError({ statusCode: 400 });
expect(result.kind).toBe("permanent");
expect(result.statusCode).toBe(400);
});
it("provides actionable hints for common cases", () => {
expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
expect(
formatMSTeamsSendErrorHint({
kind: "permanent",
errorCode: "ContentStreamNotAllowed",
}),
).toContain("expired the content stream");
});
it("classifies transport-level network errors and provides smba egress hint (#77674)", () => {
const econnrefused = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" });
const enotfound = Object.assign(new Error("getaddrinfo ENOTFOUND smba.trafficmanager.net"), {
code: "ENOTFOUND",
});
const etimedout = Object.assign(new Error("ETIMEDOUT"), { code: "ETIMEDOUT" });
const econnrefusedResult = classifyMSTeamsSendError(econnrefused);
expect(econnrefusedResult.kind).toBe("network");
expect(econnrefusedResult.errorCode).toBe("ECONNREFUSED");
const enotfoundResult = classifyMSTeamsSendError(enotfound);
expect(enotfoundResult.kind).toBe("network");
expect(enotfoundResult.errorCode).toBe("ENOTFOUND");
const etimedoutResult = classifyMSTeamsSendError(etimedout);
expect(etimedoutResult.kind).toBe("network");
expect(etimedoutResult.errorCode).toBe("ETIMEDOUT");
// Hints for network errors must mention smba (Connector endpoint) and egress
expect(formatMSTeamsSendErrorHint({ kind: "network" })).toContain("smba");
expect(formatMSTeamsSendErrorHint({ kind: "network" })).toContain("egress");
});
it("still classifies HTTP errors as unknown when no status code and no network code", () => {
expect(classifyMSTeamsSendError(new Error("unexpected error")).kind).toBe("unknown");
expect(classifyMSTeamsSendError(null).kind).toBe("unknown");
});
describe("isRevokedProxyError", () => {
it("returns true for revoked proxy TypeError", () => {
expect(
isRevokedProxyError(new TypeError("Cannot perform 'set' on a proxy that has been revoked")),
).toBe(true);
expect(
isRevokedProxyError(new TypeError("Cannot perform 'get' on a proxy that has been revoked")),
).toBe(true);
});
it("returns false for non-TypeError errors", () => {
expect(isRevokedProxyError(new Error("proxy that has been revoked"))).toBe(false);
});
it("returns false for unrelated TypeErrors", () => {
expect(isRevokedProxyError(new TypeError("undefined is not a function"))).toBe(false);
});
it("returns false for non-error values", () => {
expect(isRevokedProxyError(null)).toBe(false);
expect(isRevokedProxyError("proxy that has been revoked")).toBe(false);
});
});
describe("withRevokedProxyFallback", () => {
it("returns primary result when no error occurs", async () => {
await expect(
withRevokedProxyFallback({
run: async () => "ok",
onRevoked: async () => "fallback",
}),
).resolves.toBe("ok");
});
it("uses fallback when proxy-revoked TypeError is thrown", async () => {
const onRevokedLog = vi.fn();
await expect(
withRevokedProxyFallback({
run: async () => {
throw new TypeError("Cannot perform 'get' on a proxy that has been revoked");
},
onRevoked: async () => "fallback",
onRevokedLog,
}),
).resolves.toBe("fallback");
expect(onRevokedLog).toHaveBeenCalledOnce();
});
it("rethrows non-revoked errors", async () => {
const err = Object.assign(new Error("boom"), { statusCode: 500 });
await expect(
withRevokedProxyFallback({
run: async () => {
throw err;
},
onRevoked: async () => "fallback",
}),
).rejects.toBe(err);
});
});
});

View File

@@ -0,0 +1,294 @@
// Msteams plugin module implements errors behavior.
import { asFiniteNumberInRange, parseStrictFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
const MAX_SAFE_RETRY_AFTER_SECONDS = Number.MAX_SAFE_INTEGER / 1000;
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 extractStatusCode(err: unknown): number | null {
if (!isRecord(err)) {
return null;
}
const parseStatusCode = (value: unknown): number | null => {
if (typeof value === "number") {
return Number.isInteger(value) && value >= 100 && value <= 599 ? value : null;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!/^\d{3}$/.test(trimmed)) {
return null;
}
const parsed = Number(trimmed);
return parsed >= 100 && parsed <= 599 ? parsed : null;
}
return null;
};
const direct = err.statusCode ?? err.status;
const directStatus = parseStatusCode(direct);
if (directStatus !== null) {
return directStatus;
}
const response = err.response;
if (isRecord(response)) {
const responseStatus = parseStatusCode(response.status);
if (responseStatus !== null) {
return responseStatus;
}
}
return null;
}
function extractErrorCode(err: unknown): string | null {
if (!isRecord(err)) {
return null;
}
const direct = err.code;
if (typeof direct === "string" && direct.trim()) {
return direct;
}
const response = err.response;
if (!isRecord(response)) {
return null;
}
const body = response.body;
if (isRecord(body)) {
const error = body.error;
if (isRecord(error) && typeof error.code === "string" && error.code.trim()) {
return error.code;
}
}
return null;
}
function extractRetryAfterMs(err: unknown): number | null {
if (!isRecord(err)) {
return null;
}
const direct = err.retryAfterMs ?? err.retry_after_ms;
const directMs = asFiniteNumberInRange(direct, {
min: 0,
max: Number.MAX_SAFE_INTEGER,
});
if (directMs !== undefined) {
return directMs;
}
const retryAfter = err.retryAfter ?? err.retry_after;
const retryAfterSeconds = asFiniteNumberInRange(retryAfter, {
min: 0,
max: MAX_SAFE_RETRY_AFTER_SECONDS,
});
if (retryAfterSeconds !== undefined) {
return retryAfterSeconds * 1000;
}
if (typeof retryAfter === "string") {
const parsed = parseNonNegativeRetryAfterSeconds(retryAfter);
if (parsed !== undefined) {
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 = parseNonNegativeRetryAfterSeconds(raw);
if (parsed !== undefined) {
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 = parseNonNegativeRetryAfterSeconds(raw);
if (parsed !== undefined) {
return parsed * 1000;
}
}
}
return null;
}
function parseNonNegativeRetryAfterSeconds(raw: string): number | undefined {
const trimmed = raw.trim();
if (!/^\d+(?:\.\d+)?$/.test(trimmed)) {
return undefined;
}
return asFiniteNumberInRange(parseStrictFiniteNumber(trimmed), {
min: 0,
max: MAX_SAFE_RETRY_AFTER_SECONDS,
});
}
type MSTeamsSendErrorKind =
| "auth"
| "throttled"
| "transient"
| "permanent"
| "network"
| "unknown";
type MSTeamsSendErrorClassification = {
kind: MSTeamsSendErrorKind;
statusCode?: number;
retryAfterMs?: number;
errorCode?: string;
};
/**
* 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);
const errorCode = extractErrorCode(err) ?? undefined;
if (statusCode === 401) {
return { kind: "auth", statusCode, errorCode };
}
if (statusCode === 403) {
if (errorCode === "ContentStreamNotAllowed") {
return { kind: "permanent", statusCode, errorCode };
}
return { kind: "auth", statusCode, errorCode };
}
if (statusCode === 429) {
return {
kind: "throttled",
statusCode,
retryAfterMs: retryAfterMs ?? undefined,
errorCode,
};
}
if (statusCode === 408 || (statusCode != null && statusCode >= 500)) {
return {
kind: "transient",
statusCode,
retryAfterMs: retryAfterMs ?? undefined,
errorCode,
};
}
if (statusCode != null && statusCode >= 400) {
return { kind: "permanent", statusCode, errorCode };
}
// Transport-level errors (no HTTP status code) — check for well-known
// network error codes that indicate egress is blocked (#77674).
if (statusCode == null) {
const networkCode = isRecord(err) && typeof err.code === "string" ? err.code : null;
if (
networkCode === "ECONNREFUSED" ||
networkCode === "ENOTFOUND" ||
networkCode === "EHOSTUNREACH" ||
networkCode === "ETIMEDOUT" ||
networkCode === "ECONNRESET"
) {
return { kind: "network", errorCode: networkCode };
}
}
return {
kind: "unknown",
statusCode: statusCode ?? undefined,
retryAfterMs: retryAfterMs ?? undefined,
errorCode,
};
}
/**
* Detect whether an error is caused by a revoked Proxy.
*
* The Bot Framework SDK wraps TurnContext in a Proxy that is revoked once the
* turn handler returns. Any later access (e.g. from a debounced callback)
* throws a TypeError whose message contains the distinctive "proxy that has
* been revoked" string.
*/
export function isRevokedProxyError(err: unknown): boolean {
if (!(err instanceof TypeError)) {
return false;
}
return /proxy that has been revoked/i.test(err.message);
}
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.errorCode === "ContentStreamNotAllowed") {
return "Teams expired the content stream; stop streaming earlier and fall back to normal message delivery";
}
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";
}
if (classification.kind === "network") {
return "transport-level failure sending reply to Teams Bot Connector (smba.trafficmanager.net) — check egress firewall rules allow outbound HTTPS to smba.trafficmanager.net";
}
return undefined;
}

View File

@@ -0,0 +1,201 @@
// Msteams plugin module implements feedback invoke behavior.
import path from "node:path";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatUnknownError } from "./errors.js";
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
import { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js";
import { isFeedbackInvokeAuthorized } from "./monitor-handler.js";
import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js";
import { getMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
/**
* Run the message-submit (feedback) invoke handler.
*
* Teams delivers feedback (`actionName === "feedback"`) on AI-generated
* messages as a `message/submitAction` invoke. The SDK wraps a void return
* into the HTTP 200 InvokeResponse, so this function intentionally does
* not ack itself — the legacy `ctx.sendActivity({ type: "invokeResponse",
* … })` shape is gone (it became an outbound BF activity on the new SDK
* instead of the HTTP response).
*
* Returns `true` if the invoke matched the feedback shape and was
* consumed (whether or not it was authorized / written / reflected on),
* `false` if the invoke didn't look like feedback at all and the caller
* should fall through to other handlers.
*/
export async function runMSTeamsFeedbackInvokeHandler(
context: MSTeamsTurnContext,
deps: MSTeamsMessageHandlerDeps,
): Promise<boolean> {
const activity = context.activity;
const value = activity.value as
| {
actionName?: string;
actionValue?: { reaction?: string; feedback?: string };
replyToId?: string;
}
| undefined;
if (!value) {
return false;
}
// Teams feedback invoke format: actionName="feedback", actionValue.reaction="like"|"dislike"
if (value.actionName !== "feedback") {
return false;
}
const reaction = value.actionValue?.reaction;
if (reaction !== "like" && reaction !== "dislike") {
deps.log.debug?.("ignoring feedback with unknown reaction", { reaction });
return false;
}
const msteamsCfg = deps.cfg.channels?.msteams;
if (msteamsCfg?.feedbackEnabled === false) {
deps.log.debug?.("feedback handling disabled");
return true; // Still consume the invoke
}
if (!(await isFeedbackInvokeAuthorized(context, deps))) {
return true;
}
// Extract user comment from the nested JSON string
let userComment: string | undefined;
if (value.actionValue?.feedback) {
try {
const parsed = JSON.parse(value.actionValue.feedback) as { feedbackText?: string };
userComment = parsed.feedbackText || undefined;
} catch {
// Best effort — feedback text is optional
}
}
// Strip ;messageid=... suffix to match the normalized ID used by the message handler.
const rawConversationId = activity.conversation?.id ?? "unknown";
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
const senderId = activity.from?.aadObjectId ?? activity.from?.id ?? "unknown";
const messageId = value.replyToId ?? activity.replyToId ?? "unknown";
const isNegative = reaction === "dislike";
// Route feedback using the same chat-type logic as normal messages
// so session keys, agent IDs, and transcript paths match.
const convType = normalizeOptionalLowercaseString(activity.conversation?.conversationType);
const isDirectMessage = convType === "personal" || (!convType && !activity.conversation?.isGroup);
const isChannel = convType === "channel";
const core = getMSTeamsRuntime();
const route = core.channel.routing.resolveAgentRoute({
cfg: deps.cfg,
channel: "msteams",
peer: {
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
id: isDirectMessage ? senderId : conversationId,
},
});
// Match the thread-aware session key used by the message handler so feedback
// events land in the correct per-thread transcript. For channel threads, the
// thread root ID comes from the ;messageid= suffix on the conversation ID or
// from activity.replyToId.
const feedbackThreadId = isChannel
? (extractMSTeamsConversationMessageId(rawConversationId) ?? activity.replyToId ?? undefined)
: undefined;
if (feedbackThreadId) {
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: feedbackThreadId,
parentSessionKey: route.sessionKey,
});
route.sessionKey = threadKeys.sessionKey;
}
// Log feedback event to session JSONL
const feedbackEvent = buildFeedbackEvent({
messageId,
value: isNegative ? "negative" : "positive",
comment: userComment,
sessionKey: route.sessionKey,
agentId: route.agentId,
conversationId,
});
deps.log.info("received feedback", {
value: feedbackEvent.value,
messageId,
conversationId,
hasComment: Boolean(userComment),
});
// Write feedback event to session transcript
try {
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, {
agentId: route.agentId,
});
const safeKey = route.sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_");
const transcriptFile = path.join(storePath, `${safeKey}.jsonl`);
await appendRegularFile({
filePath: transcriptFile,
content: `${JSON.stringify(feedbackEvent)}\n`,
rejectSymlinkParents: true,
}).catch(() => {
// Best effort — transcript dir may not exist yet
});
} catch {
// Best effort
}
// Build conversation reference for proactive messages (ack + reflection follow-up)
const conversationRef = {
activityId: activity.id,
user: {
id: activity.from?.id,
name: activity.from?.name,
aadObjectId: activity.from?.aadObjectId,
},
agent: activity.recipient
? { id: activity.recipient.id, name: activity.recipient.name }
: undefined,
bot: activity.recipient
? { id: activity.recipient.id, name: activity.recipient.name }
: undefined,
conversation: {
id: conversationId,
conversationType: activity.conversation?.conversationType,
tenantId: activity.conversation?.tenantId,
},
channelId: activity.channelId ?? "msteams",
serviceUrl: activity.serviceUrl,
locale: activity.locale,
};
// For negative feedback, trigger background reflection (fire-and-forget).
// No ack message — the reflection follow-up serves as the acknowledgement.
// Sending anything during the invoke handler causes "unable to reach app" errors.
if (isNegative && msteamsCfg?.feedbackReflection !== false) {
// Note: thumbedDownResponse is not populated here because we don't cache
// sent message text. The agent still has full session context for reflection
// since the reflection runs in the same session. The user comment (if any)
// provides additional signal.
runFeedbackReflection({
cfg: deps.cfg,
app: deps.app,
appId: deps.appId,
conversationRef,
sessionKey: route.sessionKey,
agentId: route.agentId,
conversationId,
feedbackMessageId: messageId,
userComment,
log: deps.log,
}).catch((err: unknown) => {
deps.log.error("feedback reflection failed", { error: formatUnknownError(err) });
});
}
return true;
}

View File

@@ -0,0 +1,119 @@
// Msteams plugin module implements feedback reflection prompt behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
/** Max chars of the thumbed-down response to include in the reflection prompt. */
const MAX_RESPONSE_CHARS = 500;
type ParsedReflectionResponse = {
learning: string;
followUp: boolean;
userMessage?: string;
};
export function buildReflectionPrompt(params: {
thumbedDownResponse?: string;
userComment?: string;
}): string {
const parts: string[] = ["A user indicated your previous response wasn't helpful."];
if (params.thumbedDownResponse) {
const truncated =
params.thumbedDownResponse.length > MAX_RESPONSE_CHARS
? `${truncateUtf16Safe(params.thumbedDownResponse, MAX_RESPONSE_CHARS)}...`
: params.thumbedDownResponse;
parts.push(`\nYour response was:\n> ${truncated}`);
}
if (params.userComment) {
parts.push(`\nUser's comment: "${params.userComment}"`);
}
parts.push(
"\nBriefly reflect: what could you improve? Consider tone, length, " +
"accuracy, relevance, and specificity. Reply with a single JSON object " +
'only, no markdown or prose, using this exact shape:\n{"learning":"...",' +
'"followUp":false,"userMessage":""}\n' +
"- learning: a short internal adjustment note (1-2 sentences) for your " +
"future behavior in this conversation.\n" +
"- followUp: true only if the user needs a direct follow-up message.\n" +
"- userMessage: only the exact user-facing message to send; empty string " +
"when followUp is false.",
);
return parts.join("\n");
}
function parseBooleanLike(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "true" || normalized === "yes") {
return true;
}
if (normalized === "false" || normalized === "no") {
return false;
}
}
return undefined;
}
function parseStructuredReflectionValue(value: unknown): ParsedReflectionResponse | null {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const candidate = value as {
learning?: unknown;
followUp?: unknown;
userMessage?: unknown;
};
const learning = typeof candidate.learning === "string" ? candidate.learning.trim() : undefined;
if (!learning) {
return null;
}
return {
learning,
followUp: parseBooleanLike(candidate.followUp) ?? false,
userMessage:
typeof candidate.userMessage === "string" && candidate.userMessage.trim()
? candidate.userMessage.trim()
: undefined,
};
}
export function parseReflectionResponse(text: string): ParsedReflectionResponse | null {
const trimmed = text.trim();
if (!trimmed) {
return null;
}
const candidates = [
trimmed,
...(trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.slice(1, 2) ?? []),
];
for (const candidateText of candidates) {
const candidate = candidateText.trim();
if (!candidate) {
continue;
}
try {
const parsed = parseStructuredReflectionValue(JSON.parse(candidate));
if (parsed) {
return parsed;
}
} catch {
// Fall through to the next parse strategy.
}
}
// Safe fallback: keep the internal learning, but never auto-message the user.
return {
learning: trimmed,
followUp: false,
};
}

View File

@@ -0,0 +1,99 @@
// Msteams plugin module implements feedback reflection store behavior.
import crypto from "node:crypto";
import { getMSTeamsRuntime } from "./runtime.js";
/** Default cooldown between reflections per session (5 minutes). */
export const DEFAULT_COOLDOWN_MS = 300_000;
/** Tracks last reflection time per session to enforce cooldown. */
const lastReflectionBySession = new Map<string, number>();
/** Maximum cooldown entries before pruning expired ones. */
const MAX_COOLDOWN_ENTRIES = 500;
const LEARNINGS_NAMESPACE = "feedback-learnings";
const MAX_LEARNING_ENTRIES = 10_000;
type FeedbackLearningEntry = {
sessionKey: string;
learnings: string[];
updatedAt: number;
};
function learningStoreKey(storePath: string, sessionKey: string): string {
return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex");
}
function openLearningStore() {
return getMSTeamsRuntime().state.openKeyedStore<FeedbackLearningEntry>({
namespace: LEARNINGS_NAMESPACE,
maxEntries: MAX_LEARNING_ENTRIES,
});
}
/** Prune expired cooldown entries to prevent unbounded memory growth. */
function pruneExpiredCooldowns(cooldownMs: number): void {
if (lastReflectionBySession.size <= MAX_COOLDOWN_ENTRIES) {
return;
}
const now = Date.now();
for (const [key, time] of lastReflectionBySession) {
if (now - time >= cooldownMs) {
lastReflectionBySession.delete(key);
}
}
}
/** Check if a reflection is allowed (cooldown not active). */
export function isReflectionAllowed(sessionKey: string, cooldownMs?: number): boolean {
const cooldown = cooldownMs ?? DEFAULT_COOLDOWN_MS;
const lastTime = lastReflectionBySession.get(sessionKey);
if (lastTime == null) {
return true;
}
return Date.now() - lastTime >= cooldown;
}
/** Record that a reflection was run for a session. */
export function recordReflectionTime(sessionKey: string, cooldownMs?: number): void {
lastReflectionBySession.set(sessionKey, Date.now());
pruneExpiredCooldowns(cooldownMs ?? DEFAULT_COOLDOWN_MS);
}
/** Clear reflection cooldown tracking (for tests). */
export function clearReflectionCooldowns(): void {
lastReflectionBySession.clear();
}
/** Store a learning derived from feedback reflection. */
export async function storeSessionLearning(params: {
storePath: string;
sessionKey: string;
learning: string;
}): Promise<void> {
const store = openLearningStore();
const key = learningStoreKey(params.storePath, params.sessionKey);
const existing = await store.lookup(key);
let learnings = existing?.learnings ?? [];
learnings.push(params.learning);
if (learnings.length > 10) {
learnings = learnings.slice(-10);
}
await store.register(key, {
sessionKey: params.sessionKey,
learnings,
updatedAt: Date.now(),
});
}
/** Load session learnings for injection into extraSystemPrompt. */
export async function loadSessionLearnings(
storePath: string,
sessionKey: string,
): Promise<string[]> {
const key = learningStoreKey(storePath, sessionKey);
const stored = await openLearningStore().lookup(key);
if (stored) {
return stored.learnings;
}
return [];
}

View File

@@ -0,0 +1,279 @@
// Msteams tests cover feedback reflection plugin behavior.
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { storeSessionLearning } from "./feedback-reflection-store.js";
import {
buildFeedbackEvent,
buildReflectionPrompt,
clearReflectionCooldowns,
isReflectionAllowed,
loadSessionLearnings,
parseReflectionResponse,
recordReflectionTime,
} from "./feedback-reflection.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-support/runtime.js";
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
// on the ES2024 String.prototype.isWellFormed() runtime API.
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
describe("buildFeedbackEvent", () => {
it("builds a well-formed custom event", () => {
const event = buildFeedbackEvent({
messageId: "msg-123",
value: "negative",
comment: "too verbose",
sessionKey: "msteams:user1",
agentId: "default",
conversationId: "19:abc",
});
expect(event.type).toBe("custom");
expect(event.event).toBe("feedback");
expect(event.value).toBe("negative");
expect(event.comment).toBe("too verbose");
expect(event.messageId).toBe("msg-123");
expect(event.ts).toBeGreaterThan(0);
});
it("omits comment when not provided", () => {
const event = buildFeedbackEvent({
messageId: "msg-123",
value: "positive",
sessionKey: "msteams:user1",
agentId: "default",
conversationId: "19:abc",
});
expect(event.comment).toBeUndefined();
expect(event.value).toBe("positive");
});
});
describe("buildReflectionPrompt", () => {
it("includes the thumbed-down response", () => {
const prompt = buildReflectionPrompt({
thumbedDownResponse: "Here is a long explanation...",
});
expect(prompt).toContain("previous response wasn't helpful");
expect(prompt).toContain("Here is a long explanation...");
expect(prompt).toContain("reflect");
});
it("truncates long responses", () => {
const longResponse = "x".repeat(600);
const prompt = buildReflectionPrompt({
thumbedDownResponse: longResponse,
});
expect(prompt).toContain("...");
expect(prompt.length).toBeLessThan(longResponse.length + 500);
});
it("does not split UTF-16 surrogate pairs when truncating a thumbed-down response", () => {
const thumbedDownResponse = `${"a".repeat(499)}🦞${"b".repeat(20)}`;
const prompt = buildReflectionPrompt({ thumbedDownResponse });
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(prompt).toContain(`${"a".repeat(499)}...`);
expect(prompt).not.toContain("\ud83e");
expect(prompt).not.toContain("\udd9e");
});
it("keeps a boundary emoji when it fully fits before the truncation cap", () => {
const thumbedDownResponse = `${"a".repeat(498)}🦞${"b".repeat(20)}`;
const prompt = buildReflectionPrompt({ thumbedDownResponse });
expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE);
expect(prompt).toContain(`${"a".repeat(498)}🦞...`);
});
it("includes user comment when provided", () => {
const prompt = buildReflectionPrompt({
thumbedDownResponse: "Some response",
userComment: "Too wordy",
});
expect(prompt).toContain('User\'s comment: "Too wordy"');
});
it("works without optional params", () => {
const prompt = buildReflectionPrompt({});
expect(prompt).toContain("previous response wasn't helpful");
expect(prompt).toContain('"followUp":false');
});
});
describe("parseReflectionResponse", () => {
it("parses strict JSON output", () => {
expect(
parseReflectionResponse(
'{"learning":"Be more direct next time.","followUp":true,"userMessage":"Sorry about that. I will keep it tighter."}',
),
).toEqual({
learning: "Be more direct next time.",
followUp: true,
userMessage: "Sorry about that. I will keep it tighter.",
});
});
it("parses JSON inside markdown fences", () => {
expect(
parseReflectionResponse(
'```json\n{"learning":"Ask a clarifying question first.","followUp":false,"userMessage":""}\n```',
),
).toEqual({
learning: "Ask a clarifying question first.",
followUp: false,
userMessage: undefined,
});
});
it("falls back to internal-only learning when parsing fails", () => {
expect(parseReflectionResponse("Be more concise.\nFollow up: yes.")).toEqual({
learning: "Be more concise.\nFollow up: yes.",
followUp: false,
});
});
});
describe("reflection cooldown", () => {
afterEach(() => {
clearReflectionCooldowns();
vi.restoreAllMocks();
});
it("allows first reflection", () => {
expect(isReflectionAllowed("session-1")).toBe(true);
});
it("blocks reflection within cooldown", () => {
recordReflectionTime("session-1");
expect(isReflectionAllowed("session-1", 60_000)).toBe(false);
});
it("allows reflection after cooldown expires", () => {
// Manually set a past timestamp
recordReflectionTime("session-1");
// Override the map entry to simulate time passing
clearReflectionCooldowns();
expect(isReflectionAllowed("session-1", 1)).toBe(true);
});
it("tracks sessions independently", () => {
recordReflectionTime("session-1");
expect(isReflectionAllowed("session-1", 60_000)).toBe(false);
expect(isReflectionAllowed("session-2", 60_000)).toBe(true);
});
it("keeps longer custom cooldown entries during pruning", () => {
vi.spyOn(Date, "now").mockReturnValue(0);
recordReflectionTime("target", 600_000);
vi.spyOn(Date, "now").mockReturnValue(301_000);
for (let index = 0; index <= 500; index += 1) {
recordReflectionTime(`session-${index}`, 600_000);
}
expect(isReflectionAllowed("target", 600_000)).toBe(false);
});
});
describe("loadSessionLearnings", () => {
let tmpDir: string;
beforeEach(() => {
resetPluginStateStoreForTests();
setMSTeamsRuntime(msteamsRuntimeStub);
});
afterEach(async () => {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("returns empty array when file doesn't exist", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
const learnings = await loadSessionLearnings(tmpDir, "nonexistent");
expect(learnings).toStrictEqual([]);
});
it("reads persisted learnings from plugin state", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams:user1",
learning: "Be concise",
});
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams:user1",
learning: "Use examples",
});
const learnings = await loadSessionLearnings(tmpDir, "msteams:user1");
expect(learnings).toEqual(["Be concise", "Use examples"]);
});
it("keeps distinct session keys isolated across the filename persistence boundary", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams:user1",
learning: "Use bullets",
});
await storeSessionLearning({
storePath: tmpDir,
sessionKey: "msteams/user1",
learning: "Avoid bullets",
});
await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual(["Use bullets"]);
await expect(loadSessionLearnings(tmpDir, "msteams/user1")).resolves.toEqual(["Avoid bullets"]);
});
it("keeps the same session key isolated by store path", async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-"));
process.env.OPENCLAW_STATE_DIR = tmpDir;
const workStorePath = path.join(tmpDir, "work");
const opsStorePath = path.join(tmpDir, "ops");
await storeSessionLearning({
storePath: workStorePath,
sessionKey: "msteams:user1",
learning: "Use bullets",
});
await storeSessionLearning({
storePath: opsStorePath,
sessionKey: "msteams:user1",
learning: "Avoid bullets",
});
await expect(loadSessionLearnings(workStorePath, "msteams:user1")).resolves.toEqual([
"Use bullets",
]);
await expect(loadSessionLearnings(opsStorePath, "msteams:user1")).resolves.toEqual([
"Avoid bullets",
]);
});
});

View File

@@ -0,0 +1,272 @@
// Msteams plugin module implements feedback reflection behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
dispatchReplyFromConfigWithSettledDispatcher,
type OpenClawConfig,
} from "../runtime-api.js";
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { formatUnknownError } from "./errors.js";
import { buildReflectionPrompt, parseReflectionResponse } from "./feedback-reflection-prompt.js";
import {
DEFAULT_COOLDOWN_MS,
clearReflectionCooldowns,
isReflectionAllowed,
loadSessionLearnings,
recordReflectionTime,
storeSessionLearning,
} from "./feedback-reflection-store.js";
import { buildConversationReference } from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { sendMSTeamsActivityWithReference } from "./sdk-proactive.js";
import type { MSTeamsApp } from "./sdk.js";
export type FeedbackEvent = {
type: "custom";
event: "feedback";
ts: number;
messageId: string;
value: "positive" | "negative";
comment?: string;
sessionKey: string;
agentId: string;
conversationId: string;
reflectionLearning?: string;
};
export function buildFeedbackEvent(params: {
messageId: string;
value: "positive" | "negative";
comment?: string;
sessionKey: string;
agentId: string;
conversationId: string;
}): FeedbackEvent {
return {
type: "custom",
event: "feedback",
ts: Date.now(),
messageId: params.messageId,
value: params.value,
comment: params.comment,
sessionKey: params.sessionKey,
agentId: params.agentId,
conversationId: params.conversationId,
};
}
export type RunFeedbackReflectionParams = {
cfg: OpenClawConfig;
app: MSTeamsApp;
appId: string;
conversationRef: StoredConversationReference;
sessionKey: string;
agentId: string;
conversationId: string;
feedbackMessageId: string;
thumbedDownResponse?: string;
userComment?: string;
log: MSTeamsMonitorLogger;
};
function buildReflectionContext(params: {
cfg: OpenClawConfig;
conversationId: string;
sessionKey: string;
reflectionPrompt: string;
}) {
const core = getMSTeamsRuntime();
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
const body = core.channel.reply.formatAgentEnvelope({
channel: "Teams",
from: "system",
body: params.reflectionPrompt,
envelope: envelopeOptions,
});
return {
ctxPayload: core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: params.reflectionPrompt,
RawBody: params.reflectionPrompt,
CommandBody: params.reflectionPrompt,
From: `msteams:system:${params.conversationId}`,
To: `conversation:${params.conversationId}`,
SessionKey: params.sessionKey,
ChatType: "direct" as const,
SenderName: "system",
SenderId: "system",
Provider: "msteams" as const,
Surface: "msteams" as const,
Timestamp: Date.now(),
WasMentioned: true,
CommandAuthorized: false,
OriginatingChannel: "msteams" as const,
OriginatingTo: `conversation:${params.conversationId}`,
}),
};
}
function createReflectionCaptureDispatcher(params: {
cfg: OpenClawConfig;
agentId: string;
log: MSTeamsMonitorLogger;
}) {
const core = getMSTeamsRuntime();
let response = "";
const noopTypingCallbacks = {
onReplyStart: async () => {},
onIdle: () => {},
onCleanup: () => {},
};
const { dispatcher, replyOptions } = core.channel.reply.createReplyDispatcherWithTyping({
deliver: async (payload) => {
if (payload.text) {
response += (response ? "\n" : "") + payload.text;
}
},
typingCallbacks: noopTypingCallbacks,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
onError: (err) => {
params.log.debug?.("reflection reply error", { error: formatUnknownError(err) });
},
});
return {
dispatcher,
replyOptions,
readResponse: () => response,
};
}
async function sendReflectionFollowUp(params: {
cfg: OpenClawConfig;
app: MSTeamsApp;
conversationRef: StoredConversationReference;
userMessage: string;
}): Promise<void> {
const baseRef = buildConversationReference(params.conversationRef);
await sendMSTeamsActivityWithReference(
params.app,
baseRef,
{ type: "message", text: params.userMessage },
{ serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(params.cfg.channels?.msteams) },
);
}
/**
* Run a background reflection after negative feedback.
* This is designed to be called fire-and-forget (don't await in the invoke handler).
*/
export async function runFeedbackReflection(params: RunFeedbackReflectionParams): Promise<void> {
const { cfg, log, sessionKey } = params;
const cooldownMs = cfg.channels?.msteams?.feedbackReflectionCooldownMs ?? DEFAULT_COOLDOWN_MS;
if (!isReflectionAllowed(sessionKey, cooldownMs)) {
log.debug?.("skipping reflection (cooldown active)", { sessionKey });
return;
}
const reflectionPrompt = buildReflectionPrompt({
thumbedDownResponse: params.thumbedDownResponse,
userComment: params.userComment,
});
const runtime = getMSTeamsRuntime();
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
agentId: params.agentId,
});
const { ctxPayload } = buildReflectionContext({
cfg,
conversationId: params.conversationId,
sessionKey: params.sessionKey,
reflectionPrompt,
});
const capture = createReflectionCaptureDispatcher({
cfg,
agentId: params.agentId,
log,
});
try {
await dispatchReplyFromConfigWithSettledDispatcher({
ctxPayload,
cfg,
dispatcher: capture.dispatcher,
onSettled: () => {},
replyOptions: capture.replyOptions,
});
} catch (err) {
log.error("reflection dispatch failed", { error: formatUnknownError(err) });
return;
}
const reflectionResponse = capture.readResponse().trim();
if (!reflectionResponse) {
log.debug?.("reflection produced no output");
return;
}
const parsedReflection = parseReflectionResponse(reflectionResponse);
if (!parsedReflection) {
log.debug?.("reflection produced no structured output");
return;
}
recordReflectionTime(sessionKey, cooldownMs);
log.info("reflection complete", {
sessionKey,
responseLength: reflectionResponse.length,
followUp: parsedReflection.followUp,
});
try {
await storeSessionLearning({
storePath,
sessionKey: params.sessionKey,
learning: parsedReflection.learning,
});
} catch (err) {
log.debug?.("failed to store reflection learning", { error: formatUnknownError(err) });
}
const conversationType = normalizeOptionalLowercaseString(
params.conversationRef.conversation?.conversationType,
);
const shouldNotify =
conversationType === "personal" &&
parsedReflection.followUp &&
Boolean(parsedReflection.userMessage);
if (!shouldNotify) {
if (parsedReflection.followUp && conversationType !== "personal") {
log.debug?.("skipping reflection follow-up outside direct message", {
sessionKey,
conversationType,
});
}
return;
}
try {
await sendReflectionFollowUp({
cfg,
app: params.app,
conversationRef: params.conversationRef,
userMessage: parsedReflection.userMessage!,
});
log.info("sent reflection follow-up", { sessionKey });
} catch (err) {
log.debug?.("failed to send reflection follow-up", { error: formatUnknownError(err) });
}
}
export {
buildReflectionPrompt,
clearReflectionCooldowns,
isReflectionAllowed,
loadSessionLearnings,
parseReflectionResponse,
recordReflectionTime,
};

View File

@@ -0,0 +1,329 @@
// Msteams tests cover file consent helpers plugin behavior.
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
import {
clearPendingUploads,
getPendingUpload,
getPendingUploadCount,
removePendingUpload,
storePendingUpload,
} from "./pending-uploads.js";
import * as pendingUploads from "./pending-uploads.js";
describe("requiresFileConsent", () => {
const thresholdBytes = 4 * 1024 * 1024; // 4MB
it("returns true for personal chat with non-image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns true for personal chat with large image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/png",
bufferSize: 5 * 1024 * 1024, // 5MB
thresholdBytes,
}),
).toBe(true);
});
it("returns false for personal chat with small image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/png",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(false);
});
it("returns false for group chat with large non-image", () => {
expect(
requiresFileConsent({
conversationType: "groupChat",
contentType: "application/pdf",
bufferSize: 5 * 1024 * 1024,
thresholdBytes,
}),
).toBe(false);
});
it("returns false for channel with large non-image", () => {
expect(
requiresFileConsent({
conversationType: "channel",
contentType: "application/pdf",
bufferSize: 5 * 1024 * 1024,
thresholdBytes,
}),
).toBe(false);
});
it("handles case-insensitive conversation type", () => {
expect(
requiresFileConsent({
conversationType: "Personal",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
expect(
requiresFileConsent({
conversationType: "PERSONAL",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns false when conversationType is undefined", () => {
expect(
requiresFileConsent({
conversationType: undefined,
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(false);
});
it("returns true for personal chat when contentType is undefined (non-image)", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: undefined,
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns true for personal chat with file exactly at threshold", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/jpeg",
bufferSize: thresholdBytes, // exactly 4MB
thresholdBytes,
}),
).toBe(true);
});
it("returns false for personal chat with file just below threshold", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/jpeg",
bufferSize: thresholdBytes - 1, // 4MB - 1 byte
thresholdBytes,
}),
).toBe(false);
});
});
describe("prepareFileConsentActivity", () => {
const mockUploadId = "test-upload-id-123";
beforeEach(() => {
vi.spyOn(pendingUploads, "storePendingUpload").mockReturnValue(mockUploadId);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("creates activity with consent card attachment", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test content"),
filename: "test.pdf",
contentType: "application/pdf",
},
conversationId: "conv123",
description: "My file",
});
expect(result.uploadId).toBe(mockUploadId);
expect(result.activity.type).toBe("message");
expect(result.activity.attachments).toHaveLength(1);
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, unknown>;
expect(attachment.contentType).toBe("application/vnd.microsoft.teams.card.file.consent");
expect(attachment.name).toBe("test.pdf");
});
it("stores pending upload with correct data", () => {
const buffer = Buffer.from("test content");
prepareFileConsentActivity({
media: {
buffer,
filename: "test.pdf",
contentType: "application/pdf",
},
conversationId: "conv123",
description: "My file",
});
expect(pendingUploads.storePendingUpload).toHaveBeenCalledWith({
buffer,
filename: "test.pdf",
contentType: "application/pdf",
conversationId: "conv123",
});
});
it("uses default description when not provided", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "document.docx",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
conversationId: "conv456",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<
string,
{ description: string }
>;
expect(attachment.content.description).toBe("File: document.docx");
});
it("uses provided description", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "report.pdf",
contentType: "application/pdf",
},
conversationId: "conv789",
description: "Q4 Financial Report",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<
string,
{ description: string }
>;
expect(attachment.content.description).toBe("Q4 Financial Report");
});
it("includes uploadId in consent card context", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "file.txt",
contentType: "text/plain",
},
conversationId: "conv000",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<
string,
{ acceptContext: { uploadId: string } }
>;
expect(attachment.content.acceptContext.uploadId).toBe(mockUploadId);
});
it("handles media without contentType", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("binary data"),
filename: "unknown.bin",
},
conversationId: "conv111",
});
expect(result.uploadId).toBe(mockUploadId);
expect(result.activity.type).toBe("message");
});
});
describe("msteams pending uploads", () => {
beforeEach(() => {
vi.useFakeTimers();
clearPendingUploads();
});
afterEach(() => {
clearPendingUploads();
vi.useRealTimers();
});
it("stores uploads, exposes them by id, and tracks count", () => {
const id = storePendingUpload({
buffer: Buffer.from("hello"),
filename: "hello.txt",
contentType: "text/plain",
conversationId: "conv-1",
});
expect(getPendingUploadCount()).toBe(1);
const pendingUpload = getPendingUpload(id);
expect(pendingUpload).toEqual({
id,
buffer: Buffer.from("hello"),
filename: "hello.txt",
contentType: "text/plain",
conversationId: "conv-1",
createdAt: pendingUpload?.createdAt,
});
expect(typeof pendingUpload?.createdAt).toBe("number");
});
it("removes uploads explicitly and ignores empty ids", () => {
const id = storePendingUpload({
buffer: Buffer.from("hello"),
filename: "hello.txt",
conversationId: "conv-1",
});
removePendingUpload(undefined);
expect(getPendingUploadCount()).toBe(1);
removePendingUpload(id);
expect(getPendingUpload(id)).toBeUndefined();
expect(getPendingUploadCount()).toBe(0);
});
it("expires uploads by ttl even if the timeout callback has not been observed yet", () => {
const id = storePendingUpload({
buffer: Buffer.from("hello"),
filename: "hello.txt",
conversationId: "conv-1",
});
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
expect(getPendingUpload(id)).toBeUndefined();
expect(getPendingUploadCount()).toBe(0);
});
it("clears all uploads for test cleanup", () => {
storePendingUpload({
buffer: Buffer.from("a"),
filename: "a.txt",
conversationId: "conv-1",
});
storePendingUpload({
buffer: Buffer.from("b"),
filename: "b.txt",
conversationId: "conv-2",
});
clearPendingUploads();
expect(getPendingUploadCount()).toBe(0);
});
});

View File

@@ -0,0 +1,116 @@
// Msteams helper module supports file consent helpers behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildFileConsentCard } from "./file-consent.js";
import { storePendingUploadFs } from "./pending-uploads-fs.js";
import { storePendingUpload } from "./pending-uploads.js";
type FileConsentMedia = {
buffer: Buffer;
filename: string;
contentType?: string;
};
type FileConsentActivityResult = {
activity: Record<string, unknown>;
uploadId: string;
};
function buildConsentActivity(params: {
media: FileConsentMedia;
description?: string;
uploadId: string;
}): Record<string, unknown> {
const { media, description, uploadId } = params;
const consentCard = buildFileConsentCard({
filename: media.filename,
description: description || `File: ${media.filename}`,
sizeInBytes: media.buffer.length,
context: { uploadId },
});
return {
type: "message",
attachments: [consentCard],
};
}
/**
* Prepare a FileConsentCard activity for large files or non-images in personal chats.
* Returns the activity object and uploadId - caller is responsible for sending.
*
* This variant only writes to the in-memory store. Use it when the caller and
* the `fileConsent/invoke` handler share the same process (for example the
* messenger reply path). For proactive CLI sends where the invoke arrives in
* a different process, use {@link prepareFileConsentActivityFs} instead.
*/
export function prepareFileConsentActivity(params: {
media: FileConsentMedia;
conversationId: string;
description?: string;
}): FileConsentActivityResult {
const { media, conversationId, description } = params;
const uploadId = storePendingUpload({
buffer: media.buffer,
filename: media.filename,
contentType: media.contentType,
conversationId,
});
const activity = buildConsentActivity({ media, description, uploadId });
return { activity, uploadId };
}
/**
* Prepare a FileConsentCard activity and persist the pending upload to the
* filesystem so a different process can read it when the user accepts.
*
* This is used by the proactive CLI `message send --media` path: the CLI
* process sends the card and exits, but the `fileConsent/invoke` callback is
* delivered to the long-lived gateway monitor process. The FS-backed store
* bridges those two processes. The in-memory store is also populated so
* same-process flows keep the fast path.
*/
export async function prepareFileConsentActivityFs(params: {
media: FileConsentMedia;
conversationId: string;
description?: string;
}): Promise<FileConsentActivityResult> {
const { media, conversationId, description } = params;
// Populate the in-memory store first so the uploadId is consistent, then
// mirror the same entry to the FS store under the same id so an invoke
// handler in another process can find it.
const uploadId = storePendingUpload({
buffer: media.buffer,
filename: media.filename,
contentType: media.contentType,
conversationId,
});
await storePendingUploadFs({
id: uploadId,
buffer: media.buffer,
filename: media.filename,
contentType: media.contentType,
conversationId,
});
const activity = buildConsentActivity({ media, description, uploadId });
return { activity, uploadId };
}
/**
* Check if a file requires FileConsentCard flow.
* True for: personal chat AND (large file OR non-image)
*/
export function requiresFileConsent(params: {
conversationType: string | undefined;
contentType: string | undefined;
bufferSize: number;
thresholdBytes: number;
}): boolean {
const isPersonal = normalizeOptionalLowercaseString(params.conversationType) === "personal";
const isImage = params.contentType?.startsWith("image/") ?? false;
const isLargeFile = params.bufferSize >= params.thresholdBytes;
return isPersonal && (isLargeFile || !isImage);
}

View File

@@ -0,0 +1,154 @@
// Msteams plugin module implements file consent invoke behavior.
import { formatUnknownError } from "./errors.js";
import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
import { normalizeMSTeamsConversationId } from "./inbound.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { getPendingUploadFs, removePendingUploadFs } from "./pending-uploads-fs.js";
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
import { withRevokedProxyFallback } from "./revoked-context.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
/**
* Handle fileConsent/invoke activities for large file uploads.
*/
async function handleMSTeamsFileConsentInvoke(
context: MSTeamsTurnContext,
log: MSTeamsMonitorLogger,
): Promise<boolean> {
const expiredUploadMessage =
"The file upload request has expired. Please try sending the file again.";
const activity = context.activity;
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
return false;
}
const consentResponse = parseFileConsentInvoke(activity);
if (!consentResponse) {
log.debug?.("invalid file consent invoke", { value: activity.value });
return false;
}
const uploadId =
typeof consentResponse.context?.uploadId === "string"
? consentResponse.context.uploadId
: undefined;
// Prefer the in-memory store (same-process reply path); fall back to the
// FS-backed store so CLI `message send --media` flows work even when the
// invoke callback is delivered to a different process.
const inMemoryFile = getPendingUpload(uploadId);
const fsFile = inMemoryFile ? undefined : await getPendingUploadFs(uploadId);
const pendingFile:
| {
buffer: Buffer;
filename: string;
contentType?: string;
conversationId: string;
consentCardActivityId?: string;
}
| undefined = inMemoryFile ?? fsFile;
if (pendingFile) {
const pendingConversationId = normalizeMSTeamsConversationId(pendingFile.conversationId);
const invokeConversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
if (!invokeConversationId || pendingConversationId !== invokeConversationId) {
log.info("file consent conversation mismatch", {
uploadId,
expectedConversationId: pendingConversationId,
receivedConversationId: invokeConversationId || undefined,
});
if (consentResponse.action === "accept") {
await context.sendActivity(expiredUploadMessage);
}
return true;
}
}
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
if (pendingFile) {
log.debug?.("user accepted file consent, uploading", {
uploadId,
filename: pendingFile.filename,
size: pendingFile.buffer.length,
});
try {
await uploadToConsentUrl({
url: consentResponse.uploadInfo.uploadUrl,
buffer: pendingFile.buffer,
contentType: pendingFile.contentType,
});
const fileInfoCard = buildFileInfoCard({
filename: consentResponse.uploadInfo.name,
contentUrl: consentResponse.uploadInfo.contentUrl,
uniqueId: consentResponse.uploadInfo.uniqueId,
fileType: consentResponse.uploadInfo.fileType,
});
if (!pendingFile.consentCardActivityId) {
await context.sendActivity({
type: "message",
attachments: [fileInfoCard],
});
}
if (pendingFile.consentCardActivityId) {
try {
await context.updateActivity({
id: pendingFile.consentCardActivityId,
type: "message",
attachments: [fileInfoCard],
});
} catch {
await context.sendActivity({
type: "message",
attachments: [fileInfoCard],
});
}
}
log.info("file upload complete", {
uploadId,
filename: consentResponse.uploadInfo.name,
uniqueId: consentResponse.uploadInfo.uniqueId,
});
} catch (err) {
log.error("file upload failed", { uploadId, error: formatUnknownError(err) });
await context.sendActivity("File upload failed. Please try again.");
} finally {
removePendingUpload(uploadId);
await removePendingUploadFs(uploadId);
}
} else {
log.debug?.("pending file not found for consent", { uploadId });
await context.sendActivity(expiredUploadMessage);
}
} else {
log.debug?.("user declined file consent", { uploadId });
removePendingUpload(uploadId);
await removePendingUploadFs(uploadId);
}
return true;
}
/**
* Run the file-consent invoke handler after the SDK route has acknowledged the
* invoke. This intentionally does not send its own invokeResponse; it only does
* the delayed upload/update work.
*/
export async function runMSTeamsFileConsentInvokeHandler(
context: MSTeamsTurnContext,
log: MSTeamsMonitorLogger,
): Promise<void> {
try {
await withRevokedProxyFallback({
run: async () => await handleMSTeamsFileConsentInvoke(context, log),
onRevoked: async () => true,
onRevokedLog: () => {
log.debug?.("turn context revoked during file consent invoke; skipping delayed response");
},
});
} catch (err) {
log.debug?.("file consent handler error", { error: formatUnknownError(err) });
}
}

View File

@@ -0,0 +1,379 @@
// Msteams tests cover file consent plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
CONSENT_UPLOAD_HOST_ALLOWLIST,
isPrivateOrReservedIP,
uploadToConsentUrl,
validateConsentUploadUrl,
} from "./file-consent.js";
import { buildUserAgent } from "./user-agent.js";
// Helper: a resolveFn that returns a public IP by default
const publicResolve = async () => ({ address: "13.107.136.10" });
// Helper: a resolveFn that returns a private IP
const privateResolve = (ip: string) => async () => ({ address: ip });
// Helper: a resolveFn that returns multiple addresses
const multiResolve = (ips: string[]) => async () => ips.map((address) => ({ address }));
// Helper: a resolveFn that fails
const failingResolve = async () => {
throw new Error("DNS failure");
};
const firstFetchCall = (fetchFn: ReturnType<typeof vi.fn<typeof fetch>>) => {
const [call] = fetchFn.mock.calls;
if (!call) {
throw new Error("expected fetch call");
}
return call;
};
// ─── isPrivateOrReservedIP ───────────────────────────────────────────────────
describe("isPrivateOrReservedIP", () => {
it.each([
["10.0.0.1", true],
["10.255.255.255", true],
["172.16.0.1", true],
["172.31.255.255", true],
["172.15.0.1", false],
["172.32.0.1", false],
["192.168.0.1", true],
["192.168.255.255", true],
["127.0.0.1", true],
["127.255.255.255", true],
["169.254.0.1", true],
["169.254.169.254", true],
["0.0.0.0", true],
["8.8.8.8", false],
["13.107.136.10", false],
["52.96.0.1", false],
] as const)("IPv4 %s → %s", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
it.each([
["::1", true],
["::", true],
["fe80::1", true],
["fe80::", true],
["fc00::1", true],
["fd12:3456::1", true],
["2001:0db8::1", true],
["2620:1ec:c11::200", false],
// IPv4-mapped IPv6 addresses
["::ffff:127.0.0.1", true],
["::ffff:10.0.0.1", true],
["::ffff:192.168.1.1", true],
["::ffff:169.254.169.254", true],
["::ffff:8.8.8.8", false],
["::ffff:13.107.136.10", false],
] as const)("IPv6 %s → %s", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
it.each([
["999.999.999.999", true],
["256.0.0.1", true],
["10.0.0.256", true],
["-1.0.0.1", false],
["1.2.3.4.5", false],
] as const)("malformed IPv4 %s → %s", (ip, expected) => {
expect(isPrivateOrReservedIP(ip)).toBe(expected);
});
});
// ─── validateConsentUploadUrl ────────────────────────────────────────────────
describe("validateConsentUploadUrl", () => {
it("accepts a valid SharePoint HTTPS URL", async () => {
await expect(
validateConsentUploadUrl("https://contoso.sharepoint.com/sites/uploads/file.pdf", {
resolveFn: publicResolve,
}),
).resolves.toBeUndefined();
});
it("accepts subdomains of allowlisted domains", async () => {
await expect(
validateConsentUploadUrl(
"https://contoso-my.sharepoint.com/personal/user/Documents/file.docx",
{ resolveFn: publicResolve },
),
).resolves.toBeUndefined();
});
it("accepts graph.microsoft.com", async () => {
await expect(
validateConsentUploadUrl("https://graph.microsoft.com/v1.0/me/drive/items/123/content", {
resolveFn: publicResolve,
}),
).resolves.toBeUndefined();
});
it("rejects non-HTTPS URLs", async () => {
await expect(
validateConsentUploadUrl("http://contoso.sharepoint.com/file.pdf", {
resolveFn: publicResolve,
}),
).rejects.toThrow("must use HTTPS");
});
it("rejects invalid URLs", async () => {
await expect(
validateConsentUploadUrl("not a url", { resolveFn: publicResolve }),
).rejects.toThrow("not a valid URL");
});
it("rejects hosts not in the allowlist", async () => {
await expect(
validateConsentUploadUrl("https://evil.example.com/exfil", { resolveFn: publicResolve }),
).rejects.toThrow("not in the allowed domains");
});
it("rejects an SSRF attempt with internal metadata URL", async () => {
await expect(
validateConsentUploadUrl("https://169.254.169.254/latest/meta-data/", {
resolveFn: publicResolve,
}),
).rejects.toThrow("not in the allowed domains");
});
it("rejects localhost", async () => {
await expect(
validateConsentUploadUrl("https://localhost:8080/internal", { resolveFn: publicResolve }),
).rejects.toThrow("not in the allowed domains");
});
it("rejects when DNS resolves to a private IPv4 (10.x)", async () => {
await expect(
validateConsentUploadUrl("https://malicious.sharepoint.com/exfil", {
resolveFn: privateResolve("10.0.0.1"),
}),
).rejects.toThrow("private/reserved IP");
});
it("rejects when DNS resolves to loopback", async () => {
await expect(
validateConsentUploadUrl("https://evil.sharepoint.com/path", {
resolveFn: privateResolve("127.0.0.1"),
}),
).rejects.toThrow("private/reserved IP");
});
it("rejects when DNS resolves to link-local (169.254.x.x)", async () => {
await expect(
validateConsentUploadUrl("https://evil.sharepoint.com/path", {
resolveFn: privateResolve("169.254.169.254"),
}),
).rejects.toThrow("private/reserved IP");
});
it("rejects when DNS resolves to IPv6 loopback", async () => {
await expect(
validateConsentUploadUrl("https://evil.sharepoint.com/path", {
resolveFn: privateResolve("::1"),
}),
).rejects.toThrow("private/reserved IP");
});
it("rejects when DNS resolves to IPv4-mapped IPv6 private address", async () => {
await expect(
validateConsentUploadUrl("https://evil.sharepoint.com/path", {
resolveFn: privateResolve("::ffff:10.0.0.1"),
}),
).rejects.toThrow("private/reserved IP");
});
it("rejects when DNS resolves to IPv4-mapped IPv6 loopback", async () => {
await expect(
validateConsentUploadUrl("https://evil.sharepoint.com/path", {
resolveFn: privateResolve("::ffff:127.0.0.1"),
}),
).rejects.toThrow("private/reserved IP");
});
it("rejects when any DNS answer is private/reserved", async () => {
await expect(
validateConsentUploadUrl("https://evil.sharepoint.com/path", {
resolveFn: multiResolve(["13.107.136.10", "10.0.0.1"]),
}),
).rejects.toThrow("private/reserved IP");
});
it("accepts when all DNS answers are public", async () => {
await expect(
validateConsentUploadUrl("https://evil.sharepoint.com/path", {
resolveFn: multiResolve(["13.107.136.10", "52.96.0.1"]),
}),
).resolves.toBeUndefined();
});
it("rejects when DNS resolution fails", async () => {
await expect(
validateConsentUploadUrl("https://nonexistent.sharepoint.com/path", {
resolveFn: failingResolve,
}),
).rejects.toThrow("Failed to resolve");
});
it("accepts a custom allowlist", async () => {
await expect(
validateConsentUploadUrl("https://custom.example.org/file", {
allowlist: ["example.org"],
resolveFn: publicResolve,
}),
).resolves.toBeUndefined();
});
it("rejects hosts that are suffix-tricked (e.g. notsharepoint.com)", async () => {
await expect(
validateConsentUploadUrl("https://notsharepoint.com/file", { resolveFn: publicResolve }),
).rejects.toThrow("not in the allowed domains");
});
it("rejects file:// protocol", async () => {
await expect(
validateConsentUploadUrl("file:///etc/passwd", { resolveFn: publicResolve }),
).rejects.toThrow("must use HTTPS");
});
});
// ─── CONSENT_UPLOAD_HOST_ALLOWLIST ───────────────────────────────────────────
describe("CONSENT_UPLOAD_HOST_ALLOWLIST", () => {
it("contains only Microsoft/SharePoint domains", () => {
for (const domain of CONSENT_UPLOAD_HOST_ALLOWLIST) {
expect(
domain.includes("microsoft") ||
domain.includes("sharepoint") ||
domain.includes("onedrive") ||
domain.includes("1drv") ||
domain.includes("live.com"),
).toBe(true);
}
});
it("does not contain overly broad domains", () => {
const broad = [
"microsoft.com",
"azure.com",
"blob.core.windows.net",
"azureedge.net",
"trafficmanager.net",
];
for (const domain of broad) {
expect(CONSENT_UPLOAD_HOST_ALLOWLIST).not.toContain(domain);
}
});
});
// ─── uploadToConsentUrl (integration with validation) ────────────────────────
describe("uploadToConsentUrl", () => {
it("sends the OpenClaw User-Agent header with consent uploads", async () => {
const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 200 }));
await uploadToConsentUrl({
url: "https://contoso.sharepoint.com/upload",
buffer: Buffer.from("hello"),
fetchFn,
validationOpts: { resolveFn: publicResolve },
});
expect(fetchFn).toHaveBeenCalledOnce();
const [url, opts] = firstFetchCall(fetchFn);
expect(url).toBe("https://contoso.sharepoint.com/upload");
expect(opts?.method).toBe("PUT");
expect(opts?.headers).toEqual({
"Content-Range": "bytes 0-4/5",
"Content-Type": "application/octet-stream",
"User-Agent": buildUserAgent(),
});
expect(opts?.body).toEqual(new Uint8Array(Buffer.from("hello")));
});
it("blocks upload to a disallowed host", async () => {
const mockFetch = vi.fn();
await expect(
uploadToConsentUrl({
url: "https://evil.example.com/exfil",
buffer: Buffer.from("secret data"),
fetchFn: mockFetch,
validationOpts: { resolveFn: publicResolve },
}),
).rejects.toThrow("not in the allowed domains");
expect(mockFetch).not.toHaveBeenCalled();
});
it("blocks upload to a private IP", async () => {
const mockFetch = vi.fn();
await expect(
uploadToConsentUrl({
url: "https://compromised.sharepoint.com/upload",
buffer: Buffer.from("data"),
fetchFn: mockFetch,
validationOpts: { resolveFn: privateResolve("10.0.0.1") },
}),
).rejects.toThrow("private/reserved IP");
expect(mockFetch).not.toHaveBeenCalled();
});
it("allows upload to a valid SharePoint URL and performs PUT", async () => {
const mockFetch = vi.fn<typeof fetch>(async () => new Response(null, { status: 200 }));
const buffer = Buffer.from("file content");
await uploadToConsentUrl({
url: "https://contoso.sharepoint.com/sites/uploads/file.pdf",
buffer,
contentType: "application/pdf",
fetchFn: mockFetch,
validationOpts: { resolveFn: publicResolve },
});
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = firstFetchCall(mockFetch);
expect(url).toBe("https://contoso.sharepoint.com/sites/uploads/file.pdf");
expect(opts).toEqual({
method: "PUT",
headers: {
"User-Agent": buildUserAgent(),
"Content-Type": "application/pdf",
"Content-Range": "bytes 0-11/12",
},
body: new Uint8Array(buffer),
});
});
it("throws on non-OK response after passing validation", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: "Forbidden",
});
await expect(
uploadToConsentUrl({
url: "https://contoso.sharepoint.com/sites/uploads/file.pdf",
buffer: Buffer.from("data"),
fetchFn: mockFetch,
validationOpts: { resolveFn: publicResolve },
}),
).rejects.toThrow("File upload to consent URL failed: 403 Forbidden");
});
it("blocks HTTP (non-HTTPS) upload before fetch is called", async () => {
const mockFetch = vi.fn();
await expect(
uploadToConsentUrl({
url: "http://contoso.sharepoint.com/upload",
buffer: Buffer.from("data"),
fetchFn: mockFetch,
validationOpts: { resolveFn: publicResolve },
}),
).rejects.toThrow("must use HTTPS");
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,223 @@
/**
* FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats.
*
* Teams requires user consent before the bot can upload large files. This module provides
* utilities for:
* - Building FileConsentCard attachments (to request upload permission)
* - Building FileInfoCard attachments (to confirm upload completion)
* - Parsing fileConsent/invoke activities
*/
import { lookup } from "node:dns/promises";
import { isPrivateIpAddress } from "openclaw/plugin-sdk/ssrf-policy";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildUserAgent } from "./user-agent.js";
/**
* Allowlist of domains that are valid targets for file consent uploads.
* These are the Microsoft/SharePoint domains that Teams legitimately provides
* as upload destinations in the FileConsentCard flow.
*/
export const CONSENT_UPLOAD_HOST_ALLOWLIST = [
"sharepoint.com",
"sharepoint.us",
"sharepoint.de",
"sharepoint.cn",
"sharepoint-df.com",
"storage.live.com",
"onedrive.com",
"1drv.ms",
"graph.microsoft.com",
"graph.microsoft.us",
"graph.microsoft.de",
"graph.microsoft.cn",
] as const;
/**
* Returns true if the given IPv4 or IPv6 address is private, internal, or
* special-use and must never be reached via consent uploads.
*/
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
/**
* Validate that a consent upload URL is safe to PUT to.
* Checks:
* 1. Protocol is HTTPS
* 2. Hostname matches the consent upload allowlist
* 3. Resolved IP is not in a private/reserved range (anti-SSRF)
*
* @throws Error if the URL fails validation
*/
export async function validateConsentUploadUrl(
url: string,
opts?: {
allowlist?: readonly string[];
resolveFn?: (hostname: string) => Promise<{ address: string } | { address: string }[]>;
},
): Promise<void> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error("Consent upload URL is not a valid URL");
}
// 1. Protocol check
if (parsed.protocol !== "https:") {
throw new Error(`Consent upload URL must use HTTPS, got ${parsed.protocol}`);
}
// 2. Hostname allowlist check
const hostname = normalizeLowercaseStringOrEmpty(parsed.hostname);
const allowlist = opts?.allowlist ?? CONSENT_UPLOAD_HOST_ALLOWLIST;
const hostAllowed = allowlist.some(
(entry) => hostname === entry || hostname.endsWith(`.${entry}`),
);
if (!hostAllowed) {
throw new Error(`Consent upload URL hostname "${hostname}" is not in the allowed domains`);
}
// 3. DNS resolution — reject private/reserved IPs.
// Check all resolved addresses to avoid SSRF bypass via mixed public/private answers.
const resolveFn = opts?.resolveFn ?? ((name: string) => lookup(name, { all: true }));
let resolved: { address: string }[];
try {
const result = await resolveFn(hostname);
resolved = Array.isArray(result) ? result : [result];
} catch {
throw new Error(`Failed to resolve consent upload URL hostname "${hostname}"`);
}
for (const entry of resolved) {
if (isPrivateOrReservedIP(entry.address)) {
throw new Error(`Consent upload URL resolves to a private/reserved IP (${entry.address})`);
}
}
}
interface FileConsentCardParams {
filename: string;
description?: string;
sizeInBytes: number;
/** Custom context data to include in the card (passed back in the invoke) */
context?: Record<string, unknown>;
}
interface FileInfoCardParams {
filename: string;
contentUrl: string;
uniqueId: string;
fileType: string;
}
/**
* Build a FileConsentCard attachment for requesting upload permission.
* Use this for files >= 4MB in personal (1:1) chats.
*/
export function buildFileConsentCard(params: FileConsentCardParams) {
return {
contentType: "application/vnd.microsoft.teams.card.file.consent",
name: params.filename,
content: {
description: params.description ?? `File: ${params.filename}`,
sizeInBytes: params.sizeInBytes,
acceptContext: { filename: params.filename, ...params.context },
declineContext: { filename: params.filename, ...params.context },
},
};
}
/**
* Build a FileInfoCard attachment for confirming upload completion.
* Send this after successfully uploading the file to the consent URL.
*/
export function buildFileInfoCard(params: FileInfoCardParams) {
return {
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: params.contentUrl,
name: params.filename,
content: {
uniqueId: params.uniqueId,
fileType: params.fileType,
},
};
}
interface FileConsentUploadInfo {
name: string;
uploadUrl: string;
contentUrl: string;
uniqueId: string;
fileType: string;
}
interface FileConsentResponse {
action: "accept" | "decline";
uploadInfo?: FileConsentUploadInfo;
context?: Record<string, unknown>;
}
/**
* Parse a fileConsent/invoke activity.
* Returns null if the activity is not a file consent invoke.
*/
export function parseFileConsentInvoke(activity: {
name?: string;
value?: unknown;
}): FileConsentResponse | null {
if (activity.name !== "fileConsent/invoke") {
return null;
}
const value = activity.value as {
type?: string;
action?: string;
uploadInfo?: FileConsentUploadInfo;
context?: Record<string, unknown>;
};
if (value?.type !== "fileUpload") {
return null;
}
return {
action: value.action === "accept" ? "accept" : "decline",
uploadInfo: value.uploadInfo,
context: value.context,
};
}
/**
* Upload a file to the consent URL provided by Teams.
* The URL is provided in the fileConsent/invoke response after user accepts.
*
* @throws Error if the URL fails SSRF validation (non-HTTPS, disallowed host, private IP)
*/
export async function uploadToConsentUrl(params: {
url: string;
buffer: Buffer;
contentType?: string;
fetchFn?: typeof fetch;
/** Override for testing — custom allowlist and DNS resolver */
validationOpts?: {
allowlist?: readonly string[];
resolveFn?: (hostname: string) => Promise<{ address: string } | { address: string }[]>;
};
}): Promise<void> {
await validateConsentUploadUrl(params.url, params.validationOpts);
const fetchFn = params.fetchFn ?? fetch;
const res = await fetchFn(params.url, {
method: "PUT",
headers: {
"User-Agent": buildUserAgent(),
"Content-Type": params.contentType ?? "application/octet-stream",
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
},
body: new Uint8Array(params.buffer),
});
if (!res.ok) {
throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
}
}

View File

@@ -0,0 +1,37 @@
// Msteams plugin module implements graph chat behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { DriveItemProperties } from "./graph-upload.js";
export function buildTeamsFileInfoCard(file: DriveItemProperties): {
contentType: string;
contentUrl: string;
name: string;
content: {
uniqueId: string;
fileType: string;
};
} {
// Extract unique ID from eTag (remove quotes, braces, and version suffix)
// Example eTag formats: "{GUID},version" or "\"{GUID},version\""
const rawETag = file.eTag;
const uniqueId =
rawETag
.replace(/^["']|["']$/g, "") // Remove outer quotes
.replace(/[{}]/g, "") // Remove curly braces
.split(",")[0] ?? rawETag; // Take the GUID part before comma
// Extract file extension from filename
const lastDot = file.name.lastIndexOf(".");
const fileType =
lastDot >= 0 ? normalizeLowercaseStringOrEmpty(file.name.slice(lastDot + 1)) : "";
return {
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: file.webDavUrl,
name: file.name,
content: {
uniqueId,
fileType,
},
};
}

View File

@@ -0,0 +1,333 @@
// Msteams tests cover graph group management plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
addParticipantMSTeams,
removeParticipantMSTeams,
renameGroupMSTeams,
} from "./graph-group-management.js";
const mockState = vi.hoisted(() => ({
resolveGraphToken: vi.fn(),
fetchGraphJson: vi.fn(),
postGraphJson: vi.fn(),
deleteGraphRequest: vi.fn(),
patchGraphJson: vi.fn(),
findPreferredDmByUserId: vi.fn(),
}));
vi.mock("./graph.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./graph.js")>();
return {
...actual,
resolveGraphToken: mockState.resolveGraphToken,
fetchGraphJson: mockState.fetchGraphJson,
postGraphJson: mockState.postGraphJson,
deleteGraphRequest: mockState.deleteGraphRequest,
patchGraphJson: mockState.patchGraphJson,
};
});
vi.mock("./conversation-store-state.js", () => ({
createMSTeamsConversationStoreState: () => ({
findPreferredDmByUserId: mockState.findPreferredDmByUserId,
}),
}));
const TOKEN = "test-graph-token";
const CHAT_ID = "19:abc@thread.tacv2";
const CHANNEL_TO = "team-id-1/channel-id-1";
function postGraphBodyAt(index: number): Record<string, unknown> {
const call = mockState.postGraphJson.mock.calls[index];
if (!call) {
throw new Error(`expected Graph post call ${index}`);
}
const body = call[0]?.body;
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new Error(`expected Graph post call ${index} body`);
}
return body as Record<string, unknown>;
}
describe("addParticipantMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("adds member to a chat with default role", async () => {
mockState.postGraphJson.mockResolvedValue({});
const result = await addParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "user-aad-id-1",
});
expect(result).toEqual({ added: { userId: "user-aad-id-1", chatId: CHAT_ID } });
expect(mockState.postGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/members`,
body: {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: ["member"],
"user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-1')",
},
});
});
it("adds member to a chat with owner role", async () => {
mockState.postGraphJson.mockResolvedValue({});
const result = await addParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "user-aad-id-2",
role: "owner",
});
expect(result).toEqual({ added: { userId: "user-aad-id-2", chatId: CHAT_ID } });
expect(mockState.postGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/members`,
body: {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: ["owner"],
"user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-2')",
},
});
});
it("normalizes role casing and whitespace", async () => {
mockState.postGraphJson.mockResolvedValue({});
await addParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "user-aad-id-2",
role: " OWNER ",
});
expect(mockState.postGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/members`,
body: {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: ["owner"],
"user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-2')",
},
});
});
it("rejects unknown roles", async () => {
await expect(
addParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "user-aad-id-2",
role: "admin",
}),
).rejects.toThrow('role must be "member" or "owner"');
expect(mockState.postGraphJson).not.toHaveBeenCalled();
});
it("constructs correct user@odata.bind URL", async () => {
mockState.postGraphJson.mockResolvedValue({});
await addParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "abc-def-123",
});
const calledBody = postGraphBodyAt(0);
expect(calledBody["user@odata.bind"]).toBe(
"https://graph.microsoft.com/v1.0/users('abc-def-123')",
);
});
it("escapes user ids before building the OData bind URL", async () => {
mockState.postGraphJson.mockResolvedValue({});
await addParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "o'hara@example.com",
});
const calledBody = postGraphBodyAt(0);
expect(calledBody["user@odata.bind"]).toBe(
"https://graph.microsoft.com/v1.0/users('o''hara@example.com')",
);
});
it("adds member to a channel", async () => {
mockState.postGraphJson.mockResolvedValue({});
const result = await addParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
userId: "user-aad-id-3",
});
expect(result).toEqual({ added: { userId: "user-aad-id-3", chatId: CHANNEL_TO } });
expect(mockState.postGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/members",
body: {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: ["member"],
"user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-3')",
},
});
});
});
describe("removeParticipantMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("lists members, finds match, deletes by membershipId", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{ id: "membership-1", userId: "user-aad-id-1" },
{ id: "membership-2", userId: "user-aad-id-2" },
],
});
mockState.deleteGraphRequest.mockResolvedValue(undefined);
const result = await removeParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "user-aad-id-2",
});
expect(result).toEqual({ removed: { userId: "user-aad-id-2", chatId: CHAT_ID } });
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/members`,
});
expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/members/membership-2`,
});
});
it("throws when user not found in member list", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{ id: "membership-1", userId: "user-aad-id-1" },
{ id: "membership-3", userId: "user-aad-id-3" },
],
});
await expect(
removeParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "user-not-in-list",
}),
).rejects.toThrow("User user-not-in-list is not a member of this conversation");
});
it("removes member from a channel", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [{ id: "membership-5", userId: "user-aad-id-5" }],
});
mockState.deleteGraphRequest.mockResolvedValue(undefined);
const result = await removeParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
userId: "user-aad-id-5",
});
expect(result).toEqual({ removed: { userId: "user-aad-id-5", chatId: CHANNEL_TO } });
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/members",
});
expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/members/membership-5",
});
});
it("follows member pagination before concluding the user is missing", async () => {
mockState.fetchGraphJson
.mockResolvedValueOnce({
value: [{ id: "membership-1", userId: "user-aad-id-1" }],
"@odata.nextLink":
"https://graph.microsoft.com/v1.0/chats/19%3Aabc%40thread.tacv2/members?$skip=2",
})
.mockResolvedValueOnce({
value: [{ id: "membership-9", userId: "user-aad-id-9" }],
});
mockState.deleteGraphRequest.mockResolvedValue(undefined);
const result = await removeParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
userId: "user-aad-id-9",
});
expect(result).toEqual({ removed: { userId: "user-aad-id-9", chatId: CHAT_ID } });
expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(1, {
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/members`,
});
expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(2, {
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/members?$skip=2`,
});
expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/members/membership-9`,
});
});
});
describe("renameGroupMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("renames a chat with topic", async () => {
mockState.patchGraphJson.mockResolvedValue(undefined);
const result = await renameGroupMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
name: "New Chat Name",
});
expect(result).toEqual({ renamed: { chatId: CHAT_ID, newName: "New Chat Name" } });
expect(mockState.patchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}`,
body: { topic: "New Chat Name" },
});
});
it("renames a channel with displayName", async () => {
mockState.patchGraphJson.mockResolvedValue(undefined);
const result = await renameGroupMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
name: "New Channel Name",
});
expect(result).toEqual({ renamed: { chatId: CHANNEL_TO, newName: "New Channel Name" } });
expect(mockState.patchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1",
body: { displayName: "New Channel Name" },
});
});
});

View File

@@ -0,0 +1,169 @@
// Msteams plugin module implements graph group management behavior.
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveConversationPath, resolveGraphConversationId } from "./graph-messages.js";
import {
deleteGraphRequest,
escapeOData,
fetchGraphJson,
patchGraphJson,
postGraphJson,
resolveGraphToken,
} from "./graph.js";
// ---------------------------------------------------------------------------
// Add Participant
// ---------------------------------------------------------------------------
type AddParticipantMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
userId: string;
role?: string;
};
type AddParticipantMSTeamsResult = {
added: { userId: string; chatId: string };
};
type ConversationMemberRole = "member" | "owner";
function normalizeConversationMemberRole(role: string | undefined): ConversationMemberRole {
const normalized = role?.trim().toLowerCase() ?? "";
if (!normalized) {
return "member";
}
if (normalized === "member" || normalized === "owner") {
return normalized;
}
throw new Error('MS Teams participant role must be "member" or "owner".');
}
/**
* Add a user to a chat or channel via Graph API.
*/
export async function addParticipantMSTeams(
params: AddParticipantMSTeamsParams,
): Promise<AddParticipantMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
const body = {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: [normalizeConversationMemberRole(params.role)],
"user@odata.bind": `https://graph.microsoft.com/v1.0/users('${escapeOData(params.userId)}')`,
};
await postGraphJson<unknown>({
token,
path: `${conv.basePath}/members`,
body,
});
return { added: { userId: params.userId, chatId: conversationId } };
}
// ---------------------------------------------------------------------------
// Remove Participant
// ---------------------------------------------------------------------------
type RemoveParticipantMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
userId: string;
};
type RemoveParticipantMSTeamsResult = {
removed: { userId: string; chatId: string };
};
type GraphConversationMember = {
id?: string;
userId?: string;
};
type GraphConversationMemberResponse = {
value?: GraphConversationMember[];
"@odata.nextLink"?: string;
};
/**
* Remove a user from a chat or channel via Graph API.
* Lists members first to resolve the membership ID, then deletes.
*/
export async function removeParticipantMSTeams(
params: RemoveParticipantMSTeamsParams,
): Promise<RemoveParticipantMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
// List members to find the membership ID for the target user. Graph can
// paginate large chats/channels, so walk `@odata.nextLink` before concluding
// the user is missing.
const MAX_PAGES = 10;
let nextPath: string | undefined = `${conv.basePath}/members`;
let page = 0;
let member: GraphConversationMember | undefined;
while (nextPath && page < MAX_PAGES && !member) {
const membersRes: GraphConversationMemberResponse =
await fetchGraphJson<GraphConversationMemberResponse>({
token,
path: nextPath,
});
member = (membersRes.value ?? []).find(
(candidate: GraphConversationMember) => candidate.userId === params.userId,
);
if (member) {
break;
}
const nextLink: string | undefined = membersRes["@odata.nextLink"];
nextPath = nextLink ? nextLink.replace("https://graph.microsoft.com/v1.0", "") : undefined;
page++;
}
if (!member?.id) {
throw new Error(`User ${params.userId} is not a member of this conversation`);
}
await deleteGraphRequest({
token,
path: `${conv.basePath}/members/${encodeURIComponent(member.id)}`,
});
return { removed: { userId: params.userId, chatId: conversationId } };
}
// ---------------------------------------------------------------------------
// Rename Group
// ---------------------------------------------------------------------------
type RenameGroupMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
name: string;
};
type RenameGroupMSTeamsResult = {
renamed: { chatId: string; newName: string };
};
/**
* Rename a chat (topic) or channel (displayName) via Graph API.
*/
export async function renameGroupMSTeams(
params: RenameGroupMSTeamsParams,
): Promise<RenameGroupMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
const body = conv.kind === "chat" ? { topic: params.name } : { displayName: params.name };
await patchGraphJson<unknown>({
token,
path: conv.basePath,
body,
});
return { renamed: { chatId: conversationId, newName: params.name } };
}

View File

@@ -0,0 +1,90 @@
// Msteams tests cover graph members plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { getMemberInfoMSTeams } from "./graph-members.js";
const mockState = vi.hoisted(() => ({
resolveGraphToken: vi.fn(),
fetchGraphJson: vi.fn(),
}));
vi.mock("./graph.js", () => {
return {
resolveGraphToken: mockState.resolveGraphToken,
fetchGraphJson: mockState.fetchGraphJson,
};
});
const TOKEN = "test-graph-token";
describe("getMemberInfoMSTeams", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("fetches user profile and maps all fields", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "user-123",
displayName: "Alice Smith",
mail: "alice@contoso.com",
jobTitle: "Engineer",
userPrincipalName: "alice@contoso.com",
officeLocation: "Building 1",
});
const result = await getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
userId: "user-123",
});
expect(result).toEqual({
user: {
id: "user-123",
displayName: "Alice Smith",
mail: "alice@contoso.com",
jobTitle: "Engineer",
userPrincipalName: "alice@contoso.com",
officeLocation: "Building 1",
},
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/users/${encodeURIComponent("user-123")}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation`,
});
});
it("handles sparse data with some fields undefined", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "user-456",
displayName: "Bob",
});
const result = await getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
userId: "user-456",
});
expect(result).toEqual({
user: {
id: "user-456",
displayName: "Bob",
mail: undefined,
jobTitle: undefined,
userPrincipalName: undefined,
officeLocation: undefined,
},
});
});
it("propagates Graph API errors", async () => {
mockState.fetchGraphJson.mockRejectedValue(new Error("Graph API 404: user not found"));
await expect(
getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
userId: "nonexistent-user",
}),
).rejects.toThrow("Graph API 404: user not found");
});
});

View File

@@ -0,0 +1,49 @@
// Msteams plugin module implements graph members behavior.
import type { OpenClawConfig } from "../runtime-api.js";
import { fetchGraphJson, resolveGraphToken } from "./graph.js";
type GraphUserProfile = {
id?: string;
displayName?: string;
mail?: string;
jobTitle?: string;
userPrincipalName?: string;
officeLocation?: string;
};
type GetMemberInfoMSTeamsParams = {
cfg: OpenClawConfig;
userId: string;
};
type GetMemberInfoMSTeamsResult = {
user: {
id: string | undefined;
displayName: string | undefined;
mail: string | undefined;
jobTitle: string | undefined;
userPrincipalName: string | undefined;
officeLocation: string | undefined;
};
};
/**
* Fetch a user profile from Microsoft Graph by user ID.
*/
export async function getMemberInfoMSTeams(
params: GetMemberInfoMSTeamsParams,
): Promise<GetMemberInfoMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const path = `/users/${encodeURIComponent(params.userId)}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation`;
const user = await fetchGraphJson<GraphUserProfile>({ token, path });
return {
user: {
id: user.id,
displayName: user.displayName,
mail: user.mail,
jobTitle: user.jobTitle,
userPrincipalName: user.userPrincipalName,
officeLocation: user.officeLocation,
},
};
}

View File

@@ -0,0 +1,254 @@
// Msteams tests cover graph messages.actions plugin behavior.
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
CHANNEL_TO,
CHAT_ID,
TOKEN,
type GraphMessagesTestModule,
getGraphMessagesMockState,
installGraphMessagesMockDefaults,
loadGraphMessagesTestModule,
} from "./graph-messages.test-helpers.js";
const mockState = getGraphMessagesMockState();
installGraphMessagesMockDefaults();
let pinMessageMSTeams: GraphMessagesTestModule["pinMessageMSTeams"];
let reactMessageMSTeams: GraphMessagesTestModule["reactMessageMSTeams"];
let unpinMessageMSTeams: GraphMessagesTestModule["unpinMessageMSTeams"];
let unreactMessageMSTeams: GraphMessagesTestModule["unreactMessageMSTeams"];
beforeAll(async () => {
({ pinMessageMSTeams, reactMessageMSTeams, unpinMessageMSTeams, unreactMessageMSTeams } =
await loadGraphMessagesTestModule());
});
const emptyReactionCases: Array<{
name: string;
invoke: () => Promise<unknown>;
}> = [
{
name: "reactMessageMSTeams",
invoke: () =>
reactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
reactionType: " ",
}),
},
{
name: "unreactMessageMSTeams",
invoke: () =>
unreactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
reactionType: "",
}),
},
];
describe("MSTeams reaction validation", () => {
it.each(emptyReactionCases)("$name rejects empty reaction type", async ({ invoke }) => {
await expect(invoke()).rejects.toThrow(/Reaction type is required/);
});
});
describe("pinMessageMSTeams", () => {
it("pins a message in a chat via message@odata.bind body", async () => {
mockState.postGraphJson.mockResolvedValue({ id: "pinned-1" });
const result = await pinMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
});
expect(result).toEqual({ ok: true, pinnedMessageId: "pinned-1" });
expect(mockState.postGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages`,
body: {
"message@odata.bind": `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(
CHAT_ID,
)}/messages/${encodeURIComponent("msg-1")}`,
},
});
});
it("rejects pinning a message in a channel on Graph v1.0", async () => {
await expect(
pinMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
messageId: "msg-2",
}),
).rejects.toThrow(/Pin\/unpin is not supported for channel messages/);
expect(mockState.postGraphJson).not.toHaveBeenCalled();
});
});
describe("unpinMessageMSTeams", () => {
it("unpins a message from a chat", async () => {
mockState.deleteGraphRequest.mockResolvedValue(undefined);
const result = await unpinMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
pinnedMessageId: "pinned-1",
});
expect(result).toEqual({ ok: true });
expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages/${encodeURIComponent("pinned-1")}`,
});
});
it("rejects unpinning a message from a channel on Graph v1.0", async () => {
await expect(
unpinMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
pinnedMessageId: "pinned-2",
}),
).rejects.toThrow(/Pin\/unpin is not supported for channel messages/);
expect(mockState.deleteGraphRequest).not.toHaveBeenCalled();
});
});
describe("reactMessageMSTeams", () => {
it("sets a like reaction on a chat message", async () => {
mockState.postGraphBetaJson.mockResolvedValue(undefined);
const result = await reactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
reactionType: "like",
});
expect(result).toEqual({ ok: true });
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
body: { reactionType: "like" },
});
});
it("sets a reaction on a channel message", async () => {
mockState.postGraphBetaJson.mockResolvedValue(undefined);
const result = await reactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
messageId: "msg-2",
reactionType: "heart",
});
expect(result).toEqual({ ok: true });
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/setReaction",
body: { reactionType: "heart" },
});
});
it("normalizes reaction type to lowercase", async () => {
mockState.postGraphBetaJson.mockResolvedValue(undefined);
await reactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
reactionType: "LAUGH",
});
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
body: { reactionType: "laugh" },
});
});
it("passes through non-well-known reaction types (e.g. Unicode emoji)", async () => {
// Graph setReaction accepts arbitrary Unicode emoji plus the legacy
// well-known types; normalizeReactionType only lowercases the legacy set
// and lets any other non-empty value through unchanged.
mockState.postGraphBetaJson.mockResolvedValue(undefined);
await reactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
reactionType: "🎉",
});
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
body: { reactionType: "🎉" },
});
});
it("resolves user: target through conversation store", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "a:bot-id",
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
});
mockState.postGraphBetaJson.mockResolvedValue(undefined);
await reactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:aad-user-1",
messageId: "msg-1",
reactionType: "like",
});
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1");
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages/msg-1/setReaction`,
body: { reactionType: "like" },
});
});
});
describe("unreactMessageMSTeams", () => {
it("removes a reaction from a chat message", async () => {
mockState.postGraphBetaJson.mockResolvedValue(undefined);
const result = await unreactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
reactionType: "sad",
});
expect(result).toEqual({ ok: true });
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/unsetReaction`,
body: { reactionType: "sad" },
});
});
it("removes a reaction from a channel message", async () => {
mockState.postGraphBetaJson.mockResolvedValue(undefined);
const result = await unreactMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
messageId: "msg-2",
reactionType: "angry",
});
expect(result).toEqual({ ok: true });
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/unsetReaction",
body: { reactionType: "angry" },
});
});
});

View File

@@ -0,0 +1,392 @@
// Msteams tests cover graph messages.read plugin behavior.
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
CHANNEL_TO,
CHAT_ID,
TOKEN,
type GraphMessagesTestModule,
getGraphMessagesMockState,
installGraphMessagesMockDefaults,
loadGraphMessagesTestModule,
} from "./graph-messages.test-helpers.js";
const mockState = getGraphMessagesMockState();
installGraphMessagesMockDefaults();
let getMessageMSTeams: GraphMessagesTestModule["getMessageMSTeams"];
let listPinsMSTeams: GraphMessagesTestModule["listPinsMSTeams"];
let listReactionsMSTeams: GraphMessagesTestModule["listReactionsMSTeams"];
beforeAll(async () => {
({ getMessageMSTeams, listPinsMSTeams, listReactionsMSTeams } =
await loadGraphMessagesTestModule());
});
describe("getMessageMSTeams", () => {
it("resolves user: target using graphChatId from store", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "a:bot-framework-dm-id",
reference: { graphChatId: "19:graph-native-chat@thread.tacv2" },
});
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "From user DM" },
createdDateTime: "2026-03-23T12:00:00Z",
});
await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:aad-object-id-123",
messageId: "msg-1",
});
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-object-id-123");
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent("19:graph-native-chat@thread.tacv2")}/messages/msg-1`,
});
});
it("falls back to conversationId when it starts with 19:", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "19:resolved-chat@thread.tacv2",
reference: {},
});
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello" },
createdDateTime: "2026-03-23T10:00:00Z",
});
await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:aad-id",
messageId: "msg-1",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent("19:resolved-chat@thread.tacv2")}/messages/msg-1`,
});
});
it("throws when user: target has no stored conversation", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue(null);
await expect(
getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:unknown-user",
messageId: "msg-1",
}),
).rejects.toThrow("No conversation found for user:unknown-user");
});
it("throws when user: target has Bot Framework ID and no graphChatId", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "a:bot-framework-dm-id",
reference: {},
});
await expect(
getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "user:some-user",
messageId: "msg-1",
}),
).rejects.toThrow("Bot Framework ID");
});
it("strips conversation: prefix from target", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello" },
from: undefined,
createdDateTime: "2026-03-23T10:00:00Z",
});
await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: `conversation:${CHAT_ID}`,
messageId: "msg-1",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`,
});
});
it("reads a message from a chat conversation", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello world", contentType: "text" },
from: { user: { id: "user-1", displayName: "Alice" } },
createdDateTime: "2026-03-23T10:00:00Z",
});
const result = await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
});
expect(result).toEqual({
id: "msg-1",
text: "Hello world",
from: { user: { id: "user-1", displayName: "Alice" } },
createdAt: "2026-03-23T10:00:00Z",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`,
});
});
it("reads a message from a channel conversation", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-2",
body: { content: "Channel message" },
from: { application: { id: "app-1", displayName: "Bot" } },
createdDateTime: "2026-03-23T11:00:00Z",
});
const result = await getMessageMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
messageId: "msg-2",
});
expect(result).toEqual({
id: "msg-2",
text: "Channel message",
from: { application: { id: "app-1", displayName: "Bot" } },
createdAt: "2026-03-23T11:00:00Z",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2",
});
});
});
describe("listPinsMSTeams", () => {
it("lists pinned messages in a chat", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{
id: "pinned-1",
message: { id: "msg-1", body: { content: "Pinned msg" } },
},
{
id: "pinned-2",
message: { id: "msg-2", body: { content: "Another pin" } },
},
],
});
const result = await listPinsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
});
expect(result.pins).toEqual([
{ id: "pinned-1", pinnedMessageId: "pinned-1", messageId: "msg-1", text: "Pinned msg" },
{ id: "pinned-2", pinnedMessageId: "pinned-2", messageId: "msg-2", text: "Another pin" },
]);
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages?$expand=message`,
});
});
it("returns empty array when no pins exist", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
const result = await listPinsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
});
expect(result.pins).toStrictEqual([]);
});
it("follows @odata.nextLink pagination", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [{ id: "pinned-1", message: { id: "msg-1", body: { content: "First page" } } }],
"@odata.nextLink":
"https://graph.microsoft.com/v1.0/chats/19%3Aabc%40thread.tacv2/pinnedMessages?$expand=message&$skiptoken=page2",
});
mockState.fetchGraphAbsoluteUrl.mockResolvedValue({
value: [{ id: "pinned-2", message: { id: "msg-2", body: { content: "Second page" } } }],
});
const result = await listPinsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
});
expect(result.pins).toEqual([
{ id: "pinned-1", pinnedMessageId: "pinned-1", messageId: "msg-1", text: "First page" },
{ id: "pinned-2", pinnedMessageId: "pinned-2", messageId: "msg-2", text: "Second page" },
]);
expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledWith({
token: TOKEN,
url: "https://graph.microsoft.com/v1.0/chats/19%3Aabc%40thread.tacv2/pinnedMessages?$expand=message&$skiptoken=page2",
});
});
it("stops paginating after max pages", async () => {
const makePageResponse = (pageNum: number) => ({
value: [
{
id: `pinned-${pageNum}`,
message: { id: `msg-${pageNum}`, body: { content: `Page ${pageNum}` } },
},
],
"@odata.nextLink": `https://graph.microsoft.com/v1.0/next?page=${pageNum + 1}`,
});
mockState.fetchGraphJson.mockResolvedValue(makePageResponse(1));
for (let i = 2; i <= 10; i++) {
mockState.fetchGraphAbsoluteUrl.mockResolvedValueOnce(makePageResponse(i));
}
const result = await listPinsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
});
expect(result.pins).toHaveLength(10);
expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledTimes(9);
});
it("throws for channel list-pins (not supported on Graph v1.0)", async () => {
await expect(
listPinsMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
}),
).rejects.toThrow("not supported for channels");
});
});
describe("listReactionsMSTeams", () => {
it("lists reactions grouped by type with user details", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello" },
reactions: [
{ reactionType: "like", user: { id: "u1", displayName: "Alice" } },
{ reactionType: "like", user: { id: "u2", displayName: "Bob" } },
{ reactionType: "heart", user: { id: "u1", displayName: "Alice" } },
],
});
const result = await listReactionsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
});
expect(result.reactions).toEqual([
{
reactionType: "like",
name: "like",
emoji: "\u{1F44D}",
count: 2,
users: [
{ id: "u1", displayName: "Alice" },
{ id: "u2", displayName: "Bob" },
],
},
{
reactionType: "heart",
name: "heart",
emoji: "\u2764\uFE0F",
count: 1,
users: [{ id: "u1", displayName: "Alice" }],
},
]);
});
it("returns empty array when message has no reactions", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "No reactions" },
});
const result = await listReactionsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
});
expect(result.reactions).toStrictEqual([]);
});
it("counts reactions from users without an ID", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-1",
body: { content: "Hello" },
reactions: [
{ reactionType: "like", user: { id: "u1", displayName: "Alice" } },
{ reactionType: "like", user: { displayName: "Deleted User" } },
{ reactionType: "like", user: undefined },
{ reactionType: "like" },
{ reactionType: "heart", user: { id: "u2", displayName: "Bob" } },
],
});
const result = await listReactionsMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
messageId: "msg-1",
});
expect(result.reactions).toEqual([
{
reactionType: "like",
name: "like",
emoji: "\u{1F44D}",
count: 4,
users: [{ id: "u1", displayName: "Alice" }],
},
{
reactionType: "heart",
name: "heart",
emoji: "\u2764\uFE0F",
count: 1,
users: [{ id: "u2", displayName: "Bob" }],
},
]);
});
it("fetches from channel path for channel targets", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "msg-2",
body: { content: "Channel msg" },
reactions: [{ reactionType: "surprised", user: { id: "u3", displayName: "Carol" } }],
});
const result = await listReactionsMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
messageId: "msg-2",
});
expect(result.reactions).toEqual([
{
reactionType: "surprised",
name: "surprised",
emoji: "\u{1F62E}",
count: 1,
users: [{ id: "u3", displayName: "Carol" }],
},
]);
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2",
});
});
});

View File

@@ -0,0 +1,228 @@
// Msteams tests cover graph messages.search plugin behavior.
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
CHANNEL_TO,
CHAT_ID,
type GraphMessagesTestModule,
getGraphMessagesMockState,
installGraphMessagesMockDefaults,
loadGraphMessagesTestModule,
} from "./graph-messages.test-helpers.js";
const mockState = getGraphMessagesMockState();
installGraphMessagesMockDefaults();
let searchMessagesMSTeams: GraphMessagesTestModule["searchMessagesMSTeams"];
beforeAll(async () => {
({ searchMessagesMSTeams } = await loadGraphMessagesTestModule());
});
function readFirstGraphPath(): string {
const [call] = mockState.fetchGraphJson.mock.calls;
if (!call) {
throw new Error("Expected Graph fetch call");
}
const [request] = call;
if (!request || typeof request !== "object" || typeof request.path !== "string") {
throw new Error("Expected Graph fetch request path");
}
return request.path;
}
describe("searchMessagesMSTeams", () => {
it("searches chat messages with query string", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{
id: "msg-1",
body: { content: "Meeting notes from Monday" },
from: { user: { id: "u1", displayName: "Alice" } },
createdDateTime: "2026-03-25T10:00:00Z",
},
],
});
const result = await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "meeting notes",
});
expect(result.messages).toEqual([
{
id: "msg-1",
text: "Meeting notes from Monday",
from: { user: { id: "u1", displayName: "Alice" } },
createdAt: "2026-03-25T10:00:00Z",
},
]);
const calledPath = readFirstGraphPath();
expect(calledPath).toContain(`/chats/${encodeURIComponent(CHAT_ID)}/messages?`);
expect(calledPath).toContain("$search=");
expect(calledPath).toContain("$top=25");
const decoded = decodeURIComponent(calledPath);
expect(decoded).toContain('$search="meeting notes"');
});
it("searches channel messages", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{
id: "msg-2",
body: { content: "Sprint review" },
from: { user: { id: "u2", displayName: "Bob" } },
createdDateTime: "2026-03-25T11:00:00Z",
},
],
});
const result = await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
query: "sprint",
});
expect(result.messages).toHaveLength(1);
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("/teams/team-id-1/channels/channel-id-1/messages?");
});
it("applies limit parameter", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
limit: 10,
});
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("$top=10");
});
it("clamps limit to max 50", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
limit: 100,
});
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("$top=50");
});
it("clamps limit to min 1", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
limit: 0,
});
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("$top=1");
});
it("applies from filter", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "budget",
from: "Alice",
});
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("$filter=");
const decoded = decodeURIComponent(calledPath);
expect(decoded).toContain("from/user/displayName eq 'Alice'");
});
it("escapes single quotes in from filter", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
from: "O'Brien",
});
const calledPath = readFirstGraphPath();
const decoded = decodeURIComponent(calledPath);
expect(decoded).toContain("O''Brien");
});
it("strips double quotes from query to prevent injection", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: 'say "hello" world',
});
const calledPath = readFirstGraphPath();
const decoded = decodeURIComponent(calledPath);
expect(decoded).toContain('$search="say hello world"');
expect(decoded).not.toContain('""');
});
it("passes ConsistencyLevel: eventual header", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: "test-graph-token",
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages?$search=${encodeURIComponent(
'"test"',
)}&$top=25`,
headers: { ConsistencyLevel: "eventual" },
});
});
it("returns empty array when no messages match", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
const result = await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "nonexistent",
});
expect(result.messages).toStrictEqual([]);
});
it("resolves user: target through conversation store", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "a:bot-id",
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
});
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: "user:aad-user-1",
query: "hello",
});
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1");
const calledPath = readFirstGraphPath();
expect(calledPath).toContain(
`/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages?`,
);
});
});

View File

@@ -0,0 +1,51 @@
// Msteams helper module supports graph messages helpers behavior.
import { beforeEach, vi } from "vitest";
const graphMessagesMockState = vi.hoisted(() => ({
resolveGraphToken: vi.fn(),
fetchGraphJson: vi.fn(),
fetchGraphAbsoluteUrl: vi.fn(),
postGraphJson: vi.fn(),
postGraphBetaJson: vi.fn(),
deleteGraphRequest: vi.fn(),
findPreferredDmByUserId: vi.fn(),
}));
vi.mock("./graph.js", () => {
return {
resolveGraphToken: graphMessagesMockState.resolveGraphToken,
fetchGraphJson: graphMessagesMockState.fetchGraphJson,
fetchGraphAbsoluteUrl: graphMessagesMockState.fetchGraphAbsoluteUrl,
postGraphJson: graphMessagesMockState.postGraphJson,
postGraphBetaJson: graphMessagesMockState.postGraphBetaJson,
deleteGraphRequest: graphMessagesMockState.deleteGraphRequest,
escapeOData: vi.fn((value: string) => value.replaceAll("'", "''")),
};
});
vi.mock("./conversation-store-state.js", () => ({
createMSTeamsConversationStoreState: () => ({
findPreferredDmByUserId: graphMessagesMockState.findPreferredDmByUserId,
}),
}));
export const TOKEN = "test-graph-token";
export const CHAT_ID = "19:abc@thread.tacv2";
export const CHANNEL_TO = "team-id-1/channel-id-1";
export function getGraphMessagesMockState(): typeof graphMessagesMockState {
return graphMessagesMockState;
}
export type GraphMessagesTestModule = typeof import("./graph-messages.js");
export function loadGraphMessagesTestModule(): Promise<GraphMessagesTestModule> {
return import("./graph-messages.js");
}
export function installGraphMessagesMockDefaults(): void {
beforeEach(() => {
vi.clearAllMocks();
graphMessagesMockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
}

View File

@@ -0,0 +1,535 @@
// Msteams plugin module implements graph messages behavior.
import type { OpenClawConfig } from "../runtime-api.js";
import { createMSTeamsConversationStoreState } from "./conversation-store-state.js";
import {
type GraphResponse,
deleteGraphRequest,
escapeOData,
fetchGraphAbsoluteUrl,
fetchGraphJson,
postGraphBetaJson,
postGraphJson,
resolveGraphToken,
} from "./graph.js";
type GraphMessageBody = {
content?: string;
contentType?: string;
};
type GraphMessageFrom = {
user?: { id?: string; displayName?: string };
application?: { id?: string; displayName?: string };
};
type GraphMessage = {
id?: string;
body?: GraphMessageBody;
from?: GraphMessageFrom;
createdDateTime?: string;
};
type GraphPinnedMessage = {
id?: string;
message?: GraphMessage;
};
type GraphPinnedMessagesResponse = {
value?: GraphPinnedMessage[];
"@odata.nextLink"?: string;
};
/**
* Resolve the Graph API path prefix for a conversation.
* If `to` contains "/" it's a `teamId/channelId` (channel path),
* otherwise it's a chat ID.
*/
/**
* Strip common target prefixes (`conversation:`, `user:`) so raw
* conversation IDs can be used directly in Graph paths.
*/
function stripTargetPrefix(raw: string): string {
const trimmed = raw.trim();
if (/^conversation:/i.test(trimmed)) {
return trimmed.slice("conversation:".length).trim();
}
if (/^user:/i.test(trimmed)) {
return trimmed.slice("user:".length).trim();
}
return trimmed;
}
/**
* Resolve a target to a Graph-compatible conversation ID.
* `user:<aadId>` targets are looked up in the conversation store to find the
* actual `19:xxx@thread.*` chat ID that Graph API requires.
* Conversation IDs and `teamId/channelId` pairs pass through unchanged.
*/
export async function resolveGraphConversationId(to: string): Promise<string> {
const trimmed = to.trim();
const isUserTarget = /^user:/i.test(trimmed);
const cleaned = stripTargetPrefix(trimmed);
// teamId/channelId or already a conversation ID (19:xxx) — use directly
if (!isUserTarget) {
return cleaned;
}
// user:<aadId> — look up the conversation store for the real chat ID
const store = createMSTeamsConversationStoreState();
const found = await store.findPreferredDmByUserId(cleaned);
if (!found) {
throw new Error(
`No conversation found for user:${cleaned}. ` +
"The bot must receive a message from this user before Graph API operations work.",
);
}
// Prefer the cached Graph-native chat ID (19:xxx format) over the Bot Framework
// conversation ID, which may be in a non-Graph format (a:xxx / 8:orgid:xxx) for
// personal DMs. send-context.ts resolves and caches this on first send.
if (found.reference.graphChatId) {
return found.reference.graphChatId;
}
if (found.conversationId.startsWith("19:")) {
return found.conversationId;
}
throw new Error(
`Conversation for user:${cleaned} uses a Bot Framework ID (${found.conversationId}) ` +
"that Graph API does not accept. Send a message to this user first so the Graph chat ID is cached.",
);
}
export function resolveConversationPath(to: string): {
kind: "chat" | "channel";
basePath: string;
chatId?: string;
teamId?: string;
channelId?: string;
} {
const cleaned = stripTargetPrefix(to);
if (cleaned.includes("/")) {
const [teamId, channelId] = cleaned.split("/", 2);
return {
kind: "channel",
basePath: `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}`,
teamId,
channelId,
};
}
// Conversation IDs like 19:xxx@thread.tacv2 may represent either group chats
// or channel threads. Without a teamId/channelId pair (format "teamId/channelId")
// we route through /chats/{id} which works for group chats and 1:1 DMs.
// Channel operations that require /teams/{teamId}/channels/{channelId} paths
// must be called with the explicit teamId/channelId target format.
return {
kind: "chat",
basePath: `/chats/${encodeURIComponent(cleaned)}`,
chatId: cleaned,
};
}
export type GetMessageMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
messageId: string;
};
export type GetMessageMSTeamsResult = {
id: string;
text: string | undefined;
from: GraphMessageFrom | undefined;
createdAt: string | undefined;
};
/**
* Retrieve a single message by ID from a chat or channel via Graph API.
*/
export async function getMessageMSTeams(
params: GetMessageMSTeamsParams,
): Promise<GetMessageMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`;
const msg = await fetchGraphJson<GraphMessage>({ token, path });
return {
id: msg.id ?? params.messageId,
text: msg.body?.content,
from: msg.from,
createdAt: msg.createdDateTime,
};
}
export type PinMessageMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
messageId: string;
};
/**
* Pin a message in a chat conversation via Graph API.
*
* Chat pinning uses the v1.0 endpoint: `POST /chats/{chatId}/pinnedMessages`.
*
* Channel pinning uses `POST /teams/{teamId}/channels/{channelId}/pinnedMessages`.
* **Note:** The channel pin endpoint may require the Graph beta API or specific
* tenant-level permissions. As of March 2026, general availability is not
* confirmed for all tenants. If the call returns 404 or 403, the endpoint may
* not be enabled for the target tenant.
*/
export async function pinMessageMSTeams(
params: PinMessageMSTeamsParams,
): Promise<{ ok: true; pinnedMessageId?: string }> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
if (conv.kind === "channel") {
// Graph v1.0 does not expose pinnedMessages on channels — only on chats.
// Attempting this would 404.
throw new Error(
"Pin/unpin is not supported for channel messages on Graph v1.0. " +
"Only chat conversations support pinned messages.",
);
}
// Graph API expects message@odata.bind with the full message resource URI
const body = {
"message@odata.bind": `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(params.messageId)}`,
};
const result = await postGraphJson<{ id?: string }>({
token,
path: `${conv.basePath}/pinnedMessages`,
body,
});
return { ok: true, pinnedMessageId: result.id };
}
export type UnpinMessageMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
/** The pinned-message resource ID returned by pin or list-pins (not the message ID). */
pinnedMessageId: string;
};
/**
* Unpin a message in a chat conversation via Graph API.
* `pinnedMessageId` is the pinned-message resource ID (from pin or list-pins),
* not the underlying chat message ID.
*
* Channel unpin uses `DELETE /teams/{teamId}/channels/{channelId}/pinnedMessages/{id}`.
* See the note on {@link pinMessageMSTeams} regarding beta/GA status.
*/
export async function unpinMessageMSTeams(
params: UnpinMessageMSTeamsParams,
): Promise<{ ok: true }> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
if (conv.kind === "channel") {
throw new Error(
"Pin/unpin is not supported for channel messages on Graph v1.0. " +
"Only chat conversations support pinned messages.",
);
}
const path = `${conv.basePath}/pinnedMessages/${encodeURIComponent(params.pinnedMessageId)}`;
await deleteGraphRequest({ token, path });
return { ok: true };
}
export type ListPinsMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
};
export type ListPinsMSTeamsResult = {
pins: Array<{ id: string; pinnedMessageId: string; messageId?: string; text?: string }>;
};
/** Maximum number of pagination pages to follow to avoid unbounded loops. */
const LIST_PINS_MAX_PAGES = 10;
/**
* List all pinned messages in a chat conversation via Graph API.
* Follows `@odata.nextLink` pagination to collect the full pin set.
*
* Channel list-pins uses the same endpoint pattern as channel pin/unpin.
* See the note on {@link pinMessageMSTeams} regarding beta/GA status.
*/
export async function listPinsMSTeams(
params: ListPinsMSTeamsParams,
): Promise<ListPinsMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
if (conv.kind === "channel") {
throw new Error(
"Listing pinned messages is not supported for channels on Graph v1.0. " +
"Only chat conversations support pinned messages.",
);
}
const path = `${conv.basePath}/pinnedMessages?$expand=message`;
const allPins: Array<{ id: string; pinnedMessageId: string; messageId?: string; text?: string }> =
[];
let res = await fetchGraphJson<GraphPinnedMessagesResponse>({ token, path });
let pages = 1;
while (true) {
for (const pin of res.value ?? []) {
allPins.push({
id: pin.id ?? "",
pinnedMessageId: pin.id ?? "",
messageId: pin.message?.id,
text: pin.message?.body?.content,
});
}
const nextLink = res["@odata.nextLink"];
if (!nextLink || pages >= LIST_PINS_MAX_PAGES) {
break;
}
res = await fetchGraphAbsoluteUrl<GraphPinnedMessagesResponse>({ token, url: nextLink });
pages++;
}
return { pins: allPins };
}
// ---------------------------------------------------------------------------
// Reactions
// ---------------------------------------------------------------------------
export const TEAMS_REACTION_TYPES = [
"like",
"heart",
"laugh",
"surprised",
"sad",
"angry",
] as const;
export type TeamsReactionType = (typeof TEAMS_REACTION_TYPES)[number];
type GraphReaction = {
reactionType?: string;
user?: { id?: string; displayName?: string };
createdDateTime?: string;
};
type GraphMessageWithReactions = GraphMessage & {
reactions?: GraphReaction[];
};
export type ReactMessageMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
messageId: string;
reactionType: string;
};
export type ListReactionsMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
messageId: string;
};
/** Map well-known reaction type names to representative emoji for CLI display. */
const REACTION_TYPE_EMOJI: Record<string, string> = {
like: "\u{1F44D}",
heart: "\u2764\uFE0F",
laugh: "\u{1F606}",
surprised: "\u{1F62E}",
sad: "\u{1F622}",
angry: "\u{1F621}",
};
export type ReactionSummary = {
reactionType: string;
/** Display name for the reaction (matches reactionType for known types). */
name: string;
/** Emoji representation when available. */
emoji?: string;
count: number;
users: Array<{ id: string; displayName?: string }>;
};
export type ListReactionsMSTeamsResult = {
reactions: ReactionSummary[];
};
/**
* Normalize a reaction type string. Graph setReaction/unsetReaction accepts
* the well-known legacy names (like, heart, laugh, surprised, sad, angry)
* as well as Unicode emoji values — so we pass unknown types through rather
* than rejecting them.
*/
function normalizeReactionType(raw: string): string {
const normalized = raw.trim();
if (!normalized) {
throw new Error(`Reaction type is required. Common types: ${TEAMS_REACTION_TYPES.join(", ")}`);
}
// Lowercase only the well-known names; Unicode emoji should pass through as-is
const lowered = normalized.toLowerCase();
if (TEAMS_REACTION_TYPES.includes(lowered as TeamsReactionType)) {
return lowered;
}
return normalized;
}
/**
* Add an emoji reaction to a message via Graph API (beta).
*
* Writes (setReaction) require a Delegated token, so we pass
* `preferDelegated: true`. The resolver falls back to the app-only token when
* delegated auth is not configured, preserving today's behavior while letting
* delegated-auth-enabled deployments hit the user-scoped endpoint.
*/
export async function reactMessageMSTeams(
params: ReactMessageMSTeamsParams,
): Promise<{ ok: true }> {
const reactionType = normalizeReactionType(params.reactionType);
const token = await resolveGraphToken(params.cfg, { preferDelegated: true });
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}/setReaction`;
await postGraphBetaJson<unknown>({ token, path, body: { reactionType } });
return { ok: true };
}
/**
* Remove an emoji reaction from a message via Graph API (beta).
*
* Writes (unsetReaction) require a Delegated token, so we pass
* `preferDelegated: true`. See `reactMessageMSTeams` for fallback rules.
*/
export async function unreactMessageMSTeams(
params: ReactMessageMSTeamsParams,
): Promise<{ ok: true }> {
const reactionType = normalizeReactionType(params.reactionType);
const token = await resolveGraphToken(params.cfg, { preferDelegated: true });
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}/unsetReaction`;
await postGraphBetaJson<unknown>({ token, path, body: { reactionType } });
return { ok: true };
}
/**
* List reactions on a message, grouped by type.
* Uses Graph v1.0 (reactions are included in the message resource).
*/
export async function listReactionsMSTeams(
params: ListReactionsMSTeamsParams,
): Promise<ListReactionsMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
const path = `${basePath}/messages/${encodeURIComponent(params.messageId)}`;
const msg = await fetchGraphJson<GraphMessageWithReactions>({ token, path });
const grouped = new Map<
string,
{ count: number; users: Array<{ id: string; displayName?: string }> }
>();
for (const reaction of msg.reactions ?? []) {
const type = reaction.reactionType ?? "unknown";
if (!grouped.has(type)) {
grouped.set(type, { count: 0, users: [] });
}
const group = grouped.get(type)!;
// Count every reaction regardless of whether the user ID is present
// (deleted accounts, guests, or anonymous users may lack a user ID)
group.count++;
if (reaction.user?.id) {
group.users.push({
id: reaction.user.id,
displayName: reaction.user.displayName,
});
}
}
const reactions: ReactionSummary[] = Array.from(grouped.entries()).map(([type, group]) => ({
reactionType: type,
name: type,
emoji: REACTION_TYPE_EMOJI[type],
count: group.count,
users: group.users,
}));
return { reactions };
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
export type SearchMessagesMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
query: string;
from?: string;
limit?: number;
};
export type SearchMessagesMSTeamsResult = {
messages: Array<{
id: string;
text: string | undefined;
from: GraphMessageFrom | undefined;
createdAt: string | undefined;
}>;
};
const SEARCH_DEFAULT_LIMIT = 25;
const SEARCH_MAX_LIMIT = 50;
/**
* Search messages in a chat or channel by content via Graph API.
* Uses `$search` for full-text body search and optional `$filter` for sender.
*/
export async function searchMessagesMSTeams(
params: SearchMessagesMSTeamsParams,
): Promise<SearchMessagesMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
const rawLimit = params.limit ?? SEARCH_DEFAULT_LIMIT;
const top = Number.isFinite(rawLimit)
? Math.min(Math.max(Math.floor(rawLimit), 1), SEARCH_MAX_LIMIT)
: SEARCH_DEFAULT_LIMIT;
// Strip double quotes from the query to prevent OData $search injection
const sanitizedQuery = params.query.replace(/"/g, "");
// Build query string manually (not URLSearchParams) to preserve literal $
// in OData parameter names, consistent with other Graph calls in this module.
const parts = [`$search=${encodeURIComponent(`"${sanitizedQuery}"`)}`];
parts.push(`$top=${top}`);
if (params.from) {
parts.push(
`$filter=${encodeURIComponent(`from/user/displayName eq '${escapeOData(params.from)}'`)}`,
);
}
const path = `${basePath}/messages?${parts.join("&")}`;
// ConsistencyLevel: eventual is required by Graph API for $search queries
const res = await fetchGraphJson<GraphResponse<GraphMessage>>({
token,
path,
headers: { ConsistencyLevel: "eventual" },
});
const messages = (res.value ?? []).map((msg) => ({
id: msg.id ?? "",
text: msg.body?.content,
from: msg.from,
createdAt: msg.createdDateTime,
}));
return { messages };
}

View File

@@ -0,0 +1,223 @@
// Msteams tests cover graph teams plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { getChannelInfoMSTeams, listChannelsMSTeams } from "./graph-teams.js";
const mockState = vi.hoisted(() => ({
resolveGraphToken: vi.fn(),
fetchGraphJson: vi.fn(),
}));
vi.mock("./graph.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./graph.js")>();
return {
...actual,
resolveGraphToken: mockState.resolveGraphToken,
fetchGraphJson: mockState.fetchGraphJson,
};
});
const TOKEN = "test-graph-token";
function graphFetchPathAt(index: number): string | undefined {
const call = mockState.fetchGraphJson.mock.calls[index];
if (!call) {
throw new Error(`expected Graph fetch call ${index}`);
}
return call[0]?.path;
}
describe("listChannelsMSTeams", () => {
beforeEach(() => {
mockState.resolveGraphToken.mockReset().mockResolvedValue(TOKEN);
mockState.fetchGraphJson.mockReset();
});
it("returns channels with all fields mapped", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{
id: "ch-1",
displayName: "General",
description: "The default channel",
membershipType: "standard",
},
{
id: "ch-2",
displayName: "Engineering",
description: "Engineering discussions",
membershipType: "private",
},
],
});
const result = await listChannelsMSTeams({
cfg: {} as OpenClawConfig,
teamId: "team-abc",
});
expect(result.channels).toEqual([
{
id: "ch-1",
displayName: "General",
description: "The default channel",
membershipType: "standard",
},
{
id: "ch-2",
displayName: "Engineering",
description: "Engineering discussions",
membershipType: "private",
},
]);
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/teams/${encodeURIComponent("team-abc")}/channels?$select=id,displayName,description,membershipType`,
});
});
it("returns empty array when team has no channels", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
const result = await listChannelsMSTeams({
cfg: {} as OpenClawConfig,
teamId: "team-empty",
});
expect(result.channels).toStrictEqual([]);
});
it("returns empty array when value is undefined", async () => {
mockState.fetchGraphJson.mockResolvedValue({});
const result = await listChannelsMSTeams({
cfg: {} as OpenClawConfig,
teamId: "team-no-value",
});
expect(result.channels).toStrictEqual([]);
});
it("follows @odata.nextLink across multiple pages", async () => {
mockState.fetchGraphJson
.mockResolvedValueOnce({
value: [
{ id: "ch-1", displayName: "General", description: null, membershipType: "standard" },
],
"@odata.nextLink":
"https://graph.microsoft.com/v1.0/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=1",
})
.mockResolvedValueOnce({
value: [
{ id: "ch-2", displayName: "Random", description: "Fun", membershipType: "standard" },
],
"@odata.nextLink":
"https://graph.microsoft.com/v1.0/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=2",
})
.mockResolvedValueOnce({
value: [
{ id: "ch-3", displayName: "Private", description: null, membershipType: "private" },
],
});
const result = await listChannelsMSTeams({
cfg: {} as OpenClawConfig,
teamId: "team-paged",
});
expect(result.channels).toHaveLength(3);
expect(result.channels.map((ch) => ch.id)).toEqual(["ch-1", "ch-2", "ch-3"]);
expect(result.truncated).toBe(false);
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(3);
// Second call should use the relative path stripped from the nextLink
expect(graphFetchPathAt(1)).toBe(
"/teams/team-paged/channels?$select=id,displayName,description,membershipType&$skip=1",
);
});
it("stops after 10 pages to avoid runaway pagination", async () => {
for (let i = 0; i < 11; i++) {
mockState.fetchGraphJson.mockResolvedValueOnce({
value: [
{
id: `ch-${i}`,
displayName: `Channel ${i}`,
description: null,
membershipType: "standard",
},
],
"@odata.nextLink": `https://graph.microsoft.com/v1.0/teams/team-huge/channels?$skip=${i + 1}`,
});
}
const result = await listChannelsMSTeams({
cfg: {} as OpenClawConfig,
teamId: "team-huge",
});
// Should stop at 10 pages even though more nextLinks are available
expect(result.channels).toHaveLength(10);
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(10);
expect(result.truncated).toBe(true);
});
});
describe("getChannelInfoMSTeams", () => {
beforeEach(() => {
mockState.resolveGraphToken.mockReset().mockResolvedValue(TOKEN);
mockState.fetchGraphJson.mockReset();
});
it("returns channel with all fields", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "ch-1",
displayName: "General",
description: "The default channel",
membershipType: "standard",
webUrl: "https://teams.microsoft.com/l/channel/ch-1/General",
createdDateTime: "2026-01-15T09:00:00Z",
});
const result = await getChannelInfoMSTeams({
cfg: {} as OpenClawConfig,
teamId: "team-abc",
channelId: "ch-1",
});
expect(result.channel).toEqual({
id: "ch-1",
displayName: "General",
description: "The default channel",
membershipType: "standard",
webUrl: "https://teams.microsoft.com/l/channel/ch-1/General",
createdDateTime: "2026-01-15T09:00:00Z",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/teams/${encodeURIComponent("team-abc")}/channels/${encodeURIComponent("ch-1")}?$select=id,displayName,description,membershipType,webUrl,createdDateTime`,
});
});
it("handles missing optional fields gracefully", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "ch-2",
displayName: "Private Channel",
});
const result = await getChannelInfoMSTeams({
cfg: {} as OpenClawConfig,
teamId: "team-abc",
channelId: "ch-2",
});
expect(result.channel).toEqual({
id: "ch-2",
displayName: "Private Channel",
description: undefined,
membershipType: undefined,
webUrl: undefined,
createdDateTime: undefined,
});
});
});

View File

@@ -0,0 +1,115 @@
// Msteams plugin module implements graph teams behavior.
import type { OpenClawConfig } from "../runtime-api.js";
import { type GraphResponse, fetchGraphJson, resolveGraphToken } from "./graph.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type GraphTeamsChannel = {
id?: string;
displayName?: string;
description?: string;
membershipType?: string;
webUrl?: string;
createdDateTime?: string;
};
type ListChannelsMSTeamsParams = {
cfg: OpenClawConfig;
teamId: string;
};
type ListChannelsMSTeamsResult = {
channels: Array<{
id: string | undefined;
displayName: string | undefined;
description: string | undefined;
membershipType: string | undefined;
}>;
truncated?: boolean;
};
type GetChannelInfoMSTeamsParams = {
cfg: OpenClawConfig;
teamId: string;
channelId: string;
};
type GetChannelInfoMSTeamsResult = {
channel: {
id: string | undefined;
displayName: string | undefined;
description: string | undefined;
membershipType: string | undefined;
webUrl: string | undefined;
createdDateTime: string | undefined;
};
};
// ---------------------------------------------------------------------------
// List channels for a team
// ---------------------------------------------------------------------------
/**
* List channels in a team via Graph API.
* Returns id, displayName, description, and membershipType for each channel.
* Follows @odata.nextLink for paginated results (up to 10 pages).
*/
export async function listChannelsMSTeams(
params: ListChannelsMSTeamsParams,
): Promise<ListChannelsMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const firstPath = `/teams/${encodeURIComponent(params.teamId)}/channels?$select=id,displayName,description,membershipType`;
const collected: GraphTeamsChannel[] = [];
let nextPath: string | undefined = firstPath;
const MAX_PAGES = 10;
let page = 0;
while (nextPath && page < MAX_PAGES) {
type PagedChannelResponse = GraphResponse<GraphTeamsChannel> & {
"@odata.nextLink"?: string;
};
const res: PagedChannelResponse = await fetchGraphJson<PagedChannelResponse>({
token,
path: nextPath,
});
collected.push(...(res.value ?? []));
const nextLink: string | undefined = res["@odata.nextLink"];
// Strip the Graph API root so fetchGraphJson receives a relative path
nextPath = nextLink ? nextLink.replace("https://graph.microsoft.com/v1.0", "") : undefined;
page++;
}
const channels = collected.map((ch) => ({
id: ch.id,
displayName: ch.displayName,
description: ch.description,
membershipType: ch.membershipType,
}));
return { channels, truncated: Boolean(nextPath) };
}
// ---------------------------------------------------------------------------
// Get channel info
// ---------------------------------------------------------------------------
/**
* Get detailed information about a single channel in a team via Graph API.
* Returns id, displayName, description, membershipType, webUrl, and createdDateTime.
*/
export async function getChannelInfoMSTeams(
params: GetChannelInfoMSTeamsParams,
): Promise<GetChannelInfoMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const path = `/teams/${encodeURIComponent(params.teamId)}/channels/${encodeURIComponent(params.channelId)}?$select=id,displayName,description,membershipType,webUrl,createdDateTime`;
const ch = await fetchGraphJson<GraphTeamsChannel>({ token, path });
return {
channel: {
id: ch.id,
displayName: ch.displayName,
description: ch.description,
membershipType: ch.membershipType,
webUrl: ch.webUrl,
createdDateTime: ch.createdDateTime,
},
};
}

View File

@@ -0,0 +1,291 @@
// Msteams tests cover graph thread plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
_teamGroupIdCacheForTest,
fetchChannelMessage,
fetchThreadReplies,
formatThreadContext,
resolveTeamGroupId,
stripHtmlFromTeamsMessage,
} from "./graph-thread.js";
import { fetchGraphJson } from "./graph.js";
vi.mock("./graph.js", () => ({
fetchGraphJson: vi.fn(),
}));
const firstGraphPath = () => {
const [call] = vi.mocked(fetchGraphJson).mock.calls;
if (!call) {
throw new Error("expected Graph fetch call");
}
return call[0].path;
};
describe("stripHtmlFromTeamsMessage", () => {
it("preserves @mention display names from <at> tags", () => {
expect(stripHtmlFromTeamsMessage("<at>Alice</at> hello")).toBe("@Alice hello");
});
it("strips other HTML tags", () => {
expect(stripHtmlFromTeamsMessage("<p>Hello <b>world</b></p>")).toBe("Hello world");
});
it("decodes common HTML entities", () => {
expect(stripHtmlFromTeamsMessage("&amp; &lt;b&gt; &quot;x&quot; &#39;y&#39; &nbsp;z")).toBe(
"& <b> \"x\" 'y' z",
);
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {
// Graph encodes literally-typed entity text by escaping its '&' to '&amp;'.
// Decoding '&amp;' first would re-decode the now-bare '&lt;'/'&gt;' into
// angle brackets, corrupting the user's literal text.
expect(stripHtmlFromTeamsMessage("The token is &amp;lt;APIKEY&amp;gt;")).toBe(
"The token is &lt;APIKEY&gt;",
);
});
it("normalizes multiple whitespace to single space", () => {
expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");
});
it("handles <at> tags with attributes", () => {
expect(stripHtmlFromTeamsMessage('<at id="123">Bob</at> please review')).toBe(
"@Bob please review",
);
});
it("returns empty string for empty input", () => {
expect(stripHtmlFromTeamsMessage("")).toBe("");
});
});
describe("resolveTeamGroupId", () => {
beforeEach(() => {
vi.mocked(fetchGraphJson).mockReset();
_teamGroupIdCacheForTest.clear();
});
it("fetches team id from Graph and caches it", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-1" } as never);
const result = await resolveTeamGroupId("tok", "team-123");
expect(result).toBe("group-guid-1");
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "tok",
path: "/teams/team-123?$select=id",
});
});
it("returns cached value without calling Graph again", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-2" } as never);
await resolveTeamGroupId("tok", "team-456");
await resolveTeamGroupId("tok", "team-456");
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
});
it("does not cache team ids when the expiry would exceed a valid Date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid-boundary" } as never);
await resolveTeamGroupId("tok", "team-boundary");
await resolveTeamGroupId("tok", "team-boundary");
expect(fetchGraphJson).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("evicts cached team ids when the current clock is invalid", async () => {
vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid-invalid-clock" } as never);
await resolveTeamGroupId("tok", "team-invalid-clock");
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
await resolveTeamGroupId("tok", "team-invalid-clock");
} finally {
dateNow.mockRestore();
}
expect(fetchGraphJson).toHaveBeenCalledTimes(2);
});
it("falls back to conversationTeamId when Graph returns no id", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
const result = await resolveTeamGroupId("tok", "team-fallback");
expect(result).toBe("team-fallback");
});
});
describe("fetchChannelMessage", () => {
beforeEach(() => {
vi.mocked(fetchGraphJson).mockReset();
});
it("fetches the parent message with correct path", async () => {
const mockMsg = { id: "msg-1", body: { content: "hello", contentType: "text" } };
vi.mocked(fetchGraphJson).mockResolvedValueOnce(mockMsg as never);
const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1");
expect(result).toEqual(mockMsg);
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "tok",
path: "/teams/group-1/channels/channel-1/messages/msg-1?$select=id,from,body,createdDateTime",
});
});
it("returns undefined on fetch error", async () => {
vi.mocked(fetchGraphJson).mockRejectedValueOnce(new Error("forbidden") as never);
const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1");
expect(result).toBeUndefined();
});
it("URL-encodes group, channel, and message IDs", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
await fetchChannelMessage("tok", "g/1", "c/2", "m/3");
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "tok",
path: "/teams/g%2F1/channels/c%2F2/messages/m%2F3?$select=id,from,body,createdDateTime",
});
});
});
describe("fetchThreadReplies", () => {
beforeEach(() => {
vi.mocked(fetchGraphJson).mockReset();
});
it("fetches replies with correct path and default limit", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
value: [{ id: "reply-1" }, { id: "reply-2" }],
} as never);
const result = await fetchThreadReplies("tok", "group-1", "channel-1", "msg-1");
expect(result).toHaveLength(2);
expect(fetchGraphJson).toHaveBeenCalledWith({
token: "tok",
path: "/teams/group-1/channels/channel-1/messages/msg-1/replies?$top=50&$select=id,from,body,createdDateTime",
});
});
it("clamps limit to 50 maximum", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ value: [] } as never);
await fetchThreadReplies("tok", "g", "c", "m", 200);
expect(firstGraphPath()).toContain("$top=50");
});
it("clamps limit to 1 minimum", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ value: [] } as never);
await fetchThreadReplies("tok", "g", "c", "m", 0);
expect(firstGraphPath()).toContain("$top=1");
});
it("returns empty array when value is missing", async () => {
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
const result = await fetchThreadReplies("tok", "g", "c", "m");
expect(result).toStrictEqual([]);
});
});
describe("formatThreadContext", () => {
it("formats messages as sender: content lines", () => {
const messages = [
{
id: "m1",
from: { user: { displayName: "Alice" } },
body: { content: "Hello!", contentType: "text" },
},
{
id: "m2",
from: { user: { displayName: "Bob" } },
body: { content: "World!", contentType: "text" },
},
];
expect(formatThreadContext(messages)).toBe("Alice: Hello!\nBob: World!");
});
it("skips the current message by id", () => {
const messages = [
{
id: "m1",
from: { user: { displayName: "Alice" } },
body: { content: "Hello!", contentType: "text" },
},
{
id: "m2",
from: { user: { displayName: "Bob" } },
body: { content: "Current", contentType: "text" },
},
];
expect(formatThreadContext(messages, "m2")).toBe("Alice: Hello!");
});
it("strips HTML from html contentType messages", () => {
const messages = [
{
id: "m1",
from: { user: { displayName: "Carol" } },
body: { content: "<p>Hello <b>world</b></p>", contentType: "html" },
},
];
expect(formatThreadContext(messages)).toBe("Carol: Hello world");
});
it("uses application displayName when user is absent", () => {
const messages = [
{
id: "m1",
from: { application: { displayName: "BotApp" } },
body: { content: "automated msg", contentType: "text" },
},
];
expect(formatThreadContext(messages)).toBe("BotApp: automated msg");
});
it("skips messages with empty content", () => {
const messages = [
{
id: "m1",
from: { user: { displayName: "Alice" } },
body: { content: "", contentType: "text" },
},
{
id: "m2",
from: { user: { displayName: "Bob" } },
body: { content: "actual content", contentType: "text" },
},
];
expect(formatThreadContext(messages)).toBe("Bob: actual content");
});
it("falls back to 'unknown' sender when from is missing", () => {
const messages = [
{
id: "m1",
body: { content: "orphan msg", contentType: "text" },
},
];
expect(formatThreadContext(messages)).toBe("unknown: orphan msg");
});
it("returns empty string for empty messages array", () => {
expect(formatThreadContext([])).toBe("");
});
});

View File

@@ -0,0 +1,168 @@
// Msteams plugin module implements graph thread behavior.
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { fetchGraphJson, type GraphResponse } from "./graph.js";
export type GraphThreadMessage = {
id?: string;
from?: {
user?: { displayName?: string; id?: string };
application?: { displayName?: string; id?: string };
};
body?: { content?: string; contentType?: string };
createdDateTime?: string;
};
// TTL cache for team ID -> group GUID mapping.
const teamGroupIdCache = new Map<string, { groupId: string; expiresAt: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function resolveTeamGroupIdCacheExpiresAt(nowRaw = Date.now()): number | undefined {
const now = asDateTimestampMs(nowRaw);
return now === undefined
? undefined
: resolveExpiresAtMsFromDurationMs(CACHE_TTL_MS, { nowMs: now });
}
/**
* Strip HTML tags from Teams message content, preserving @mention display names.
* Teams wraps mentions in <at>Name</at> tags.
*/
export function stripHtmlFromTeamsMessage(html: string): string {
// Preserve mention display names by replacing <at>Name</at> with @Name.
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
// Strip remaining HTML tags.
text = text.replace(/<[^>]*>/g, " ");
// Decode common HTML entities. &amp; must be decoded LAST to prevent
// double-decoding (e.g. &amp;lt; → &lt; not <), matching decodeHtmlEntities
// in inbound.ts.
text = text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
// Normalize whitespace.
return text.replace(/\s+/g, " ").trim();
}
/**
* Resolve the Azure AD group GUID for a Teams conversation team ID.
* Results are cached with a TTL to avoid repeated Graph API calls.
*/
export async function resolveTeamGroupId(
token: string,
conversationTeamId: string,
): Promise<string> {
const cached = teamGroupIdCache.get(conversationTeamId);
if (cached) {
const now = asDateTimestampMs(Date.now());
const expiresAt = asDateTimestampMs(cached.expiresAt);
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
return cached.groupId;
}
teamGroupIdCache.delete(conversationTeamId);
}
// The team ID in channelData is typically the group ID itself for standard teams.
// Validate by fetching /teams/{id} and returning the confirmed id.
// Requires Team.ReadBasic.All permission; fall back to raw ID if missing.
try {
const path = `/teams/${encodeURIComponent(conversationTeamId)}?$select=id`;
const team = await fetchGraphJson<{ id?: string }>({ token, path });
const groupId = team.id ?? conversationTeamId;
// Only cache when the Graph lookup succeeds — caching a fallback raw ID
// can cause silent failures for the entire TTL if the ID is not a valid
// Graph team GUID (e.g. Bot Framework conversation key).
const expiresAt = resolveTeamGroupIdCacheExpiresAt();
if (expiresAt !== undefined) {
teamGroupIdCache.set(conversationTeamId, {
groupId,
expiresAt,
});
}
return groupId;
} catch {
// Fallback to raw team ID without caching so subsequent calls retry the
// Graph lookup instead of using a potentially invalid cached value.
return conversationTeamId;
}
}
/**
* Fetch a single channel message (the parent/root of a thread).
* Returns undefined on error so callers can degrade gracefully.
*/
export async function fetchChannelMessage(
token: string,
groupId: string,
channelId: string,
messageId: string,
): Promise<GraphThreadMessage | undefined> {
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}?$select=id,from,body,createdDateTime`;
try {
return await fetchGraphJson<GraphThreadMessage>({ token, path });
} catch {
return undefined;
}
}
/**
* Fetch thread replies for a channel message, ordered chronologically.
*
* **Limitation:** The Graph API replies endpoint (`/messages/{id}/replies`) does not
* support `$orderby`, so results are always returned in ascending (oldest-first) order.
* Combined with the `$top` cap of 50, this means only the **oldest 50 replies** are
* returned for long threads — newer replies are silently omitted. There is currently no
* Graph API workaround for this; pagination via `@odata.nextLink` can retrieve more
* replies but still in ascending order only.
*/
export async function fetchThreadReplies(
token: string,
groupId: string,
channelId: string,
messageId: string,
limit = 50,
): Promise<GraphThreadMessage[]> {
const top = Math.min(Math.max(limit, 1), 50);
// NOTE: Graph replies endpoint returns oldest-first and does not support $orderby.
// For threads with >50 replies, only the oldest 50 are returned. The most recent
// replies (often the most relevant context) may be truncated.
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies?$top=${top}&$select=id,from,body,createdDateTime`;
const res = await fetchGraphJson<GraphResponse<GraphThreadMessage>>({ token, path });
return res.value ?? [];
}
/**
* Format thread messages into a context string for the agent.
* Skips the current message (by id) and blank messages.
*/
export function formatThreadContext(
messages: GraphThreadMessage[],
currentMessageId?: string,
): string {
const lines: string[] = [];
for (const msg of messages) {
if (msg.id && msg.id === currentMessageId) {
continue;
} // Skip the triggering message.
const sender = msg.from?.user?.displayName ?? msg.from?.application?.displayName ?? "unknown";
const contentType = msg.body?.contentType ?? "text";
const rawContent = msg.body?.content ?? "";
const content =
contentType === "html" ? stripHtmlFromTeamsMessage(rawContent) : rawContent.trim();
if (!content) {
continue;
}
lines.push(`${sender}: ${content}`);
}
return lines.join("\n");
}
// Exported for testing only.
export { teamGroupIdCache as _teamGroupIdCacheForTest };

View File

@@ -0,0 +1,288 @@
// Msteams tests cover graph upload plugin behavior.
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import { buildTeamsFileInfoCard } from "./graph-chat.js";
import { resolveGraphChatId, uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
type FetchCall = [string, { method?: string; headers?: Record<string, string> } | undefined];
function requireFetchCall(fetchFn: ReturnType<typeof vi.fn>, index = 0): FetchCall {
const call = fetchFn.mock.calls[index] as unknown as FetchCall | undefined;
if (!call) {
throw new Error(`fetch call ${index} missing`);
}
return call;
}
function expectGraphUploadFetch(fetchFn: ReturnType<typeof vi.fn>, expectedUrl: string): void {
const [url, init] = requireFetchCall(fetchFn);
expect(url).toBe(expectedUrl);
expect(init?.method).toBe("PUT");
expect(init?.headers?.Authorization).toBe("Bearer graph-token");
expect(init?.headers?.["Content-Type"]).toBe("application/octet-stream");
expect(init?.headers?.["User-Agent"]).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/);
}
function bodyOnlyErrorResponse(body: string, status = 500): Response {
return {
ok: false,
status,
headers: new Headers(),
body: new Response(body).body,
} as unknown as Response;
}
describe("graph upload helpers", () => {
const tokenProvider = {
getAccessToken: vi.fn(async () => "graph-token"),
};
it("uploads to OneDrive with the personal drive path", async () => {
const fetchFn = vi.fn(
async () =>
new Response(
JSON.stringify({ id: "item-1", webUrl: "https://example.com/1", name: "a.txt" }),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
const result = await uploadToOneDrive({
buffer: Buffer.from("hello"),
filename: "a.txt",
tokenProvider,
fetchFn: withFetchPreconnect(fetchFn),
});
expectGraphUploadFetch(
fetchFn,
"https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/a.txt:/content",
);
expect(result).toEqual({
id: "item-1",
webUrl: "https://example.com/1",
name: "a.txt",
});
});
it("uploads to SharePoint with the site drive path", async () => {
const fetchFn = vi.fn(
async () =>
new Response(
JSON.stringify({ id: "item-2", webUrl: "https://example.com/2", name: "b.txt" }),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
const result = await uploadToSharePoint({
buffer: Buffer.from("world"),
filename: "b.txt",
siteId: "site-123",
tokenProvider,
fetchFn: withFetchPreconnect(fetchFn),
});
expectGraphUploadFetch(
fetchFn,
"https://graph.microsoft.com/v1.0/sites/site-123/drive/root:/OpenClawShared/b.txt:/content",
);
expect(result).toEqual({
id: "item-2",
webUrl: "https://example.com/2",
name: "b.txt",
});
});
it("rejects upload responses missing required fields", async () => {
const fetchFn = vi.fn(
async () =>
new Response(JSON.stringify({ id: "item-3" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(
uploadToSharePoint({
buffer: Buffer.from("world"),
filename: "bad.txt",
siteId: "site-123",
tokenProvider,
fetchFn: withFetchPreconnect(fetchFn),
}),
).rejects.toThrow("SharePoint upload response missing required fields");
});
it("bounds upload error bodies without requiring response.text()", async () => {
const fetchFn = vi.fn(async () =>
bodyOnlyErrorResponse(`${"upload-denied ".repeat(4096)}tail-marker`, 413),
);
let error: unknown;
try {
await uploadToSharePoint({
buffer: Buffer.from("world"),
filename: "large.txt",
siteId: "site-123",
tokenProvider,
fetchFn: fetchFn as unknown as typeof fetch,
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain("SharePoint upload failed (413): upload-denied");
expect(message).not.toContain("tail-marker");
expect(message.length).toBeLessThan(700);
});
});
describe("resolveGraphChatId", () => {
const tokenProvider = {
getAccessToken: vi.fn(async () => "graph-token"),
};
it("returns the ID directly when it already starts with 19:", async () => {
const fetchFn = vi.fn();
const result = await resolveGraphChatId({
botFrameworkConversationId: "19:abc123@thread.tacv2",
tokenProvider,
fetchFn: withFetchPreconnect(fetchFn),
});
// Should short-circuit without making any API call
expect(fetchFn).not.toHaveBeenCalled();
expect(result).toBe("19:abc123@thread.tacv2");
});
it("resolves personal DM chat ID via Graph API using user AAD object ID", async () => {
const fetchFn = vi.fn(
async () =>
new Response(JSON.stringify({ value: [{ id: "19:dm-chat-id@unq.gbl.spaces" }] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await resolveGraphChatId({
botFrameworkConversationId: "a:1abc_bot_framework_dm_id",
userAadObjectId: "user-aad-object-id-123",
tokenProvider,
fetchFn: withFetchPreconnect(fetchFn),
});
expect(fetchFn).toHaveBeenCalledTimes(1);
const [callUrlRaw, init] = requireFetchCall(fetchFn);
expect(init?.headers?.Authorization).toBe("Bearer graph-token");
expect(init?.headers?.["User-Agent"]).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/);
const callUrl = new URL(callUrlRaw);
expect(callUrl.origin).toBe("https://graph.microsoft.com");
expect(callUrl.pathname).toBe("/v1.0/me/chats");
expect(callUrl.searchParams.get("$filter")).toBe(
"chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq 'user-aad-object-id-123')",
);
expect(callUrl.searchParams.get("$select")).toBe("id");
expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
});
it("resolves personal DM chat ID without user AAD object ID (lists all 1:1 chats)", async () => {
const fetchFn = vi.fn(
async () =>
new Response(JSON.stringify({ value: [{ id: "19:fallback-chat@unq.gbl.spaces" }] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await resolveGraphChatId({
botFrameworkConversationId: "8:orgid:user-object-id",
tokenProvider,
fetchFn: withFetchPreconnect(fetchFn),
});
expect(fetchFn).toHaveBeenCalledOnce();
expect(result).toBe("19:fallback-chat@unq.gbl.spaces");
});
it("returns null when Graph API returns no chats", async () => {
const fetchFn = vi.fn(
async () =>
new Response(JSON.stringify({ value: [] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await resolveGraphChatId({
botFrameworkConversationId: "a:1unknown_dm",
userAadObjectId: "some-user",
tokenProvider,
fetchFn: withFetchPreconnect(fetchFn),
});
expect(result).toBeNull();
});
it("returns null when Graph API call fails", async () => {
const fetchFn = vi.fn(
async () =>
new Response("Unauthorized", {
status: 401,
headers: { "content-type": "text/plain" },
}),
);
const result = await resolveGraphChatId({
botFrameworkConversationId: "a:1some_dm_id",
userAadObjectId: "some-user",
tokenProvider,
fetchFn: withFetchPreconnect(fetchFn),
});
expect(result).toBeNull();
});
});
describe("buildTeamsFileInfoCard", () => {
it("extracts a unique id from quoted etags and lowercases file extensions", () => {
expect(
buildTeamsFileInfoCard({
eTag: '"{ABC-123},42"',
name: "Quarterly.Report.PDF",
webDavUrl: "https://sharepoint.example.com/file.pdf",
}),
).toEqual({
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: "https://sharepoint.example.com/file.pdf",
name: "Quarterly.Report.PDF",
content: {
uniqueId: "ABC-123",
fileType: "pdf",
},
});
});
it("keeps the raw etag when no version suffix exists and handles extensionless files", () => {
expect(
buildTeamsFileInfoCard({
eTag: "plain-etag",
name: "README",
webDavUrl: "https://sharepoint.example.com/readme",
}),
).toEqual({
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: "https://sharepoint.example.com/readme",
name: "README",
content: {
uniqueId: "plain-etag",
fileType: "",
},
});
});
});

View File

@@ -0,0 +1,524 @@
/**
* OneDrive/SharePoint upload utilities for MS Teams file sending.
*
* For group chats and channels, files are uploaded to SharePoint and shared via a link.
* This module provides utilities for:
* - Uploading files to OneDrive (personal scope - now deprecated for bot use)
* - Uploading files to SharePoint (group/channel scope)
* - Creating sharing links (organization-wide or per-user)
* - Getting chat members for per-user sharing
*/
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import { createMSTeamsHttpError } from "./http-error.js";
import { buildUserAgent } from "./user-agent.js";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
const GRAPH_SCOPE = "https://graph.microsoft.com";
interface OneDriveUploadResult {
id: string;
webUrl: string;
name: string;
}
/**
* Upload a file to the user's OneDrive root folder.
* For larger files, this uses the simple upload endpoint (up to 4MB).
*/
export async function uploadToOneDrive(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<OneDriveUploadResult> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
// Use "OpenClawShared" folder to organize bot-uploaded files
const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
method: "PUT",
headers: {
"User-Agent": buildUserAgent(),
Authorization: `Bearer ${token}`,
"Content-Type": params.contentType ?? "application/octet-stream",
},
body: new Uint8Array(params.buffer),
});
if (!res.ok) {
throw await createMSTeamsHttpError(res, "OneDrive upload failed");
}
const data = (await res.json()) as {
id?: string;
webUrl?: string;
name?: string;
};
if (!data.id || !data.webUrl || !data.name) {
throw new Error("OneDrive upload response missing required fields");
}
return {
id: data.id,
webUrl: data.webUrl,
name: data.name,
};
}
interface OneDriveSharingLink {
webUrl: string;
}
/**
* Create a sharing link for a OneDrive file.
* The link allows organization members to view the file.
*/
async function createSharingLink(params: {
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
/** Sharing scope: "organization" (default) or "anonymous" */
scope?: "organization" | "anonymous";
fetchFn?: typeof fetch;
}): Promise<OneDriveSharingLink> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
method: "POST",
headers: {
"User-Agent": buildUserAgent(),
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "view",
scope: params.scope ?? "organization",
}),
});
if (!res.ok) {
throw await createMSTeamsHttpError(res, "Create sharing link failed");
}
const data = (await res.json()) as {
link?: { webUrl?: string };
};
if (!data.link?.webUrl) {
throw new Error("Create sharing link response missing webUrl");
}
return {
webUrl: data.link.webUrl,
};
}
/**
* Upload a file to OneDrive and create a sharing link.
* Convenience function for the common case.
*/
export async function uploadAndShareOneDrive(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
scope?: "organization" | "anonymous";
fetchFn?: typeof fetch;
}): Promise<{
itemId: string;
webUrl: string;
shareUrl: string;
name: string;
}> {
const uploaded = await uploadToOneDrive({
buffer: params.buffer,
filename: params.filename,
contentType: params.contentType,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
const shareLink = await createSharingLink({
itemId: uploaded.id,
tokenProvider: params.tokenProvider,
scope: params.scope,
fetchFn: params.fetchFn,
});
return {
itemId: uploaded.id,
webUrl: uploaded.webUrl,
shareUrl: shareLink.webUrl,
name: uploaded.name,
};
}
// ============================================================================
// SharePoint upload functions for group chats and channels
// ============================================================================
/**
* Upload a file to a SharePoint site.
* This is used for group chats and channels where /me/drive doesn't work for bots.
*
* @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2")
*/
export async function uploadToSharePoint(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
siteId: string;
fetchFn?: typeof fetch;
}): Promise<OneDriveUploadResult> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
// Use "OpenClawShared" folder to organize bot-uploaded files
const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
const res = await fetchFn(
`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
{
method: "PUT",
headers: {
"User-Agent": buildUserAgent(),
Authorization: `Bearer ${token}`,
"Content-Type": params.contentType ?? "application/octet-stream",
},
body: new Uint8Array(params.buffer),
},
);
if (!res.ok) {
throw await createMSTeamsHttpError(res, "SharePoint upload failed");
}
const data = (await res.json()) as {
id?: string;
webUrl?: string;
name?: string;
};
if (!data.id || !data.webUrl || !data.name) {
throw new Error("SharePoint upload response missing required fields");
}
return {
id: data.id,
webUrl: data.webUrl,
name: data.name,
};
}
interface ChatMember {
aadObjectId: string;
displayName?: string;
}
/**
* Properties needed for native Teams file card attachments.
* The eTag is used as the attachment ID and webDavUrl as the contentUrl.
*/
export interface DriveItemProperties {
/** The eTag of the driveItem (used as attachment ID) */
eTag: string;
/** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */
webDavUrl: string;
/** The filename */
name: string;
}
/**
* Get driveItem properties needed for native Teams file card attachments.
* This fetches the eTag and webDavUrl which are required for "reference" type attachments.
*
* @param params.siteId - SharePoint site ID
* @param params.itemId - The driveItem ID (returned from upload)
*/
export async function getDriveItemProperties(params: {
siteId: string;
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<DriveItemProperties> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(
`${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
{ headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` } },
);
if (!res.ok) {
throw await createMSTeamsHttpError(res, "Get driveItem properties failed");
}
const data = (await res.json()) as {
eTag?: string;
webDavUrl?: string;
name?: string;
};
if (!data.eTag || !data.webDavUrl || !data.name) {
throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)");
}
return {
eTag: data.eTag,
webDavUrl: data.webDavUrl,
name: data.name,
};
}
/**
* Resolve the Graph API-native chat ID from a Bot Framework conversation ID.
*
* Bot Framework personal DM conversation IDs use formats like `a:1xxx@unq.gbl.spaces`
* or `8:orgid:xxx` that the Graph API does not accept. Graph API requires the
* `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format.
*
* This function looks up the matching Graph chat by querying the bot's chats filtered
* by the target user's AAD object ID.
*/
export async function resolveGraphChatId(params: {
/** Bot Framework conversation ID (may be in non-Graph format for personal DMs) */
botFrameworkConversationId: string;
/** AAD object ID of the user in the conversation (used for filtering chats) */
userAadObjectId?: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<string | null> {
const { botFrameworkConversationId, userAadObjectId, tokenProvider } = params;
const fetchFn = params.fetchFn ?? fetch;
// If the conversation ID already looks like a valid Graph chat ID, return it directly.
// Graph chat IDs start with "19:" — Bot Framework group chat IDs already use this format.
if (botFrameworkConversationId.startsWith("19:")) {
return botFrameworkConversationId;
}
// For personal DMs with non-Graph conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`),
// query the bot's chats to find the matching one.
const token = await tokenProvider.getAccessToken(GRAPH_SCOPE);
// Build filter: if we have the user's AAD object ID, narrow the search to 1:1 chats
// with that member. Otherwise, fall back to listing all 1:1 chats.
let path: string;
if (userAadObjectId) {
const encoded = encodeURIComponent(
`chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq '${userAadObjectId}')`,
);
path = `/me/chats?$filter=${encoded}&$select=id`;
} else {
// Fallback: list all 1:1 chats when no user ID is available.
// Only safe when the bot has exactly one 1:1 chat; returns null otherwise to
// avoid sending to the wrong person's chat.
path = `/me/chats?$filter=${encodeURIComponent("chatType eq 'oneOnOne'")}&$select=id`;
}
const res = await fetchFn(`${GRAPH_ROOT}${path}`, {
headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
});
if (!res.ok) {
return null;
}
const data = (await res.json()) as {
value?: Array<{ id?: string }>;
};
const chats = data.value ?? [];
// When filtered by userAadObjectId, any non-empty result is the right 1:1 chat.
if (userAadObjectId && chats.length > 0 && chats[0]?.id) {
return chats[0].id;
}
// Without a user ID we can only be certain when exactly one chat is returned;
// multiple results would be ambiguous and could route to the wrong person.
if (!userAadObjectId && chats.length === 1 && chats[0]?.id) {
return chats[0].id;
}
return null;
}
/**
* Get members of a Teams chat for per-user sharing.
* Used to create sharing links scoped to only the chat participants.
*/
async function getChatMembers(params: {
chatId: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<ChatMember[]> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw await createMSTeamsHttpError(res, "Get chat members failed");
}
const data = (await res.json()) as {
value?: Array<{
userId?: string;
displayName?: string;
}>;
};
return (data.value ?? [])
.map((m) => ({
aadObjectId: m.userId ?? "",
displayName: m.displayName,
}))
.filter((m) => m.aadObjectId);
}
/**
* Create a sharing link for a SharePoint drive item.
* For organization scope (default), uses v1.0 API.
* For per-user scope, uses beta API with recipients.
*/
async function createSharePointSharingLink(params: {
siteId: string;
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
/** Sharing scope: "organization" (default) or "users" (per-user with recipients) */
scope?: "organization" | "users";
/** Required when scope is "users": AAD object IDs of recipients */
recipientObjectIds?: string[];
fetchFn?: typeof fetch;
}): Promise<OneDriveSharingLink> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const scope = params.scope ?? "organization";
// Per-user sharing requires beta API
const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT;
const body: Record<string, unknown> = {
type: "view",
scope: scope === "users" ? "users" : "organization",
};
// Add recipients for per-user sharing
if (scope === "users" && params.recipientObjectIds?.length) {
body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id }));
}
const res = await fetchFn(
`${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`,
{
method: "POST",
headers: {
"User-Agent": buildUserAgent(),
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
},
);
if (!res.ok) {
throw await createMSTeamsHttpError(res, "Create SharePoint sharing link failed");
}
const data = (await res.json()) as {
link?: { webUrl?: string };
};
if (!data.link?.webUrl) {
throw new Error("Create SharePoint sharing link response missing webUrl");
}
return {
webUrl: data.link.webUrl,
};
}
/**
* Upload a file to SharePoint and create a sharing link.
*
* For group chats, this creates a per-user sharing link scoped to chat members.
* For channels, this creates an organization-wide sharing link.
*
* @param params.siteId - SharePoint site ID
* @param params.chatId - Optional chat ID for per-user sharing (group chats)
* @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All)
*/
export async function uploadAndShareSharePoint(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
siteId: string;
chatId?: string;
usePerUserSharing?: boolean;
fetchFn?: typeof fetch;
}): Promise<{
itemId: string;
webUrl: string;
shareUrl: string;
name: string;
}> {
// 1. Upload file to SharePoint
const uploaded = await uploadToSharePoint({
buffer: params.buffer,
filename: params.filename,
contentType: params.contentType,
tokenProvider: params.tokenProvider,
siteId: params.siteId,
fetchFn: params.fetchFn,
});
// 2. Determine sharing scope
let scope: "organization" | "users" = "organization";
let recipientObjectIds: string[] | undefined;
if (params.usePerUserSharing && params.chatId) {
try {
const members = await getChatMembers({
chatId: params.chatId,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
if (members.length > 0) {
scope = "users";
recipientObjectIds = members.map((m) => m.aadObjectId);
}
} catch {
// Fall back to organization scope if we can't get chat members
// (e.g., missing Chat.Read.All permission)
}
}
// 3. Create sharing link
const shareLink = await createSharePointSharingLink({
siteId: params.siteId,
itemId: uploaded.id,
tokenProvider: params.tokenProvider,
scope,
recipientObjectIds,
fetchFn: params.fetchFn,
});
return {
itemId: uploaded.id,
webUrl: uploaded.webUrl,
shareUrl: shareLink.webUrl,
name: uploaded.name,
};
}

View File

@@ -0,0 +1,30 @@
// Msteams plugin module implements graph users behavior.
import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js";
export async function searchGraphUsers(params: {
token: string;
query: string;
top?: number;
}): Promise<GraphUser[]> {
const query = params.query.trim();
if (!query) {
return [];
}
if (query.includes("@")) {
const escaped = escapeOData(query);
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token: params.token, path });
return res.value ?? [];
}
const top = typeof params.top === "number" && params.top > 0 ? params.top : 10;
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${top}`;
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
token: params.token,
path,
headers: { ConsistencyLevel: "eventual" },
});
return res.value ?? [];
}

View File

@@ -0,0 +1,593 @@
// Msteams tests cover graph plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
loadMSTeamsSdkWithAuthMock,
createMSTeamsTokenProviderMock,
readAccessTokenMock,
resolveMSTeamsCredentialsMock,
} = vi.hoisted(() => {
return {
loadMSTeamsSdkWithAuthMock: vi.fn(),
createMSTeamsTokenProviderMock: vi.fn(),
readAccessTokenMock: vi.fn(),
resolveMSTeamsCredentialsMock: vi.fn(),
};
});
vi.mock("./sdk.js", () => ({
loadMSTeamsSdkWithAuth: loadMSTeamsSdkWithAuthMock,
createMSTeamsTokenProvider: createMSTeamsTokenProviderMock,
}));
vi.mock("./token-response.js", () => ({
readAccessToken: readAccessTokenMock,
}));
vi.mock("./token.js", () => ({
resolveMSTeamsCredentials: resolveMSTeamsCredentialsMock,
}));
vi.mock("../runtime-api.js", async (importOriginal) => {
const original = await importOriginal<typeof import("../runtime-api.js")>();
return {
...original,
fetchWithSsrFGuard: async (params: { url: string; init?: RequestInit }) => ({
response: await globalThis.fetch(params.url, params.init),
finalUrl: params.url,
release: async () => undefined,
}),
};
});
import { searchGraphUsers } from "./graph-users.js";
import {
deleteGraphRequest,
escapeOData,
fetchAllGraphPages,
fetchGraphJson,
listChannelsForTeam,
listTeamsByName,
normalizeQuery,
postGraphBetaJson,
postGraphJson,
resolveGraphToken,
} from "./graph.js";
const originalFetch = globalThis.fetch;
const graphToken = "graph-token";
const mockCredentials = {
appId: "app-id",
appPassword: "app-password",
tenantId: "tenant-id",
};
const mockApp = { id: "mock-app" };
const groupOne = { id: "group-1" };
const opsTeam = { id: "team-1", displayName: "Ops" };
const deploymentsChannel = { id: "chan-1", displayName: "Deployments" };
const userOne = { id: "user-1", displayName: "User One" };
const bobUser = { id: "user-2", displayName: "Bob" };
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function textResponse(body: string, init?: ResponseInit): Response {
return new Response(body, init);
}
function mockFetch(handler: Parameters<typeof vi.fn>[0]) {
globalThis.fetch = vi.fn(handler) as unknown as typeof fetch;
}
function mockJsonFetchResponse(body: unknown, init?: ResponseInit) {
mockFetch(async () => jsonResponse(body, init));
}
function mockTextFetchResponse(body: string, init?: ResponseInit) {
mockFetch(async () => textResponse(body, init));
}
function graphStreamResponse(body: unknown): {
response: Response;
arrayBuffer: ReturnType<typeof vi.fn>;
} {
const encoded = new TextEncoder().encode(JSON.stringify(body));
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoded);
controller.close();
},
});
const arrayBuffer = vi.fn(async () => {
throw new Error("Graph response must stay streaming");
});
return {
response: {
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "application/json" }),
body: stream,
arrayBuffer,
} as unknown as Response,
arrayBuffer,
};
}
function graphCollection<T>(...items: T[]) {
return { value: items };
}
function mockGraphCollection(...items: unknown[]) {
mockJsonFetchResponse(graphCollection(...items));
}
function requestUrl(input: string | URL | Request) {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
return input.url;
}
function fetchCallUrl(index: number) {
const input = vi.mocked(globalThis.fetch).mock.calls[index]?.[0];
if (!input) {
return "";
}
return requestUrl(input);
}
function fetchCallInit(index: number) {
return vi.mocked(globalThis.fetch).mock.calls[index]?.[1];
}
function fetchCallHeader(index: number, name: string) {
const headers = fetchCallInit(index)?.headers;
if (!headers) {
throw new Error(`Expected fetch headers at index ${index}`);
}
return (headers as Record<string, string>)[name];
}
function expectFetchPathContains(index: number, expectedPath: string) {
expect(fetchCallUrl(index)).toContain(expectedPath);
}
function fetchCallSearchParam(index: number, name: string): string | null {
const url = fetchCallUrl(index);
if (!url) {
throw new Error(`Expected fetch call ${index}`);
}
return new URL(url).searchParams.get(name);
}
async function expectSearchGraphUsers(
query: string,
expected: Array<Record<string, unknown>>,
options?: { token?: string; top?: number },
) {
await expect(
searchGraphUsers({
token: options?.token ?? graphToken,
query,
top: options?.top,
}),
).resolves.toEqual(expected);
}
async function expectRejectsToThrow(promise: Promise<unknown>, message: string) {
await expect(promise).rejects.toThrow(message);
}
function mockGraphTokenResolution(options?: {
rawToken?: string | null;
resolvedToken?: string | null;
}) {
const rawToken = options && "rawToken" in options ? options.rawToken : "raw-graph-token";
const resolvedToken =
options && "resolvedToken" in options ? options.resolvedToken : "resolved-token";
const getAccessToken = vi.fn(async () => rawToken);
loadMSTeamsSdkWithAuthMock.mockResolvedValue({ app: mockApp });
createMSTeamsTokenProviderMock.mockReturnValue({ getAccessToken });
resolveMSTeamsCredentialsMock.mockReturnValue(mockCredentials);
readAccessTokenMock.mockReturnValue(resolvedToken);
return { getAccessToken };
}
describe("msteams graph helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("normalizes queries and escapes OData apostrophes", () => {
expect(normalizeQuery(" Team Alpha ")).toBe("Team Alpha");
expect(normalizeQuery(" ")).toBe("");
expect(escapeOData("alice.o'hara")).toBe("alice.o''hara");
});
it("fetches Graph JSON and surfaces Graph errors with response text", async () => {
mockGraphCollection(groupOne);
await expect(
fetchGraphJson<{ value: Array<{ id: string }> }>({
token: graphToken,
path: "/groups?$select=id",
headers: { ConsistencyLevel: "eventual" },
}),
).resolves.toEqual(graphCollection(groupOne));
expect(fetchCallUrl(0)).toBe("https://graph.microsoft.com/v1.0/groups?$select=id");
expect(fetchCallHeader(0, "Authorization")).toBe(`Bearer ${graphToken}`);
expect(fetchCallHeader(0, "ConsistencyLevel")).toBe("eventual");
mockTextFetchResponse("forbidden", { status: 403 });
await expectRejectsToThrow(
fetchGraphJson({
token: graphToken,
path: "/teams/team-1/channels",
}),
"Graph /teams/team-1/channels failed (403): forbidden",
);
mockTextFetchResponse("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
await expectRejectsToThrow(
fetchGraphJson({
token: graphToken,
path: "/teams/team-1/channels",
}),
"Graph /teams/team-1/channels failed: malformed JSON response",
);
});
it("keeps successful Graph responses streaming for bounded JSON parsing", async () => {
const { response, arrayBuffer } = graphStreamResponse(graphCollection(groupOne));
mockFetch(async () => response);
await expect(
fetchGraphJson<{ value: Array<{ id: string }> }>({
token: graphToken,
path: "/groups?$select=id",
}),
).resolves.toEqual(graphCollection(groupOne));
expect(arrayBuffer).not.toHaveBeenCalled();
});
it("posts Graph JSON to v1 and beta roots and treats empty mutation responses as undefined", async () => {
mockFetch(async (input) => {
if (requestUrl(input).startsWith("https://graph.microsoft.com/beta")) {
return new Response(null, { status: 204 });
}
return jsonResponse({ id: "created-1" });
});
await expect(
postGraphJson<{ id: string }>({
token: graphToken,
path: "/chats/chat-1/pinnedMessages",
body: { messageId: "msg-1" },
}),
).resolves.toEqual({ id: "created-1" });
await expect(
postGraphBetaJson<undefined>({
token: graphToken,
path: "/chats/chat-1/messages/msg-1/setReaction",
body: { reactionType: "like" },
}),
).resolves.toBeUndefined();
expect(fetchCallUrl(0)).toBe("https://graph.microsoft.com/v1.0/chats/chat-1/pinnedMessages");
expect(fetchCallInit(0)?.method).toBe("POST");
expect(fetchCallInit(0)?.body).toBe(JSON.stringify({ messageId: "msg-1" }));
expect(fetchCallHeader(0, "Authorization")).toBe(`Bearer ${graphToken}`);
expect(fetchCallHeader(0, "Content-Type")).toBe("application/json");
expect(fetchCallUrl(1)).toBe(
"https://graph.microsoft.com/beta/chats/chat-1/messages/msg-1/setReaction",
);
expect(fetchCallInit(1)?.method).toBe("POST");
expect(fetchCallInit(1)?.body).toBe(JSON.stringify({ reactionType: "like" }));
});
it("surfaces POST and DELETE graph failures with method-specific labels", async () => {
mockFetch(async (_input, init) => {
const method = init?.method ?? "GET";
if (method === "DELETE") {
return textResponse("not found", { status: 404 });
}
return textResponse("denied", { status: 403 });
});
await expectRejectsToThrow(
postGraphJson({
token: graphToken,
path: "/teams/team-1/channels",
body: { displayName: "Deployments" },
}),
"Graph POST /teams/team-1/channels failed (403): denied",
);
await expectRejectsToThrow(
deleteGraphRequest({
token: graphToken,
path: "/teams/team-1/channels/channel-1",
}),
"Graph DELETE /teams/team-1/channels/channel-1 failed (404): not found",
);
});
it("resolves Graph tokens through the SDK auth provider", async () => {
const { getAccessToken } = mockGraphTokenResolution();
await expect(resolveGraphToken({ channels: { msteams: {} } })).resolves.toBe("resolved-token");
expect(createMSTeamsTokenProviderMock).toHaveBeenCalledWith(mockApp);
expect(getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com");
});
it("fails closed for China cloud Graph token resolution", async () => {
mockGraphTokenResolution();
await expectRejectsToThrow(
resolveGraphToken({ channels: { msteams: { cloud: "China" } } }),
"Microsoft Teams Graph operations are not supported for channels.msteams.cloud=China",
);
expect(loadMSTeamsSdkWithAuthMock).not.toHaveBeenCalled();
});
it("fails when credentials or access tokens are unavailable", async () => {
resolveMSTeamsCredentialsMock.mockReturnValue(undefined);
await expectRejectsToThrow(resolveGraphToken({ channels: {} }), "MS Teams credentials missing");
mockGraphTokenResolution({ rawToken: null, resolvedToken: null });
await expectRejectsToThrow(
resolveGraphToken({ channels: { msteams: {} } }),
"MS Teams graph token unavailable",
);
});
it("builds encoded Graph paths for teams and channels", async () => {
mockFetch(async (input) => {
if (requestUrl(input).includes("/groups?")) {
return jsonResponse(graphCollection(opsTeam));
}
return jsonResponse(graphCollection(deploymentsChannel));
});
await expect(listTeamsByName(graphToken, "Bob's Team")).resolves.toEqual([opsTeam]);
await expect(listChannelsForTeam(graphToken, "team/ops")).resolves.toEqual([
deploymentsChannel,
]);
expect(fetchCallSearchParam(0, "$filter")).toBe(
"resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'Bob''s Team')",
);
expect(fetchCallSearchParam(0, "$select")).toBe("id,displayName");
expectFetchPathContains(1, "/teams/team%2Fops/channels?$select=id,displayName");
});
it("returns no graph users for blank queries", async () => {
mockJsonFetchResponse({});
await expectSearchGraphUsers(" ", [], { token: "token-1" });
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("uses exact mail or UPN lookup for email-like graph user queries", async () => {
mockGraphCollection(userOne);
await expectSearchGraphUsers("alice.o'hara@example.com", [userOne], {
token: "token-2",
});
expect(fetchCallSearchParam(0, "$filter")).toBe(
"(mail eq 'alice.o''hara@example.com' or userPrincipalName eq 'alice.o''hara@example.com')",
);
expect(fetchCallSearchParam(0, "$select")).toBe("id,displayName,mail,userPrincipalName");
});
it("uses displayName search with eventual consistency and default top handling", async () => {
mockFetch(async (input) => {
if (requestUrl(input).includes("displayName%3Abob")) {
return jsonResponse(graphCollection(bobUser));
}
return jsonResponse({});
});
await expectSearchGraphUsers("bob", [bobUser], {
token: "token-3",
top: 25,
});
await expectSearchGraphUsers("carol", [], { token: "token-4" });
expectFetchPathContains(
0,
"/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25",
);
expect(fetchCallHeader(0, "ConsistencyLevel")).toBe("eventual");
expectFetchPathContains(
1,
"/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10",
);
});
describe("fetchAllGraphPages", () => {
type Item = { id: string; name: string };
/** Build a paged Graph response with optional nextLink. */
function pagedResponse(items: Item[], nextLink?: string) {
const body: Record<string, unknown> = { value: items };
if (nextLink) {
body["@odata.nextLink"] = nextLink;
}
return body;
}
it("single page, no nextLink", async () => {
const items = [{ id: "1", name: "a" }];
mockJsonFetchResponse(pagedResponse(items));
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items",
});
expect(result).toEqual({ items, truncated: false });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it("multiple pages with nextLink chain", async () => {
const page1Items = [{ id: "1", name: "a" }];
const page2Items = [{ id: "2", name: "b" }];
const page3Items = [{ id: "3", name: "c" }];
let callCount = 0;
mockFetch(async () => {
callCount++;
if (callCount === 1) {
return jsonResponse(
pagedResponse(page1Items, "https://graph.microsoft.com/v1.0/items?$skiptoken=page2"),
);
}
if (callCount === 2) {
return jsonResponse(
pagedResponse(page2Items, "https://graph.microsoft.com/v1.0/items?$skiptoken=page3"),
);
}
return jsonResponse(pagedResponse(page3Items));
});
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items",
});
expect(result.items).toEqual([...page1Items, ...page2Items, ...page3Items]);
expect(result.truncated).toBe(false);
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
});
it("truncation at maxPages", async () => {
mockFetch(async () =>
jsonResponse(
pagedResponse(
[{ id: "x", name: "x" }],
"https://graph.microsoft.com/v1.0/items?$skiptoken=more",
),
),
);
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items",
maxPages: 2,
});
expect(result.items).toHaveLength(2);
expect(result.truncated).toBe(true);
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it("findOne early exit", async () => {
const target = { id: "target", name: "found-it" };
let callCount = 0;
mockFetch(async () => {
callCount++;
if (callCount === 1) {
return jsonResponse(
pagedResponse(
[{ id: "1", name: "a" }],
"https://graph.microsoft.com/v1.0/items?$skiptoken=p2",
),
);
}
// Page 2 contains the target; page 3 should never be fetched
return jsonResponse(
pagedResponse(
[{ id: "2", name: "b" }, target],
"https://graph.microsoft.com/v1.0/items?$skiptoken=p3",
),
);
});
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items",
findOne: (item) => item.id === "target",
});
expect(result.found).toEqual(target);
expect(result.truncated).toBe(false);
// Page 1 items + page 2 items (where match was found)
expect(result.items).toEqual([{ id: "1", name: "a" }, { id: "2", name: "b" }, target]);
// Only 2 fetches; page 3 was never requested
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it("findOne with no match (exhausted)", async () => {
mockJsonFetchResponse(pagedResponse([{ id: "1", name: "a" }]));
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items",
findOne: (item) => item.id === "missing",
});
expect(result.found).toBeUndefined();
expect(result.truncated).toBe(false);
expect(result.items).toEqual([{ id: "1", name: "a" }]);
});
it("findOne with no match (truncated)", async () => {
mockFetch(async () =>
jsonResponse(
pagedResponse(
[{ id: "x", name: "x" }],
"https://graph.microsoft.com/v1.0/items?$skiptoken=more",
),
),
);
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items",
maxPages: 2,
findOne: (item) => item.id === "missing",
});
expect(result.found).toBeUndefined();
expect(result.truncated).toBe(true);
expect(result.items).toHaveLength(2);
});
it("empty first page", async () => {
mockJsonFetchResponse(pagedResponse([]));
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items",
});
expect(result).toEqual({ items: [], truncated: false });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,312 @@
// Msteams plugin module implements graph behavior.
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard, type MSTeamsConfig } from "../runtime-api.js";
import { GRAPH_ROOT } from "./attachments/shared.js";
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
import { createMSTeamsHttpError } from "./http-error.js";
import { responseWithRelease } from "./response-with-release.js";
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { readAccessToken } from "./token-response.js";
import { resolveDelegatedAccessToken, resolveMSTeamsCredentials } from "./token.js";
import { buildUserAgent } from "./user-agent.js";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
export type GraphUser = {
id?: string;
displayName?: string;
userPrincipalName?: string;
mail?: string;
};
type GraphGroup = {
id?: string;
displayName?: string;
};
type GraphChannel = {
id?: string;
displayName?: string;
};
export type GraphResponse<T> = { value?: T[] };
export function normalizeQuery(value?: string | null): string {
return value?.trim() ?? "";
}
export function escapeOData(value: string): string {
return value.replace(/'/g, "''");
}
async function requestGraph(params: {
token: string;
path: string;
method?: "GET" | "POST" | "PATCH" | "DELETE";
root?: string;
headers?: Record<string, string>;
body?: unknown;
errorPrefix?: string;
}): Promise<Response> {
const hasBody = params.body !== undefined;
const url = `${params.root ?? GRAPH_ROOT}${params.path}`;
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
init: {
method: params.method,
headers: {
"User-Agent": buildUserAgent(),
Authorization: `Bearer ${params.token}`,
...(hasBody ? { "Content-Type": "application/json" } : {}),
...params.headers,
},
body: hasBody ? JSON.stringify(params.body) : undefined,
},
auditContext: "msteams.graph",
});
let releaseInFinally = true;
try {
if (!response.ok) {
throw await createMSTeamsHttpError(
response,
`${params.errorPrefix ?? "Graph"} ${params.path} failed`,
);
}
releaseInFinally = false;
return responseWithRelease(response, release);
} finally {
if (releaseInFinally) {
await release();
}
}
}
async function readOptionalGraphJson<T>(res: Response, label: string): Promise<T> {
// Use optional chaining to stay resilient to partial test mocks that do not
// provide a status or Headers instance (they only shim `ok` + `json()`).
if (res.status === 204 || res.headers?.get?.("content-length") === "0") {
return undefined as T;
}
return await readProviderJsonResponse<T>(res, label);
}
export async function fetchGraphJson<T>(params: {
token: string;
path: string;
headers?: Record<string, string>;
/** HTTP method; defaults to "GET" */
method?: string;
/** Request body (serialized as JSON). Only used for non-GET methods. */
body?: unknown;
}): Promise<T> {
const res = await requestGraph({
token: params.token,
path: params.path,
method: params.method as "GET" | "POST" | "DELETE" | undefined,
body: params.body,
headers: params.headers,
});
return await readOptionalGraphJson<T>(res, `Graph ${params.path} failed`);
}
/**
* Fetch JSON from an absolute Graph API URL (for example @odata.nextLink
* pagination URLs) without prepending GRAPH_ROOT.
*/
export async function fetchGraphAbsoluteUrl<T>(params: {
token: string;
url: string;
headers?: Record<string, string>;
}): Promise<T> {
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
init: {
headers: {
"User-Agent": buildUserAgent(),
Authorization: `Bearer ${params.token}`,
...params.headers,
},
},
auditContext: "msteams.graph.absolute",
});
try {
if (!response.ok) {
throw await createMSTeamsHttpError(response, `Graph ${params.url} failed`);
}
return await readProviderJsonResponse<T>(response, `Graph ${params.url} failed`);
} finally {
await release();
}
}
/** Graph collection response with optional pagination link. */
type GraphPagedResponse<T> = {
value?: T[];
"@odata.nextLink"?: string;
};
/** Result of a paginated Graph API fetch. */
type PaginatedResult<T> = {
items: T[];
truncated: boolean;
found?: T;
};
/**
* Fetch all pages of a Graph API collection, following @odata.nextLink.
* Optionally stop early when `findOne` matches an item.
*/
export async function fetchAllGraphPages<T>(params: {
token: string;
path: string;
headers?: Record<string, string>;
/** Max pages to fetch before stopping. Default: 50. */
maxPages?: number;
/** Stop pagination early when this predicate returns true. */
findOne?: (item: T) => boolean;
}): Promise<PaginatedResult<T>> {
const maxPages = params.maxPages ?? 50;
const items: T[] = [];
let nextPath: string | undefined = params.path;
for (let page = 0; page < maxPages && nextPath; page++) {
const res: GraphPagedResponse<T> = await fetchGraphJson<GraphPagedResponse<T>>({
token: params.token,
path: nextPath,
headers: params.headers,
});
const pageItems = res.value ?? [];
if (params.findOne) {
const match = pageItems.find(params.findOne);
if (match) {
items.push(...pageItems);
return { items, truncated: false, found: match };
}
}
items.push(...pageItems);
// @odata.nextLink is an absolute URL; strip the Graph root to get a relative path
const rawNext: string | undefined = res["@odata.nextLink"];
if (rawNext) {
nextPath = rawNext
.replace("https://graph.microsoft.com/v1.0", "")
.replace("https://graph.microsoft.com/beta", "");
} else {
nextPath = undefined;
}
}
return { items, truncated: Boolean(nextPath) };
}
export async function resolveGraphToken(
cfg: unknown,
options?: { preferDelegated?: boolean },
): Promise<string> {
const msteamsCfg = (cfg as { channels?: { msteams?: MSTeamsConfig } })?.channels?.msteams;
const creds = resolveMSTeamsCredentials(msteamsCfg);
if (!creds) {
throw new Error("MS Teams credentials missing");
}
if (msteamsCfg?.cloud === "China") {
throw new Error(
"Microsoft Teams Graph operations are not supported for channels.msteams.cloud=China until Graph requests are routed through the Azure China Graph endpoint.",
);
}
// Try delegated token if requested and configured
if (options?.preferDelegated && msteamsCfg?.delegatedAuth?.enabled && creds.type === "secret") {
const delegated = await resolveDelegatedAccessToken({
tenantId: creds.tenantId,
clientId: creds.appId,
clientSecret: creds.appPassword,
});
if (delegated) {
return delegated;
}
// Fall through to app-only token
}
const { app } = await loadMSTeamsSdkWithAuth(creds, resolveMSTeamsSdkCloudOptions(msteamsCfg));
const tokenProvider = createMSTeamsTokenProvider(app);
const graphTokenValue = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const accessToken = readAccessToken(graphTokenValue);
if (!accessToken) {
throw new Error("MS Teams graph token unavailable");
}
return accessToken;
}
export async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
const escaped = escapeOData(query);
const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
const { items } = await fetchAllGraphPages<GraphGroup>({ token, path, maxPages: 5 });
return items;
}
export async function postGraphJson<T>(params: {
token: string;
path: string;
body?: unknown;
}): Promise<T> {
const res = await requestGraph({
token: params.token,
path: params.path,
method: "POST",
body: params.body,
errorPrefix: "Graph POST",
});
return readOptionalGraphJson<T>(res, `Graph POST ${params.path} failed`);
}
export async function postGraphBetaJson<T>(params: {
token: string;
path: string;
body?: unknown;
}): Promise<T> {
const res = await requestGraph({
token: params.token,
path: params.path,
method: "POST",
root: GRAPH_BETA,
body: params.body,
errorPrefix: "Graph beta POST",
});
return readOptionalGraphJson<T>(res, `Graph beta POST ${params.path} failed`);
}
export async function deleteGraphRequest(params: { token: string; path: string }): Promise<void> {
await requestGraph({
token: params.token,
path: params.path,
method: "DELETE",
errorPrefix: "Graph DELETE",
});
}
export async function patchGraphJson<T>(params: {
token: string;
path: string;
body?: unknown;
}): Promise<T> {
const res = await requestGraph({
token: params.token,
path: params.path,
method: "PATCH",
body: params.body,
errorPrefix: "Graph PATCH",
});
return readOptionalGraphJson<T>(res, `Graph PATCH ${params.path} failed`);
}
export async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
const { items } = await fetchAllGraphPages<GraphChannel>({ token, path, maxPages: 10 });
return items;
}

View File

@@ -0,0 +1,37 @@
// Msteams tests cover http error plugin behavior.
import { describe, expect, it } from "vitest";
import { createMSTeamsHttpError, readMSTeamsHttpErrorDetail } from "./http-error.js";
function bodyOnlyErrorResponse(body: string, status = 429): Response {
return {
ok: false,
status,
headers: new Headers(),
body: new Response(body).body,
} as unknown as Response;
}
describe("msteams http errors", () => {
it("creates bounded provider errors without relying on response.text()", async () => {
const error = await createMSTeamsHttpError(
bodyOnlyErrorResponse(`${"x".repeat(24 * 1024)}tail-marker`),
"Teams request failed",
);
expect(error.message).toContain("Teams request failed (429):");
expect(error.message).not.toContain("tail-marker");
expect(error.message.length).toBeLessThan(700);
expect((error as { statusCode?: number }).statusCode).toBe(429);
});
it("returns a bounded response detail for non-throwing callers", async () => {
const detail = await readMSTeamsHttpErrorDetail(
bodyOnlyErrorResponse(`${"denied ".repeat(4096)}tail-marker`, 403),
"HTTP 403",
);
expect(detail).toContain("denied");
expect(detail).not.toContain("tail-marker");
expect(detail.length).toBeLessThan(700);
});
});

View File

@@ -0,0 +1,20 @@
// Msteams plugin module implements http error behavior.
import {
createProviderHttpError,
extractProviderErrorDetail,
} from "openclaw/plugin-sdk/provider-http";
export async function createMSTeamsHttpError(
response: Response,
label: string,
options?: { statusPrefix?: string },
): Promise<Error> {
return await createProviderHttpError(response, label, options);
}
export async function readMSTeamsHttpErrorDetail(
response: Response,
fallback: string,
): Promise<string> {
return (await extractProviderErrorDetail(response).catch(() => undefined)) ?? fallback;
}

View File

@@ -0,0 +1,222 @@
// Msteams tests cover inbound plugin behavior.
import { describe, expect, it } from "vitest";
import {
decodeHtmlEntities,
extractMSTeamsQuoteInfo,
htmlToPlainText,
normalizeMSTeamsConversationId,
parseMSTeamsActivityTimestamp,
stripMSTeamsMentionTags,
wasMSTeamsBotMentioned,
} from "./inbound.js";
describe("msteams inbound", () => {
describe("stripMSTeamsMentionTags", () => {
it("removes <at>...</at> tags and trims", () => {
expect(stripMSTeamsMentionTags("<at>Bot</at> hi")).toBe("hi");
expect(stripMSTeamsMentionTags("hi <at>Bot</at>")).toBe("hi");
});
it("removes <at ...> tags with attributes", () => {
expect(stripMSTeamsMentionTags('<at id="1">Bot</at> hi')).toBe("hi");
expect(stripMSTeamsMentionTags('hi <at itemid="2">Bot</at>')).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");
if (!ts) {
throw new Error("expected MSTeams timestamp parser to return a Date");
}
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);
});
});
describe("decodeHtmlEntities", () => {
it("decodes common entities", () => {
expect(decodeHtmlEntities("&amp;&lt;&gt;&quot;&#39;&#x27;&nbsp;")).toBe("&<>\"'' ");
});
it("leaves plain text unchanged", () => {
expect(decodeHtmlEntities("hello world")).toBe("hello world");
});
it("prevents double-decoding: &amp;lt; should become &lt; not <", () => {
// If &amp; were decoded first, &amp;lt; → &lt; → < (wrong).
// With &amp; decoded last, &amp;lt; stays as &lt; (correct).
expect(decodeHtmlEntities("&amp;lt;b&amp;gt;")).toBe("&lt;b&gt;");
});
});
describe("htmlToPlainText", () => {
it("strips tags and decodes entities", () => {
expect(htmlToPlainText("<strong>Hello &amp; world</strong>")).toBe("Hello & world");
});
it("collapses whitespace from tag removal", () => {
expect(htmlToPlainText("<p>foo</p><p>bar</p>")).toBe("foo bar");
});
it("trims leading and trailing whitespace", () => {
expect(htmlToPlainText(" <span>hi</span> ")).toBe("hi");
});
});
describe("extractMSTeamsQuoteInfo", () => {
const replyAttachment = (overrides?: { content?: string; contentType?: string }) => ({
contentType: overrides?.contentType ?? "text/html",
content:
overrides?.content ??
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
'<strong itemprop="mri">Alice</strong>' +
'<p itemprop="copy">Hello world</p>' +
"</blockquote>",
});
it("extracts sender and body from a Teams reply attachment", () => {
const result = extractMSTeamsQuoteInfo([replyAttachment()]);
expect(result).toEqual({ sender: "Alice", body: "Hello world" });
});
it("returns undefined for empty attachments array", () => {
expect(extractMSTeamsQuoteInfo([])).toBeUndefined();
});
it("returns undefined when no reply blockquote is present", () => {
expect(
extractMSTeamsQuoteInfo([{ contentType: "text/html", content: "<p>just a message</p>" }]),
).toBeUndefined();
});
it("uses 'unknown' as sender when sender element is absent", () => {
const result = extractMSTeamsQuoteInfo([
{
contentType: "text/html",
content:
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
'<p itemprop="copy">quoted text</p>' +
"</blockquote>",
},
]);
expect(result).toEqual({ sender: "unknown", body: "quoted text" });
});
it("returns undefined when body element is absent", () => {
const result = extractMSTeamsQuoteInfo([
{
contentType: "text/html",
content:
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
'<strong itemprop="mri">Alice</strong>' +
"</blockquote>",
},
]);
expect(result).toBeUndefined();
});
it("decodes HTML entities in body text", () => {
const result = extractMSTeamsQuoteInfo([
{
contentType: "text/html",
content:
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
'<strong itemprop="mri">Bob</strong>' +
'<p itemprop="copy">2 &lt; 3 &amp; 4 &gt; 1</p>' +
"</blockquote>",
},
]);
expect(result).toEqual({ sender: "Bob", body: "2 < 3 & 4 > 1" });
});
it("handles multiline body by collapsing whitespace", () => {
const result = extractMSTeamsQuoteInfo([
{
contentType: "text/html",
content:
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
'<strong itemprop="mri">Carol</strong>' +
'<p itemprop="copy">line one\nline two</p>' +
"</blockquote>",
},
]);
expect(result?.body).toBe("line one line two");
});
it("skips non-string content values", () => {
expect(
extractMSTeamsQuoteInfo([{ contentType: "application/json", content: { foo: "bar" } }]),
).toBeUndefined();
});
it("handles object content with .text property containing the reply HTML", () => {
const htmlContent =
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
'<strong itemprop="mri">Dave</strong>' +
'<p itemprop="copy">hello from object</p>' +
"</blockquote>";
const result = extractMSTeamsQuoteInfo([
{ contentType: "text/html", content: { text: htmlContent } },
]);
expect(result).toEqual({ sender: "Dave", body: "hello from object" });
});
it("handles object content with .body property containing the reply HTML", () => {
const htmlContent =
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
'<strong itemprop="mri">Eve</strong>' +
'<p itemprop="copy">hello from body field</p>' +
"</blockquote>";
const result = extractMSTeamsQuoteInfo([
{ contentType: "text/html", content: { body: htmlContent } },
]);
expect(result).toEqual({ sender: "Eve", body: "hello from body field" });
});
it("finds quote in second attachment when first has no quote", () => {
const result = extractMSTeamsQuoteInfo([
{ contentType: "text/plain", content: "plain text" },
replyAttachment(),
]);
expect(result).toEqual({ sender: "Alice", body: "Hello world" });
});
});
});

View File

@@ -0,0 +1,149 @@
// Msteams plugin module implements inbound behavior.
type MSTeamsQuoteInfo = {
sender: string;
body: string;
};
/**
* Decode common HTML entities to plain text.
*/
export function decodeHtmlEntities(html: string): string {
return html
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&"); // must be last to prevent double-decoding (e.g. &amp;lt; → &lt; not <)
}
/**
* Strip HTML tags, preserving text content.
*/
export function htmlToPlainText(html: string): string {
return decodeHtmlEntities(
html
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim(),
);
}
/**
* Extract quote info from MS Teams HTML reply attachments.
* Teams wraps quoted content in a blockquote with itemtype="http://schema.skype.com/Reply".
*/
export function extractMSTeamsQuoteInfo(
attachments: Array<{ contentType?: string | null; content?: unknown }>,
): MSTeamsQuoteInfo | undefined {
for (const att of attachments) {
// Content may be a plain string or an object with .text/.body (e.g. Adaptive Card payloads).
let content = "";
if (typeof att.content === "string") {
content = att.content;
} else if (typeof att.content === "object" && att.content !== null) {
const record = att.content as Record<string, unknown>;
content =
typeof record.text === "string"
? record.text
: typeof record.body === "string"
? record.body
: "";
}
if (!content) {
continue;
}
// Look for the Skype Reply schema blockquote.
if (!content.includes("http://schema.skype.com/Reply")) {
continue;
}
// Extract sender from <strong itemprop="mri">.
const senderMatch = /<strong[^>]*itemprop=["']mri["'][^>]*>(.*?)<\/strong>/i.exec(content);
const sender = senderMatch?.[1] ? htmlToPlainText(senderMatch[1]) : undefined;
// Extract body from <p itemprop="copy">.
const bodyMatch = /<p[^>]*itemprop=["']copy["'][^>]*>(.*?)<\/p>/is.exec(content);
const body = bodyMatch?.[1] ? htmlToPlainText(bodyMatch[1]) : undefined;
if (body) {
return { sender: sender ?? "unknown", body };
}
}
return undefined;
}
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 extractMSTeamsConversationMessageId(raw: string): string | undefined {
if (!raw) {
return undefined;
}
const match = /(?:^|;)messageid=([^;]+)/i.exec(raw);
const value = match?.[1]?.trim() ?? "";
return value || undefined;
}
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 <at>...</at> tags
return text.replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
}
/**
* Bot Framework uses 'a:xxx' conversation IDs for personal chats, but Graph API
* requires the '19:{userId}_{botAppId}@unq.gbl.spaces' format.
*
* This is the documented Graph API format for 1:1 chat thread IDs between a user
* and a bot/app. See Microsoft docs "Get chat between user and app":
* https://learn.microsoft.com/en-us/graph/api/userscopeteamsappinstallation-get-chat
*
* The format is only synthesized when the Bot Framework conversation ID starts with
* 'a:' (the opaque format used by BF but not recognized by Graph). If the ID already
* has the '19:...' Graph format, it is passed through unchanged.
*/
export function translateMSTeamsDmConversationIdForGraph(params: {
isDirectMessage: boolean;
conversationId: string;
aadObjectId?: string | null;
appId?: string | null;
}): string {
const { isDirectMessage, conversationId, aadObjectId, appId } = params;
return isDirectMessage && conversationId.startsWith("a:") && aadObjectId && appId
? `19:${aadObjectId}_${appId}@unq.gbl.spaces`
: conversationId;
}
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);
}

View File

@@ -0,0 +1,5 @@
// Msteams plugin entrypoint registers its OpenClaw integration.
export { monitorMSTeamsProvider } from "./monitor.js";
export { probeMSTeams } from "./probe.js";
export { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";

View File

@@ -0,0 +1,221 @@
// Msteams tests cover media helpers plugin behavior.
import { describe, expect, it } from "vitest";
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
describe("msteams media-helpers", () => {
const mediaInputClassCases: Array<{
name: string;
mime: Array<[input: string, expected: string]>;
filename: Array<[input: string, expected: string]>;
}> = [
{
name: "data URLs",
mime: [
["data:image/png;base64,iVBORw0KGgo=", "image/png"],
["data:image/jpeg;base64,/9j/4AAQ", "image/jpeg"],
["data:image/gif;base64,R0lGOD", "image/gif"],
],
filename: [
["data:image/png;base64,iVBORw0KGgo=", "image.png"],
["data:image/jpeg;base64,/9j/4AAQ", "image.jpg"],
],
},
{
name: "local paths",
mime: [
["/tmp/image.png", "image/png"],
["/Users/test/photo.jpg", "image/jpeg"],
],
filename: [
["/tmp/screenshot.png", "screenshot.png"],
["/Users/test/photo.jpg", "photo.jpg"],
],
},
{
name: "tilde paths",
mime: [["~/Downloads/image.gif", "image/gif"]],
filename: [["~/Downloads/image.gif", "image.gif"]],
},
];
describe("getMimeType", () => {
it("detects png from URL", async () => {
expect(await getMimeType("https://example.com/image.png")).toBe("image/png");
});
it("detects jpeg from URL (both extensions)", async () => {
expect(await getMimeType("https://example.com/photo.jpg")).toBe("image/jpeg");
expect(await getMimeType("https://example.com/photo.jpeg")).toBe("image/jpeg");
});
it("detects gif from URL", async () => {
expect(await getMimeType("https://example.com/anim.gif")).toBe("image/gif");
});
it("detects webp from URL", async () => {
expect(await getMimeType("https://example.com/modern.webp")).toBe("image/webp");
});
it("handles URLs with query strings", async () => {
expect(await getMimeType("https://example.com/image.png?v=123")).toBe("image/png");
});
it.each(mediaInputClassCases)("handles $name", async ({ mime }) => {
for (const [input, expected] of mime) {
expect(await getMimeType(input)).toBe(expected);
}
});
it("handles data URLs without base64", async () => {
expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml");
});
it("defaults to application/octet-stream for unknown extensions", async () => {
expect(await getMimeType("https://example.com/image")).toBe("application/octet-stream");
expect(await getMimeType("https://example.com/image.unknown")).toBe(
"application/octet-stream",
);
});
it("is case-insensitive", async () => {
expect(await getMimeType("https://example.com/IMAGE.PNG")).toBe("image/png");
expect(await getMimeType("https://example.com/Photo.JPEG")).toBe("image/jpeg");
});
it("detects document types", async () => {
expect(await getMimeType("https://example.com/doc.pdf")).toBe("application/pdf");
expect(await getMimeType("https://example.com/doc.docx")).toBe(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
expect(await getMimeType("https://example.com/spreadsheet.xlsx")).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
});
describe("extractFilename", () => {
it("extracts filename from URL with extension", async () => {
expect(await extractFilename("https://example.com/photo.jpg")).toBe("photo.jpg");
});
it("extracts filename from URL with path", async () => {
expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png");
});
it("handles URLs without extension by deriving from MIME", async () => {
// Now defaults to application/octet-stream → .bin fallback
expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin");
});
it.each(mediaInputClassCases)("handles $name", async ({ filename }) => {
for (const [input, expected] of filename) {
expect(await extractFilename(input)).toBe(expected);
}
});
it("handles document data URLs", async () => {
expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf");
});
it("returns fallback for empty URL", async () => {
expect(await extractFilename("")).toBe("file.bin");
});
it("extracts original filename from embedded pattern", async () => {
// Pattern: {original}---{uuid}.{ext}
expect(
await extractFilename("/media/inbound/report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
).toBe("report.pdf");
});
it("extracts original filename with uppercase UUID", async () => {
expect(
await extractFilename(
"/media/inbound/Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx",
),
).toBe("Document.docx");
});
it("falls back to UUID filename for legacy paths", async () => {
// UUID-only filename (legacy format, no embedded name)
expect(await extractFilename("/media/inbound/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf")).toBe(
"a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf",
);
});
it("handles --- in filename without valid UUID pattern", async () => {
// foo---bar.txt (bar is not a valid UUID)
expect(await extractFilename("/media/inbound/foo---bar.txt")).toBe("foo---bar.txt");
});
});
describe("isLocalPath", () => {
it("returns true for file:// URLs", () => {
expect(isLocalPath("file:///tmp/image.png")).toBe(true);
expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true);
});
it("returns true for absolute paths", () => {
expect(isLocalPath("/tmp/image.png")).toBe(true);
expect(isLocalPath("/Users/test/photo.jpg")).toBe(true);
});
it("returns true for tilde paths", () => {
expect(isLocalPath("~/Downloads/image.png")).toBe(true);
});
it("returns true for Windows absolute drive paths", () => {
expect(isLocalPath("C:\\Users\\test\\image.png")).toBe(true);
expect(isLocalPath("D:/data/photo.jpg")).toBe(true);
});
it("returns true for Windows UNC paths", () => {
expect(isLocalPath("\\\\server\\share\\image.png")).toBe(true);
});
it("returns true for Windows rooted paths", () => {
expect(isLocalPath("\\tmp\\openclaw\\file.txt")).toBe(true);
});
it("returns false for http URLs", () => {
expect(isLocalPath("http://example.com/image.png")).toBe(false);
expect(isLocalPath("https://example.com/image.png")).toBe(false);
});
it("returns false for data URLs", () => {
expect(isLocalPath("data:image/png;base64,iVBORw0KGgo=")).toBe(false);
});
});
describe("extractMessageId", () => {
it("extracts id from valid response", () => {
expect(extractMessageId({ id: "msg123" })).toBe("msg123");
});
it("returns null for missing id", () => {
expect(extractMessageId({ foo: "bar" })).toBeNull();
});
it("returns null for empty id", () => {
expect(extractMessageId({ id: "" })).toBeNull();
});
it("returns null for non-string id", () => {
expect(extractMessageId({ id: 123 })).toBeNull();
expect(extractMessageId({ id: null })).toBeNull();
});
it("returns null for null response", () => {
expect(extractMessageId(null)).toBeNull();
});
it("returns null for undefined response", () => {
expect(extractMessageId(undefined)).toBeNull();
});
it("returns null for non-object response", () => {
expect(extractMessageId("string")).toBeNull();
expect(extractMessageId(123)).toBeNull();
});
});
});

View File

@@ -0,0 +1,105 @@
/**
* MIME type detection and filename extraction for MSTeams media attachments.
*/
import path from "node:path";
import {
detectMime,
extensionForMime,
extractOriginalFilename,
getFileExtension,
} from "../runtime-api.js";
/**
* Detect MIME type from URL extension or data URL.
* Uses shared MIME detection for consistency with core handling.
*/
export async function getMimeType(url: string): Promise<string> {
// Handle data URLs: data:image/png;base64,...
if (url.startsWith("data:")) {
const match = url.match(/^data:([^;,]+)/);
if (match?.[1]) {
return match[1];
}
}
// Use shared MIME detection (extension-based for URLs)
const detected = await detectMime({ filePath: url });
return detected ?? "application/octet-stream";
}
/**
* Extract filename from URL or local path.
* For local paths, extracts original filename if stored with embedded name pattern.
* Falls back to deriving the extension from MIME type when no extension present.
*/
export async function extractFilename(url: string): Promise<string> {
// Handle data URLs: derive extension from MIME
if (url.startsWith("data:")) {
const mime = await getMimeType(url);
const ext = extensionForMime(mime) ?? ".bin";
const prefix = mime.startsWith("image/") ? "image" : "file";
return `${prefix}${ext}`;
}
// Try to extract from URL pathname
try {
const pathname = new URL(url).pathname;
const basename = path.basename(pathname);
const existingExt = getFileExtension(pathname);
if (basename && existingExt) {
return basename;
}
// No extension in URL, derive from MIME
const mime = await getMimeType(url);
const ext = extensionForMime(mime) ?? ".bin";
const prefix = mime.startsWith("image/") ? "image" : "file";
return basename ? `${basename}${ext}` : `${prefix}${ext}`;
} catch {
// Local paths - use extractOriginalFilename to extract embedded original name
return extractOriginalFilename(url);
}
}
/**
* Check if a URL refers to a local file path.
*/
export function isLocalPath(url: string): boolean {
if (url.startsWith("file://") || url.startsWith("/") || url.startsWith("~")) {
return true;
}
// Windows rooted path on current drive (e.g. \tmp\file.txt)
if (url.startsWith("\\") && !url.startsWith("\\\\")) {
return true;
}
// Windows drive-letter absolute path (e.g. C:\foo\bar.txt or C:/foo/bar.txt)
if (/^[a-zA-Z]:[\\/]/.test(url)) {
return true;
}
// Windows UNC path (e.g. \\server\share\file.txt)
if (url.startsWith("\\\\")) {
return true;
}
return false;
}
/**
* Extract the message ID from a Bot Framework response.
*/
export 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;
}

View File

@@ -0,0 +1,160 @@
// Msteams tests cover mentions plugin behavior.
import { describe, expect, it } from "vitest";
import { parseMentions } from "./mentions.js";
function requireFirstEntity(result: ReturnType<typeof parseMentions>) {
const entity = result.entities[0];
if (!entity) {
throw new Error("expected parseMentions to return at least one entity");
}
return entity;
}
function requireOnlyEntity(result: ReturnType<typeof parseMentions>) {
expect(result.entities).toHaveLength(1);
return requireFirstEntity(result);
}
describe("mention-free text contract", () => {
it("parseMentions handles text without mentions", () => {
const result = parseMentions("Hello world!");
expect(result.text).toBe("Hello world!");
expect(result.entities).toHaveLength(0);
});
});
describe("parseMentions", () => {
it("parses single mention", () => {
const result = parseMentions("Hello @[John Doe](28:a1b2c3-d4e5f6)!");
expect(result.text).toBe("Hello <at>John Doe</at>!");
expect(requireOnlyEntity(result)).toEqual({
type: "mention",
text: "<at>John Doe</at>",
mentioned: {
id: "28:a1b2c3-d4e5f6",
name: "John Doe",
},
});
});
it("parses multiple mentions", () => {
const result = parseMentions("Hey @[Alice](28:aaa) and @[Bob](28:bbb), can you review this?");
expect(result.text).toBe("Hey <at>Alice</at> and <at>Bob</at>, can you review this?");
expect(result.entities).toHaveLength(2);
expect(result.entities[0]).toEqual({
type: "mention",
text: "<at>Alice</at>",
mentioned: {
id: "28:aaa",
name: "Alice",
},
});
expect(result.entities[1]).toEqual({
type: "mention",
text: "<at>Bob</at>",
mentioned: {
id: "28:bbb",
name: "Bob",
},
});
});
it("handles empty text", () => {
const result = parseMentions("");
expect(result.text).toBe("");
expect(result.entities).toHaveLength(0);
});
it("handles mention with spaces in name", () => {
const result = parseMentions("@[John Peter Smith](28:a1b2c3)");
expect(result.text).toBe("<at>John Peter Smith</at>");
expect(requireFirstEntity(result).mentioned.name).toBe("John Peter Smith");
});
it("trims whitespace from id and name", () => {
const result = parseMentions("@[ John Doe ]( 28:a1b2c3 )");
expect(requireOnlyEntity(result)).toEqual({
type: "mention",
text: "<at>John Doe</at>",
mentioned: {
id: "28:a1b2c3",
name: "John Doe",
},
});
});
it("handles Japanese characters in mention at start of message", () => {
const input = "@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!";
const result = parseMentions(input);
expect(result.text).toBe("<at>タナカ タロウ</at> スキル化完了しました!");
expect(requireOnlyEntity(result)).toEqual({
type: "mention",
text: "<at>タナカ タロウ</at>",
mentioned: {
id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
name: "タナカ タロウ",
},
});
// Verify entity text exactly matches what's in the formatted text
const entityText = requireFirstEntity(result).text;
expect(result.text).toContain(entityText);
expect(result.text.indexOf(entityText)).toBe(0);
});
it("skips mention-like patterns with non-Teams IDs (e.g. in code blocks)", () => {
// This reproduces the actual failing payload: the message contains a real mention
// plus `@[表示名](ユーザーID)` as documentation text inside backticks.
const input =
"@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!📋\n\n" +
"**作成したスキル:** `teams-mention`\n" +
"- 機能: Teamsでのメンション形式 `@[表示名](ユーザーID)`\n\n" +
"**追加対応:**\n" +
"- ユーザーのID `a1b2c3d4-e5f6-7890-abcd-ef1234567890` を登録済み";
const result = parseMentions(input);
// Only the real mention should be parsed; the documentation example should be left as-is
const firstEntity = requireOnlyEntity(result);
expect(firstEntity.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
expect(firstEntity.mentioned.name).toBe("タナカ タロウ");
// The documentation pattern must remain untouched in the text
expect(result.text).toContain("`@[表示名](ユーザーID)`");
});
it("accepts Bot Framework IDs (28:xxx)", () => {
const result = parseMentions("@[Bot](28:abc-123)");
expect(requireOnlyEntity(result).mentioned.id).toBe("28:abc-123");
});
it("accepts Bot Framework IDs with non-hex payloads (29:xxx)", () => {
const result = parseMentions("@[Bot](29:08q2j2o3jc09au90eucae)");
expect(requireOnlyEntity(result).mentioned.id).toBe("29:08q2j2o3jc09au90eucae");
});
it("accepts org-scoped IDs with extra segments (8:orgid:...)", () => {
const result = parseMentions("@[User](8:orgid:2d8c2d2c-1111-2222-3333-444444444444)");
expect(requireOnlyEntity(result).mentioned.id).toBe(
"8:orgid:2d8c2d2c-1111-2222-3333-444444444444",
);
});
it("accepts AAD object IDs (UUIDs)", () => {
const result = parseMentions("@[User](a1b2c3d4-e5f6-7890-abcd-ef1234567890)");
expect(requireOnlyEntity(result).mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
});
it("rejects non-ID strings as mention targets", () => {
const result = parseMentions("See @[docs](https://example.com) for details");
expect(result.entities).toHaveLength(0);
// Original text preserved
expect(result.text).toBe("See @[docs](https://example.com) for details");
});
});

View File

@@ -0,0 +1,77 @@
/**
* MS Teams mention handling utilities.
*
* Mentions in Teams require:
* 1. Text containing <at>Name</at> tags
* 2. entities array with mention metadata
*/
type MentionEntity = {
type: "mention";
text: string;
mentioned: {
id: string;
name: string;
};
};
/**
* Check whether an ID looks like a valid Teams user/bot identifier.
* Accepts:
* - Bot Framework IDs: "28:xxx..." / "29:xxx..." / "8:orgid:..."
* - AAD object IDs (UUIDs): "d5318c29-33ac-4e6b-bd42-57b8b793908f"
*
* Keep this permissive enough for real Teams IDs while still rejecting
* documentation placeholders like `@[表示名](ユーザーID)`.
*/
const TEAMS_BOT_ID_PATTERN = /^\d+:[a-z0-9._=-]+(?::[a-z0-9._=-]+)*$/i;
const AAD_OBJECT_ID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
function isValidTeamsId(id: string): boolean {
return TEAMS_BOT_ID_PATTERN.test(id) || AAD_OBJECT_ID_PATTERN.test(id);
}
/**
* Parse mentions from text in the format @[Name](id).
* Example: "Hello @[John Doe](28:xxx-yyy-zzz)!"
*
* Only matches where the id looks like a real Teams user/bot ID are treated
* as mentions. This avoids false positives from documentation or code samples
* embedded in the message (e.g. `@[表示名](ユーザーID)` in backticks).
*
* Returns both the formatted text with <at> tags and the entities array.
*/
export function parseMentions(text: string): {
text: string;
entities: MentionEntity[];
} {
const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
const entities: MentionEntity[] = [];
// Replace @[Name](id) with <at>Name</at> only for valid Teams IDs
const formattedText = text.replace(mentionPattern, (match, name, id) => {
const trimmedId = id.trim();
// Skip matches where the id doesn't look like a real Teams identifier
if (!isValidTeamsId(trimmedId)) {
return match;
}
const trimmedName = name.trim();
const mentionTag = `<at>${trimmedName}</at>`;
entities.push({
type: "mention",
text: mentionTag,
mentioned: {
id: trimmedId,
name: trimmedName,
},
});
return mentionTag;
});
return {
text: formattedText,
entities,
};
}

Some files were not shown because too many files have changed in this diff Show More