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
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:
11
extensions/msteams/README.md
Normal file
11
extensions/msteams/README.md
Normal 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.
|
||||
4
extensions/msteams/api.ts
Normal file
4
extensions/msteams/api.ts
Normal 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";
|
||||
2
extensions/msteams/channel-config-api.ts
Normal file
2
extensions/msteams/channel-config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Msteams API module exposes the plugin public contract.
|
||||
export { MSTeamsChannelConfigSchema } from "./src/config-schema.js";
|
||||
3
extensions/msteams/channel-plugin-api.ts
Normal file
3
extensions/msteams/channel-plugin-api.ts
Normal 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";
|
||||
5
extensions/msteams/config-api.ts
Normal file
5
extensions/msteams/config-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Msteams API module exposes the plugin public contract.
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
MSTeamsConfigSchema,
|
||||
} from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
46
extensions/msteams/directory-contract-api.ts
Normal file
46
extensions/msteams/directory-contract-api.ts
Normal 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,
|
||||
};
|
||||
350
extensions/msteams/doctor-contract-api.test.ts
Normal file
350
extensions/msteams/doctor-contract-api.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
545
extensions/msteams/doctor-contract-api.ts
Normal file
545
extensions/msteams/doctor-contract-api.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
21
extensions/msteams/index.ts
Normal file
21
extensions/msteams/index.ts
Normal 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
1711
extensions/msteams/npm-shrinkwrap.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
extensions/msteams/openclaw.plugin.json
Normal file
17
extensions/msteams/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
69
extensions/msteams/package.json
Normal file
69
extensions/msteams/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
67
extensions/msteams/runtime-api.ts
Normal file
67
extensions/msteams/runtime-api.ts
Normal 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";
|
||||
6
extensions/msteams/secret-contract-api.ts
Normal file
6
extensions/msteams/secret-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Msteams API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
14
extensions/msteams/setup-entry.ts
Normal file
14
extensions/msteams/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
3
extensions/msteams/setup-plugin-api.ts
Normal file
3
extensions/msteams/setup-plugin-api.ts
Normal 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";
|
||||
52
extensions/msteams/src/adaptive-card-submit.ts
Normal file
52
extensions/msteams/src/adaptive-card-submit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
7
extensions/msteams/src/ai-entity.ts
Normal file
7
extensions/msteams/src/ai-entity.ts
Normal 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"],
|
||||
};
|
||||
45
extensions/msteams/src/approval-auth.ts
Normal file
45
extensions/msteams/src/approval-auth.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
415
extensions/msteams/src/attachments.graph.test.ts
Normal file
415
extensions/msteams/src/attachments.graph.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
306
extensions/msteams/src/attachments.helpers.test.ts
Normal file
306
extensions/msteams/src/attachments.helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
18
extensions/msteams/src/attachments.test-helpers.ts
Normal file
18
extensions/msteams/src/attachments.test-helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
740
extensions/msteams/src/attachments.test.ts
Normal file
740
extensions/msteams/src/attachments.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
20
extensions/msteams/src/attachments.ts
Normal file
20
extensions/msteams/src/attachments.ts
Normal 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";
|
||||
609
extensions/msteams/src/attachments/bot-framework.test.ts
Normal file
609
extensions/msteams/src/attachments/bot-framework.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
386
extensions/msteams/src/attachments/bot-framework.ts
Normal file
386
extensions/msteams/src/attachments/bot-framework.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
335
extensions/msteams/src/attachments/download.ts
Normal file
335
extensions/msteams/src/attachments/download.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
444
extensions/msteams/src/attachments/graph.test.ts
Normal file
444
extensions/msteams/src/attachments/graph.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
494
extensions/msteams/src/attachments/graph.ts
Normal file
494
extensions/msteams/src/attachments/graph.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
220
extensions/msteams/src/attachments/html.ts
Normal file
220
extensions/msteams/src/attachments/html.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
15
extensions/msteams/src/attachments/payload.ts
Normal file
15
extensions/msteams/src/attachments/payload.ts
Normal 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 });
|
||||
}
|
||||
188
extensions/msteams/src/attachments/remote-media.test.ts
Normal file
188
extensions/msteams/src/attachments/remote-media.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
extensions/msteams/src/attachments/remote-media.ts
Normal file
77
extensions/msteams/src/attachments/remote-media.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
680
extensions/msteams/src/attachments/shared.test.ts
Normal file
680
extensions/msteams/src/attachments/shared.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
730
extensions/msteams/src/attachments/shared.ts
Normal file
730
extensions/msteams/src/attachments/shared.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
49
extensions/msteams/src/attachments/types.ts
Normal file
49
extensions/msteams/src/attachments/types.ts
Normal 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;
|
||||
};
|
||||
165
extensions/msteams/src/auth-coverage.test.ts
Normal file
165
extensions/msteams/src/auth-coverage.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
62
extensions/msteams/src/block-streaming-config.test.ts
Normal file
62
extensions/msteams/src/block-streaming-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
58
extensions/msteams/src/bot-framework-service-url.ts
Normal file
58
extensions/msteams/src/bot-framework-service-url.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
2
extensions/msteams/src/channel-api.ts
Normal file
2
extensions/msteams/src/channel-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Msteams API module exposes the plugin public contract.
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
965
extensions/msteams/src/channel.actions.test.ts
Normal file
965
extensions/msteams/src/channel.actions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
177
extensions/msteams/src/channel.directory.test.ts
Normal file
177
extensions/msteams/src/channel.directory.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
228
extensions/msteams/src/channel.message-adapter.test.ts
Normal file
228
extensions/msteams/src/channel.message-adapter.test.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
57
extensions/msteams/src/channel.runtime.ts
Normal file
57
extensions/msteams/src/channel.runtime.ts
Normal 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,
|
||||
};
|
||||
78
extensions/msteams/src/channel.setup.ts
Normal file
78
extensions/msteams/src/channel.setup.ts
Normal 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,
|
||||
};
|
||||
201
extensions/msteams/src/channel.test.ts
Normal file
201
extensions/msteams/src/channel.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
1205
extensions/msteams/src/channel.ts
Normal file
1205
extensions/msteams/src/channel.ts
Normal file
File diff suppressed because it is too large
Load Diff
154
extensions/msteams/src/cloud.test.ts
Normal file
154
extensions/msteams/src/cloud.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
145
extensions/msteams/src/cloud.ts
Normal file
145
extensions/msteams/src/cloud.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
7
extensions/msteams/src/config-schema.ts
Normal file
7
extensions/msteams/src/config-schema.ts
Normal 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,
|
||||
});
|
||||
49
extensions/msteams/src/config-ui-hints.ts
Normal file
49
extensions/msteams/src/config-ui-hints.ts
Normal 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>;
|
||||
203
extensions/msteams/src/conversation-store-helpers.test.ts
Normal file
203
extensions/msteams/src/conversation-store-helpers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
106
extensions/msteams/src/conversation-store-helpers.ts
Normal file
106
extensions/msteams/src/conversation-store-helpers.ts
Normal 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;
|
||||
}
|
||||
250
extensions/msteams/src/conversation-store-state.test.ts
Normal file
250
extensions/msteams/src/conversation-store-state.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
229
extensions/msteams/src/conversation-store-state.ts
Normal file
229
extensions/msteams/src/conversation-store-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
305
extensions/msteams/src/conversation-store.shared.test.ts
Normal file
305
extensions/msteams/src/conversation-store.shared.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
71
extensions/msteams/src/conversation-store.ts
Normal file
71
extensions/msteams/src/conversation-store.ts
Normal 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>;
|
||||
};
|
||||
157
extensions/msteams/src/directory-live.test.ts
Normal file
157
extensions/msteams/src/directory-live.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
112
extensions/msteams/src/directory-live.ts
Normal file
112
extensions/msteams/src/directory-live.ts
Normal 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;
|
||||
}
|
||||
28
extensions/msteams/src/doctor.ts
Normal file
28
extensions/msteams/src/doctor.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
214
extensions/msteams/src/errors.test.ts
Normal file
214
extensions/msteams/src/errors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
294
extensions/msteams/src/errors.ts
Normal file
294
extensions/msteams/src/errors.ts
Normal 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;
|
||||
}
|
||||
201
extensions/msteams/src/feedback-invoke.ts
Normal file
201
extensions/msteams/src/feedback-invoke.ts
Normal 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;
|
||||
}
|
||||
119
extensions/msteams/src/feedback-reflection-prompt.ts
Normal file
119
extensions/msteams/src/feedback-reflection-prompt.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
99
extensions/msteams/src/feedback-reflection-store.ts
Normal file
99
extensions/msteams/src/feedback-reflection-store.ts
Normal 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 [];
|
||||
}
|
||||
279
extensions/msteams/src/feedback-reflection.test.ts
Normal file
279
extensions/msteams/src/feedback-reflection.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
272
extensions/msteams/src/feedback-reflection.ts
Normal file
272
extensions/msteams/src/feedback-reflection.ts
Normal 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,
|
||||
};
|
||||
329
extensions/msteams/src/file-consent-helpers.test.ts
Normal file
329
extensions/msteams/src/file-consent-helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
116
extensions/msteams/src/file-consent-helpers.ts
Normal file
116
extensions/msteams/src/file-consent-helpers.ts
Normal 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);
|
||||
}
|
||||
154
extensions/msteams/src/file-consent-invoke.ts
Normal file
154
extensions/msteams/src/file-consent-invoke.ts
Normal 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) });
|
||||
}
|
||||
}
|
||||
379
extensions/msteams/src/file-consent.test.ts
Normal file
379
extensions/msteams/src/file-consent.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
223
extensions/msteams/src/file-consent.ts
Normal file
223
extensions/msteams/src/file-consent.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
37
extensions/msteams/src/graph-chat.ts
Normal file
37
extensions/msteams/src/graph-chat.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
333
extensions/msteams/src/graph-group-management.test.ts
Normal file
333
extensions/msteams/src/graph-group-management.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
169
extensions/msteams/src/graph-group-management.ts
Normal file
169
extensions/msteams/src/graph-group-management.ts
Normal 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 } };
|
||||
}
|
||||
90
extensions/msteams/src/graph-members.test.ts
Normal file
90
extensions/msteams/src/graph-members.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
49
extensions/msteams/src/graph-members.ts
Normal file
49
extensions/msteams/src/graph-members.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
254
extensions/msteams/src/graph-messages.actions.test.ts
Normal file
254
extensions/msteams/src/graph-messages.actions.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
392
extensions/msteams/src/graph-messages.read.test.ts
Normal file
392
extensions/msteams/src/graph-messages.read.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
228
extensions/msteams/src/graph-messages.search.test.ts
Normal file
228
extensions/msteams/src/graph-messages.search.test.ts
Normal 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?`,
|
||||
);
|
||||
});
|
||||
});
|
||||
51
extensions/msteams/src/graph-messages.test-helpers.ts
Normal file
51
extensions/msteams/src/graph-messages.test-helpers.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
535
extensions/msteams/src/graph-messages.ts
Normal file
535
extensions/msteams/src/graph-messages.ts
Normal 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 };
|
||||
}
|
||||
223
extensions/msteams/src/graph-teams.test.ts
Normal file
223
extensions/msteams/src/graph-teams.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
115
extensions/msteams/src/graph-teams.ts
Normal file
115
extensions/msteams/src/graph-teams.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
291
extensions/msteams/src/graph-thread.test.ts
Normal file
291
extensions/msteams/src/graph-thread.test.ts
Normal 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("& <b> "x" 'y' z")).toBe(
|
||||
"& <b> \"x\" 'y' z",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-decode escaped entities (decodes & last)", () => {
|
||||
// Graph encodes literally-typed entity text by escaping its '&' to '&'.
|
||||
// Decoding '&' first would re-decode the now-bare '<'/'>' into
|
||||
// angle brackets, corrupting the user's literal text.
|
||||
expect(stripHtmlFromTeamsMessage("The token is &lt;APIKEY&gt;")).toBe(
|
||||
"The token is <APIKEY>",
|
||||
);
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
168
extensions/msteams/src/graph-thread.ts
Normal file
168
extensions/msteams/src/graph-thread.ts
Normal 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. & must be decoded LAST to prevent
|
||||
// double-decoding (e.g. &lt; → < not <), matching decodeHtmlEntities
|
||||
// in inbound.ts.
|
||||
text = text
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/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 };
|
||||
288
extensions/msteams/src/graph-upload.test.ts
Normal file
288
extensions/msteams/src/graph-upload.test.ts
Normal 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: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
524
extensions/msteams/src/graph-upload.ts
Normal file
524
extensions/msteams/src/graph-upload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
extensions/msteams/src/graph-users.ts
Normal file
30
extensions/msteams/src/graph-users.ts
Normal 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 ?? [];
|
||||
}
|
||||
593
extensions/msteams/src/graph.test.ts
Normal file
593
extensions/msteams/src/graph.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
312
extensions/msteams/src/graph.ts
Normal file
312
extensions/msteams/src/graph.ts
Normal 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;
|
||||
}
|
||||
37
extensions/msteams/src/http-error.test.ts
Normal file
37
extensions/msteams/src/http-error.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
extensions/msteams/src/http-error.ts
Normal file
20
extensions/msteams/src/http-error.ts
Normal 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;
|
||||
}
|
||||
222
extensions/msteams/src/inbound.test.ts
Normal file
222
extensions/msteams/src/inbound.test.ts
Normal 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("&<>"'' ")).toBe("&<>\"'' ");
|
||||
});
|
||||
|
||||
it("leaves plain text unchanged", () => {
|
||||
expect(decodeHtmlEntities("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("prevents double-decoding: &lt; should become < not <", () => {
|
||||
// If & were decoded first, &lt; → < → < (wrong).
|
||||
// With & decoded last, &lt; stays as < (correct).
|
||||
expect(decodeHtmlEntities("&lt;b&gt;")).toBe("<b>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("htmlToPlainText", () => {
|
||||
it("strips tags and decodes entities", () => {
|
||||
expect(htmlToPlainText("<strong>Hello & 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 < 3 & 4 > 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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
149
extensions/msteams/src/inbound.ts
Normal file
149
extensions/msteams/src/inbound.ts
Normal 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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&"); // must be last to prevent double-decoding (e.g. &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);
|
||||
}
|
||||
5
extensions/msteams/src/index.ts
Normal file
5
extensions/msteams/src/index.ts
Normal 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";
|
||||
221
extensions/msteams/src/media-helpers.test.ts
Normal file
221
extensions/msteams/src/media-helpers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
105
extensions/msteams/src/media-helpers.ts
Normal file
105
extensions/msteams/src/media-helpers.ts
Normal 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;
|
||||
}
|
||||
160
extensions/msteams/src/mentions.test.ts
Normal file
160
extensions/msteams/src/mentions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
77
extensions/msteams/src/mentions.ts
Normal file
77
extensions/msteams/src/mentions.ts
Normal 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
Reference in New Issue
Block a user