Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
41
openclaw/extensions/msteams/CHANGELOG.md
Normal file
41
openclaw/extensions/msteams/CHANGELOG.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.26
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.22
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Features
|
||||
|
||||
- Bot Framework gateway monitor (Express + JWT auth) with configurable webhook path/port and `/api/messages` fallback.
|
||||
- Onboarding flow for Azure Bot credentials (config + env var detection) and DM policy setup.
|
||||
- Channel capabilities: DMs, group chats, channels, threads, media, polls, and `teams` alias.
|
||||
- DM pairing/allowlist enforcement plus group policies with per-team/channel overrides and mention gating.
|
||||
- Inbound debounce + history context for room/group chats; mention tag stripping and timestamp parsing.
|
||||
- Proactive messaging via stored conversation references (file store with TTL/size pruning).
|
||||
- Outbound text/media send with markdown chunking, 4k limit, split/inline media handling.
|
||||
- Adaptive Card polls: build cards, parse votes, and persist poll state with vote tracking.
|
||||
- Attachment processing: placeholders + HTML summaries, inline image extraction (including data: URLs).
|
||||
- Media downloads with host allowlist, auth scope fallback, and Graph hostedContents/attachments fallback.
|
||||
- Retry/backoff on transient/throttled sends with classified errors + helpful hints.
|
||||
17
openclaw/extensions/msteams/index.ts
Normal file
17
openclaw/extensions/msteams/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { msteamsPlugin } from "./src/channel.js";
|
||||
import { setMSTeamsRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "msteams",
|
||||
name: "Microsoft Teams",
|
||||
description: "Microsoft Teams channel plugin (Bot Framework)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
setMSTeamsRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: msteamsPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
openclaw/extensions/msteams/openclaw.plugin.json
Normal file
9
openclaw/extensions/msteams/openclaw.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "msteams",
|
||||
"channels": ["msteams"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
32
openclaw/extensions/msteams/package.json
Normal file
32
openclaw/extensions/msteams/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@microsoft/agents-hosting": "^1.3.1",
|
||||
"express": "^5.2.1"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "msteams",
|
||||
"label": "Microsoft Teams",
|
||||
"selectionLabel": "Microsoft Teams (Bot Framework)",
|
||||
"docsPath": "/channels/msteams",
|
||||
"docsLabel": "msteams",
|
||||
"blurb": "Bot Framework; enterprise support.",
|
||||
"aliases": [
|
||||
"teams"
|
||||
],
|
||||
"order": 60
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/msteams",
|
||||
"localPath": "extensions/msteams",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
783
openclaw/extensions/msteams/src/attachments.test.ts
Normal file
783
openclaw/extensions/msteams/src/attachments.test.ts
Normal file
@@ -0,0 +1,783 @@
|
||||
import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
buildMSTeamsMediaPayload,
|
||||
downloadMSTeamsAttachments,
|
||||
downloadMSTeamsGraphMedia,
|
||||
} from "./attachments.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
const GRAPH_HOST = "graph.microsoft.com";
|
||||
const SHAREPOINT_HOST = "contoso.sharepoint.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_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 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_TEXT_HTML = "text/html";
|
||||
const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
|
||||
const REDIRECT_STATUS_CODES = [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 detectMimeMock = vi.fn(async () => CONTENT_TYPE_IMAGE_PNG);
|
||||
const saveMediaBufferMock = vi.fn(async () => ({
|
||||
path: SAVED_PNG_PATH,
|
||||
contentType: CONTENT_TYPE_IMAGE_PNG,
|
||||
}));
|
||||
const readRemoteMediaResponse = async (
|
||||
res: Response,
|
||||
params: Pick<RemoteMediaFetchParams, "maxBytes" | "filePathHint">,
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
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()),
|
||||
);
|
||||
}
|
||||
|
||||
const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => {
|
||||
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" });
|
||||
if (REDIRECT_STATUS_CODES.includes(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 runtimeStub = {
|
||||
media: {
|
||||
detectMime: detectMimeMock as unknown as PluginRuntime["media"]["detectMime"],
|
||||
},
|
||||
channel: {
|
||||
media: {
|
||||
fetchRemoteMedia:
|
||||
fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
||||
saveMediaBuffer:
|
||||
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
type DownloadAttachmentsParams = Parameters<typeof downloadMSTeamsAttachments>[0];
|
||||
type DownloadGraphMediaParams = Parameters<typeof downloadMSTeamsGraphMedia>[0];
|
||||
type DownloadedMedia = Awaited<ReturnType<typeof downloadMSTeamsAttachments>>;
|
||||
type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>;
|
||||
type DownloadAttachmentsBuildOverrides = Partial<
|
||||
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts">
|
||||
> &
|
||||
Pick<DownloadAttachmentsParams, "allowHosts">;
|
||||
type DownloadAttachmentsNoFetchOverrides = Partial<
|
||||
Omit<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "fetchFn">
|
||||
> &
|
||||
Pick<DownloadAttachmentsParams, "allowHosts">;
|
||||
type DownloadGraphMediaOverrides = Partial<
|
||||
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
|
||||
>;
|
||||
type FetchFn = typeof fetch;
|
||||
type MSTeamsAttachments = DownloadAttachmentsParams["attachments"];
|
||||
type AttachmentPlaceholderInput = Parameters<typeof buildMSTeamsAttachmentPlaceholder>[0];
|
||||
type GraphMessageUrlParams = Parameters<typeof buildMSTeamsGraphMessageUrls>[0];
|
||||
type LabeledCase = { label: string };
|
||||
type FetchCallExpectation = { expectFetchCalled?: boolean };
|
||||
type DownloadedMediaExpectation = { path?: string; placeholder?: string };
|
||||
type MSTeamsMediaPayloadExpectation = {
|
||||
firstPath: string;
|
||||
paths: string[];
|
||||
types: string[];
|
||||
};
|
||||
|
||||
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_ALLOW_HOSTS = [TEST_HOST];
|
||||
const DEFAULT_SHAREPOINT_ALLOW_HOSTS = [GRAPH_HOST, SHAREPOINT_HOST];
|
||||
const DEFAULT_SHARE_REFERENCE_URL = createUrlForHost(SHAREPOINT_HOST, "site/file");
|
||||
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 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 = () => ({ getAccessToken: vi.fn(async () => "token") });
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
const createMediaEntriesWithType = (contentType: string, ...paths: string[]) =>
|
||||
paths.map((path) => ({ path, contentType }));
|
||||
const createHostedContentsWithType = (contentType: string, ...ids: string[]) =>
|
||||
ids.map((id) => ({ id, contentType, contentBytes: PNG_BASE64 }));
|
||||
const createImageMediaEntries = (...paths: string[]) =>
|
||||
createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths);
|
||||
const createHostedImageContents = (...ids: string[]) =>
|
||||
createHostedContentsWithType(CONTENT_TYPE_IMAGE_PNG, ...ids);
|
||||
const createPdfResponse = (payload: Buffer | string = PDF_BUFFER) => {
|
||||
return createBufferResponse(payload, CONTENT_TYPE_APPLICATION_PDF);
|
||||
};
|
||||
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 createJsonResponse = (payload: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(payload), { status });
|
||||
const createTextResponse = (body: string, status = 200) => new Response(body, { 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 createOkFetchMock = (contentType: string, payload = "png") =>
|
||||
vi.fn(async () => 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,
|
||||
...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 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 expectAttachmentMediaLength = (media: DownloadedMedia, expectedLength: number) => {
|
||||
expect(media).toHaveLength(expectedLength);
|
||||
};
|
||||
const expectSingleMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation = {}) => {
|
||||
expectAttachmentMediaLength(media, 1);
|
||||
expectFirstMedia(media, expected);
|
||||
};
|
||||
const expectMediaBufferSaved = () => {
|
||||
expect(saveMediaBufferMock).toHaveBeenCalled();
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
const expectMSTeamsMediaPayload = (
|
||||
payload: MSTeamsMediaPayload,
|
||||
expected: MSTeamsMediaPayloadExpectation,
|
||||
) => {
|
||||
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);
|
||||
};
|
||||
type AttachmentPlaceholderCase = LabeledCase & {
|
||||
attachments: AttachmentPlaceholderInput;
|
||||
expected: string;
|
||||
};
|
||||
type CountedAttachmentPlaceholderCaseDef = LabeledCase & {
|
||||
attachments: AttachmentPlaceholderCase["attachments"];
|
||||
count: number;
|
||||
formatPlaceholder: (count: number) => string;
|
||||
};
|
||||
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;
|
||||
};
|
||||
type GraphUrlExpectationCase = LabeledCase & {
|
||||
params: GraphMessageUrlParams;
|
||||
expectedPath: string;
|
||||
};
|
||||
type ChannelGraphUrlCaseParams = {
|
||||
messageId: string;
|
||||
replyToId?: string;
|
||||
conversationId?: string;
|
||||
};
|
||||
type GraphMediaDownloadResult = {
|
||||
fetchMock: ReturnType<typeof createGraphFetchMock>;
|
||||
media: Awaited<ReturnType<typeof downloadMSTeamsGraphMedia>>;
|
||||
};
|
||||
type GraphMediaSuccessCase = LabeledCase & {
|
||||
buildOptions: () => GraphFetchMockOptions;
|
||||
expectedLength: number;
|
||||
assert?: (params: GraphMediaDownloadResult) => void;
|
||||
};
|
||||
const EMPTY_ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [
|
||||
withLabel("returns empty string when no attachments", { attachments: undefined, expected: "" }),
|
||||
withLabel("returns empty string when attachments are empty", { attachments: [], expected: "" }),
|
||||
];
|
||||
const COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS: CountedAttachmentPlaceholderCaseDef[] = [
|
||||
withLabel("returns image placeholder for one image attachment", {
|
||||
attachments: createImageAttachments(TEST_URL_IMAGE_PNG),
|
||||
count: 1,
|
||||
formatPlaceholder: formatImagePlaceholder,
|
||||
}),
|
||||
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 },
|
||||
],
|
||||
count: 2,
|
||||
formatPlaceholder: formatImagePlaceholder,
|
||||
}),
|
||||
withLabel("treats Teams file.download.info image attachments as images", {
|
||||
attachments: createTeamsFileDownloadInfoAttachments(),
|
||||
count: 1,
|
||||
formatPlaceholder: formatImagePlaceholder,
|
||||
}),
|
||||
withLabel("returns document placeholder for non-image attachments", {
|
||||
attachments: createPdfAttachments(TEST_URL_PDF),
|
||||
count: 1,
|
||||
formatPlaceholder: formatDocumentPlaceholder,
|
||||
}),
|
||||
withLabel("returns document placeholder with count for many non-image attachments", {
|
||||
attachments: createPdfAttachments(TEST_URL_PDF_1, TEST_URL_PDF_2),
|
||||
count: 2,
|
||||
formatPlaceholder: formatDocumentPlaceholder,
|
||||
}),
|
||||
withLabel("counts one inline image in html attachments", {
|
||||
attachments: createHtmlImageAttachments([TEST_URL_HTML_A], "<p>hi</p>"),
|
||||
count: 1,
|
||||
formatPlaceholder: formatImagePlaceholder,
|
||||
}),
|
||||
withLabel("counts many inline images in html attachments", {
|
||||
attachments: createHtmlImageAttachments([TEST_URL_HTML_A, TEST_URL_HTML_B]),
|
||||
count: 2,
|
||||
formatPlaceholder: formatImagePlaceholder,
|
||||
}),
|
||||
];
|
||||
const ATTACHMENT_PLACEHOLDER_CASES: AttachmentPlaceholderCase[] = [
|
||||
...EMPTY_ATTACHMENT_PLACEHOLDER_CASES,
|
||||
...COUNTED_ATTACHMENT_PLACEHOLDER_CASE_DEFS.map((testCase) =>
|
||||
withLabel(testCase.label, {
|
||||
attachments: testCase.attachments,
|
||||
expected: testCase.formatPlaceholder(testCase.count),
|
||||
}),
|
||||
),
|
||||
];
|
||||
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({
|
||||
path: SAVED_PDF_PATH,
|
||||
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 GRAPH_MEDIA_SUCCESS_CASES: GraphMediaSuccessCase[] = [
|
||||
withLabel("downloads hostedContents images", {
|
||||
buildOptions: () => ({ hostedContents: createHostedImageContents("1") }),
|
||||
expectedLength: 1,
|
||||
assert: ({ fetchMock }) => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expectMediaBufferSaved();
|
||||
},
|
||||
}),
|
||||
withLabel("merges SharePoint reference attachments with hosted content", {
|
||||
buildOptions: () => {
|
||||
return {
|
||||
hostedContents: createHostedImageContents("hosted-1"),
|
||||
...buildDefaultShareReferenceGraphFetchOptions({
|
||||
onShareRequest: () => createPdfResponse(),
|
||||
}),
|
||||
};
|
||||
},
|
||||
expectedLength: 2,
|
||||
}),
|
||||
];
|
||||
const CHANNEL_GRAPH_URL_CASES: Array<LabeledCase & ChannelGraphUrlCaseParams> = [
|
||||
withLabel("builds channel message urls", {
|
||||
conversationId: "19:thread@thread.tacv2",
|
||||
messageId: "123",
|
||||
}),
|
||||
withLabel("builds channel reply urls when replyToId is present", {
|
||||
messageId: "reply-id",
|
||||
replyToId: "root-id",
|
||||
}),
|
||||
];
|
||||
const GRAPH_URL_EXPECTATION_CASES: GraphUrlExpectationCase[] = [
|
||||
...CHANNEL_GRAPH_URL_CASES.map<GraphUrlExpectationCase>(({ label, ...params }) =>
|
||||
withLabel(label, {
|
||||
params: createChannelGraphMessageUrlParams(params),
|
||||
expectedPath: buildExpectedChannelMessagePath(params),
|
||||
}),
|
||||
),
|
||||
withLabel("builds chat message urls", {
|
||||
params: {
|
||||
conversationType: "groupChat" as const,
|
||||
conversationId: "19:chat@thread.v2",
|
||||
messageId: "456",
|
||||
},
|
||||
expectedPath: "/chats/19%3Achat%40thread.v2/messages/456",
|
||||
}),
|
||||
];
|
||||
|
||||
type GraphFetchMockOptions = {
|
||||
hostedContents?: unknown[];
|
||||
attachments?: unknown[];
|
||||
messageAttachments?: unknown[];
|
||||
onShareRequest?: (url: string) => Response | Promise<Response>;
|
||||
onUnhandled?: (url: string) => Response | Promise<Response> | undefined;
|
||||
};
|
||||
|
||||
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),
|
||||
...overrides,
|
||||
});
|
||||
return { fetchMock, media };
|
||||
};
|
||||
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);
|
||||
};
|
||||
const runGraphMediaSuccessCase = async ({
|
||||
buildOptions,
|
||||
expectedLength,
|
||||
assert,
|
||||
}: GraphMediaSuccessCase) => {
|
||||
const { fetchMock, media } = await downloadGraphMediaWithMockOptions(buildOptions());
|
||||
expectAttachmentMediaLength(media.media, expectedLength);
|
||||
assert?.({ fetchMock, media });
|
||||
};
|
||||
|
||||
describe("msteams attachments", () => {
|
||||
beforeEach(() => {
|
||||
detectMimeMock.mockClear();
|
||||
saveMediaBufferMock.mockClear();
|
||||
fetchRemoteMediaMock.mockClear();
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
describe("buildMSTeamsAttachmentPlaceholder", () => {
|
||||
it.each<AttachmentPlaceholderCase>(ATTACHMENT_PLACEHOLDER_CASES)(
|
||||
"$label",
|
||||
({ attachments, expected }) => {
|
||||
expect(buildMSTeamsAttachmentPlaceholder(attachments)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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.each<AttachmentAuthRetryCase>(ATTACHMENT_AUTH_RETRY_CASES)(
|
||||
"$label",
|
||||
runAttachmentAuthRetryCase,
|
||||
);
|
||||
|
||||
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 = typeof input === "string" ? input : input.toString();
|
||||
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("buildMSTeamsGraphMessageUrls", () => {
|
||||
it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => {
|
||||
const urls = buildMSTeamsGraphMessageUrls(params);
|
||||
expect(urls[0]).toContain(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMSTeamsGraphMedia", () => {
|
||||
it.each<GraphMediaSuccessCase>(GRAPH_MEDIA_SUCCESS_CASES)("$label", runGraphMediaSuccessCase);
|
||||
|
||||
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) => String(call[0]));
|
||||
expect(calledUrls.some((url) => url.startsWith(GRAPH_SHARES_URL_PREFIX))).toBe(true);
|
||||
expect(calledUrls).not.toContain(escapedUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMSTeamsMediaPayload", () => {
|
||||
it("returns single and multi-file fields", async () => {
|
||||
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],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
18
openclaw/extensions/msteams/src/attachments.ts
Normal file
18
openclaw/extensions/msteams/src/attachments.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
downloadMSTeamsAttachments,
|
||||
/** @deprecated Use `downloadMSTeamsAttachments` instead. */
|
||||
downloadMSTeamsImageAttachments,
|
||||
} from "./attachments/download.js";
|
||||
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
export {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
summarizeMSTeamsHtmlAttachments,
|
||||
} from "./attachments/html.js";
|
||||
export { buildMSTeamsMediaPayload } from "./attachments/payload.js";
|
||||
export type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
MSTeamsGraphMediaResult,
|
||||
MSTeamsHtmlAttachmentSummary,
|
||||
MSTeamsInboundMedia,
|
||||
} from "./attachments/types.js";
|
||||
217
openclaw/extensions/msteams/src/attachments/download.ts
Normal file
217
openclaw/extensions/msteams/src/attachments/download.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { fetchWithBearerAuthScopeFallback } from "openclaw/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
extractInlineImageCandidates,
|
||||
inferPlaceholder,
|
||||
isDownloadableAttachment,
|
||||
isRecord,
|
||||
isUrlAllowed,
|
||||
normalizeContentType,
|
||||
resolveMediaSsrfPolicy,
|
||||
resolveRequestUrl,
|
||||
resolveAuthAllowedHosts,
|
||||
resolveAllowedHosts,
|
||||
} 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 = typeof att.name === "string" ? att.name.trim() : "";
|
||||
|
||||
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
|
||||
if (!isRecord(att.content)) {
|
||||
return null;
|
||||
}
|
||||
const downloadUrl =
|
||||
typeof att.content.downloadUrl === "string" ? att.content.downloadUrl.trim() : "";
|
||||
if (!downloadUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileType = typeof att.content.fileType === "string" ? att.content.fileType.trim() : "";
|
||||
const uniqueId = typeof att.content.uniqueId === "string" ? att.content.uniqueId.trim() : "";
|
||||
const fileName = typeof att.content.fileName === "string" ? att.content.fileName.trim() : "";
|
||||
|
||||
const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
|
||||
return {
|
||||
url: downloadUrl,
|
||||
fileHint: fileHint || undefined,
|
||||
contentTypeHint: undefined,
|
||||
placeholder: inferPlaceholder({
|
||||
contentType,
|
||||
fileName: fileHint,
|
||||
fileType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
|
||||
if (!contentUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
url: contentUrl,
|
||||
fileHint: name || undefined,
|
||||
contentTypeHint: contentType,
|
||||
placeholder: inferPlaceholder({ contentType, fileName: name }),
|
||||
};
|
||||
}
|
||||
|
||||
function scopeCandidatesForUrl(url: string): string[] {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
const looksLikeGraph =
|
||||
host.endsWith("graph.microsoft.com") ||
|
||||
host.endsWith("sharepoint.com") ||
|
||||
host.endsWith("1drv.ms") ||
|
||||
host.includes("sharepoint");
|
||||
return looksLikeGraph
|
||||
? ["https://graph.microsoft.com", "https://api.botframework.com"]
|
||||
: ["https://api.botframework.com", "https://graph.microsoft.com"];
|
||||
} catch {
|
||||
return ["https://api.botframework.com", "https://graph.microsoft.com"];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithAuthFallback(params: {
|
||||
url: string;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
requestInit?: RequestInit;
|
||||
authAllowHosts: string[];
|
||||
}): Promise<Response> {
|
||||
return await fetchWithBearerAuthScopeFallback({
|
||||
url: params.url,
|
||||
scopes: scopeCandidatesForUrl(params.url),
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
requestInit: params.requestInit,
|
||||
requireHttps: true,
|
||||
shouldAttachAuth: (url) => isUrlAllowed(url, params.authAllowHosts),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsInboundMedia[]> {
|
||||
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
||||
if (list.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||
const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
|
||||
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);
|
||||
|
||||
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 {
|
||||
// Data inline candidates (base64 data URLs) don't have original filenames
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
inline.data,
|
||||
inline.contentType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inline.placeholder,
|
||||
});
|
||||
} catch {
|
||||
// Ignore decode failures and continue.
|
||||
}
|
||||
}
|
||||
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: (input, init) =>
|
||||
fetchWithAuthFallback({
|
||||
url: resolveRequestUrl(input),
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
requestInit: init,
|
||||
authAllowHosts,
|
||||
}),
|
||||
});
|
||||
out.push(media);
|
||||
} catch {
|
||||
// Ignore download failures and continue with next candidate.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types).
|
||||
*/
|
||||
export const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments;
|
||||
374
openclaw/extensions/msteams/src/attachments/graph.ts
Normal file
374
openclaw/extensions/msteams/src/attachments/graph.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadMSTeamsAttachments } from "./download.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
GRAPH_ROOT,
|
||||
inferPlaceholder,
|
||||
isRecord,
|
||||
isUrlAllowed,
|
||||
normalizeContentType,
|
||||
resolveMediaSsrfPolicy,
|
||||
resolveRequestUrl,
|
||||
resolveAllowedHosts,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
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;
|
||||
};
|
||||
|
||||
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 typeof current === "string" && current.trim() ? current.trim() : undefined;
|
||||
}
|
||||
|
||||
export function buildMSTeamsGraphMessageUrls(params: {
|
||||
conversationType?: string | null;
|
||||
conversationId?: string | null;
|
||||
messageId?: string | null;
|
||||
replyToId?: string | null;
|
||||
conversationMessageId?: string | null;
|
||||
channelData?: unknown;
|
||||
}): string[] {
|
||||
const conversationType = params.conversationType?.trim().toLowerCase() ?? "";
|
||||
const messageIdCandidates = new Set<string>();
|
||||
const pushCandidate = (value: string | null | undefined) => {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (trimmed) {
|
||||
messageIdCandidates.add(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
pushCandidate(params.messageId);
|
||||
pushCandidate(params.conversationMessageId);
|
||||
pushCandidate(readNestedString(params.channelData, ["messageId"]));
|
||||
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
|
||||
|
||||
const replyToId = typeof params.replyToId === "string" ? params.replyToId.trim() : "";
|
||||
|
||||
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 Array.from(new Set(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 Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
async function fetchGraphCollection<T>(params: {
|
||||
url: string;
|
||||
accessToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{ status: number; items: T[] }> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: params.url,
|
||||
fetchImpl: fetchFn,
|
||||
init: {
|
||||
headers: { 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?: T[] };
|
||||
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;
|
||||
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
||||
const hosted = await fetchGraphCollection<GraphHostedContent>({
|
||||
url: `${params.messageUrl}/hostedContents`,
|
||||
accessToken: params.accessToken,
|
||||
fetchFn: params.fetchFn,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
});
|
||||
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 : "";
|
||||
if (!contentBytes) {
|
||||
continue;
|
||||
}
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(contentBytes, "base64");
|
||||
} catch {
|
||||
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 {
|
||||
// Ignore save failures.
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsGraphMediaResult> {
|
||||
if (!params.messageUrl || !params.tokenProvider) {
|
||||
return { media: [] };
|
||||
}
|
||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
|
||||
const messageUrl = params.messageUrl;
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
|
||||
} catch {
|
||||
return { media: [], messageUrl, tokenError: true };
|
||||
}
|
||||
|
||||
// Fetch the full message to get SharePoint file attachments (for group chats)
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const sharePointMedia: MSTeamsInboundMedia[] = [];
|
||||
const downloadedReferenceUrls = new Set<string>();
|
||||
try {
|
||||
const { response: msgRes, release } = await fetchWithSsrFGuard({
|
||||
url: messageUrl,
|
||||
fetchImpl: fetchFn,
|
||||
init: {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
policy: ssrfPolicy,
|
||||
auditContext: "msteams.graph.message",
|
||||
});
|
||||
try {
|
||||
if (msgRes.ok) {
|
||||
const msgData = (await msgRes.json()) as {
|
||||
body?: { content?: string; contentType?: string };
|
||||
attachments?: Array<{
|
||||
id?: string;
|
||||
contentUrl?: string;
|
||||
contentType?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Extract SharePoint file attachments (contentType: "reference")
|
||||
// Download any file type, not just images
|
||||
const spAttachments = (msgData.attachments ?? []).filter(
|
||||
(a) => a.contentType === "reference" && a.contentUrl && a.name,
|
||||
);
|
||||
for (const att of spAttachments) {
|
||||
const name = att.name ?? "file";
|
||||
|
||||
try {
|
||||
// SharePoint URLs need to be accessed via Graph shares API
|
||||
const shareUrl = att.contentUrl!;
|
||||
if (!isUrlAllowed(shareUrl, allowHosts)) {
|
||||
continue;
|
||||
}
|
||||
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
||||
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
||||
|
||||
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: sharesUrl,
|
||||
filePathHint: name,
|
||||
maxBytes: params.maxBytes,
|
||||
contentTypeHint: "application/octet-stream",
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
ssrfPolicy,
|
||||
fetchImpl: async (input, init) => {
|
||||
const requestUrl = resolveRequestUrl(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return await fetchFn(requestUrl, { ...init, headers });
|
||||
},
|
||||
});
|
||||
sharePointMedia.push(media);
|
||||
downloadedReferenceUrls.add(shareUrl);
|
||||
} catch {
|
||||
// Ignore SharePoint download failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch {
|
||||
// Ignore message fetch failures.
|
||||
}
|
||||
|
||||
const hosted = await downloadGraphHostedContent({
|
||||
accessToken,
|
||||
messageUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchFn: params.fetchFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
ssrfPolicy,
|
||||
});
|
||||
|
||||
const attachments = await fetchGraphCollection<GraphAttachment>({
|
||||
url: `${messageUrl}/attachments`,
|
||||
accessToken,
|
||||
fetchFn: params.fetchFn,
|
||||
ssrfPolicy,
|
||||
});
|
||||
|
||||
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
|
||||
const filteredAttachments =
|
||||
sharePointMedia.length > 0
|
||||
? normalizedAttachments.filter((att) => {
|
||||
const contentType = att.contentType?.toLowerCase();
|
||||
if (contentType !== "reference") {
|
||||
return true;
|
||||
}
|
||||
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
return !downloadedReferenceUrls.has(url);
|
||||
})
|
||||
: normalizedAttachments;
|
||||
const attachmentMedia = await downloadMSTeamsAttachments({
|
||||
attachments: filteredAttachments,
|
||||
maxBytes: params.maxBytes,
|
||||
tokenProvider: params.tokenProvider,
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
});
|
||||
|
||||
return {
|
||||
media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
|
||||
hostedCount: hosted.count,
|
||||
attachmentCount: filteredAttachments.length + sharePointMedia.length,
|
||||
hostedStatus: hosted.status,
|
||||
attachmentStatus: attachments.status,
|
||||
messageUrl,
|
||||
};
|
||||
}
|
||||
90
openclaw/extensions/msteams/src/attachments/html.ts
Normal file
90
openclaw/extensions/msteams/src/attachments/html.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
ATTACHMENT_TAG_RE,
|
||||
extractHtmlFromAttachment,
|
||||
extractInlineImageCandidates,
|
||||
IMG_SRC_RE,
|
||||
isLikelyImageAttachment,
|
||||
safeHostForUrl,
|
||||
} from "./shared.js";
|
||||
import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
|
||||
|
||||
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,
|
||||
): string {
|
||||
const list = Array.isArray(attachments) ? attachments : [];
|
||||
if (list.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const imageCount = list.filter(isLikelyImageAttachment).length;
|
||||
const inlineCount = extractInlineImageCandidates(list).length;
|
||||
const totalImages = imageCount + inlineCount;
|
||||
if (totalImages > 0) {
|
||||
return `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`;
|
||||
}
|
||||
const count = list.length;
|
||||
return `<media:document>${count > 1 ? ` (${count} files)` : ""}`;
|
||||
}
|
||||
14
openclaw/extensions/msteams/src/attachments/payload.ts
Normal file
14
openclaw/extensions/msteams/src/attachments/payload.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { buildMediaPayload } from "openclaw/plugin-sdk";
|
||||
|
||||
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 });
|
||||
}
|
||||
45
openclaw/extensions/msteams/src/attachments/remote-media.ts
Normal file
45
openclaw/extensions/msteams/src/attachments/remote-media.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
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>;
|
||||
|
||||
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
||||
url: string;
|
||||
filePathHint: string;
|
||||
maxBytes: number;
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
contentTypeHint?: string;
|
||||
placeholder?: string;
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsInboundMedia> {
|
||||
const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
|
||||
url: params.url,
|
||||
fetchImpl: params.fetchImpl,
|
||||
filePathHint: params.filePathHint,
|
||||
maxBytes: params.maxBytes,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
});
|
||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer: fetched.buffer,
|
||||
headerMime: fetched.contentType ?? params.contentTypeHint,
|
||||
filePath: params.filePathHint,
|
||||
});
|
||||
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
mime ?? params.contentTypeHint,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
originalFilename,
|
||||
);
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder:
|
||||
params.placeholder ??
|
||||
inferPlaceholder({ contentType: saved.contentType, fileName: params.filePathHint }),
|
||||
};
|
||||
}
|
||||
28
openclaw/extensions/msteams/src/attachments/shared.test.ts
Normal file
28
openclaw/extensions/msteams/src/attachments/shared.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isUrlAllowed,
|
||||
resolveAllowedHosts,
|
||||
resolveAuthAllowedHosts,
|
||||
resolveMediaSsrfPolicy,
|
||||
} from "./shared.js";
|
||||
|
||||
describe("msteams attachment allowlists", () => {
|
||||
it("normalizes wildcard host lists", () => {
|
||||
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
||||
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
273
openclaw/extensions/msteams/src/attachments/shared.ts
Normal file
273
openclaw/extensions/msteams/src/attachments/shared.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
||||
normalizeHostnameSuffixAllowlist,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
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;
|
||||
};
|
||||
|
||||
export 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;
|
||||
|
||||
export 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",
|
||||
"blob.core.windows.net",
|
||||
"azureedge.net",
|
||||
"microsoft.com",
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
|
||||
"api.botframework.com",
|
||||
"botframework.com",
|
||||
"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 function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return String(input);
|
||||
}
|
||||
|
||||
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 = params.contentType?.toLowerCase() ?? "";
|
||||
const name = params.fileName?.toLowerCase() ?? "";
|
||||
const fileType = params.fileType?.toLowerCase() ?? "";
|
||||
|
||||
const looksLikeImage =
|
||||
mime.startsWith("image/") || IMAGE_EXT_RE.test(name) || IMAGE_EXT_RE.test(`x.${fileType}`);
|
||||
|
||||
return looksLikeImage ? "<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 decodeDataImage(src: string): InlineImageCandidate | null {
|
||||
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const contentType = match[1]?.toLowerCase();
|
||||
const isBase64 = Boolean(match[2]);
|
||||
if (!isBase64) {
|
||||
return null;
|
||||
}
|
||||
const payload = match[3] ?? "";
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const data = Buffer.from(payload, "base64");
|
||||
return { kind: "data", data, contentType, placeholder: "<media:image>" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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[],
|
||||
): InlineImageCandidate[] {
|
||||
const out: InlineImageCandidate[] = [];
|
||||
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 decoded = decodeDataImage(src);
|
||||
if (decoded) {
|
||||
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 new URL(url).hostname.toLowerCase();
|
||||
} 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 function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
||||
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
|
||||
}
|
||||
|
||||
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
|
||||
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
|
||||
}
|
||||
37
openclaw/extensions/msteams/src/attachments/types.ts
Normal file
37
openclaw/extensions/msteams/src/attachments/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type MSTeamsAttachmentLike = {
|
||||
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;
|
||||
};
|
||||
66
openclaw/extensions/msteams/src/channel.directory.test.ts
Normal file
66
openclaw/extensions/msteams/src/channel.directory.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { msteamsPlugin } from "./channel.js";
|
||||
|
||||
describe("msteams directory", () => {
|
||||
const runtimeEnv: RuntimeEnv = {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
expect(msteamsPlugin.directory).toBeTruthy();
|
||||
expect(msteamsPlugin.directory?.listPeers).toBeTruthy();
|
||||
expect(msteamsPlugin.directory?.listGroups).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
msteamsPlugin.directory!.listPeers!({
|
||||
cfg,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "user:alice" },
|
||||
{ kind: "user", id: "user:Bob" },
|
||||
{ kind: "user", id: "user:carol" },
|
||||
{ kind: "user", id: "user:bob" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
msteamsPlugin.directory!.listGroups!({
|
||||
cfg,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "group", id: "conversation:chan1" },
|
||||
{ kind: "group", id: "conversation:chan2" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
460
openclaw/extensions/msteams/src/channel.ts
Normal file
460
openclaw/extensions/msteams/src/channel.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildBaseChannelStatusSummary,
|
||||
buildChannelConfigSchema,
|
||||
createDefaultChannelRuntimeState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
MSTeamsConfigSchema,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js";
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
import {
|
||||
normalizeMSTeamsMessagingTarget,
|
||||
normalizeMSTeamsUserInput,
|
||||
parseMSTeamsConversationId,
|
||||
parseMSTeamsTeamChannelInput,
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.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: "Bot Framework; enterprise support.",
|
||||
aliases: ["teams"],
|
||||
order: 60,
|
||||
} as const;
|
||||
|
||||
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
id: "msteams",
|
||||
meta: {
|
||||
...meta,
|
||||
aliases: [...meta.aliases],
|
||||
},
|
||||
onboarding: msteamsOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "msteamsUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
await sendMessageMSTeams({
|
||||
cfg,
|
||||
to: id,
|
||||
text: PAIRING_APPROVED_MESSAGE,
|
||||
});
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "thread"],
|
||||
polls: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"- Adaptive Cards supported. Use `action=send` with `card={type,version,body}` to send rich cards.",
|
||||
"- MSTeams targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:ID` or `user:Display Name` (requires Graph API) for DMs, `conversation:19:...@thread.tacv2` for groups/channels. Prefer IDs over display names for speed.",
|
||||
],
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.msteams"] },
|
||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||
config: {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
resolveAccount: (cfg) => ({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
enabled: cfg.channels?.msteams?.enabled !== false,
|
||||
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
||||
}),
|
||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
setAccountEnabled: ({ cfg, enabled }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
}),
|
||||
deleteAccount: ({ cfg }) => {
|
||||
const next = { ...cfg } as OpenClawConfig;
|
||||
const nextChannels = { ...cfg.channels };
|
||||
delete nextChannels.msteams;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
next.channels = nextChannels;
|
||||
} else {
|
||||
delete next.channels;
|
||||
}
|
||||
return next;
|
||||
},
|
||||
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [],
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
resolveDefaultTo: ({ cfg }) => cfg.channels?.msteams?.defaultTo?.trim() || undefined,
|
||||
},
|
||||
security: {
|
||||
collectWarnings: ({ cfg }) => {
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.msteams !== undefined,
|
||||
groupPolicy: cfg.channels?.msteams?.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
if (groupPolicy !== "open") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
applyAccountConfig: ({ cfg }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMSTeamsMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^conversation:/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^user:/i.test(trimmed)) {
|
||||
// Only treat as ID if the value after user: looks like a UUID
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(id);
|
||||
}
|
||||
return trimmed.includes("@thread");
|
||||
},
|
||||
hint: "<conversationId|user:ID|conversation:ID>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, query, limit }) => {
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== "*") {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
|
||||
const trimmed = userId.trim();
|
||||
if (trimmed) {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
|
||||
.map((raw) => {
|
||||
const lowered = raw.toLowerCase();
|
||||
if (lowered.startsWith("user:")) {
|
||||
return raw;
|
||||
}
|
||||
if (lowered.startsWith("conversation:")) {
|
||||
return raw;
|
||||
}
|
||||
return `user:${raw}`;
|
||||
})
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
},
|
||||
listGroups: async ({ cfg, query, limit }) => {
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
|
||||
for (const channelId of Object.keys(team.channels ?? {})) {
|
||||
const trimmed = channelId.trim();
|
||||
if (trimmed && trimmed !== "*") {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => raw.replace(/^conversation:/i, "").trim())
|
||||
.map((id) => `conversation:${id}`)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
},
|
||||
listPeersLive: async ({ cfg, query, limit }) =>
|
||||
listMSTeamsDirectoryPeersLive({ cfg, query, limit }),
|
||||
listGroupsLive: async ({ cfg, query, limit }) =>
|
||||
listMSTeamsDirectoryGroupsLive({ cfg, query, limit }),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, inputs, kind, runtime }) => {
|
||||
const results = inputs.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
id: undefined as string | undefined,
|
||||
name: undefined as string | undefined,
|
||||
note: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
const stripPrefix = (value: string) => normalizeMSTeamsUserInput(value);
|
||||
|
||||
if (kind === "user") {
|
||||
const pending: Array<{ input: string; query: string; index: number }> = [];
|
||||
results.forEach((entry, index) => {
|
||||
const trimmed = entry.input.trim();
|
||||
if (!trimmed) {
|
||||
entry.note = "empty input";
|
||||
return;
|
||||
}
|
||||
const cleaned = stripPrefix(trimmed);
|
||||
if (/^[0-9a-fA-F-]{16,}$/.test(cleaned) || cleaned.includes("@")) {
|
||||
entry.resolved = true;
|
||||
entry.id = cleaned;
|
||||
return;
|
||||
}
|
||||
pending.push({ input: entry.input, query: cleaned, index });
|
||||
});
|
||||
|
||||
if (pending.length > 0) {
|
||||
try {
|
||||
const resolved = await resolveMSTeamsUserAllowlist({
|
||||
cfg,
|
||||
entries: pending.map((entry) => entry.query),
|
||||
});
|
||||
resolved.forEach((entry, idx) => {
|
||||
const target = results[pending[idx]?.index ?? -1];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.resolved = entry.resolved;
|
||||
target.id = entry.id;
|
||||
target.name = entry.name;
|
||||
target.note = entry.note;
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
||||
pending.forEach(({ index }) => {
|
||||
const entry = results[index];
|
||||
if (entry) {
|
||||
entry.note = "lookup failed";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const pending: Array<{ input: string; query: string; index: number }> = [];
|
||||
results.forEach((entry, index) => {
|
||||
const trimmed = entry.input.trim();
|
||||
if (!trimmed) {
|
||||
entry.note = "empty input";
|
||||
return;
|
||||
}
|
||||
const conversationId = parseMSTeamsConversationId(trimmed);
|
||||
if (conversationId !== null) {
|
||||
entry.resolved = Boolean(conversationId);
|
||||
entry.id = conversationId || undefined;
|
||||
entry.note = conversationId ? "conversation id" : "empty conversation id";
|
||||
return;
|
||||
}
|
||||
const parsed = parseMSTeamsTeamChannelInput(trimmed);
|
||||
if (!parsed.team) {
|
||||
entry.note = "missing team";
|
||||
return;
|
||||
}
|
||||
const query = parsed.channel ? `${parsed.team}/${parsed.channel}` : parsed.team;
|
||||
pending.push({ input: entry.input, query, index });
|
||||
});
|
||||
|
||||
if (pending.length > 0) {
|
||||
try {
|
||||
const resolved = await resolveMSTeamsChannelAllowlist({
|
||||
cfg,
|
||||
entries: pending.map((entry) => entry.query),
|
||||
});
|
||||
resolved.forEach((entry, idx) => {
|
||||
const target = results[pending[idx]?.index ?? -1];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (!entry.resolved || !entry.teamId) {
|
||||
target.resolved = false;
|
||||
target.note = entry.note;
|
||||
return;
|
||||
}
|
||||
target.resolved = true;
|
||||
if (entry.channelId) {
|
||||
target.id = `${entry.teamId}/${entry.channelId}`;
|
||||
target.name =
|
||||
entry.channelName && entry.teamName
|
||||
? `${entry.teamName}/${entry.channelName}`
|
||||
: (entry.channelName ?? entry.teamName);
|
||||
} else {
|
||||
target.id = entry.teamId;
|
||||
target.name = entry.teamName;
|
||||
target.note = "team id";
|
||||
}
|
||||
if (entry.note) {
|
||||
target.note = entry.note;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
||||
pending.forEach(({ index }) => {
|
||||
const entry = results[index];
|
||||
if (entry) {
|
||||
entry.note = "lookup failed";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
listActions: ({ cfg }) => {
|
||||
const enabled =
|
||||
cfg.channels?.msteams?.enabled !== false &&
|
||||
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
|
||||
if (!enabled) {
|
||||
return [];
|
||||
}
|
||||
return ["poll"] satisfies ChannelMessageActionName[];
|
||||
},
|
||||
supportsCards: ({ cfg }) => {
|
||||
return (
|
||||
cfg.channels?.msteams?.enabled !== false &&
|
||||
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams))
|
||||
);
|
||||
},
|
||||
handleAction: async (ctx) => {
|
||||
// Handle send action with card parameter
|
||||
if (ctx.action === "send" && ctx.params.card) {
|
||||
const card = ctx.params.card as Record<string, unknown>;
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: "";
|
||||
if (!to) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: "Card send requires a target (to)." }],
|
||||
details: { error: "Card send requires a target (to)." },
|
||||
};
|
||||
}
|
||||
const result = await sendAdaptiveCardMSTeams({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
card,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
channel: "msteams",
|
||||
messageId: result.messageId,
|
||||
conversationId: result.conversationId,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: { ok: true, channel: "msteams", messageId: result.messageId },
|
||||
};
|
||||
}
|
||||
// Return null to fall through to default handler
|
||||
return null as never;
|
||||
},
|
||||
},
|
||||
outbound: msteamsOutbound,
|
||||
status: {
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
...buildBaseChannelStatusSummary(snapshot),
|
||||
port: snapshot.port ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
port: runtime?.port ?? null,
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const { monitorMSTeamsProvider } = await import("./index.js");
|
||||
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
|
||||
ctx.setStatus({ accountId: ctx.accountId, port });
|
||||
ctx.log?.info(`starting provider (port ${port})`);
|
||||
return monitorMSTeamsProvider({
|
||||
cfg: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import { msteamsRuntimeStub } from "./test-runtime.js";
|
||||
|
||||
describe("msteams conversation store (fs)", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(msteamsRuntimeStub);
|
||||
});
|
||||
|
||||
it("filters and prunes expired entries (but keeps legacy ones)", 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 store = createMSTeamsConversationStoreFs({ env, ttlMs: 1_000 });
|
||||
|
||||
const ref: StoredConversationReference = {
|
||||
conversation: { id: "19:active@thread.tacv2" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
user: { id: "u1", aadObjectId: "aad1" },
|
||||
};
|
||||
|
||||
await store.upsert("19:active@thread.tacv2", ref);
|
||||
|
||||
const filePath = path.join(stateDir, "msteams-conversations.json");
|
||||
const raw = await fs.promises.readFile(filePath, "utf-8");
|
||||
const json = JSON.parse(raw) as {
|
||||
version: number;
|
||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>;
|
||||
};
|
||||
|
||||
json.conversations["19:old@thread.tacv2"] = {
|
||||
...ref,
|
||||
conversation: { id: "19:old@thread.tacv2" },
|
||||
lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
};
|
||||
|
||||
// Legacy entry without lastSeenAt should be preserved.
|
||||
json.conversations["19:legacy@thread.tacv2"] = {
|
||||
...ref,
|
||||
conversation: { id: "19:legacy@thread.tacv2" },
|
||||
};
|
||||
|
||||
await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
||||
|
||||
const list = await store.list();
|
||||
const ids = list.map((e) => e.conversationId).toSorted();
|
||||
expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
|
||||
|
||||
expect(await store.get("19:old@thread.tacv2")).toBeNull();
|
||||
expect(await store.get("19:legacy@thread.tacv2")).not.toBeNull();
|
||||
|
||||
await store.upsert("19:new@thread.tacv2", {
|
||||
...ref,
|
||||
conversation: { id: "19:new@thread.tacv2" },
|
||||
});
|
||||
|
||||
const rawAfter = await fs.promises.readFile(filePath, "utf-8");
|
||||
const jsonAfter = JSON.parse(rawAfter) as typeof json;
|
||||
expect(Object.keys(jsonAfter.conversations).toSorted()).toEqual([
|
||||
"19:active@thread.tacv2",
|
||||
"19:legacy@thread.tacv2",
|
||||
"19:new@thread.tacv2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
165
openclaw/extensions/msteams/src/conversation-store-fs.ts
Normal file
165
openclaw/extensions/msteams/src/conversation-store-fs.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
MSTeamsConversationStoreEntry,
|
||||
StoredConversationReference,
|
||||
} from "./conversation-store.js";
|
||||
import { resolveMSTeamsStorePath } from "./storage.js";
|
||||
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
|
||||
|
||||
type ConversationStoreData = {
|
||||
version: 1;
|
||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>;
|
||||
};
|
||||
|
||||
const STORE_FILENAME = "msteams-conversations.json";
|
||||
const MAX_CONVERSATIONS = 1000;
|
||||
const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseTimestamp(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pruneToLimit(
|
||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
||||
) {
|
||||
const entries = Object.entries(conversations);
|
||||
if (entries.length <= MAX_CONVERSATIONS) {
|
||||
return conversations;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0;
|
||||
const bTs = parseTimestamp(b[1].lastSeenAt) ?? 0;
|
||||
return aTs - bTs;
|
||||
});
|
||||
|
||||
const keep = entries.slice(entries.length - MAX_CONVERSATIONS);
|
||||
return Object.fromEntries(keep);
|
||||
}
|
||||
|
||||
function pruneExpired(
|
||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
||||
nowMs: number,
|
||||
ttlMs: number,
|
||||
) {
|
||||
let removed = false;
|
||||
const kept: typeof conversations = {};
|
||||
for (const [conversationId, reference] of Object.entries(conversations)) {
|
||||
const lastSeenAt = parseTimestamp(reference.lastSeenAt);
|
||||
// Preserve legacy entries that have no lastSeenAt until they're seen again.
|
||||
if (lastSeenAt != null && nowMs - lastSeenAt > ttlMs) {
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
kept[conversationId] = reference;
|
||||
}
|
||||
return { conversations: kept, removed };
|
||||
}
|
||||
|
||||
function normalizeConversationId(raw: string): string {
|
||||
return raw.split(";")[0] ?? raw;
|
||||
}
|
||||
|
||||
export function createMSTeamsConversationStoreFs(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
ttlMs?: number;
|
||||
stateDir?: string;
|
||||
storePath?: string;
|
||||
}): MSTeamsConversationStore {
|
||||
const ttlMs = params?.ttlMs ?? CONVERSATION_TTL_MS;
|
||||
const filePath = resolveMSTeamsStorePath({
|
||||
filename: STORE_FILENAME,
|
||||
env: params?.env,
|
||||
homedir: params?.homedir,
|
||||
stateDir: params?.stateDir,
|
||||
storePath: params?.storePath,
|
||||
});
|
||||
|
||||
const empty: ConversationStoreData = { version: 1, conversations: {} };
|
||||
|
||||
const readStore = async (): Promise<ConversationStoreData> => {
|
||||
const { value } = await readJsonFile<ConversationStoreData>(filePath, empty);
|
||||
if (
|
||||
value.version !== 1 ||
|
||||
!value.conversations ||
|
||||
typeof value.conversations !== "object" ||
|
||||
Array.isArray(value.conversations)
|
||||
) {
|
||||
return empty;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const pruned = pruneExpired(value.conversations, nowMs, ttlMs).conversations;
|
||||
return { version: 1, conversations: pruneToLimit(pruned) };
|
||||
};
|
||||
|
||||
const list = async (): Promise<MSTeamsConversationStoreEntry[]> => {
|
||||
const store = await readStore();
|
||||
return Object.entries(store.conversations).map(([conversationId, reference]) => ({
|
||||
conversationId,
|
||||
reference,
|
||||
}));
|
||||
};
|
||||
|
||||
const get = async (conversationId: string): Promise<StoredConversationReference | null> => {
|
||||
const store = await readStore();
|
||||
return store.conversations[normalizeConversationId(conversationId)] ?? null;
|
||||
};
|
||||
|
||||
const findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => {
|
||||
const target = id.trim();
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
for (const entry of await list()) {
|
||||
const { conversationId, reference } = entry;
|
||||
if (reference.user?.aadObjectId === target) {
|
||||
return { conversationId, reference };
|
||||
}
|
||||
if (reference.user?.id === target) {
|
||||
return { conversationId, reference };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const upsert = async (
|
||||
conversationId: string,
|
||||
reference: StoredConversationReference,
|
||||
): Promise<void> => {
|
||||
const normalizedId = normalizeConversationId(conversationId);
|
||||
await withFileLock(filePath, empty, async () => {
|
||||
const store = await readStore();
|
||||
store.conversations[normalizedId] = {
|
||||
...reference,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
};
|
||||
const nowMs = Date.now();
|
||||
store.conversations = pruneExpired(store.conversations, nowMs, ttlMs).conversations;
|
||||
store.conversations = pruneToLimit(store.conversations);
|
||||
await writeJsonFile(filePath, store);
|
||||
});
|
||||
};
|
||||
|
||||
const remove = async (conversationId: string): Promise<boolean> => {
|
||||
const normalizedId = normalizeConversationId(conversationId);
|
||||
return await withFileLock(filePath, empty, async () => {
|
||||
const store = await readStore();
|
||||
if (!(normalizedId in store.conversations)) {
|
||||
return false;
|
||||
}
|
||||
delete store.conversations[normalizedId];
|
||||
await writeJsonFile(filePath, store);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
return { upsert, get, list, remove, findByUserId };
|
||||
}
|
||||
47
openclaw/extensions/msteams/src/conversation-store-memory.ts
Normal file
47
openclaw/extensions/msteams/src/conversation-store-memory.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
MSTeamsConversationStoreEntry,
|
||||
StoredConversationReference,
|
||||
} from "./conversation-store.js";
|
||||
|
||||
export function createMSTeamsConversationStoreMemory(
|
||||
initial: MSTeamsConversationStoreEntry[] = [],
|
||||
): MSTeamsConversationStore {
|
||||
const map = new Map<string, StoredConversationReference>();
|
||||
for (const { conversationId, reference } of initial) {
|
||||
map.set(conversationId, reference);
|
||||
}
|
||||
|
||||
return {
|
||||
upsert: async (conversationId, reference) => {
|
||||
map.set(conversationId, reference);
|
||||
},
|
||||
get: async (conversationId) => {
|
||||
return map.get(conversationId) ?? null;
|
||||
},
|
||||
list: async () => {
|
||||
return Array.from(map.entries()).map(([conversationId, reference]) => ({
|
||||
conversationId,
|
||||
reference,
|
||||
}));
|
||||
},
|
||||
remove: async (conversationId) => {
|
||||
return map.delete(conversationId);
|
||||
},
|
||||
findByUserId: async (id) => {
|
||||
const target = id.trim();
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
for (const [conversationId, reference] of map.entries()) {
|
||||
if (reference.user?.aadObjectId === target) {
|
||||
return { conversationId, reference };
|
||||
}
|
||||
if (reference.user?.id === target) {
|
||||
return { conversationId, reference };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
41
openclaw/extensions/msteams/src/conversation-store.ts
Normal file
41
openclaw/extensions/msteams/src/conversation-store.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 = {
|
||||
/** Activity ID from the last message */
|
||||
activityId?: 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 };
|
||||
/** 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;
|
||||
};
|
||||
|
||||
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>;
|
||||
findByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
|
||||
};
|
||||
108
openclaw/extensions/msteams/src/directory-live.ts
Normal file
108
openclaw/extensions/msteams/src/directory-live.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
|
||||
import { searchGraphUsers } from "./graph-users.js";
|
||||
import {
|
||||
type GraphChannel,
|
||||
type GraphGroup,
|
||||
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("/")
|
||||
? rawQuery
|
||||
.split("/", 2)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
: [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 (!name.toLowerCase().includes(channelQuery.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
kind: "group",
|
||||
id: `conversation:${channel.id}`,
|
||||
name: `${teamName}/${name}`,
|
||||
handle: `#${name}`,
|
||||
raw: channel,
|
||||
});
|
||||
if (results.length >= limit) {
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
45
openclaw/extensions/msteams/src/errors.test.ts
Normal file
45
openclaw/extensions/msteams/src/errors.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyMSTeamsSendError,
|
||||
formatMSTeamsSendErrorHint,
|
||||
formatUnknownError,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("msteams errors", () => {
|
||||
it("formats unknown errors", () => {
|
||||
expect(formatUnknownError("oops")).toBe("oops");
|
||||
expect(formatUnknownError(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("classifies auth errors", () => {
|
||||
expect(classifyMSTeamsSendError({ statusCode: 401 }).kind).toBe("auth");
|
||||
expect(classifyMSTeamsSendError({ statusCode: 403 }).kind).toBe("auth");
|
||||
});
|
||||
|
||||
it("classifies throttling errors and parses retry-after", () => {
|
||||
expect(classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5" })).toMatchObject({
|
||||
kind: "throttled",
|
||||
statusCode: 429,
|
||||
retryAfterMs: 1500,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies transient errors", () => {
|
||||
expect(classifyMSTeamsSendError({ statusCode: 503 })).toMatchObject({
|
||||
kind: "transient",
|
||||
statusCode: 503,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies permanent 4xx errors", () => {
|
||||
expect(classifyMSTeamsSendError({ statusCode: 400 })).toMatchObject({
|
||||
kind: "permanent",
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("provides actionable hints for common cases", () => {
|
||||
expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
|
||||
expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
|
||||
});
|
||||
});
|
||||
190
openclaw/extensions/msteams/src/errors.ts
Normal file
190
openclaw/extensions/msteams/src/errors.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
export function formatUnknownError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err === null) {
|
||||
return "null";
|
||||
}
|
||||
if (err === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
|
||||
return String(err);
|
||||
}
|
||||
if (typeof err === "symbol") {
|
||||
return err.description ?? err.toString();
|
||||
}
|
||||
if (typeof err === "function") {
|
||||
return err.name ? `[function ${err.name}]` : "[function]";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(err) ?? "unknown error";
|
||||
} catch {
|
||||
return "unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function extractStatusCode(err: unknown): number | null {
|
||||
if (!isRecord(err)) {
|
||||
return null;
|
||||
}
|
||||
const direct = err.statusCode ?? err.status;
|
||||
if (typeof direct === "number" && Number.isFinite(direct)) {
|
||||
return direct;
|
||||
}
|
||||
if (typeof direct === "string") {
|
||||
const parsed = Number.parseInt(direct, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const response = err.response;
|
||||
if (isRecord(response)) {
|
||||
const status = response.status;
|
||||
if (typeof status === "number" && Number.isFinite(status)) {
|
||||
return status;
|
||||
}
|
||||
if (typeof status === "string") {
|
||||
const parsed = Number.parseInt(status, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractRetryAfterMs(err: unknown): number | null {
|
||||
if (!isRecord(err)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const direct = err.retryAfterMs ?? err.retry_after_ms;
|
||||
if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const retryAfter = err.retryAfter ?? err.retry_after;
|
||||
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
|
||||
return retryAfter >= 0 ? retryAfter * 1000 : null;
|
||||
}
|
||||
if (typeof retryAfter === "string") {
|
||||
const parsed = Number.parseFloat(retryAfter);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
const response = err.response;
|
||||
if (!isRecord(response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers = response.headers;
|
||||
if (!headers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRecord(headers)) {
|
||||
const raw = headers["retry-after"] ?? headers["Retry-After"];
|
||||
if (typeof raw === "string") {
|
||||
const parsed = Number.parseFloat(raw);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Headers-like interface
|
||||
if (
|
||||
typeof headers === "object" &&
|
||||
headers !== null &&
|
||||
"get" in headers &&
|
||||
typeof (headers as { get?: unknown }).get === "function"
|
||||
) {
|
||||
const raw = (headers as { get: (name: string) => string | null }).get("retry-after");
|
||||
if (raw) {
|
||||
const parsed = Number.parseFloat(raw);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type MSTeamsSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown";
|
||||
|
||||
export type MSTeamsSendErrorClassification = {
|
||||
kind: MSTeamsSendErrorKind;
|
||||
statusCode?: number;
|
||||
retryAfterMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify outbound send errors for safe retries and actionable logs.
|
||||
*
|
||||
* Important: We only mark errors as retryable when we have an explicit HTTP
|
||||
* status code that indicates the message was not accepted (e.g. 429, 5xx).
|
||||
* For transport-level errors where delivery is ambiguous, we prefer to avoid
|
||||
* retries to reduce the chance of duplicate posts.
|
||||
*/
|
||||
export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassification {
|
||||
const statusCode = extractStatusCode(err);
|
||||
const retryAfterMs = extractRetryAfterMs(err);
|
||||
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return { kind: "auth", statusCode };
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
return {
|
||||
kind: "throttled",
|
||||
statusCode,
|
||||
retryAfterMs: retryAfterMs ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode === 408 || (statusCode != null && statusCode >= 500)) {
|
||||
return {
|
||||
kind: "transient",
|
||||
statusCode,
|
||||
retryAfterMs: retryAfterMs ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode != null && statusCode >= 400) {
|
||||
return { kind: "permanent", statusCode };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unknown",
|
||||
statusCode: statusCode ?? undefined,
|
||||
retryAfterMs: retryAfterMs ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMSTeamsSendErrorHint(
|
||||
classification: MSTeamsSendErrorClassification,
|
||||
): string | undefined {
|
||||
if (classification.kind === "auth") {
|
||||
return "check msteams appId/appPassword/tenantId (or env vars MSTEAMS_APP_ID/MSTEAMS_APP_PASSWORD/MSTEAMS_TENANT_ID)";
|
||||
}
|
||||
if (classification.kind === "throttled") {
|
||||
return "Teams throttled the bot; backing off may help";
|
||||
}
|
||||
if (classification.kind === "transient") {
|
||||
return "transient Teams/Bot Framework error; retry may succeed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
243
openclaw/extensions/msteams/src/file-consent-helpers.test.ts
Normal file
243
openclaw/extensions/msteams/src/file-consent-helpers.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.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");
|
||||
});
|
||||
});
|
||||
73
openclaw/extensions/msteams/src/file-consent-helpers.ts
Normal file
73
openclaw/extensions/msteams/src/file-consent-helpers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared helpers for FileConsentCard flow in MSTeams.
|
||||
*
|
||||
* FileConsentCard is required for:
|
||||
* - Personal (1:1) chats with large files (>=4MB)
|
||||
* - Personal chats with non-image files (PDFs, documents, etc.)
|
||||
*
|
||||
* This module consolidates the logic used by both send.ts (proactive sends)
|
||||
* and messenger.ts (reply path) to avoid duplication.
|
||||
*/
|
||||
|
||||
import { buildFileConsentCard } from "./file-consent.js";
|
||||
import { storePendingUpload } from "./pending-uploads.js";
|
||||
|
||||
export type FileConsentMedia = {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type FileConsentActivityResult = {
|
||||
activity: Record<string, unknown>;
|
||||
uploadId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepare a FileConsentCard activity for large files or non-images in personal chats.
|
||||
* Returns the activity object and uploadId - caller is responsible for sending.
|
||||
*/
|
||||
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 consentCard = buildFileConsentCard({
|
||||
filename: media.filename,
|
||||
description: description || `File: ${media.filename}`,
|
||||
sizeInBytes: media.buffer.length,
|
||||
context: { uploadId },
|
||||
});
|
||||
|
||||
const activity: Record<string, unknown> = {
|
||||
type: "message",
|
||||
attachments: [consentCard],
|
||||
};
|
||||
|
||||
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 = params.conversationType?.toLowerCase() === "personal";
|
||||
const isImage = params.contentType?.startsWith("image/") ?? false;
|
||||
const isLargeFile = params.bufferSize >= params.thresholdBytes;
|
||||
return isPersonal && (isLargeFile || !isImage);
|
||||
}
|
||||
126
openclaw/extensions/msteams/src/file-consent.ts
Normal file
126
openclaw/extensions/msteams/src/file-consent.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export 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>;
|
||||
}
|
||||
|
||||
export 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface FileConsentUploadInfo {
|
||||
name: string;
|
||||
uploadUrl: string;
|
||||
contentUrl: string;
|
||||
uniqueId: string;
|
||||
fileType: string;
|
||||
}
|
||||
|
||||
export 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.
|
||||
*/
|
||||
export async function uploadToConsentUrl(params: {
|
||||
url: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<void> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const res = await fetchFn(params.url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"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}`);
|
||||
}
|
||||
}
|
||||
1
openclaw/extensions/msteams/src/file-lock.ts
Normal file
1
openclaw/extensions/msteams/src/file-lock.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { withFileLock } from "openclaw/plugin-sdk";
|
||||
53
openclaw/extensions/msteams/src/graph-chat.ts
Normal file
53
openclaw/extensions/msteams/src/graph-chat.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Native Teams file card attachments for Bot Framework.
|
||||
*
|
||||
* The Bot Framework SDK supports `application/vnd.microsoft.teams.card.file.info`
|
||||
* content type which produces native Teams file cards.
|
||||
*
|
||||
* @see https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
|
||||
*/
|
||||
|
||||
import type { DriveItemProperties } from "./graph-upload.js";
|
||||
|
||||
/**
|
||||
* Build a native Teams file card attachment for Bot Framework.
|
||||
*
|
||||
* This uses the `application/vnd.microsoft.teams.card.file.info` content type
|
||||
* which is supported by Bot Framework and produces native Teams file cards
|
||||
* (the same display as when a user manually shares a file).
|
||||
*
|
||||
* @param file - DriveItem properties from getDriveItemProperties()
|
||||
* @returns Attachment object for Bot Framework sendActivity()
|
||||
*/
|
||||
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 ? file.name.slice(lastDot + 1).toLowerCase() : "";
|
||||
|
||||
return {
|
||||
contentType: "application/vnd.microsoft.teams.card.file.info",
|
||||
contentUrl: file.webDavUrl,
|
||||
name: file.name,
|
||||
content: {
|
||||
uniqueId,
|
||||
fileType,
|
||||
},
|
||||
};
|
||||
}
|
||||
452
openclaw/extensions/msteams/src/graph-upload.ts
Normal file
452
openclaw/extensions/msteams/src/graph-upload.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||
const GRAPH_SCOPE = "https://graph.microsoft.com";
|
||||
|
||||
export 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: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OneDriveSharingLink {
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sharing link for a OneDrive file.
|
||||
* The link allows organization members to view the file.
|
||||
*/
|
||||
export 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: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "view",
|
||||
scope: params.scope ?? "organization",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
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: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members of a Teams chat for per-user sharing.
|
||||
* Used to create sharing links scoped to only the chat participants.
|
||||
*/
|
||||
export 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: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export 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: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const respBody = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
66
openclaw/extensions/msteams/src/graph-users.test.ts
Normal file
66
openclaw/extensions/msteams/src/graph-users.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { searchGraphUsers } from "./graph-users.js";
|
||||
import { fetchGraphJson } from "./graph.js";
|
||||
|
||||
vi.mock("./graph.js", () => ({
|
||||
escapeOData: vi.fn((value: string) => value.replace(/'/g, "''")),
|
||||
fetchGraphJson: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("searchGraphUsers", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchGraphJson).mockReset();
|
||||
});
|
||||
|
||||
it("returns empty array for blank queries", async () => {
|
||||
await expect(searchGraphUsers({ token: "token-1", query: " " })).resolves.toEqual([]);
|
||||
expect(fetchGraphJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses exact mail/upn filter lookup for email-like queries", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
|
||||
value: [{ id: "user-1", displayName: "User One" }],
|
||||
} as never);
|
||||
|
||||
const result = await searchGraphUsers({
|
||||
token: "token-2",
|
||||
query: "alice.o'hara@example.com",
|
||||
});
|
||||
|
||||
expect(fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: "token-2",
|
||||
path: "/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName",
|
||||
});
|
||||
expect(result).toEqual([{ id: "user-1", displayName: "User One" }]);
|
||||
});
|
||||
|
||||
it("uses displayName search with eventual consistency and custom top", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
|
||||
value: [{ id: "user-2", displayName: "Bob" }],
|
||||
} as never);
|
||||
|
||||
const result = await searchGraphUsers({
|
||||
token: "token-3",
|
||||
query: "bob",
|
||||
top: 25,
|
||||
});
|
||||
|
||||
expect(fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: "token-3",
|
||||
path: "/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25",
|
||||
headers: { ConsistencyLevel: "eventual" },
|
||||
});
|
||||
expect(result).toEqual([{ id: "user-2", displayName: "Bob" }]);
|
||||
});
|
||||
|
||||
it("falls back to default top and empty value handling", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
|
||||
|
||||
await expect(searchGraphUsers({ token: "token-4", query: "carol" })).resolves.toEqual([]);
|
||||
expect(fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: "token-4",
|
||||
path: "/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10",
|
||||
headers: { ConsistencyLevel: "eventual" },
|
||||
});
|
||||
});
|
||||
});
|
||||
29
openclaw/extensions/msteams/src/graph-users.ts
Normal file
29
openclaw/extensions/msteams/src/graph-users.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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 ?? [];
|
||||
}
|
||||
81
openclaw/extensions/msteams/src/graph.ts
Normal file
81
openclaw/extensions/msteams/src/graph.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { MSTeamsConfig } from "openclaw/plugin-sdk";
|
||||
import { GRAPH_ROOT } from "./attachments/shared.js";
|
||||
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
export type GraphUser = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
userPrincipalName?: string;
|
||||
mail?: string;
|
||||
};
|
||||
|
||||
export type GraphGroup = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
export 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, "''");
|
||||
}
|
||||
|
||||
export async function fetchGraphJson<T>(params: {
|
||||
token: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<T> {
|
||||
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
...params.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function resolveGraphToken(cfg: unknown): Promise<string> {
|
||||
const creds = resolveMSTeamsCredentials(
|
||||
(cfg as { channels?: { msteams?: unknown } })?.channels?.msteams as MSTeamsConfig | undefined,
|
||||
);
|
||||
if (!creds) {
|
||||
throw new Error("MS Teams credentials missing");
|
||||
}
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
|
||||
const accessToken = readAccessToken(token);
|
||||
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 res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
|
||||
return res.value ?? [];
|
||||
}
|
||||
|
||||
export async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
|
||||
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
|
||||
return res.value ?? [];
|
||||
}
|
||||
66
openclaw/extensions/msteams/src/inbound.test.ts
Normal file
66
openclaw/extensions/msteams/src/inbound.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
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");
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
48
openclaw/extensions/msteams/src/inbound.ts
Normal file
48
openclaw/extensions/msteams/src/inbound.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export type MentionableActivity = {
|
||||
recipient?: { id?: string } | null;
|
||||
entities?: Array<{
|
||||
type?: string;
|
||||
mentioned?: { id?: string };
|
||||
}> | null;
|
||||
};
|
||||
|
||||
export function normalizeMSTeamsConversationId(raw: string): string {
|
||||
return raw.split(";")[0] ?? raw;
|
||||
}
|
||||
|
||||
export function 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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
4
openclaw/extensions/msteams/src/index.ts
Normal file
4
openclaw/extensions/msteams/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { monitorMSTeamsProvider } from "./monitor.js";
|
||||
export { probeMSTeams } from "./probe.js";
|
||||
export { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||
export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
|
||||
202
openclaw/extensions/msteams/src/media-helpers.test.ts
Normal file
202
openclaw/extensions/msteams/src/media-helpers.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
||||
|
||||
describe("msteams media-helpers", () => {
|
||||
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("handles data URLs", async () => {
|
||||
expect(await getMimeType("data:image/png;base64,iVBORw0KGgo=")).toBe("image/png");
|
||||
expect(await getMimeType("data:image/jpeg;base64,/9j/4AAQ")).toBe("image/jpeg");
|
||||
expect(await getMimeType("data:image/gif;base64,R0lGOD")).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("handles data URLs without base64", async () => {
|
||||
expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml");
|
||||
});
|
||||
|
||||
it("handles local paths", async () => {
|
||||
expect(await getMimeType("/tmp/image.png")).toBe("image/png");
|
||||
expect(await getMimeType("/Users/test/photo.jpg")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("handles tilde paths", async () => {
|
||||
expect(await getMimeType("~/Downloads/image.gif")).toBe("image/gif");
|
||||
});
|
||||
|
||||
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("handles data URLs", async () => {
|
||||
expect(await extractFilename("data:image/png;base64,iVBORw0KGgo=")).toBe("image.png");
|
||||
expect(await extractFilename("data:image/jpeg;base64,/9j/4AAQ")).toBe("image.jpg");
|
||||
});
|
||||
|
||||
it("handles document data URLs", async () => {
|
||||
expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf");
|
||||
});
|
||||
|
||||
it("handles local paths", async () => {
|
||||
expect(await extractFilename("/tmp/screenshot.png")).toBe("screenshot.png");
|
||||
expect(await extractFilename("/Users/test/photo.jpg")).toBe("photo.jpg");
|
||||
});
|
||||
|
||||
it("handles tilde paths", async () => {
|
||||
expect(await extractFilename("~/Downloads/image.gif")).toBe("image.gif");
|
||||
});
|
||||
|
||||
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
openclaw/extensions/msteams/src/media-helpers.ts
Normal file
105
openclaw/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 "openclaw/plugin-sdk";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
235
openclaw/extensions/msteams/src/mentions.test.ts
Normal file
235
openclaw/extensions/msteams/src/mentions.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMentionEntities, formatMentionText, parseMentions } from "./mentions.js";
|
||||
|
||||
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(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0]).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 text without mentions", () => {
|
||||
const result = parseMentions("Hello world!");
|
||||
|
||||
expect(result.text).toBe("Hello world!");
|
||||
expect(result.entities).toHaveLength(0);
|
||||
});
|
||||
|
||||
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(result.entities[0]?.mentioned.name).toBe("John Peter Smith");
|
||||
});
|
||||
|
||||
it("trims whitespace from id and name", () => {
|
||||
const result = parseMentions("@[ John Doe ]( 28:a1b2c3 )");
|
||||
|
||||
expect(result.entities[0]).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(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0]).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 = result.entities[0]?.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
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||
expect(result.entities[0]?.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(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0]?.mentioned.id).toBe("28:abc-123");
|
||||
});
|
||||
|
||||
it("accepts Bot Framework IDs with non-hex payloads (29:xxx)", () => {
|
||||
const result = parseMentions("@[Bot](29:08q2j2o3jc09au90eucae)");
|
||||
expect(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0]?.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(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0]?.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(result.entities).toHaveLength(1);
|
||||
expect(result.entities[0]?.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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMentionEntities", () => {
|
||||
it("builds entities from mention info", () => {
|
||||
const mentions = [
|
||||
{ id: "28:aaa", name: "Alice" },
|
||||
{ id: "28:bbb", name: "Bob" },
|
||||
];
|
||||
|
||||
const entities = buildMentionEntities(mentions);
|
||||
|
||||
expect(entities).toHaveLength(2);
|
||||
expect(entities[0]).toEqual({
|
||||
type: "mention",
|
||||
text: "<at>Alice</at>",
|
||||
mentioned: {
|
||||
id: "28:aaa",
|
||||
name: "Alice",
|
||||
},
|
||||
});
|
||||
expect(entities[1]).toEqual({
|
||||
type: "mention",
|
||||
text: "<at>Bob</at>",
|
||||
mentioned: {
|
||||
id: "28:bbb",
|
||||
name: "Bob",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("handles empty list", () => {
|
||||
const entities = buildMentionEntities([]);
|
||||
expect(entities).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMentionText", () => {
|
||||
it("formats text with single mention", () => {
|
||||
const text = "Hello @John!";
|
||||
const mentions = [{ id: "28:xxx", name: "John" }];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hello <at>John</at>!");
|
||||
});
|
||||
|
||||
it("formats text with multiple mentions", () => {
|
||||
const text = "Hey @Alice and @Bob";
|
||||
const mentions = [
|
||||
{ id: "28:aaa", name: "Alice" },
|
||||
{ id: "28:bbb", name: "Bob" },
|
||||
];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hey <at>Alice</at> and <at>Bob</at>");
|
||||
});
|
||||
|
||||
it("handles case-insensitive matching", () => {
|
||||
const text = "Hey @alice and @ALICE";
|
||||
const mentions = [{ id: "28:aaa", name: "Alice" }];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hey <at>Alice</at> and <at>Alice</at>");
|
||||
});
|
||||
|
||||
it("handles text without mentions", () => {
|
||||
const text = "Hello world";
|
||||
const mentions = [{ id: "28:xxx", name: "John" }];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("escapes regex metacharacters in names", () => {
|
||||
const text = "Hey @John(Test) and @Alice.Smith";
|
||||
const mentions = [
|
||||
{ id: "28:xxx", name: "John(Test)" },
|
||||
{ id: "28:yyy", name: "Alice.Smith" },
|
||||
];
|
||||
|
||||
const result = formatMentionText(text, mentions);
|
||||
|
||||
expect(result).toBe("Hey <at>John(Test)</at> and <at>Alice.Smith</at>");
|
||||
});
|
||||
});
|
||||
114
openclaw/extensions/msteams/src/mentions.ts
Normal file
114
openclaw/extensions/msteams/src/mentions.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* MS Teams mention handling utilities.
|
||||
*
|
||||
* Mentions in Teams require:
|
||||
* 1. Text containing <at>Name</at> tags
|
||||
* 2. entities array with mention metadata
|
||||
*/
|
||||
|
||||
export type MentionEntity = {
|
||||
type: "mention";
|
||||
text: string;
|
||||
mentioned: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MentionInfo = {
|
||||
/** User/bot ID (e.g., "28:xxx" or AAD object ID) */
|
||||
id: string;
|
||||
/** Display name */
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mention entities array from a list of mentions.
|
||||
* Use this when you already have the mention info and formatted text.
|
||||
*/
|
||||
export function buildMentionEntities(mentions: MentionInfo[]): MentionEntity[] {
|
||||
return mentions.map((mention) => ({
|
||||
type: "mention",
|
||||
text: `<at>${mention.name}</at>`,
|
||||
mentioned: {
|
||||
id: mention.id,
|
||||
name: mention.name,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format text with mentions using <at> tags.
|
||||
* This is a convenience function when you want to manually format mentions.
|
||||
*/
|
||||
export function formatMentionText(text: string, mentions: MentionInfo[]): string {
|
||||
let formatted = text;
|
||||
for (const mention of mentions) {
|
||||
// Replace @Name or @name with <at>Name</at>
|
||||
const escapedName = mention.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const namePattern = new RegExp(`@${escapedName}`, "gi");
|
||||
formatted = formatted.replace(namePattern, `<at>${mention.name}</at>`);
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
317
openclaw/extensions/msteams/src/messenger.test.ts
Normal file
317
openclaw/extensions/msteams/src/messenger.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
const graphUploadMockState = vi.hoisted(() => ({
|
||||
uploadAndShareOneDrive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph-upload.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./graph-upload.js")>("./graph-upload.js");
|
||||
return {
|
||||
...actual,
|
||||
uploadAndShareOneDrive: graphUploadMockState.uploadAndShareOneDrive,
|
||||
};
|
||||
});
|
||||
|
||||
import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
|
||||
import {
|
||||
type MSTeamsAdapter,
|
||||
renderReplyPayloadsToMessages,
|
||||
sendMSTeamsMessages,
|
||||
} from "./messenger.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
const chunkMarkdownText = (text: string, limit: number) => {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
if (limit <= 0 || text.length <= limit) {
|
||||
return [text];
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < text.length; index += limit) {
|
||||
chunks.push(text.slice(index, index + limit));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const runtimeStub = {
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText,
|
||||
chunkMarkdownTextWithMode: chunkMarkdownText,
|
||||
resolveMarkdownTableMode: () => "code",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
const createNoopAdapter = (): MSTeamsAdapter => ({
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
});
|
||||
|
||||
const createRecordedSendActivity = (
|
||||
sink: string[],
|
||||
failFirstWithStatusCode?: number,
|
||||
): ((activity: unknown) => Promise<{ id: string }>) => {
|
||||
let attempts = 0;
|
||||
return async (activity: unknown) => {
|
||||
const { text } = activity as { text?: string };
|
||||
const content = text ?? "";
|
||||
sink.push(content);
|
||||
attempts += 1;
|
||||
if (failFirstWithStatusCode !== undefined && attempts === 1) {
|
||||
throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode });
|
||||
}
|
||||
return { id: `id:${content}` };
|
||||
};
|
||||
};
|
||||
|
||||
describe("msteams messenger", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
graphUploadMockState.uploadAndShareOneDrive.mockReset();
|
||||
graphUploadMockState.uploadAndShareOneDrive.mockResolvedValue({
|
||||
itemId: "item123",
|
||||
webUrl: "https://onedrive.example.com/item123",
|
||||
shareUrl: "https://onedrive.example.com/share/item123",
|
||||
name: "upload.txt",
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderReplyPayloadsToMessages", () => {
|
||||
it("filters silent replies", () => {
|
||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||
textChunkLimit: 4000,
|
||||
tableMode: "code",
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not filter non-exact silent reply prefixes", () => {
|
||||
const messages = renderReplyPayloadsToMessages(
|
||||
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
|
||||
{ textChunkLimit: 4000, tableMode: "code" },
|
||||
);
|
||||
expect(messages).toEqual([{ text: `${SILENT_REPLY_TOKEN} -- ignored` }]);
|
||||
});
|
||||
|
||||
it("splits media into separate messages by default", () => {
|
||||
const messages = renderReplyPayloadsToMessages(
|
||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||
{ textChunkLimit: 4000, tableMode: "code" },
|
||||
);
|
||||
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
||||
});
|
||||
|
||||
it("supports inline media mode", () => {
|
||||
const messages = renderReplyPayloadsToMessages(
|
||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||
{ textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
|
||||
);
|
||||
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
||||
});
|
||||
|
||||
it("chunks long text when enabled", () => {
|
||||
const long = "hello ".repeat(200);
|
||||
const messages = renderReplyPayloadsToMessages([{ text: long }], {
|
||||
textChunkLimit: 50,
|
||||
tableMode: "code",
|
||||
});
|
||||
expect(messages.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMSTeamsMessages", () => {
|
||||
const baseRef: StoredConversationReference = {
|
||||
activityId: "activity123",
|
||||
user: { id: "user123", name: "User" },
|
||||
agent: { id: "bot123", name: "Bot" },
|
||||
conversation: { id: "19:abc@thread.tacv2;messageid=deadbeef" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
};
|
||||
|
||||
it("sends thread messages via the provided context", async () => {
|
||||
const sent: string[] = [];
|
||||
const ctx = {
|
||||
sendActivity: createRecordedSendActivity(sent),
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: [{ text: "one" }, { text: "two" }],
|
||||
});
|
||||
|
||||
expect(sent).toEqual(["one", "two"]);
|
||||
expect(ids).toEqual(["id:one", "id:two"]);
|
||||
});
|
||||
|
||||
it("sends top-level messages via continueConversation and strips activityId", async () => {
|
||||
const seen: { reference?: unknown; texts: string[] } = { texts: [] };
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
seen.reference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(seen.texts),
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
};
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(seen.texts).toEqual(["hello"]);
|
||||
expect(ids).toEqual(["id:hello"]);
|
||||
|
||||
const ref = seen.reference as {
|
||||
activityId?: string;
|
||||
conversation?: { id?: string };
|
||||
};
|
||||
expect(ref.activityId).toBeUndefined();
|
||||
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
|
||||
});
|
||||
|
||||
it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
|
||||
const tmpDir = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "msteams-mention-"));
|
||||
const localFile = path.join(tmpDir, "note.txt");
|
||||
await writeFile(localFile, "hello");
|
||||
|
||||
try {
|
||||
const sent: Array<{ text?: string; entities?: unknown[] }> = [];
|
||||
const ctx = {
|
||||
sendActivity: async (activity: unknown) => {
|
||||
sent.push(activity as { text?: string; entities?: unknown[] });
|
||||
return { id: "id:one" };
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: {
|
||||
...baseRef,
|
||||
conversation: {
|
||||
...baseRef.conversation,
|
||||
conversationType: "channel",
|
||||
},
|
||||
},
|
||||
context: ctx,
|
||||
messages: [{ text: "Hello @[John](29:08q2j2o3jc09au90eucae)", mediaUrl: localFile }],
|
||||
tokenProvider: {
|
||||
getAccessToken: async () => "token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ids).toEqual(["id:one"]);
|
||||
expect(graphUploadMockState.uploadAndShareOneDrive).toHaveBeenCalledOnce();
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0]?.text).toContain("Hello <at>John</at>");
|
||||
expect(sent[0]?.text).toContain(
|
||||
"📎 [upload.txt](https://onedrive.example.com/share/item123)",
|
||||
);
|
||||
expect(sent[0]?.entities).toEqual([
|
||||
{
|
||||
type: "mention",
|
||||
text: "<at>John</at>",
|
||||
mentioned: {
|
||||
id: "29:08q2j2o3jc09au90eucae",
|
||||
name: "John",
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retries thread sends on throttling (429)", async () => {
|
||||
const attempts: string[] = [];
|
||||
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
|
||||
|
||||
const ctx = {
|
||||
sendActivity: createRecordedSendActivity(attempts, 429),
|
||||
};
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: [{ text: "one" }],
|
||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
|
||||
});
|
||||
|
||||
expect(attempts).toEqual(["one", "one"]);
|
||||
expect(ids).toEqual(["id:one"]);
|
||||
expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
|
||||
});
|
||||
|
||||
it("does not retry thread sends on client errors (4xx)", async () => {
|
||||
const ctx = {
|
||||
sendActivity: async () => {
|
||||
throw Object.assign(new Error("bad request"), { statusCode: 400 });
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = createNoopAdapter();
|
||||
|
||||
await expect(
|
||||
sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: [{ text: "one" }],
|
||||
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it("retries top-level sends on transient (5xx)", async () => {
|
||||
const attempts: string[] = [];
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async (_appId, _reference, logic) => {
|
||||
await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
|
||||
},
|
||||
process: async () => {},
|
||||
};
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
messages: [{ text: "hello" }],
|
||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
});
|
||||
|
||||
expect(attempts).toEqual(["hello", "hello"]);
|
||||
expect(ids).toEqual(["id:hello"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
484
openclaw/extensions/msteams/src/messenger.ts
Normal file
484
openclaw/extensions/msteams/src/messenger.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
type ChunkMode,
|
||||
isSilentReplyText,
|
||||
loadWebMedia,
|
||||
type MarkdownTableMode,
|
||||
type MSTeamsReplyStyle,
|
||||
type ReplyPayload,
|
||||
SILENT_REPLY_TOKEN,
|
||||
sleep,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { classifyMSTeamsSendError } from "./errors.js";
|
||||
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
||||
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
||||
import {
|
||||
getDriveItemProperties,
|
||||
uploadAndShareOneDrive,
|
||||
uploadAndShareSharePoint,
|
||||
} from "./graph-upload.js";
|
||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
||||
import { parseMentions } from "./mentions.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
/**
|
||||
* MSTeams-specific media size limit (100MB).
|
||||
* Higher than the default because OneDrive upload handles large files well.
|
||||
*/
|
||||
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Threshold for large files that require FileConsentCard flow in personal chats.
|
||||
* Files >= 4MB use consent flow; smaller images can use inline base64.
|
||||
*/
|
||||
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
type SendContext = {
|
||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export type MSTeamsConversationReference = {
|
||||
activityId?: string;
|
||||
user?: { id?: string; name?: string; aadObjectId?: string };
|
||||
agent?: { id?: string; name?: string; aadObjectId?: string } | null;
|
||||
conversation: { id: string; conversationType?: string; tenantId?: string };
|
||||
channelId: string;
|
||||
serviceUrl?: string;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsAdapter = {
|
||||
continueConversation: (
|
||||
appId: string,
|
||||
reference: MSTeamsConversationReference,
|
||||
logic: (context: SendContext) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
process: (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
logic: (context: unknown) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type MSTeamsReplyRenderOptions = {
|
||||
textChunkLimit: number;
|
||||
chunkText?: boolean;
|
||||
mediaMode?: "split" | "inline";
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A rendered message that preserves media vs text distinction.
|
||||
* When mediaUrl is present, it will be sent as a Bot Framework attachment.
|
||||
*/
|
||||
export type MSTeamsRenderedMessage = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsSendRetryOptions = {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
};
|
||||
|
||||
export type MSTeamsSendRetryEvent = {
|
||||
messageIndex: number;
|
||||
messageCount: number;
|
||||
nextAttempt: number;
|
||||
maxAttempts: number;
|
||||
delayMs: number;
|
||||
classification: ReturnType<typeof classifyMSTeamsSendError>;
|
||||
};
|
||||
|
||||
function normalizeConversationId(rawId: string): string {
|
||||
return rawId.split(";")[0] ?? rawId;
|
||||
}
|
||||
|
||||
export function buildConversationReference(
|
||||
ref: StoredConversationReference,
|
||||
): MSTeamsConversationReference {
|
||||
const conversationId = ref.conversation?.id?.trim();
|
||||
if (!conversationId) {
|
||||
throw new Error("Invalid stored reference: missing conversation.id");
|
||||
}
|
||||
const agent = ref.agent ?? ref.bot ?? undefined;
|
||||
if (agent == null || !agent.id) {
|
||||
throw new Error("Invalid stored reference: missing agent.id");
|
||||
}
|
||||
const user = ref.user;
|
||||
if (!user?.id) {
|
||||
throw new Error("Invalid stored reference: missing user.id");
|
||||
}
|
||||
return {
|
||||
activityId: ref.activityId,
|
||||
user,
|
||||
agent,
|
||||
conversation: {
|
||||
id: normalizeConversationId(conversationId),
|
||||
conversationType: ref.conversation?.conversationType,
|
||||
tenantId: ref.conversation?.tenantId,
|
||||
},
|
||||
channelId: ref.channelId ?? "msteams",
|
||||
serviceUrl: ref.serviceUrl,
|
||||
locale: ref.locale,
|
||||
};
|
||||
}
|
||||
|
||||
function pushTextMessages(
|
||||
out: MSTeamsRenderedMessage[],
|
||||
text: string,
|
||||
opts: {
|
||||
chunkText: boolean;
|
||||
chunkLimit: number;
|
||||
chunkMode: ChunkMode;
|
||||
},
|
||||
) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (opts.chunkText) {
|
||||
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
opts.chunkLimit,
|
||||
opts.chunkMode,
|
||||
)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
|
||||
continue;
|
||||
}
|
||||
out.push({ text: trimmed });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
|
||||
return;
|
||||
}
|
||||
out.push({ text: trimmed });
|
||||
}
|
||||
|
||||
function clampMs(value: number, maxMs: number): number {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(value, maxMs);
|
||||
}
|
||||
|
||||
function resolveRetryOptions(
|
||||
retry: false | MSTeamsSendRetryOptions | undefined,
|
||||
): Required<MSTeamsSendRetryOptions> & { enabled: boolean } {
|
||||
if (!retry) {
|
||||
return { enabled: false, maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 };
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
maxAttempts: Math.max(1, retry?.maxAttempts ?? 3),
|
||||
baseDelayMs: Math.max(0, retry?.baseDelayMs ?? 250),
|
||||
maxDelayMs: Math.max(0, retry?.maxDelayMs ?? 10_000),
|
||||
};
|
||||
}
|
||||
|
||||
function computeRetryDelayMs(
|
||||
attempt: number,
|
||||
classification: ReturnType<typeof classifyMSTeamsSendError>,
|
||||
opts: Required<MSTeamsSendRetryOptions>,
|
||||
): number {
|
||||
if (classification.retryAfterMs != null) {
|
||||
return clampMs(classification.retryAfterMs, opts.maxDelayMs);
|
||||
}
|
||||
const exponential = opts.baseDelayMs * 2 ** Math.max(0, attempt - 1);
|
||||
return clampMs(exponential, opts.maxDelayMs);
|
||||
}
|
||||
|
||||
function shouldRetry(classification: ReturnType<typeof classifyMSTeamsSendError>): boolean {
|
||||
return classification.kind === "throttled" || classification.kind === "transient";
|
||||
}
|
||||
|
||||
export function renderReplyPayloadsToMessages(
|
||||
replies: ReplyPayload[],
|
||||
options: MSTeamsReplyRenderOptions,
|
||||
): MSTeamsRenderedMessage[] {
|
||||
const out: MSTeamsRenderedMessage[] = [];
|
||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||
const chunkText = options.chunkText !== false;
|
||||
const chunkMode = options.chunkMode ?? "length";
|
||||
const mediaMode = options.mediaMode ?? "split";
|
||||
const tableMode =
|
||||
options.tableMode ??
|
||||
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg: getMSTeamsRuntime().config.loadConfig(),
|
||||
channel: "msteams",
|
||||
});
|
||||
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
||||
payload.text ?? "",
|
||||
tableMode,
|
||||
);
|
||||
|
||||
if (!text && mediaList.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mediaMode === "inline") {
|
||||
// For inline mode, combine text with first media as attachment
|
||||
const firstMedia = mediaList[0];
|
||||
if (firstMedia) {
|
||||
out.push({ text: text || undefined, mediaUrl: firstMedia });
|
||||
// Additional media URLs as separate messages
|
||||
for (let i = 1; i < mediaList.length; i++) {
|
||||
if (mediaList[i]) {
|
||||
out.push({ mediaUrl: mediaList[i] });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// mediaMode === "split"
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
||||
for (const mediaUrl of mediaList) {
|
||||
if (!mediaUrl) {
|
||||
continue;
|
||||
}
|
||||
out.push({ mediaUrl });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildActivity(
|
||||
msg: MSTeamsRenderedMessage,
|
||||
conversationRef: StoredConversationReference,
|
||||
tokenProvider?: MSTeamsAccessTokenProvider,
|
||||
sharePointSiteId?: string,
|
||||
mediaMaxBytes?: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const activity: Record<string, unknown> = { type: "message" };
|
||||
|
||||
if (msg.text) {
|
||||
// Parse mentions from text (format: @[Name](id))
|
||||
const { text: formattedText, entities } = parseMentions(msg.text);
|
||||
activity.text = formattedText;
|
||||
|
||||
// Add mention entities if any mentions were found
|
||||
if (entities.length > 0) {
|
||||
activity.entities = entities;
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.mediaUrl) {
|
||||
let contentUrl = msg.mediaUrl;
|
||||
let contentType = await getMimeType(msg.mediaUrl);
|
||||
let fileName = await extractFilename(msg.mediaUrl);
|
||||
|
||||
if (isLocalPath(msg.mediaUrl)) {
|
||||
const maxBytes = mediaMaxBytes ?? MSTEAMS_MAX_MEDIA_BYTES;
|
||||
const media = await loadWebMedia(msg.mediaUrl, maxBytes);
|
||||
contentType = media.contentType ?? contentType;
|
||||
fileName = media.fileName ?? fileName;
|
||||
|
||||
// Determine conversation type and file type
|
||||
// Teams only accepts base64 data URLs for images
|
||||
const conversationType = conversationRef.conversation?.conversationType?.toLowerCase();
|
||||
const isPersonal = conversationType === "personal";
|
||||
const isImage = media.kind === "image";
|
||||
|
||||
if (
|
||||
requiresFileConsent({
|
||||
conversationType,
|
||||
contentType,
|
||||
bufferSize: media.buffer.length,
|
||||
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
|
||||
})
|
||||
) {
|
||||
// Large file or non-image in personal chat: use FileConsentCard flow
|
||||
const conversationId = conversationRef.conversation?.id ?? "unknown";
|
||||
const { activity: consentActivity } = prepareFileConsentActivity({
|
||||
media: { buffer: media.buffer, filename: fileName, contentType },
|
||||
conversationId,
|
||||
description: msg.text || undefined,
|
||||
});
|
||||
|
||||
// Return the consent activity (caller sends it)
|
||||
return consentActivity;
|
||||
}
|
||||
|
||||
if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) {
|
||||
// Non-image in group chat/channel with SharePoint site configured:
|
||||
// Upload to SharePoint and use native file card attachment
|
||||
const chatId = conversationRef.conversation?.id;
|
||||
|
||||
// Upload to SharePoint
|
||||
const uploaded = await uploadAndShareSharePoint({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType,
|
||||
tokenProvider,
|
||||
siteId: sharePointSiteId,
|
||||
chatId: chatId ?? undefined,
|
||||
usePerUserSharing: conversationType === "groupchat",
|
||||
});
|
||||
|
||||
// Get driveItem properties needed for native file card attachment
|
||||
const driveItem = await getDriveItemProperties({
|
||||
siteId: sharePointSiteId,
|
||||
itemId: uploaded.itemId,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
// Build native Teams file card attachment
|
||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||
activity.attachments = [fileCardAttachment];
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
if (!isPersonal && media.kind !== "image" && tokenProvider) {
|
||||
// Fallback: no SharePoint site configured, try OneDrive upload
|
||||
const uploaded = await uploadAndShareOneDrive({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
// Bot Framework doesn't support "reference" attachment type for sending
|
||||
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
||||
const existingText = typeof activity.text === "string" ? activity.text : undefined;
|
||||
activity.text = existingText ? `${existingText}\n\n${fileLink}` : fileLink;
|
||||
return activity;
|
||||
}
|
||||
|
||||
// Image (any chat): use base64 (works for images in all conversation types)
|
||||
const base64 = media.buffer.toString("base64");
|
||||
contentUrl = `data:${media.contentType};base64,${base64}`;
|
||||
}
|
||||
|
||||
activity.attachments = [
|
||||
{
|
||||
name: fileName,
|
||||
contentType,
|
||||
contentUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
export async function sendMSTeamsMessages(params: {
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
adapter: MSTeamsAdapter;
|
||||
appId: string;
|
||||
conversationRef: StoredConversationReference;
|
||||
context?: SendContext;
|
||||
messages: MSTeamsRenderedMessage[];
|
||||
retry?: false | MSTeamsSendRetryOptions;
|
||||
onRetry?: (event: MSTeamsSendRetryEvent) => void;
|
||||
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
/** SharePoint site ID for file uploads in group chats/channels */
|
||||
sharePointSiteId?: string;
|
||||
/** Max media size in bytes. Default: 100MB. */
|
||||
mediaMaxBytes?: number;
|
||||
}): Promise<string[]> {
|
||||
const messages = params.messages.filter(
|
||||
(m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
|
||||
);
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const retryOptions = resolveRetryOptions(params.retry);
|
||||
|
||||
const sendWithRetry = async (
|
||||
sendOnce: () => Promise<unknown>,
|
||||
meta: { messageIndex: number; messageCount: number },
|
||||
): Promise<unknown> => {
|
||||
if (!retryOptions.enabled) {
|
||||
return await sendOnce();
|
||||
}
|
||||
|
||||
let attempt = 1;
|
||||
while (true) {
|
||||
try {
|
||||
return await sendOnce();
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const canRetry = attempt < retryOptions.maxAttempts && shouldRetry(classification);
|
||||
if (!canRetry) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const delayMs = computeRetryDelayMs(attempt, classification, retryOptions);
|
||||
const nextAttempt = attempt + 1;
|
||||
params.onRetry?.({
|
||||
messageIndex: meta.messageIndex,
|
||||
messageCount: meta.messageCount,
|
||||
nextAttempt,
|
||||
maxAttempts: retryOptions.maxAttempts,
|
||||
delayMs,
|
||||
classification,
|
||||
});
|
||||
|
||||
await sleep(delayMs);
|
||||
attempt = nextAttempt;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessagesInContext = async (ctx: SendContext): Promise<string[]> => {
|
||||
const messageIds: string[] = [];
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const response = await sendWithRetry(
|
||||
async () =>
|
||||
await ctx.sendActivity(
|
||||
await buildActivity(
|
||||
message,
|
||||
params.conversationRef,
|
||||
params.tokenProvider,
|
||||
params.sharePointSiteId,
|
||||
params.mediaMaxBytes,
|
||||
),
|
||||
),
|
||||
{ messageIndex: idx, messageCount: messages.length },
|
||||
);
|
||||
messageIds.push(extractMessageId(response) ?? "unknown");
|
||||
}
|
||||
return messageIds;
|
||||
};
|
||||
|
||||
if (params.replyStyle === "thread") {
|
||||
const ctx = params.context;
|
||||
if (!ctx) {
|
||||
throw new Error("Missing context for replyStyle=thread");
|
||||
}
|
||||
return await sendMessagesInContext(ctx);
|
||||
}
|
||||
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
const proactiveRef: MSTeamsConversationReference = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
|
||||
const messageIds: string[] = [];
|
||||
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
|
||||
messageIds.push(...(await sendMessagesInContext(ctx)));
|
||||
});
|
||||
return messageIds;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import {
|
||||
type MSTeamsActivityHandler,
|
||||
type MSTeamsMessageHandlerDeps,
|
||||
registerMSTeamsHandlers,
|
||||
} from "./monitor-handler.js";
|
||||
import { clearPendingUploads, getPendingUpload, storePendingUpload } from "./pending-uploads.js";
|
||||
import type { MSTeamsPollStore } from "./polls.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
const fileConsentMockState = vi.hoisted(() => ({
|
||||
uploadToConsentUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./file-consent.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./file-consent.js")>("./file-consent.js");
|
||||
return {
|
||||
...actual,
|
||||
uploadToConsentUrl: fileConsentMockState.uploadToConsentUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const runtimeStub: PluginRuntime = {
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: () => ({
|
||||
enqueue: async () => {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
function createDeps(): MSTeamsMessageHandlerDeps {
|
||||
const adapter: MSTeamsAdapter = {
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
};
|
||||
const conversationStore: MSTeamsConversationStore = {
|
||||
upsert: async () => {},
|
||||
get: async () => null,
|
||||
list: async () => [],
|
||||
remove: async () => false,
|
||||
findByUserId: async () => null,
|
||||
};
|
||||
const pollStore: MSTeamsPollStore = {
|
||||
createPoll: async () => {},
|
||||
getPoll: async () => null,
|
||||
recordVote: async () => null,
|
||||
};
|
||||
return {
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv,
|
||||
appId: "test-app-id",
|
||||
adapter,
|
||||
tokenProvider: {
|
||||
getAccessToken: async () => "token",
|
||||
},
|
||||
textLimit: 4000,
|
||||
mediaMaxBytes: 8 * 1024 * 1024,
|
||||
conversationStore,
|
||||
pollStore,
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createActivityHandler(): MSTeamsActivityHandler {
|
||||
let handler: MSTeamsActivityHandler;
|
||||
handler = {
|
||||
onMessage: () => handler,
|
||||
onMembersAdded: () => handler,
|
||||
run: async () => {},
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
|
||||
function createInvokeContext(params: {
|
||||
conversationId: string;
|
||||
uploadId: string;
|
||||
action: "accept" | "decline";
|
||||
}): { context: MSTeamsTurnContext; sendActivity: ReturnType<typeof vi.fn> } {
|
||||
const sendActivity = vi.fn(async () => ({ id: "activity-id" }));
|
||||
const uploadInfo =
|
||||
params.action === "accept"
|
||||
? {
|
||||
name: "secret.txt",
|
||||
uploadUrl: "https://upload.example.com/put",
|
||||
contentUrl: "https://content.example.com/file",
|
||||
uniqueId: "unique-id",
|
||||
fileType: "txt",
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
context: {
|
||||
activity: {
|
||||
type: "invoke",
|
||||
name: "fileConsent/invoke",
|
||||
conversation: { id: params.conversationId },
|
||||
value: {
|
||||
type: "fileUpload",
|
||||
action: params.action,
|
||||
uploadInfo,
|
||||
context: { uploadId: params.uploadId },
|
||||
},
|
||||
},
|
||||
sendActivity,
|
||||
sendActivities: async () => [],
|
||||
} as unknown as MSTeamsTurnContext,
|
||||
sendActivity,
|
||||
};
|
||||
}
|
||||
|
||||
describe("msteams file consent invoke authz", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
clearPendingUploads();
|
||||
fileConsentMockState.uploadToConsentUrl.mockReset();
|
||||
fileConsentMockState.uploadToConsentUrl.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("uploads when invoke conversation matches pending upload conversation", async () => {
|
||||
const uploadId = storePendingUpload({
|
||||
buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
|
||||
filename: "secret.txt",
|
||||
contentType: "text/plain",
|
||||
conversationId: "19:victim@thread.v2",
|
||||
});
|
||||
const deps = createDeps();
|
||||
const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
|
||||
const { context, sendActivity } = createInvokeContext({
|
||||
conversationId: "19:victim@thread.v2;messageid=abc123",
|
||||
uploadId,
|
||||
action: "accept",
|
||||
});
|
||||
|
||||
await handler.run?.(context);
|
||||
|
||||
// invokeResponse should be sent immediately
|
||||
expect(sendActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "invokeResponse",
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for async upload to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://upload.example.com/put",
|
||||
}),
|
||||
);
|
||||
expect(getPendingUpload(uploadId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects cross-conversation accept invoke and keeps pending upload", async () => {
|
||||
const uploadId = storePendingUpload({
|
||||
buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
|
||||
filename: "secret.txt",
|
||||
contentType: "text/plain",
|
||||
conversationId: "19:victim@thread.v2",
|
||||
});
|
||||
const deps = createDeps();
|
||||
const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
|
||||
const { context, sendActivity } = createInvokeContext({
|
||||
conversationId: "19:attacker@thread.v2",
|
||||
uploadId,
|
||||
action: "accept",
|
||||
});
|
||||
|
||||
await handler.run?.(context);
|
||||
|
||||
// invokeResponse should be sent immediately
|
||||
expect(sendActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "invokeResponse",
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for async handler to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(sendActivity).toHaveBeenCalledWith(
|
||||
"The file upload request has expired. Please try sending the file again.",
|
||||
);
|
||||
});
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
|
||||
expect(getPendingUpload(uploadId)).toBeDefined();
|
||||
});
|
||||
|
||||
it("ignores cross-conversation decline invoke and keeps pending upload", async () => {
|
||||
const uploadId = storePendingUpload({
|
||||
buffer: Buffer.from("TOP_SECRET_VICTIM_FILE\n"),
|
||||
filename: "secret.txt",
|
||||
contentType: "text/plain",
|
||||
conversationId: "19:victim@thread.v2",
|
||||
});
|
||||
const deps = createDeps();
|
||||
const handler = registerMSTeamsHandlers(createActivityHandler(), deps);
|
||||
const { context, sendActivity } = createInvokeContext({
|
||||
conversationId: "19:attacker@thread.v2",
|
||||
uploadId,
|
||||
action: "decline",
|
||||
});
|
||||
|
||||
await handler.run?.(context);
|
||||
|
||||
// invokeResponse should be sent immediately
|
||||
expect(sendActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "invokeResponse",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fileConsentMockState.uploadToConsentUrl).not.toHaveBeenCalled();
|
||||
expect(getPendingUpload(uploadId)).toBeDefined();
|
||||
expect(sendActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
180
openclaw/extensions/msteams/src/monitor-handler.ts
Normal file
180
openclaw/extensions/msteams/src/monitor-handler.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js";
|
||||
import { normalizeMSTeamsConversationId } from "./inbound.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
|
||||
import type { MSTeamsPollStore } from "./polls.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
export type MSTeamsAccessTokenProvider = {
|
||||
getAccessToken: (scope: string) => Promise<string>;
|
||||
};
|
||||
|
||||
export type MSTeamsActivityHandler = {
|
||||
onMessage: (
|
||||
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
||||
) => MSTeamsActivityHandler;
|
||||
onMembersAdded: (
|
||||
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
||||
) => MSTeamsActivityHandler;
|
||||
run?: (context: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
export type MSTeamsMessageHandlerDeps = {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
appId: string;
|
||||
adapter: MSTeamsAdapter;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
textLimit: number;
|
||||
mediaMaxBytes: number;
|
||||
conversationStore: MSTeamsConversationStore;
|
||||
pollStore: MSTeamsPollStore;
|
||||
log: MSTeamsMonitorLogger;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle fileConsent/invoke activities for large file uploads.
|
||||
*/
|
||||
async function handleFileConsentInvoke(
|
||||
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;
|
||||
const pendingFile = getPendingUpload(uploadId);
|
||||
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 {
|
||||
// Upload file to the provided URL
|
||||
await uploadToConsentUrl({
|
||||
url: consentResponse.uploadInfo.uploadUrl,
|
||||
buffer: pendingFile.buffer,
|
||||
contentType: pendingFile.contentType,
|
||||
});
|
||||
|
||||
// Send confirmation card
|
||||
const fileInfoCard = buildFileInfoCard({
|
||||
filename: consentResponse.uploadInfo.name,
|
||||
contentUrl: consentResponse.uploadInfo.contentUrl,
|
||||
uniqueId: consentResponse.uploadInfo.uniqueId,
|
||||
fileType: consentResponse.uploadInfo.fileType,
|
||||
});
|
||||
|
||||
await context.sendActivity({
|
||||
type: "message",
|
||||
attachments: [fileInfoCard],
|
||||
});
|
||||
|
||||
log.info("file upload complete", {
|
||||
uploadId,
|
||||
filename: consentResponse.uploadInfo.name,
|
||||
uniqueId: consentResponse.uploadInfo.uniqueId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug?.("file upload failed", { uploadId, error: String(err) });
|
||||
await context.sendActivity(`File upload failed: ${String(err)}`);
|
||||
} finally {
|
||||
removePendingUpload(uploadId);
|
||||
}
|
||||
} else {
|
||||
log.debug?.("pending file not found for consent", { uploadId });
|
||||
await context.sendActivity(expiredUploadMessage);
|
||||
}
|
||||
} else {
|
||||
// User declined
|
||||
log.debug?.("user declined file consent", { uploadId });
|
||||
removePendingUpload(uploadId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
handler: T,
|
||||
deps: MSTeamsMessageHandlerDeps,
|
||||
): T {
|
||||
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
|
||||
|
||||
// Wrap the original run method to intercept invokes
|
||||
const originalRun = handler.run;
|
||||
if (originalRun) {
|
||||
handler.run = async (context: unknown) => {
|
||||
const ctx = context as MSTeamsTurnContext;
|
||||
// Handle file consent invokes before passing to normal flow
|
||||
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
|
||||
// Send invoke response IMMEDIATELY to prevent Teams timeout
|
||||
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
|
||||
|
||||
// Handle file upload asynchronously (don't await)
|
||||
handleFileConsentInvoke(ctx, deps.log).catch((err) => {
|
||||
deps.log.debug?.("file consent handler error", { error: String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
return originalRun.call(handler, context);
|
||||
};
|
||||
}
|
||||
|
||||
handler.onMessage(async (context, next) => {
|
||||
try {
|
||||
await handleTeamsMessage(context as MSTeamsTurnContext);
|
||||
} catch (err) {
|
||||
deps.runtime.error?.(`msteams handler failed: ${String(err)}`);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
handler.onMembersAdded(async (context, next) => {
|
||||
const membersAdded = (context as MSTeamsTurnContext).activity?.membersAdded ?? [];
|
||||
for (const member of membersAdded) {
|
||||
if (member.id !== (context as MSTeamsTurnContext).activity?.recipient?.id) {
|
||||
deps.log.debug?.("member added", { member: member.id });
|
||||
// Don't send welcome message - let the user initiate conversation.
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
return handler;
|
||||
}
|
||||
128
openclaw/extensions/msteams/src/monitor-handler/inbound-media.ts
Normal file
128
openclaw/extensions/msteams/src/monitor-handler/inbound-media.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
downloadMSTeamsAttachments,
|
||||
downloadMSTeamsGraphMedia,
|
||||
type MSTeamsAccessTokenProvider,
|
||||
type MSTeamsAttachmentLike,
|
||||
type MSTeamsHtmlAttachmentSummary,
|
||||
type MSTeamsInboundMedia,
|
||||
} from "../attachments.js";
|
||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||
|
||||
type MSTeamsLogger = {
|
||||
debug?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export async function resolveMSTeamsInboundMedia(params: {
|
||||
attachments: MSTeamsAttachmentLike[];
|
||||
htmlSummary?: MSTeamsHtmlAttachmentSummary;
|
||||
maxBytes: number;
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
conversationType: string;
|
||||
conversationId: string;
|
||||
conversationMessageId?: string;
|
||||
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
|
||||
log: MSTeamsLogger;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsInboundMedia[]> {
|
||||
const {
|
||||
attachments,
|
||||
htmlSummary,
|
||||
maxBytes,
|
||||
tokenProvider,
|
||||
allowHosts,
|
||||
conversationType,
|
||||
conversationId,
|
||||
conversationMessageId,
|
||||
activity,
|
||||
log,
|
||||
preserveFilenames,
|
||||
} = params;
|
||||
|
||||
let mediaList = await downloadMSTeamsAttachments({
|
||||
attachments,
|
||||
maxBytes,
|
||||
tokenProvider,
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
preserveFilenames,
|
||||
});
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
const onlyHtmlAttachments =
|
||||
attachments.length > 0 &&
|
||||
attachments.every((att) => String(att.contentType ?? "").startsWith("text/html"));
|
||||
|
||||
if (onlyHtmlAttachments) {
|
||||
const messageUrls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType,
|
||||
conversationId,
|
||||
messageId: activity.id ?? undefined,
|
||||
replyToId: activity.replyToId ?? undefined,
|
||||
conversationMessageId,
|
||||
channelData: activity.channelData,
|
||||
});
|
||||
if (messageUrls.length === 0) {
|
||||
log.debug?.("graph message url unavailable", {
|
||||
conversationType,
|
||||
hasChannelData: Boolean(activity.channelData),
|
||||
messageId: activity.id ?? undefined,
|
||||
replyToId: activity.replyToId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const attempts: Array<{
|
||||
url: string;
|
||||
hostedStatus?: number;
|
||||
attachmentStatus?: number;
|
||||
hostedCount?: number;
|
||||
attachmentCount?: number;
|
||||
tokenError?: boolean;
|
||||
}> = [];
|
||||
for (const messageUrl of messageUrls) {
|
||||
const graphMedia = await downloadMSTeamsGraphMedia({
|
||||
messageUrl,
|
||||
tokenProvider,
|
||||
maxBytes,
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
preserveFilenames,
|
||||
});
|
||||
attempts.push({
|
||||
url: messageUrl,
|
||||
hostedStatus: graphMedia.hostedStatus,
|
||||
attachmentStatus: graphMedia.attachmentStatus,
|
||||
hostedCount: graphMedia.hostedCount,
|
||||
attachmentCount: graphMedia.attachmentCount,
|
||||
tokenError: graphMedia.tokenError,
|
||||
});
|
||||
if (graphMedia.media.length > 0) {
|
||||
mediaList = graphMedia.media;
|
||||
break;
|
||||
}
|
||||
if (graphMedia.tokenError) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mediaList.length === 0) {
|
||||
log.debug?.("graph media fetch empty", { attempts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
log.debug?.("downloaded attachments", { count: mediaList.length });
|
||||
} else if (htmlSummary?.imgTags) {
|
||||
log.debug?.("inline images detected but none downloaded", {
|
||||
imgTags: htmlSummary.imgTags,
|
||||
srcHosts: htmlSummary.srcHosts,
|
||||
dataImages: htmlSummary.dataImages,
|
||||
cidImages: htmlSummary.cidImages,
|
||||
});
|
||||
}
|
||||
|
||||
return mediaList;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
|
||||
import { setMSTeamsRuntime } from "../runtime.js";
|
||||
import { createMSTeamsMessageHandler } from "./message-handler.js";
|
||||
|
||||
describe("msteams monitor handler authz", () => {
|
||||
it("does not treat DM pairing-store entries as group allowlist entries", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => ["attacker-aad"]);
|
||||
setMSTeamsRuntime({
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: <T>(params: {
|
||||
onFlush: (entries: T[]) => Promise<void>;
|
||||
}): { enqueue: (entry: T) => Promise<void> } => ({
|
||||
enqueue: async (entry: T) => {
|
||||
await params.onFlush([entry]);
|
||||
},
|
||||
}),
|
||||
},
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest: vi.fn(async () => null),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const conversationStore = {
|
||||
upsert: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
const deps: MSTeamsMessageHandlerDeps = {
|
||||
cfg: {
|
||||
channels: {
|
||||
msteams: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
|
||||
appId: "test-app",
|
||||
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
|
||||
tokenProvider: {
|
||||
getAccessToken: vi.fn(async () => "token"),
|
||||
},
|
||||
textLimit: 4000,
|
||||
mediaMaxBytes: 1024 * 1024,
|
||||
conversationStore:
|
||||
conversationStore as unknown as MSTeamsMessageHandlerDeps["conversationStore"],
|
||||
pollStore: {
|
||||
recordVote: vi.fn(async () => null),
|
||||
} as unknown as MSTeamsMessageHandlerDeps["pollStore"],
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as MSTeamsMessageHandlerDeps["log"],
|
||||
};
|
||||
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
await handler({
|
||||
activity: {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
text: "",
|
||||
from: {
|
||||
id: "attacker-id",
|
||||
aadObjectId: "attacker-aad",
|
||||
name: "Attacker",
|
||||
},
|
||||
recipient: {
|
||||
id: "bot-id",
|
||||
name: "Bot",
|
||||
},
|
||||
conversation: {
|
||||
id: "19:group@thread.tacv2",
|
||||
conversationType: "groupChat",
|
||||
},
|
||||
channelData: {},
|
||||
attachments: [],
|
||||
},
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledWith({
|
||||
channel: "msteams",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(conversationStore.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,691 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
createScopedPairingAccess,
|
||||
logInboundDrop,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
resolveControlCommandGate,
|
||||
resolveDefaultGroupPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveMentionGating,
|
||||
formatAllowlistMatchMeta,
|
||||
resolveEffectiveAllowFromLists,
|
||||
resolveDmGroupAccessWithLists,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
buildMSTeamsMediaPayload,
|
||||
type MSTeamsAttachmentLike,
|
||||
summarizeMSTeamsHtmlAttachments,
|
||||
} from "../attachments.js";
|
||||
import type { StoredConversationReference } from "../conversation-store.js";
|
||||
import { formatUnknownError } from "../errors.js";
|
||||
import {
|
||||
extractMSTeamsConversationMessageId,
|
||||
normalizeMSTeamsConversationId,
|
||||
parseMSTeamsActivityTimestamp,
|
||||
stripMSTeamsMentionTags,
|
||||
wasMSTeamsBotMentioned,
|
||||
} from "../inbound.js";
|
||||
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
|
||||
import {
|
||||
isMSTeamsGroupAllowed,
|
||||
resolveMSTeamsAllowlistMatch,
|
||||
resolveMSTeamsReplyPolicy,
|
||||
resolveMSTeamsRouteConfig,
|
||||
} from "../policy.js";
|
||||
import { extractMSTeamsPollVote } from "../polls.js";
|
||||
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
|
||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||
|
||||
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
const {
|
||||
cfg,
|
||||
runtime,
|
||||
appId,
|
||||
adapter,
|
||||
tokenProvider,
|
||||
textLimit,
|
||||
mediaMaxBytes,
|
||||
conversationStore,
|
||||
pollStore,
|
||||
log,
|
||||
} = deps;
|
||||
const core = getMSTeamsRuntime();
|
||||
const pairing = createScopedPairingAccess({
|
||||
core,
|
||||
channel: "msteams",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
log.debug?.(message);
|
||||
}
|
||||
};
|
||||
const msteamsCfg = cfg.channels?.msteams;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
msteamsCfg?.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const conversationHistories = new Map<string, HistoryEntry[]>();
|
||||
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
});
|
||||
|
||||
type MSTeamsDebounceEntry = {
|
||||
context: MSTeamsTurnContext;
|
||||
rawText: string;
|
||||
text: string;
|
||||
attachments: MSTeamsAttachmentLike[];
|
||||
wasMentioned: boolean;
|
||||
implicitMention: boolean;
|
||||
};
|
||||
|
||||
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
|
||||
const context = params.context;
|
||||
const activity = context.activity;
|
||||
const rawText = params.rawText;
|
||||
const text = params.text;
|
||||
const attachments = params.attachments;
|
||||
const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments);
|
||||
const rawBody = text || attachmentPlaceholder;
|
||||
const from = activity.from;
|
||||
const conversation = activity.conversation;
|
||||
|
||||
const attachmentTypes = attachments
|
||||
.map((att) => (typeof att.contentType === "string" ? att.contentType : undefined))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
|
||||
|
||||
log.info("received message", {
|
||||
rawText: rawText.slice(0, 50),
|
||||
text: text.slice(0, 50),
|
||||
attachments: attachments.length,
|
||||
attachmentTypes,
|
||||
from: from?.id,
|
||||
conversation: conversation?.id,
|
||||
});
|
||||
if (htmlSummary) {
|
||||
log.debug?.("html attachment summary", htmlSummary);
|
||||
}
|
||||
|
||||
if (!from?.id) {
|
||||
log.debug?.("skipping message without from.id");
|
||||
return;
|
||||
}
|
||||
|
||||
// Teams conversation.id may include ";messageid=..." suffix - strip it for session key.
|
||||
const rawConversationId = conversation?.id ?? "";
|
||||
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
|
||||
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
|
||||
const conversationType = conversation?.conversationType ?? "personal";
|
||||
const isGroupChat = conversationType === "groupChat" || conversation?.isGroup === true;
|
||||
const isChannel = conversationType === "channel";
|
||||
const isDirectMessage = !isGroupChat && !isChannel;
|
||||
|
||||
const senderName = from.name ?? from.id;
|
||||
const senderId = from.aadObjectId ?? from.id;
|
||||
const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
|
||||
const storedAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "msteams",
|
||||
accountId: pairing.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
|
||||
// Check DM policy for direct messages.
|
||||
const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
|
||||
const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v));
|
||||
const groupAllowFrom = msteamsCfg?.groupAllowFrom;
|
||||
const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
|
||||
allowFrom: configuredDmAllowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom: storedAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const groupPolicy =
|
||||
!isDirectMessage && msteamsCfg
|
||||
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
|
||||
: "disabled";
|
||||
const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
|
||||
const teamId = activity.channelData?.team?.id;
|
||||
const teamName = activity.channelData?.team?.name;
|
||||
const channelName = activity.channelData?.channel?.name;
|
||||
const channelGate = resolveMSTeamsRouteConfig({
|
||||
cfg: msteamsCfg,
|
||||
teamId,
|
||||
teamName,
|
||||
conversationId,
|
||||
channelName,
|
||||
});
|
||||
const senderGroupPolicy =
|
||||
groupPolicy === "disabled"
|
||||
? "disabled"
|
||||
: effectiveGroupAllowFrom.length > 0
|
||||
? "allowlist"
|
||||
: "open";
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: !isDirectMessage,
|
||||
dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
allowFrom: configuredDmAllowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom: storedAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveMSTeamsAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
}).allowed,
|
||||
});
|
||||
const effectiveDmAllowFrom = access.effectiveAllowFrom;
|
||||
|
||||
if (isDirectMessage && msteamsCfg && access.decision !== "allow") {
|
||||
if (access.reason === "dmPolicy=disabled") {
|
||||
log.debug?.("dropping dm (dms disabled)");
|
||||
return;
|
||||
}
|
||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||
allowFrom: effectiveDmAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
});
|
||||
if (access.decision === "pairing") {
|
||||
const request = await pairing.upsertPairingRequest({
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
if (request) {
|
||||
log.info("msteams pairing request created", {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
});
|
||||
}
|
||||
}
|
||||
log.debug?.("dropping dm (not allowlisted)", {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDirectMessage && msteamsCfg) {
|
||||
if (groupPolicy === "disabled") {
|
||||
log.debug?.("dropping group message (groupPolicy: disabled)", {
|
||||
conversationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (channelGate.allowlistConfigured && !channelGate.allowed) {
|
||||
log.debug?.("dropping group message (not in team/channel allowlist)", {
|
||||
conversationId,
|
||||
teamKey: channelGate.teamKey ?? "none",
|
||||
channelKey: channelGate.channelKey ?? "none",
|
||||
channelMatchKey: channelGate.channelMatchKey ?? "none",
|
||||
channelMatchSource: channelGate.channelMatchSource ?? "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) {
|
||||
log.debug?.("dropping group message (groupPolicy: allowlist, no allowlist)", {
|
||||
conversationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (effectiveGroupAllowFrom.length > 0 && access.decision !== "allow") {
|
||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
});
|
||||
if (!allowMatch.allowed) {
|
||||
log.debug?.("dropping group message (not in groupAllowFrom)", {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom;
|
||||
const ownerAllowedForCommands = isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: commandDmAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
});
|
||||
const groupAllowedForCommands = isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
});
|
||||
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||
],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
if (commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: logVerboseMessage,
|
||||
channel: "msteams",
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build conversation reference for proactive replies.
|
||||
const agent = activity.recipient;
|
||||
const conversationRef: StoredConversationReference = {
|
||||
activityId: activity.id,
|
||||
user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
|
||||
agent,
|
||||
bot: agent ? { id: agent.id, name: agent.name } : undefined,
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
conversationType,
|
||||
tenantId: conversation?.tenantId,
|
||||
},
|
||||
teamId,
|
||||
channelId: activity.channelId,
|
||||
serviceUrl: activity.serviceUrl,
|
||||
locale: activity.locale,
|
||||
};
|
||||
conversationStore.upsert(conversationId, conversationRef).catch((err) => {
|
||||
log.debug?.("failed to save conversation reference", {
|
||||
error: formatUnknownError(err),
|
||||
});
|
||||
});
|
||||
|
||||
const pollVote = extractMSTeamsPollVote(activity);
|
||||
if (pollVote) {
|
||||
try {
|
||||
const poll = await pollStore.recordVote({
|
||||
pollId: pollVote.pollId,
|
||||
voterId: senderId,
|
||||
selections: pollVote.selections,
|
||||
});
|
||||
if (!poll) {
|
||||
log.debug?.("poll vote ignored (poll not found)", {
|
||||
pollId: pollVote.pollId,
|
||||
});
|
||||
} else {
|
||||
log.info("recorded poll vote", {
|
||||
pollId: pollVote.pollId,
|
||||
voter: senderId,
|
||||
selections: pollVote.selections,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("failed to record poll vote", {
|
||||
pollId: pollVote.pollId,
|
||||
error: formatUnknownError(err),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawBody) {
|
||||
log.debug?.("skipping empty message after stripping mentions");
|
||||
return;
|
||||
}
|
||||
|
||||
const teamsFrom = isDirectMessage
|
||||
? `msteams:${senderId}`
|
||||
: isChannel
|
||||
? `msteams:channel:${conversationId}`
|
||||
: `msteams:group:${conversationId}`;
|
||||
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
||||
id: isDirectMessage ? senderId : conversationId,
|
||||
},
|
||||
});
|
||||
|
||||
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isDirectMessage
|
||||
? `Teams DM from ${senderName}`
|
||||
: `Teams message in ${conversationType} from ${senderName}`;
|
||||
|
||||
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
|
||||
});
|
||||
|
||||
const channelId = conversationId;
|
||||
const { teamConfig, channelConfig } = channelGate;
|
||||
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage,
|
||||
globalConfig: msteamsCfg,
|
||||
teamConfig,
|
||||
channelConfig,
|
||||
});
|
||||
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
|
||||
|
||||
if (!isDirectMessage) {
|
||||
const mentionGate = resolveMentionGating({
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention: true,
|
||||
wasMentioned: params.wasMentioned,
|
||||
implicitMention: params.implicitMention,
|
||||
shouldBypassMention: false,
|
||||
});
|
||||
const mentioned = mentionGate.effectiveWasMentioned;
|
||||
if (requireMention && mentionGate.shouldSkip) {
|
||||
log.debug?.("skipping message (mention required)", {
|
||||
teamId,
|
||||
channelId,
|
||||
requireMention,
|
||||
mentioned,
|
||||
});
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: conversationHistories,
|
||||
historyKey: conversationId,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: senderName,
|
||||
body: rawBody,
|
||||
timestamp: timestamp?.getTime(),
|
||||
messageId: activity.id ?? undefined,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const mediaList = await resolveMSTeamsInboundMedia({
|
||||
attachments,
|
||||
htmlSummary: htmlSummary ?? undefined,
|
||||
maxBytes: mediaMaxBytes,
|
||||
tokenProvider,
|
||||
allowHosts: msteamsCfg?.mediaAllowHosts,
|
||||
authAllowHosts: msteamsCfg?.mediaAuthAllowHosts,
|
||||
conversationType,
|
||||
conversationId,
|
||||
conversationMessageId: conversationMessageId ?? undefined,
|
||||
activity: {
|
||||
id: activity.id,
|
||||
replyToId: activity.replyToId,
|
||||
channelData: activity.channelData,
|
||||
},
|
||||
log,
|
||||
preserveFilenames: (cfg as { media?: { preserveFilenames?: boolean } }).media
|
||||
?.preserveFilenames,
|
||||
});
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: envelopeFrom,
|
||||
timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
let combinedBody = body;
|
||||
const isRoomish = !isDirectMessage;
|
||||
const historyKey = isRoomish ? conversationId : undefined;
|
||||
if (isRoomish && historyKey) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: conversationHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Teams",
|
||||
from: conversationType,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const inboundHistory =
|
||||
isRoomish && historyKey && historyLimit > 0
|
||||
? (conversationHistories.get(historyKey) ?? []).map((entry) => ({
|
||||
sender: entry.sender,
|
||||
body: entry.body,
|
||||
timestamp: entry.timestamp,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: rawBody,
|
||||
InboundHistory: inboundHistory,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: teamsFrom,
|
||||
To: teamsTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
|
||||
ConversationLabel: envelopeFrom,
|
||||
GroupSubject: !isDirectMessage ? conversationType : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
Provider: "msteams" as const,
|
||||
Surface: "msteams" as const,
|
||||
MessageSid: activity.id,
|
||||
Timestamp: timestamp?.getTime() ?? Date.now(),
|
||||
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "msteams" as const,
|
||||
OriginatingTo: teamsTo,
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
await core.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
onRecordError: (err) => {
|
||||
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||
|
||||
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
runtime,
|
||||
log,
|
||||
adapter,
|
||||
appId,
|
||||
conversationRef,
|
||||
context,
|
||||
replyStyle,
|
||||
textLimit,
|
||||
onSentMessageIds: (ids) => {
|
||||
for (const id of ids) {
|
||||
recordMSTeamsSentMessage(conversationId, id);
|
||||
}
|
||||
},
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
});
|
||||
|
||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||
try {
|
||||
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
}),
|
||||
});
|
||||
|
||||
log.info("dispatch complete", { queuedFinal, counts });
|
||||
|
||||
if (!queuedFinal) {
|
||||
if (isRoomish && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: conversationHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
|
||||
);
|
||||
if (isRoomish && historyKey) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: conversationHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("dispatch failed", { error: String(err) });
|
||||
runtime.error?.(`msteams dispatch failed: ${String(err)}`);
|
||||
try {
|
||||
await context.sendActivity(
|
||||
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} catch {
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<MSTeamsDebounceEntry>({
|
||||
debounceMs: inboundDebounceMs,
|
||||
buildKey: (entry) => {
|
||||
const conversationId = normalizeMSTeamsConversationId(
|
||||
entry.context.activity.conversation?.id ?? "",
|
||||
);
|
||||
const senderId =
|
||||
entry.context.activity.from?.aadObjectId ?? entry.context.activity.from?.id ?? "";
|
||||
if (!senderId || !conversationId) {
|
||||
return null;
|
||||
}
|
||||
return `msteams:${appId}:${conversationId}:${senderId}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
if (!entry.text.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (entry.attachments.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return !core.channel.text.hasControlCommand(entry.text, cfg);
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await handleTeamsMessageNow(last);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.text)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!combinedText.trim()) {
|
||||
return;
|
||||
}
|
||||
const combinedRawText = entries
|
||||
.map((entry) => entry.rawText)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const wasMentioned = entries.some((entry) => entry.wasMentioned);
|
||||
const implicitMention = entries.some((entry) => entry.implicitMention);
|
||||
await handleTeamsMessageNow({
|
||||
context: last.context,
|
||||
rawText: combinedRawText,
|
||||
text: combinedText,
|
||||
attachments: [],
|
||||
wasMentioned,
|
||||
implicitMention,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(`msteams debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
return async function handleTeamsMessage(context: MSTeamsTurnContext) {
|
||||
const activity = context.activity;
|
||||
const rawText = activity.text?.trim() ?? "";
|
||||
const text = stripMSTeamsMentionTags(rawText);
|
||||
const attachments = Array.isArray(activity.attachments)
|
||||
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
|
||||
: [];
|
||||
const wasMentioned = wasMSTeamsBotMentioned(activity);
|
||||
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
|
||||
const replyToId = activity.replyToId ?? undefined;
|
||||
const implicitMention = Boolean(
|
||||
conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId),
|
||||
);
|
||||
|
||||
await inboundDebouncer.enqueue({
|
||||
context,
|
||||
rawText,
|
||||
text,
|
||||
attachments,
|
||||
wasMentioned,
|
||||
implicitMention,
|
||||
});
|
||||
};
|
||||
}
|
||||
5
openclaw/extensions/msteams/src/monitor-types.ts
Normal file
5
openclaw/extensions/msteams/src/monitor-types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type MSTeamsMonitorLogger = {
|
||||
debug?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
info: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
305
openclaw/extensions/msteams/src/monitor.ts
Normal file
305
openclaw/extensions/msteams/src/monitor.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
DEFAULT_WEBHOOK_MAX_BODY_BYTES,
|
||||
mergeAllowlist,
|
||||
summarizeMapping,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { registerMSTeamsHandlers, type MSTeamsActivityHandler } from "./monitor-handler.js";
|
||||
import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js";
|
||||
import {
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
export type MonitorMSTeamsOpts = {
|
||||
cfg: OpenClawConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
conversationStore?: MSTeamsConversationStore;
|
||||
pollStore?: MSTeamsPollStore;
|
||||
};
|
||||
|
||||
export type MonitorMSTeamsResult = {
|
||||
app: unknown;
|
||||
shutdown: () => Promise<void>;
|
||||
};
|
||||
|
||||
const MSTEAMS_WEBHOOK_MAX_BODY_BYTES = DEFAULT_WEBHOOK_MAX_BODY_BYTES;
|
||||
|
||||
export async function monitorMSTeamsProvider(
|
||||
opts: MonitorMSTeamsOpts,
|
||||
): Promise<MonitorMSTeamsResult> {
|
||||
const core = getMSTeamsRuntime();
|
||||
const log = core.logging.getChildLogger({ name: "msteams" });
|
||||
let cfg = opts.cfg;
|
||||
let msteamsCfg = cfg.channels?.msteams;
|
||||
if (!msteamsCfg?.enabled) {
|
||||
log.debug?.("msteams provider disabled");
|
||||
return { app: null, shutdown: async () => {} };
|
||||
}
|
||||
|
||||
const creds = resolveMSTeamsCredentials(msteamsCfg);
|
||||
if (!creds) {
|
||||
log.error("msteams credentials not configured");
|
||||
return { app: null, shutdown: async () => {} };
|
||||
}
|
||||
const appId = creds.appId; // Extract for use in closures
|
||||
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
let allowFrom = msteamsCfg.allowFrom;
|
||||
let groupAllowFrom = msteamsCfg.groupAllowFrom;
|
||||
let teamsConfig = msteamsCfg.teams;
|
||||
|
||||
const cleanAllowEntry = (entry: string) =>
|
||||
entry
|
||||
.replace(/^(msteams|teams):/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.trim();
|
||||
|
||||
const resolveAllowlistUsers = async (label: string, entries: string[]) => {
|
||||
if (entries.length === 0) {
|
||||
return { additions: [], unresolved: [] };
|
||||
}
|
||||
const resolved = await resolveMSTeamsUserAllowlist({ cfg, entries });
|
||||
const additions: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.id) {
|
||||
additions.push(entry.id);
|
||||
} else {
|
||||
unresolved.push(entry.input);
|
||||
}
|
||||
}
|
||||
const mapping = resolved
|
||||
.filter((entry) => entry.resolved && entry.id)
|
||||
.map((entry) => `${entry.input}→${entry.id}`);
|
||||
summarizeMapping(label, mapping, unresolved, runtime);
|
||||
return { additions, unresolved };
|
||||
};
|
||||
|
||||
try {
|
||||
const allowEntries =
|
||||
allowFrom
|
||||
?.map((entry) => cleanAllowEntry(String(entry)))
|
||||
.filter((entry) => entry && entry !== "*") ?? [];
|
||||
if (allowEntries.length > 0) {
|
||||
const { additions } = await resolveAllowlistUsers("msteams users", allowEntries);
|
||||
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
||||
}
|
||||
|
||||
if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) {
|
||||
const groupEntries = groupAllowFrom
|
||||
.map((entry) => cleanAllowEntry(String(entry)))
|
||||
.filter((entry) => entry && entry !== "*");
|
||||
if (groupEntries.length > 0) {
|
||||
const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries);
|
||||
groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions });
|
||||
}
|
||||
}
|
||||
|
||||
if (teamsConfig && Object.keys(teamsConfig).length > 0) {
|
||||
const entries: Array<{ input: string; teamKey: string; channelKey?: string }> = [];
|
||||
for (const [teamKey, teamCfg] of Object.entries(teamsConfig)) {
|
||||
if (teamKey === "*") {
|
||||
continue;
|
||||
}
|
||||
const channels = teamCfg?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
|
||||
if (channelKeys.length === 0) {
|
||||
entries.push({ input: teamKey, teamKey });
|
||||
continue;
|
||||
}
|
||||
for (const channelKey of channelKeys) {
|
||||
entries.push({
|
||||
input: `${teamKey}/${channelKey}`,
|
||||
teamKey,
|
||||
channelKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
const resolved = await resolveMSTeamsChannelAllowlist({
|
||||
cfg,
|
||||
entries: entries.map((entry) => entry.input),
|
||||
});
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const nextTeams = { ...teamsConfig };
|
||||
|
||||
resolved.forEach((entry, idx) => {
|
||||
const source = entries[idx];
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
const sourceTeam = teamsConfig?.[source.teamKey] ?? {};
|
||||
if (!entry.resolved || !entry.teamId) {
|
||||
unresolved.push(entry.input);
|
||||
return;
|
||||
}
|
||||
mapping.push(
|
||||
entry.channelId
|
||||
? `${entry.input}→${entry.teamId}/${entry.channelId}`
|
||||
: `${entry.input}→${entry.teamId}`,
|
||||
);
|
||||
const existing = nextTeams[entry.teamId] ?? {};
|
||||
const mergedChannels = {
|
||||
...sourceTeam.channels,
|
||||
...existing.channels,
|
||||
};
|
||||
const mergedTeam = { ...sourceTeam, ...existing, channels: mergedChannels };
|
||||
nextTeams[entry.teamId] = mergedTeam;
|
||||
if (source.channelKey && entry.channelId) {
|
||||
const sourceChannel = sourceTeam.channels?.[source.channelKey];
|
||||
if (sourceChannel) {
|
||||
nextTeams[entry.teamId] = {
|
||||
...mergedTeam,
|
||||
channels: {
|
||||
...mergedChannels,
|
||||
[entry.channelId]: {
|
||||
...sourceChannel,
|
||||
...mergedChannels?.[entry.channelId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
teamsConfig = nextTeams;
|
||||
summarizeMapping("msteams channels", mapping, unresolved, runtime);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.log?.(`msteams resolve failed; using config entries. ${String(err)}`);
|
||||
}
|
||||
|
||||
msteamsCfg = {
|
||||
...msteamsCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
teams: teamsConfig,
|
||||
};
|
||||
cfg = {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: msteamsCfg,
|
||||
},
|
||||
};
|
||||
|
||||
const port = msteamsCfg.webhook?.port ?? 3978;
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams");
|
||||
const MB = 1024 * 1024;
|
||||
const agentDefaults = cfg.agents?.defaults;
|
||||
const mediaMaxBytes =
|
||||
typeof agentDefaults?.mediaMaxMb === "number" && agentDefaults.mediaMaxMb > 0
|
||||
? Math.floor(agentDefaults.mediaMaxMb * MB)
|
||||
: 8 * MB;
|
||||
const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs();
|
||||
const pollStore = opts.pollStore ?? createMSTeamsPollStoreFs();
|
||||
|
||||
log.info(`starting provider (port ${port})`);
|
||||
|
||||
// Dynamic import to avoid loading SDK when provider is disabled
|
||||
const express = await import("express");
|
||||
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const { ActivityHandler, MsalTokenProvider, authorizeJWT } = sdk;
|
||||
|
||||
// Auth configuration - create early so adapter is available for deliverReplies
|
||||
const tokenProvider = new MsalTokenProvider(authConfig);
|
||||
const adapter = createMSTeamsAdapter(authConfig, sdk);
|
||||
|
||||
const handler = registerMSTeamsHandlers(new ActivityHandler() as MSTeamsActivityHandler, {
|
||||
cfg,
|
||||
runtime,
|
||||
appId,
|
||||
adapter: adapter as unknown as MSTeamsAdapter,
|
||||
tokenProvider,
|
||||
textLimit,
|
||||
mediaMaxBytes,
|
||||
conversationStore,
|
||||
pollStore,
|
||||
log,
|
||||
});
|
||||
|
||||
// Create Express server
|
||||
const expressApp = express.default();
|
||||
expressApp.use(express.json({ limit: MSTEAMS_WEBHOOK_MAX_BODY_BYTES }));
|
||||
expressApp.use((err: unknown, _req: Request, res: Response, next: (err?: unknown) => void) => {
|
||||
if (err && typeof err === "object" && "status" in err && err.status === 413) {
|
||||
res.status(413).json({ error: "Payload too large" });
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
expressApp.use(authorizeJWT(authConfig));
|
||||
|
||||
// Set up the messages endpoint - use configured path and /api/messages as fallback
|
||||
const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages";
|
||||
const messageHandler = (req: Request, res: Response) => {
|
||||
void adapter
|
||||
.process(req, res, (context: unknown) => handler.run!(context))
|
||||
.catch((err: unknown) => {
|
||||
log.error("msteams webhook failed", { error: formatUnknownError(err) });
|
||||
});
|
||||
};
|
||||
|
||||
// Listen on configured path and /api/messages (standard Bot Framework path)
|
||||
expressApp.post(configuredPath, messageHandler);
|
||||
if (configuredPath !== "/api/messages") {
|
||||
expressApp.post("/api/messages", messageHandler);
|
||||
}
|
||||
|
||||
log.debug?.("listening on paths", {
|
||||
primary: configuredPath,
|
||||
fallback: "/api/messages",
|
||||
});
|
||||
|
||||
// Start listening and capture the HTTP server handle
|
||||
const httpServer = expressApp.listen(port, () => {
|
||||
log.info(`msteams provider started on port ${port}`);
|
||||
});
|
||||
|
||||
httpServer.on("error", (err) => {
|
||||
log.error("msteams server error", { error: String(err) });
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
log.info("shutting down msteams provider");
|
||||
return new Promise<void>((resolve) => {
|
||||
httpServer.close((err) => {
|
||||
if (err) {
|
||||
log.debug?.("msteams server close error", { error: String(err) });
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Handle abort signal
|
||||
if (opts.abortSignal) {
|
||||
opts.abortSignal.addEventListener("abort", () => {
|
||||
void shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
return { app: expressApp, shutdown };
|
||||
}
|
||||
406
openclaw/extensions/msteams/src/onboarding.ts
Normal file
406
openclaw/extensions/msteams/src/onboarding.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
OpenClawConfig,
|
||||
DmPolicy,
|
||||
WizardPrompter,
|
||||
MSTeamsTeamConfig,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
promptChannelAccessConfig,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
parseMSTeamsTeamEntry,
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
const channel = "msteams" as const;
|
||||
|
||||
function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.msteams?.allowFrom)?.map((entry) => String(entry))
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function looksLikeGuid(value: string): boolean {
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(value);
|
||||
}
|
||||
|
||||
async function promptMSTeamsCredentials(prompter: WizardPrompter): Promise<{
|
||||
appId: string;
|
||||
appPassword: string;
|
||||
tenantId: string;
|
||||
}> {
|
||||
const appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
const appPassword = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App Password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
const tenantId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams Tenant ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
return { appId, appPassword, tenantId };
|
||||
}
|
||||
|
||||
async function promptMSTeamsAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const existing = params.cfg.channels?.msteams?.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist MS Teams DMs by display name, UPN/email, or user id.",
|
||||
"We resolve names to user IDs via Microsoft Graph when credentials allow.",
|
||||
"Examples:",
|
||||
"- alex@example.com",
|
||||
"- Alex Johnson",
|
||||
"- 00000000-0000-0000-0000-000000000000",
|
||||
].join("\n"),
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: "MS Teams allowFrom (usernames or ids)",
|
||||
placeholder: "alex@example.com, Alex Johnson",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length === 0) {
|
||||
await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = await resolveMSTeamsUserAllowlist({
|
||||
cfg: params.cfg,
|
||||
entries: parts,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!resolved) {
|
||||
const ids = parts.filter((part) => looksLikeGuid(part));
|
||||
if (ids.length !== parts.length) {
|
||||
await params.prompter.note(
|
||||
"Graph lookup unavailable. Use user IDs only.",
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const unique = mergeAllowFromEntries(existing, ids);
|
||||
return setMSTeamsAllowFrom(params.cfg, unique);
|
||||
}
|
||||
|
||||
const unresolved = resolved.filter((item) => !item.resolved || !item.id);
|
||||
if (unresolved.length > 0) {
|
||||
await params.prompter.note(
|
||||
`Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`,
|
||||
"MS Teams allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = resolved.map((item) => item.id as string);
|
||||
const unique = mergeAllowFromEntries(existing, ids);
|
||||
return setMSTeamsAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
|
||||
async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Azure Bot registration → get App ID + Tenant ID",
|
||||
"2) Add a client secret (App Password)",
|
||||
"3) Set webhook URL + messaging endpoint",
|
||||
"Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.",
|
||||
`Docs: ${formatDocsLink("/channels/msteams", "msteams")}`,
|
||||
].join("\n"),
|
||||
"MS Teams credentials",
|
||||
);
|
||||
}
|
||||
|
||||
function setMSTeamsGroupPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
enabled: true,
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setMSTeamsTeamsAllowlist(
|
||||
cfg: OpenClawConfig,
|
||||
entries: Array<{ teamKey: string; channelKey?: string }>,
|
||||
): OpenClawConfig {
|
||||
const baseTeams = cfg.channels?.msteams?.teams ?? {};
|
||||
const teams: Record<string, { channels?: Record<string, unknown> }> = { ...baseTeams };
|
||||
for (const entry of entries) {
|
||||
const teamKey = entry.teamKey;
|
||||
if (!teamKey) {
|
||||
continue;
|
||||
}
|
||||
const existing = teams[teamKey] ?? {};
|
||||
if (entry.channelKey) {
|
||||
const channels = { ...existing.channels };
|
||||
channels[entry.channelKey] = channels[entry.channelKey] ?? {};
|
||||
teams[teamKey] = { ...existing, channels };
|
||||
} else {
|
||||
teams[teamKey] = existing;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
enabled: true,
|
||||
teams: teams as Record<string, MSTeamsTeamConfig>,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "MS Teams",
|
||||
channel,
|
||||
policyKey: "channels.msteams.dmPolicy",
|
||||
allowFromKey: "channels.msteams.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptMSTeamsAllowFrom,
|
||||
};
|
||||
|
||||
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`MS Teams: ${configured ? "configured" : "needs app credentials"}`],
|
||||
selectionHint: configured ? "configured" : "needs app creds",
|
||||
quickstartScore: configured ? 2 : 0,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter }) => {
|
||||
const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams);
|
||||
const hasConfigCreds = Boolean(
|
||||
cfg.channels?.msteams?.appId?.trim() &&
|
||||
cfg.channels?.msteams?.appPassword?.trim() &&
|
||||
cfg.channels?.msteams?.tenantId?.trim(),
|
||||
);
|
||||
const canUseEnv = Boolean(
|
||||
!hasConfigCreds &&
|
||||
process.env.MSTEAMS_APP_ID?.trim() &&
|
||||
process.env.MSTEAMS_APP_PASSWORD?.trim() &&
|
||||
process.env.MSTEAMS_TENANT_ID?.trim(),
|
||||
);
|
||||
|
||||
let next = cfg;
|
||||
let appId: string | null = null;
|
||||
let appPassword: string | null = null;
|
||||
let tenantId: string | null = null;
|
||||
|
||||
if (!resolved) {
|
||||
await noteMSTeamsCredentialHelp(prompter);
|
||||
}
|
||||
|
||||
if (canUseEnv) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message:
|
||||
"MSTEAMS_APP_ID + MSTEAMS_APP_PASSWORD + MSTEAMS_TENANT_ID detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
msteams: { ...next.channels?.msteams, enabled: true },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
|
||||
}
|
||||
} else if (hasConfigCreds) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "MS Teams credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
|
||||
}
|
||||
} else {
|
||||
({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter));
|
||||
}
|
||||
|
||||
if (appId && appPassword && tenantId) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
msteams: {
|
||||
...next.channels?.msteams,
|
||||
enabled: true,
|
||||
appId,
|
||||
appPassword,
|
||||
tenantId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap(
|
||||
([teamKey, value]) => {
|
||||
const channels = value?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels);
|
||||
if (channelKeys.length === 0) {
|
||||
return [teamKey];
|
||||
}
|
||||
return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`);
|
||||
},
|
||||
);
|
||||
const accessConfig = await promptChannelAccessConfig({
|
||||
prompter,
|
||||
label: "MS Teams channels",
|
||||
currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist",
|
||||
currentEntries,
|
||||
placeholder: "Team Name/Channel Name, teamId/conversationId",
|
||||
updatePrompt: Boolean(next.channels?.msteams?.teams),
|
||||
});
|
||||
if (accessConfig) {
|
||||
if (accessConfig.policy !== "allowlist") {
|
||||
next = setMSTeamsGroupPolicy(next, accessConfig.policy);
|
||||
} else {
|
||||
let entries = accessConfig.entries
|
||||
.map((entry) => parseMSTeamsTeamEntry(entry))
|
||||
.filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
|
||||
if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) {
|
||||
try {
|
||||
const resolved = await resolveMSTeamsChannelAllowlist({
|
||||
cfg: next,
|
||||
entries: accessConfig.entries,
|
||||
});
|
||||
const resolvedChannels = resolved.filter(
|
||||
(entry) => entry.resolved && entry.teamId && entry.channelId,
|
||||
);
|
||||
const resolvedTeams = resolved.filter(
|
||||
(entry) => entry.resolved && entry.teamId && !entry.channelId,
|
||||
);
|
||||
const unresolved = resolved
|
||||
.filter((entry) => !entry.resolved)
|
||||
.map((entry) => entry.input);
|
||||
|
||||
entries = [
|
||||
...resolvedChannels.map((entry) => ({
|
||||
teamKey: entry.teamId as string,
|
||||
channelKey: entry.channelId as string,
|
||||
})),
|
||||
...resolvedTeams.map((entry) => ({
|
||||
teamKey: entry.teamId as string,
|
||||
})),
|
||||
...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean),
|
||||
] as Array<{ teamKey: string; channelKey?: string }>;
|
||||
|
||||
if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) {
|
||||
const summary: string[] = [];
|
||||
if (resolvedChannels.length > 0) {
|
||||
summary.push(
|
||||
`Resolved channels: ${resolvedChannels
|
||||
.map((entry) => entry.channelId)
|
||||
.filter(Boolean)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (resolvedTeams.length > 0) {
|
||||
summary.push(
|
||||
`Resolved teams: ${resolvedTeams
|
||||
.map((entry) => entry.teamId)
|
||||
.filter(Boolean)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (unresolved.length > 0) {
|
||||
summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`);
|
||||
}
|
||||
await prompter.note(summary.join("\n"), "MS Teams channels");
|
||||
}
|
||||
} catch (err) {
|
||||
await prompter.note(
|
||||
`Channel lookup failed; keeping entries as typed. ${String(err)}`,
|
||||
"MS Teams channels",
|
||||
);
|
||||
}
|
||||
}
|
||||
next = setMSTeamsGroupPolicy(next, "allowlist");
|
||||
next = setMSTeamsTeamsAllowlist(next, entries);
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: { ...cfg.channels?.msteams, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
46
openclaw/extensions/msteams/src/outbound.ts
Normal file
46
openclaw/extensions/msteams/src/outbound.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
||||
import { createMSTeamsPollStoreFs } from "./polls.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||
|
||||
export const msteamsOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
pollMaxOptions: 12,
|
||||
sendText: async ({ cfg, to, text, deps }) => {
|
||||
const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text }));
|
||||
const result = await send(to, text);
|
||||
return { channel: "msteams", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
|
||||
const send =
|
||||
deps?.sendMSTeams ??
|
||||
((to, text, opts) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
|
||||
const result = await send(to, text, { mediaUrl });
|
||||
return { channel: "msteams", ...result };
|
||||
},
|
||||
sendPoll: async ({ cfg, to, poll }) => {
|
||||
const maxSelections = poll.maxSelections ?? 1;
|
||||
const result = await sendPollMSTeams({
|
||||
cfg,
|
||||
to,
|
||||
question: poll.question,
|
||||
options: poll.options,
|
||||
maxSelections,
|
||||
});
|
||||
const pollStore = createMSTeamsPollStoreFs();
|
||||
await pollStore.createPoll({
|
||||
id: result.pollId,
|
||||
question: poll.question,
|
||||
options: poll.options,
|
||||
maxSelections,
|
||||
createdAt: new Date().toISOString(),
|
||||
conversationId: result.conversationId,
|
||||
messageId: result.messageId,
|
||||
votes: {},
|
||||
});
|
||||
return result;
|
||||
},
|
||||
};
|
||||
89
openclaw/extensions/msteams/src/pending-uploads.ts
Normal file
89
openclaw/extensions/msteams/src/pending-uploads.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* In-memory storage for files awaiting user consent in the FileConsentCard flow.
|
||||
*
|
||||
* When sending large files (>=4MB) in personal chats, Teams requires user consent
|
||||
* before upload. This module stores the file data temporarily until the user
|
||||
* accepts or declines, or until the TTL expires.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export interface PendingUpload {
|
||||
id: string;
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
conversationId: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const pendingUploads = new Map<string, PendingUpload>();
|
||||
|
||||
/** TTL for pending uploads: 5 minutes */
|
||||
const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Store a file pending user consent.
|
||||
* Returns the upload ID to include in the FileConsentCard context.
|
||||
*/
|
||||
export function storePendingUpload(upload: Omit<PendingUpload, "id" | "createdAt">): string {
|
||||
const id = crypto.randomUUID();
|
||||
const entry: PendingUpload = {
|
||||
...upload,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
pendingUploads.set(id, entry);
|
||||
|
||||
// Auto-cleanup after TTL
|
||||
setTimeout(() => {
|
||||
pendingUploads.delete(id);
|
||||
}, PENDING_UPLOAD_TTL_MS);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a pending upload by ID.
|
||||
* Returns undefined if not found or expired.
|
||||
*/
|
||||
export function getPendingUpload(id?: string): PendingUpload | undefined {
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = pendingUploads.get(id);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check if expired (in case timeout hasn't fired yet)
|
||||
if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) {
|
||||
pendingUploads.delete(id);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending upload (after successful upload or user decline).
|
||||
*/
|
||||
export function removePendingUpload(id?: string): void {
|
||||
if (id) {
|
||||
pendingUploads.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of pending uploads (for monitoring/debugging).
|
||||
*/
|
||||
export function getPendingUploadCount(): number {
|
||||
return pendingUploads.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending uploads (for testing).
|
||||
*/
|
||||
export function clearPendingUploads(): void {
|
||||
pendingUploads.clear();
|
||||
}
|
||||
221
openclaw/extensions/msteams/src/policy.test.ts
Normal file
221
openclaw/extensions/msteams/src/policy.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { MSTeamsConfig } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isMSTeamsGroupAllowed,
|
||||
resolveMSTeamsReplyPolicy,
|
||||
resolveMSTeamsRouteConfig,
|
||||
} from "./policy.js";
|
||||
|
||||
describe("msteams policy", () => {
|
||||
describe("resolveMSTeamsRouteConfig", () => {
|
||||
it("returns team and channel config when present", () => {
|
||||
const cfg: MSTeamsConfig = {
|
||||
teams: {
|
||||
team123: {
|
||||
requireMention: false,
|
||||
channels: {
|
||||
chan456: { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = resolveMSTeamsRouteConfig({
|
||||
cfg,
|
||||
teamId: "team123",
|
||||
conversationId: "chan456",
|
||||
});
|
||||
|
||||
expect(res.teamConfig?.requireMention).toBe(false);
|
||||
expect(res.channelConfig?.requireMention).toBe(true);
|
||||
expect(res.allowlistConfigured).toBe(true);
|
||||
expect(res.allowed).toBe(true);
|
||||
expect(res.channelMatchKey).toBe("chan456");
|
||||
expect(res.channelMatchSource).toBe("direct");
|
||||
});
|
||||
|
||||
it("returns undefined configs when teamId is missing", () => {
|
||||
const cfg: MSTeamsConfig = {
|
||||
teams: { team123: { requireMention: false } },
|
||||
};
|
||||
|
||||
const res = resolveMSTeamsRouteConfig({
|
||||
cfg,
|
||||
teamId: undefined,
|
||||
conversationId: "chan",
|
||||
});
|
||||
expect(res.teamConfig).toBeUndefined();
|
||||
expect(res.channelConfig).toBeUndefined();
|
||||
expect(res.allowlistConfigured).toBe(true);
|
||||
expect(res.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("matches team and channel by name", () => {
|
||||
const cfg: MSTeamsConfig = {
|
||||
teams: {
|
||||
"My Team": {
|
||||
requireMention: true,
|
||||
channels: {
|
||||
"General Chat": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = resolveMSTeamsRouteConfig({
|
||||
cfg,
|
||||
teamName: "My Team",
|
||||
channelName: "General Chat",
|
||||
conversationId: "ignored",
|
||||
});
|
||||
|
||||
expect(res.teamConfig?.requireMention).toBe(true);
|
||||
expect(res.channelConfig?.requireMention).toBe(false);
|
||||
expect(res.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMSTeamsReplyPolicy", () => {
|
||||
it("forces thread replies for direct messages", () => {
|
||||
const policy = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage: true,
|
||||
globalConfig: { replyStyle: "top-level", requireMention: false },
|
||||
});
|
||||
expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
|
||||
});
|
||||
|
||||
it("defaults to requireMention=true and replyStyle=thread", () => {
|
||||
const policy = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
globalConfig: {},
|
||||
});
|
||||
expect(policy).toEqual({ requireMention: true, replyStyle: "thread" });
|
||||
});
|
||||
|
||||
it("defaults replyStyle to top-level when requireMention=false", () => {
|
||||
const policy = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
globalConfig: { requireMention: false },
|
||||
});
|
||||
expect(policy).toEqual({
|
||||
requireMention: false,
|
||||
replyStyle: "top-level",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers channel overrides over team and global defaults", () => {
|
||||
const policy = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
globalConfig: { requireMention: true },
|
||||
teamConfig: { requireMention: true },
|
||||
channelConfig: { requireMention: false },
|
||||
});
|
||||
|
||||
// requireMention from channel -> false, and replyStyle defaults from requireMention -> top-level
|
||||
expect(policy).toEqual({
|
||||
requireMention: false,
|
||||
replyStyle: "top-level",
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits team mention settings when channel config is missing", () => {
|
||||
const policy = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
globalConfig: { requireMention: true },
|
||||
teamConfig: { requireMention: false },
|
||||
});
|
||||
expect(policy).toEqual({
|
||||
requireMention: false,
|
||||
replyStyle: "top-level",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses explicit replyStyle even when requireMention defaults would differ", () => {
|
||||
const policy = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
globalConfig: { requireMention: false, replyStyle: "thread" },
|
||||
});
|
||||
expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMSTeamsGroupAllowed", () => {
|
||||
it("allows when policy is open", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
senderId: "user-id",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks when policy is disabled", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["user-id"],
|
||||
senderId: "user-id",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks allowlist when empty", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
senderId: "user-id",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows allowlist when sender matches", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["User-Id"],
|
||||
senderId: "user-id",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks sender-name allowlist matches by default", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["user"],
|
||||
senderId: "other",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows sender-name allowlist matches when explicitly enabled", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["user"],
|
||||
senderId: "other",
|
||||
senderName: "User",
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist wildcard", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["*"],
|
||||
senderId: "other",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
259
openclaw/extensions/msteams/src/policy.ts
Normal file
259
openclaw/extensions/msteams/src/policy.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import type {
|
||||
AllowlistMatch,
|
||||
ChannelGroupContext,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsConfig,
|
||||
MSTeamsReplyStyle,
|
||||
MSTeamsTeamConfig,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveToolsBySender,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
resolveNestedAllowlistDecision,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
export type MSTeamsResolvedRouteConfig = {
|
||||
teamConfig?: MSTeamsTeamConfig;
|
||||
channelConfig?: MSTeamsChannelConfig;
|
||||
allowlistConfigured: boolean;
|
||||
allowed: boolean;
|
||||
teamKey?: string;
|
||||
channelKey?: string;
|
||||
channelMatchKey?: string;
|
||||
channelMatchSource?: "direct" | "wildcard";
|
||||
};
|
||||
|
||||
export function resolveMSTeamsRouteConfig(params: {
|
||||
cfg?: MSTeamsConfig;
|
||||
teamId?: string | null | undefined;
|
||||
teamName?: string | null | undefined;
|
||||
conversationId?: string | null | undefined;
|
||||
channelName?: string | null | undefined;
|
||||
}): MSTeamsResolvedRouteConfig {
|
||||
const teamId = params.teamId?.trim();
|
||||
const teamName = params.teamName?.trim();
|
||||
const conversationId = params.conversationId?.trim();
|
||||
const channelName = params.channelName?.trim();
|
||||
const teams = params.cfg?.teams ?? {};
|
||||
const allowlistConfigured = Object.keys(teams).length > 0;
|
||||
const teamCandidates = buildChannelKeyCandidates(
|
||||
teamId,
|
||||
teamName,
|
||||
teamName ? normalizeChannelSlug(teamName) : undefined,
|
||||
);
|
||||
const teamMatch = resolveChannelEntryMatchWithFallback({
|
||||
entries: teams,
|
||||
keys: teamCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
const teamConfig = teamMatch.entry;
|
||||
const channels = teamConfig?.channels ?? {};
|
||||
const channelAllowlistConfigured = Object.keys(channels).length > 0;
|
||||
const channelCandidates = buildChannelKeyCandidates(
|
||||
conversationId,
|
||||
channelName,
|
||||
channelName ? normalizeChannelSlug(channelName) : undefined,
|
||||
);
|
||||
const channelMatch = resolveChannelEntryMatchWithFallback({
|
||||
entries: channels,
|
||||
keys: channelCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
const channelConfig = channelMatch.entry;
|
||||
|
||||
const allowed = resolveNestedAllowlistDecision({
|
||||
outerConfigured: allowlistConfigured,
|
||||
outerMatched: Boolean(teamConfig),
|
||||
innerConfigured: channelAllowlistConfigured,
|
||||
innerMatched: Boolean(channelConfig),
|
||||
});
|
||||
|
||||
return {
|
||||
teamConfig,
|
||||
channelConfig,
|
||||
allowlistConfigured,
|
||||
allowed,
|
||||
teamKey: teamMatch.matchKey ?? teamMatch.key,
|
||||
channelKey: channelMatch.matchKey ?? channelMatch.key,
|
||||
channelMatchKey: channelMatch.matchKey,
|
||||
channelMatchSource:
|
||||
channelMatch.matchSource === "direct" || channelMatch.matchSource === "wildcard"
|
||||
? channelMatch.matchSource
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMSTeamsGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg.channels?.msteams;
|
||||
if (!cfg) {
|
||||
return undefined;
|
||||
}
|
||||
const groupId = params.groupId?.trim();
|
||||
const groupChannel = params.groupChannel?.trim();
|
||||
const groupSpace = params.groupSpace?.trim();
|
||||
|
||||
const resolved = resolveMSTeamsRouteConfig({
|
||||
cfg,
|
||||
teamId: groupSpace,
|
||||
teamName: groupSpace,
|
||||
conversationId: groupId,
|
||||
channelName: groupChannel,
|
||||
});
|
||||
|
||||
if (resolved.channelConfig) {
|
||||
const senderPolicy = resolveToolsBySender({
|
||||
toolsBySender: resolved.channelConfig.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
if (senderPolicy) {
|
||||
return senderPolicy;
|
||||
}
|
||||
if (resolved.channelConfig.tools) {
|
||||
return resolved.channelConfig.tools;
|
||||
}
|
||||
const teamSenderPolicy = resolveToolsBySender({
|
||||
toolsBySender: resolved.teamConfig?.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
if (teamSenderPolicy) {
|
||||
return teamSenderPolicy;
|
||||
}
|
||||
return resolved.teamConfig?.tools;
|
||||
}
|
||||
if (resolved.teamConfig) {
|
||||
const teamSenderPolicy = resolveToolsBySender({
|
||||
toolsBySender: resolved.teamConfig.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
if (teamSenderPolicy) {
|
||||
return teamSenderPolicy;
|
||||
}
|
||||
if (resolved.teamConfig.tools) {
|
||||
return resolved.teamConfig.tools;
|
||||
}
|
||||
}
|
||||
|
||||
if (!groupId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const channelCandidates = buildChannelKeyCandidates(
|
||||
groupId,
|
||||
groupChannel,
|
||||
groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
|
||||
);
|
||||
for (const teamConfig of Object.values(cfg.teams ?? {})) {
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries: teamConfig?.channels ?? {},
|
||||
keys: channelCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
if (match.entry) {
|
||||
const senderPolicy = resolveToolsBySender({
|
||||
toolsBySender: match.entry.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
if (senderPolicy) {
|
||||
return senderPolicy;
|
||||
}
|
||||
if (match.entry.tools) {
|
||||
return match.entry.tools;
|
||||
}
|
||||
const teamSenderPolicy = resolveToolsBySender({
|
||||
toolsBySender: teamConfig?.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
if (teamSenderPolicy) {
|
||||
return teamSenderPolicy;
|
||||
}
|
||||
return teamConfig?.tools;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type MSTeamsReplyPolicy = {
|
||||
requireMention: boolean;
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
};
|
||||
|
||||
export type MSTeamsAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">;
|
||||
|
||||
export function resolveMSTeamsAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
allowNameMatching?: boolean;
|
||||
}): MSTeamsAllowlistMatch {
|
||||
return resolveAllowlistMatchSimple(params);
|
||||
}
|
||||
|
||||
export function resolveMSTeamsReplyPolicy(params: {
|
||||
isDirectMessage: boolean;
|
||||
globalConfig?: MSTeamsConfig;
|
||||
teamConfig?: MSTeamsTeamConfig;
|
||||
channelConfig?: MSTeamsChannelConfig;
|
||||
}): MSTeamsReplyPolicy {
|
||||
if (params.isDirectMessage) {
|
||||
return { requireMention: false, replyStyle: "thread" };
|
||||
}
|
||||
|
||||
const requireMention =
|
||||
params.channelConfig?.requireMention ??
|
||||
params.teamConfig?.requireMention ??
|
||||
params.globalConfig?.requireMention ??
|
||||
true;
|
||||
|
||||
const explicitReplyStyle =
|
||||
params.channelConfig?.replyStyle ??
|
||||
params.teamConfig?.replyStyle ??
|
||||
params.globalConfig?.replyStyle;
|
||||
|
||||
const replyStyle: MSTeamsReplyStyle =
|
||||
explicitReplyStyle ?? (requireMention ? "thread" : "top-level");
|
||||
|
||||
return { requireMention, replyStyle };
|
||||
}
|
||||
|
||||
export function isMSTeamsGroupAllowed(params: {
|
||||
groupPolicy: GroupPolicy;
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const { groupPolicy } = params;
|
||||
if (groupPolicy === "disabled") {
|
||||
return false;
|
||||
}
|
||||
if (groupPolicy === "open") {
|
||||
return true;
|
||||
}
|
||||
return resolveMSTeamsAllowlistMatch(params).allowed;
|
||||
}
|
||||
32
openclaw/extensions/msteams/src/polls-store-memory.ts
Normal file
32
openclaw/extensions/msteams/src/polls-store-memory.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type MSTeamsPoll,
|
||||
type MSTeamsPollStore,
|
||||
normalizeMSTeamsPollSelections,
|
||||
} from "./polls.js";
|
||||
|
||||
export function createMSTeamsPollStoreMemory(initial: MSTeamsPoll[] = []): MSTeamsPollStore {
|
||||
const polls = new Map<string, MSTeamsPoll>();
|
||||
for (const poll of initial) {
|
||||
polls.set(poll.id, { ...poll });
|
||||
}
|
||||
|
||||
const createPoll = async (poll: MSTeamsPoll) => {
|
||||
polls.set(poll.id, { ...poll });
|
||||
};
|
||||
|
||||
const getPoll = async (pollId: string) => polls.get(pollId) ?? null;
|
||||
|
||||
const recordVote = async (params: { pollId: string; voterId: string; selections: string[] }) => {
|
||||
const poll = polls.get(params.pollId);
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeMSTeamsPollSelections(poll, params.selections);
|
||||
poll.votes[params.voterId] = normalized;
|
||||
poll.updatedAt = new Date().toISOString();
|
||||
polls.set(poll.id, poll);
|
||||
return poll;
|
||||
};
|
||||
|
||||
return { createPoll, getPoll, recordVote };
|
||||
}
|
||||
38
openclaw/extensions/msteams/src/polls-store.test.ts
Normal file
38
openclaw/extensions/msteams/src/polls-store.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js";
|
||||
import { createMSTeamsPollStoreFs } from "./polls.js";
|
||||
|
||||
const createFsStore = async () => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-polls-"));
|
||||
return createMSTeamsPollStoreFs({ stateDir });
|
||||
};
|
||||
|
||||
const createMemoryStore = () => createMSTeamsPollStoreMemory();
|
||||
|
||||
describe.each([
|
||||
{ name: "memory", createStore: createMemoryStore },
|
||||
{ name: "fs", createStore: createFsStore },
|
||||
])("$name poll store", ({ createStore }) => {
|
||||
it("stores polls and records normalized votes", async () => {
|
||||
const store = await createStore();
|
||||
await store.createPoll({
|
||||
id: "poll-1",
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
maxSelections: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
votes: {},
|
||||
});
|
||||
|
||||
const poll = await store.recordVote({
|
||||
pollId: "poll-1",
|
||||
voterId: "user-1",
|
||||
selections: ["0", "1"],
|
||||
});
|
||||
|
||||
expect(poll?.votes["user-1"]).toEqual(["0"]);
|
||||
});
|
||||
});
|
||||
59
openclaw/extensions/msteams/src/polls.test.ts
Normal file
59
openclaw/extensions/msteams/src/polls.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
|
||||
import { setMSTeamsRuntime } from "./runtime.js";
|
||||
import { msteamsRuntimeStub } from "./test-runtime.js";
|
||||
|
||||
describe("msteams polls", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(msteamsRuntimeStub);
|
||||
});
|
||||
|
||||
it("builds poll cards with fallback text", () => {
|
||||
const card = buildMSTeamsPollCard({
|
||||
question: "Lunch?",
|
||||
options: ["Pizza", "Sushi"],
|
||||
});
|
||||
|
||||
expect(card.pollId).toBeTruthy();
|
||||
expect(card.fallbackText).toContain("Poll: Lunch?");
|
||||
expect(card.fallbackText).toContain("1. Pizza");
|
||||
expect(card.fallbackText).toContain("2. Sushi");
|
||||
});
|
||||
|
||||
it("extracts poll votes from activity values", () => {
|
||||
const vote = extractMSTeamsPollVote({
|
||||
value: {
|
||||
openclawPollId: "poll-1",
|
||||
choices: "0,1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(vote).toEqual({
|
||||
pollId: "poll-1",
|
||||
selections: ["0", "1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores and records poll votes", async () => {
|
||||
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-polls-"));
|
||||
const store = createMSTeamsPollStoreFs({ homedir: () => home });
|
||||
await store.createPoll({
|
||||
id: "poll-2",
|
||||
question: "Pick one",
|
||||
options: ["A", "B"],
|
||||
maxSelections: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
votes: {},
|
||||
});
|
||||
await store.recordVote({
|
||||
pollId: "poll-2",
|
||||
voterId: "user-1",
|
||||
selections: ["0", "1"],
|
||||
});
|
||||
const stored = await store.getPoll("poll-2");
|
||||
expect(stored?.votes["user-1"]).toEqual(["0"]);
|
||||
});
|
||||
});
|
||||
315
openclaw/extensions/msteams/src/polls.ts
Normal file
315
openclaw/extensions/msteams/src/polls.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import crypto from "node:crypto";
|
||||
import { resolveMSTeamsStorePath } from "./storage.js";
|
||||
import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
|
||||
|
||||
export type MSTeamsPollVote = {
|
||||
pollId: string;
|
||||
selections: string[];
|
||||
};
|
||||
|
||||
export type MSTeamsPoll = {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
maxSelections: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
conversationId?: string;
|
||||
messageId?: string;
|
||||
votes: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type MSTeamsPollStore = {
|
||||
createPoll: (poll: MSTeamsPoll) => Promise<void>;
|
||||
getPoll: (pollId: string) => Promise<MSTeamsPoll | null>;
|
||||
recordVote: (params: {
|
||||
pollId: string;
|
||||
voterId: string;
|
||||
selections: string[];
|
||||
}) => Promise<MSTeamsPoll | null>;
|
||||
};
|
||||
|
||||
export type MSTeamsPollCard = {
|
||||
pollId: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
maxSelections: number;
|
||||
card: Record<string, unknown>;
|
||||
fallbackText: string;
|
||||
};
|
||||
|
||||
type PollStoreData = {
|
||||
version: 1;
|
||||
polls: Record<string, MSTeamsPoll>;
|
||||
};
|
||||
|
||||
const STORE_FILENAME = "msteams-polls.json";
|
||||
const MAX_POLLS = 1000;
|
||||
const POLL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeChoiceValue(value: unknown): string | null {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSelections(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeChoiceValue).filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
const normalized = normalizeChoiceValue(value);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
if (normalized.includes(",")) {
|
||||
return normalized
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [normalized];
|
||||
}
|
||||
|
||||
function readNestedValue(value: unknown, keys: Array<string | number>): unknown {
|
||||
let current: unknown = value;
|
||||
for (const key of keys) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key as keyof typeof current];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
||||
const found = readNestedValue(value, keys);
|
||||
return typeof found === "string" && found.trim() ? found.trim() : undefined;
|
||||
}
|
||||
|
||||
export function extractMSTeamsPollVote(
|
||||
activity: { value?: unknown } | undefined,
|
||||
): MSTeamsPollVote | null {
|
||||
const value = activity?.value;
|
||||
if (!value || !isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const pollId =
|
||||
readNestedString(value, ["openclawPollId"]) ??
|
||||
readNestedString(value, ["pollId"]) ??
|
||||
readNestedString(value, ["openclaw", "pollId"]) ??
|
||||
readNestedString(value, ["openclaw", "poll", "id"]) ??
|
||||
readNestedString(value, ["data", "openclawPollId"]) ??
|
||||
readNestedString(value, ["data", "pollId"]) ??
|
||||
readNestedString(value, ["data", "openclaw", "pollId"]);
|
||||
if (!pollId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directSelections = extractSelections(value.choices);
|
||||
const nestedSelections = extractSelections(readNestedValue(value, ["choices"]));
|
||||
const dataSelections = extractSelections(readNestedValue(value, ["data", "choices"]));
|
||||
const selections =
|
||||
directSelections.length > 0
|
||||
? directSelections
|
||||
: nestedSelections.length > 0
|
||||
? nestedSelections
|
||||
: dataSelections;
|
||||
|
||||
if (selections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pollId,
|
||||
selections,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMSTeamsPollCard(params: {
|
||||
question: string;
|
||||
options: string[];
|
||||
maxSelections?: number;
|
||||
pollId?: string;
|
||||
}): MSTeamsPollCard {
|
||||
const pollId = params.pollId ?? crypto.randomUUID();
|
||||
const maxSelections =
|
||||
typeof params.maxSelections === "number" && params.maxSelections > 1
|
||||
? Math.floor(params.maxSelections)
|
||||
: 1;
|
||||
const cappedMaxSelections = Math.min(Math.max(1, maxSelections), params.options.length);
|
||||
const choices = params.options.map((option, index) => ({
|
||||
title: option,
|
||||
value: String(index),
|
||||
}));
|
||||
const hint =
|
||||
cappedMaxSelections > 1
|
||||
? `Select up to ${cappedMaxSelections} option${cappedMaxSelections === 1 ? "" : "s"}.`
|
||||
: "Select one option.";
|
||||
|
||||
const card = {
|
||||
type: "AdaptiveCard",
|
||||
version: "1.5",
|
||||
body: [
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: params.question,
|
||||
wrap: true,
|
||||
weight: "Bolder",
|
||||
size: "Medium",
|
||||
},
|
||||
{
|
||||
type: "Input.ChoiceSet",
|
||||
id: "choices",
|
||||
isMultiSelect: cappedMaxSelections > 1,
|
||||
style: "expanded",
|
||||
choices,
|
||||
},
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: hint,
|
||||
wrap: true,
|
||||
isSubtle: true,
|
||||
spacing: "Small",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: "Action.Submit",
|
||||
title: "Vote",
|
||||
data: {
|
||||
openclawPollId: pollId,
|
||||
pollId,
|
||||
},
|
||||
msteams: {
|
||||
type: "messageBack",
|
||||
text: "openclaw poll vote",
|
||||
displayText: "Vote recorded",
|
||||
value: { openclawPollId: pollId, pollId },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fallbackLines = [
|
||||
`Poll: ${params.question}`,
|
||||
...params.options.map((option, index) => `${index + 1}. ${option}`),
|
||||
];
|
||||
|
||||
return {
|
||||
pollId,
|
||||
question: params.question,
|
||||
options: params.options,
|
||||
maxSelections: cappedMaxSelections,
|
||||
card,
|
||||
fallbackText: fallbackLines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export type MSTeamsPollStoreFsOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
stateDir?: string;
|
||||
storePath?: string;
|
||||
};
|
||||
|
||||
function parseTimestamp(value?: string): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function pruneExpired(polls: Record<string, MSTeamsPoll>) {
|
||||
const cutoff = Date.now() - POLL_TTL_MS;
|
||||
const entries = Object.entries(polls).filter(([, poll]) => {
|
||||
const ts = parseTimestamp(poll.updatedAt ?? poll.createdAt) ?? 0;
|
||||
return ts >= cutoff;
|
||||
});
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function pruneToLimit(polls: Record<string, MSTeamsPoll>) {
|
||||
const entries = Object.entries(polls);
|
||||
if (entries.length <= MAX_POLLS) {
|
||||
return polls;
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
const aTs = parseTimestamp(a[1].updatedAt ?? a[1].createdAt) ?? 0;
|
||||
const bTs = parseTimestamp(b[1].updatedAt ?? b[1].createdAt) ?? 0;
|
||||
return aTs - bTs;
|
||||
});
|
||||
const keep = entries.slice(entries.length - MAX_POLLS);
|
||||
return Object.fromEntries(keep);
|
||||
}
|
||||
|
||||
export function normalizeMSTeamsPollSelections(poll: MSTeamsPoll, selections: string[]) {
|
||||
const maxSelections = Math.max(1, poll.maxSelections);
|
||||
const mapped = selections
|
||||
.map((entry) => Number.parseInt(entry, 10))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.filter((value) => value >= 0 && value < poll.options.length)
|
||||
.map((value) => String(value));
|
||||
const limited = maxSelections > 1 ? mapped.slice(0, maxSelections) : mapped.slice(0, 1);
|
||||
return Array.from(new Set(limited));
|
||||
}
|
||||
|
||||
export function createMSTeamsPollStoreFs(params?: MSTeamsPollStoreFsOptions): MSTeamsPollStore {
|
||||
const filePath = resolveMSTeamsStorePath({
|
||||
filename: STORE_FILENAME,
|
||||
env: params?.env,
|
||||
homedir: params?.homedir,
|
||||
stateDir: params?.stateDir,
|
||||
storePath: params?.storePath,
|
||||
});
|
||||
const empty: PollStoreData = { version: 1, polls: {} };
|
||||
|
||||
const readStore = async (): Promise<PollStoreData> => {
|
||||
const { value } = await readJsonFile<PollStoreData>(filePath, empty);
|
||||
const pruned = pruneToLimit(pruneExpired(value.polls ?? {}));
|
||||
return { version: 1, polls: pruned };
|
||||
};
|
||||
|
||||
const writeStore = async (data: PollStoreData) => {
|
||||
await writeJsonFile(filePath, data);
|
||||
};
|
||||
|
||||
const createPoll = async (poll: MSTeamsPoll) => {
|
||||
await withFileLock(filePath, empty, async () => {
|
||||
const data = await readStore();
|
||||
data.polls[poll.id] = poll;
|
||||
await writeStore({ version: 1, polls: pruneToLimit(data.polls) });
|
||||
});
|
||||
};
|
||||
|
||||
const getPoll = async (pollId: string) =>
|
||||
await withFileLock(filePath, empty, async () => {
|
||||
const data = await readStore();
|
||||
return data.polls[pollId] ?? null;
|
||||
});
|
||||
|
||||
const recordVote = async (params: { pollId: string; voterId: string; selections: string[] }) =>
|
||||
await withFileLock(filePath, empty, async () => {
|
||||
const data = await readStore();
|
||||
const poll = data.polls[params.pollId];
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeMSTeamsPollSelections(poll, params.selections);
|
||||
poll.votes[params.voterId] = normalized;
|
||||
poll.updatedAt = new Date().toISOString();
|
||||
data.polls[poll.id] = poll;
|
||||
await writeStore({ version: 1, polls: pruneToLimit(data.polls) });
|
||||
return poll;
|
||||
});
|
||||
|
||||
return { createPoll, getPoll, recordVote };
|
||||
}
|
||||
58
openclaw/extensions/msteams/src/probe.test.ts
Normal file
58
openclaw/extensions/msteams/src/probe.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { MSTeamsConfig } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hostMockState = vi.hoisted(() => ({
|
||||
tokenError: null as Error | null,
|
||||
}));
|
||||
|
||||
vi.mock("@microsoft/agents-hosting", () => ({
|
||||
getAuthConfigWithDefaults: (cfg: unknown) => cfg,
|
||||
MsalTokenProvider: class {
|
||||
async getAccessToken() {
|
||||
if (hostMockState.tokenError) {
|
||||
throw hostMockState.tokenError;
|
||||
}
|
||||
return "token";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
|
||||
describe("msteams probe", () => {
|
||||
it("returns an error when credentials are missing", async () => {
|
||||
const cfg = { enabled: true } as unknown as MSTeamsConfig;
|
||||
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("validates credentials by acquiring a token", async () => {
|
||||
hostMockState.tokenError = null;
|
||||
const cfg = {
|
||||
enabled: true,
|
||||
appId: "app",
|
||||
appPassword: "pw",
|
||||
tenantId: "tenant",
|
||||
} as unknown as MSTeamsConfig;
|
||||
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
appId: "app",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a helpful error when token acquisition fails", async () => {
|
||||
hostMockState.tokenError = new Error("bad creds");
|
||||
const cfg = {
|
||||
enabled: true,
|
||||
appId: "app",
|
||||
appPassword: "pw",
|
||||
tenantId: "tenant",
|
||||
} as unknown as MSTeamsConfig;
|
||||
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
appId: "app",
|
||||
error: "bad creds",
|
||||
});
|
||||
});
|
||||
});
|
||||
94
openclaw/extensions/msteams/src/probe.ts
Normal file
94
openclaw/extensions/msteams/src/probe.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { BaseProbeResult, MSTeamsConfig } from "openclaw/plugin-sdk";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
export type ProbeMSTeamsResult = BaseProbeResult<string> & {
|
||||
appId?: string;
|
||||
graph?: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const payload = parts[1] ?? "";
|
||||
const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
|
||||
const normalized = padded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
try {
|
||||
const decoded = Buffer.from(normalized, "base64").toString("utf8");
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const out = value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function readScopes(value: unknown): string[] | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const out = value
|
||||
.split(/\s+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsResult> {
|
||||
const creds = resolveMSTeamsCredentials(cfg);
|
||||
if (!creds) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "missing credentials (appId, appPassword, tenantId)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||
await tokenProvider.getAccessToken("https://api.botframework.com");
|
||||
let graph:
|
||||
| {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com");
|
||||
const accessToken = readAccessToken(graphToken);
|
||||
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
|
||||
graph = {
|
||||
ok: true,
|
||||
roles: readStringArray(payload?.roles),
|
||||
scopes: readScopes(payload?.scp),
|
||||
};
|
||||
} catch (err) {
|
||||
graph = { ok: false, error: formatUnknownError(err) };
|
||||
}
|
||||
return { ok: true, appId: creds.appId, ...(graph ? { graph } : {}) };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: formatUnknownError(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
132
openclaw/extensions/msteams/src/reply-dispatcher.ts
Normal file
132
openclaw/extensions/msteams/src/reply-dispatcher.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
logTypingFailure,
|
||||
resolveChannelMediaMaxBytes,
|
||||
type OpenClawConfig,
|
||||
type MSTeamsReplyStyle,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
classifyMSTeamsSendError,
|
||||
formatMSTeamsSendErrorHint,
|
||||
formatUnknownError,
|
||||
} from "./errors.js";
|
||||
import {
|
||||
type MSTeamsAdapter,
|
||||
renderReplyPayloadsToMessages,
|
||||
sendMSTeamsMessages,
|
||||
} from "./messenger.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
export function createMSTeamsReplyDispatcher(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
log: MSTeamsMonitorLogger;
|
||||
adapter: MSTeamsAdapter;
|
||||
appId: string;
|
||||
conversationRef: StoredConversationReference;
|
||||
context: MSTeamsTurnContext;
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
textLimit: number;
|
||||
onSentMessageIds?: (ids: string[]) => void;
|
||||
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
/** SharePoint site ID for file uploads in group chats/channels */
|
||||
sharePointSiteId?: string;
|
||||
}) {
|
||||
const core = getMSTeamsRuntime();
|
||||
const sendTypingIndicator = async () => {
|
||||
await params.context.sendActivity({ type: "typing" });
|
||||
};
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: sendTypingIndicator,
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => params.log.debug?.(message),
|
||||
channel: "msteams",
|
||||
action: "start",
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "msteams",
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: params.cfg,
|
||||
channel: "msteams",
|
||||
});
|
||||
const messages = renderReplyPayloadsToMessages([payload], {
|
||||
textChunkLimit: params.textLimit,
|
||||
chunkText: true,
|
||||
mediaMode: "split",
|
||||
tableMode,
|
||||
chunkMode,
|
||||
});
|
||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg: params.cfg,
|
||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||
});
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: params.replyStyle,
|
||||
adapter: params.adapter,
|
||||
appId: params.appId,
|
||||
conversationRef: params.conversationRef,
|
||||
context: params.context,
|
||||
messages,
|
||||
// Enable default retry/backoff for throttling/transient failures.
|
||||
retry: {},
|
||||
onRetry: (event) => {
|
||||
params.log.debug?.("retrying send", {
|
||||
replyStyle: params.replyStyle,
|
||||
...event,
|
||||
});
|
||||
},
|
||||
tokenProvider: params.tokenProvider,
|
||||
sharePointSiteId: params.sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
});
|
||||
if (ids.length > 0) {
|
||||
params.onSentMessageIds?.(ids);
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
const errMsg = formatUnknownError(err);
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
params.runtime.error?.(
|
||||
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
params.log.error("reply failed", {
|
||||
kind: info.kind,
|
||||
error: errMsg,
|
||||
classification,
|
||||
hint,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dispatcher,
|
||||
replyOptions: { ...replyOptions, onModelSelected },
|
||||
markDispatchIdle,
|
||||
};
|
||||
}
|
||||
198
openclaw/extensions/msteams/src/resolve-allowlist.ts
Normal file
198
openclaw/extensions/msteams/src/resolve-allowlist.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { searchGraphUsers } from "./graph-users.js";
|
||||
import {
|
||||
listChannelsForTeam,
|
||||
listTeamsByName,
|
||||
normalizeQuery,
|
||||
resolveGraphToken,
|
||||
} from "./graph.js";
|
||||
|
||||
export type MSTeamsChannelResolution = {
|
||||
input: string;
|
||||
resolved: boolean;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsUserResolution = {
|
||||
input: string;
|
||||
resolved: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
function stripProviderPrefix(raw: string): string {
|
||||
return raw.replace(/^(msteams|teams):/i, "");
|
||||
}
|
||||
|
||||
export function normalizeMSTeamsMessagingTarget(raw: string): string | undefined {
|
||||
let trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
trimmed = stripProviderPrefix(trimmed).trim();
|
||||
if (/^conversation:/i.test(trimmed)) {
|
||||
const id = trimmed.slice("conversation:".length).trim();
|
||||
return id ? `conversation:${id}` : undefined;
|
||||
}
|
||||
if (/^user:/i.test(trimmed)) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function normalizeMSTeamsUserInput(raw: string): string {
|
||||
return stripProviderPrefix(raw)
|
||||
.replace(/^(user|conversation):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseMSTeamsConversationId(raw: string): string | null {
|
||||
const trimmed = stripProviderPrefix(raw).trim();
|
||||
if (!/^conversation:/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
const id = trimmed.slice("conversation:".length).trim();
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizeMSTeamsTeamKey(raw: string): string | undefined {
|
||||
const trimmed = stripProviderPrefix(raw)
|
||||
.replace(/^team:/i, "")
|
||||
.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeMSTeamsChannelKey(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim().replace(/^#/, "").trim() ?? "";
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function parseMSTeamsTeamChannelInput(raw: string): { team?: string; channel?: string } {
|
||||
const trimmed = stripProviderPrefix(raw).trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
const parts = trimmed.split("/");
|
||||
const team = normalizeMSTeamsTeamKey(parts[0] ?? "");
|
||||
const channel =
|
||||
parts.length > 1 ? normalizeMSTeamsChannelKey(parts.slice(1).join("/")) : undefined;
|
||||
return {
|
||||
...(team ? { team } : {}),
|
||||
...(channel ? { channel } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMSTeamsTeamEntry(
|
||||
raw: string,
|
||||
): { teamKey: string; channelKey?: string } | null {
|
||||
const { team, channel } = parseMSTeamsTeamChannelInput(raw);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
teamKey: team,
|
||||
...(channel ? { channelKey: channel } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMSTeamsChannelAllowlist(params: {
|
||||
cfg: unknown;
|
||||
entries: string[];
|
||||
}): Promise<MSTeamsChannelResolution[]> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const results: MSTeamsChannelResolution[] = [];
|
||||
|
||||
for (const input of params.entries) {
|
||||
const { team, channel } = parseMSTeamsTeamChannelInput(input);
|
||||
if (!team) {
|
||||
results.push({ input, resolved: false });
|
||||
continue;
|
||||
}
|
||||
const teams = /^[0-9a-fA-F-]{16,}$/.test(team)
|
||||
? [{ id: team, displayName: team }]
|
||||
: await listTeamsByName(token, team);
|
||||
if (teams.length === 0) {
|
||||
results.push({ input, resolved: false, note: "team not found" });
|
||||
continue;
|
||||
}
|
||||
const teamMatch = teams[0];
|
||||
const teamId = teamMatch.id?.trim();
|
||||
const teamName = teamMatch.displayName?.trim() || team;
|
||||
if (!teamId) {
|
||||
results.push({ input, resolved: false, note: "team id missing" });
|
||||
continue;
|
||||
}
|
||||
if (!channel) {
|
||||
results.push({
|
||||
input,
|
||||
resolved: true,
|
||||
teamId,
|
||||
teamName,
|
||||
note: teams.length > 1 ? "multiple teams; chose first" : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const channels = await listChannelsForTeam(token, teamId);
|
||||
const channelMatch =
|
||||
channels.find((item) => item.id === channel) ??
|
||||
channels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
|
||||
channels.find((item) =>
|
||||
item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""),
|
||||
);
|
||||
if (!channelMatch?.id) {
|
||||
results.push({ input, resolved: false, note: "channel not found" });
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
input,
|
||||
resolved: true,
|
||||
teamId,
|
||||
teamName,
|
||||
channelId: channelMatch.id,
|
||||
channelName: channelMatch.displayName ?? channel,
|
||||
note: channels.length > 1 ? "multiple channels; chose first" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function resolveMSTeamsUserAllowlist(params: {
|
||||
cfg: unknown;
|
||||
entries: string[];
|
||||
}): Promise<MSTeamsUserResolution[]> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const results: MSTeamsUserResolution[] = [];
|
||||
|
||||
for (const input of params.entries) {
|
||||
const query = normalizeQuery(normalizeMSTeamsUserInput(input));
|
||||
if (!query) {
|
||||
results.push({ input, resolved: false });
|
||||
continue;
|
||||
}
|
||||
if (/^[0-9a-fA-F-]{16,}$/.test(query)) {
|
||||
results.push({ input, resolved: true, id: query });
|
||||
continue;
|
||||
}
|
||||
const users = await searchGraphUsers({ token, query, top: 10 });
|
||||
const match = users[0];
|
||||
if (!match?.id) {
|
||||
results.push({ input, resolved: false });
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
input,
|
||||
resolved: true,
|
||||
id: match.id,
|
||||
name: match.displayName ?? undefined,
|
||||
note: users.length > 1 ? "multiple matches; chose first" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
14
openclaw/extensions/msteams/src/runtime.ts
Normal file
14
openclaw/extensions/msteams/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMSTeamsRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMSTeamsRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("MSTeams runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
19
openclaw/extensions/msteams/src/sdk-types.ts
Normal file
19
openclaw/extensions/msteams/src/sdk-types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { TurnContext } from "@microsoft/agents-hosting";
|
||||
|
||||
/**
|
||||
* Minimal public surface we depend on from the Microsoft SDK types.
|
||||
*
|
||||
* Note: we intentionally avoid coupling to SDK classes with private members
|
||||
* (like TurnContext) in our own public signatures. The SDK's TS surface is also
|
||||
* stricter than what the runtime accepts (e.g. it allows plain activity-like
|
||||
* objects), so we model the minimal structural shape we rely on.
|
||||
*/
|
||||
export type MSTeamsActivity = TurnContext["activity"];
|
||||
|
||||
export type MSTeamsTurnContext = {
|
||||
activity: MSTeamsActivity;
|
||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||
sendActivities: (
|
||||
activities: Array<{ type: string } & Record<string, unknown>>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
33
openclaw/extensions/msteams/src/sdk.ts
Normal file
33
openclaw/extensions/msteams/src/sdk.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import type { MSTeamsCredentials } from "./token.js";
|
||||
|
||||
export type MSTeamsSdk = typeof import("@microsoft/agents-hosting");
|
||||
export type MSTeamsAuthConfig = ReturnType<MSTeamsSdk["getAuthConfigWithDefaults"]>;
|
||||
|
||||
export async function loadMSTeamsSdk(): Promise<MSTeamsSdk> {
|
||||
return await import("@microsoft/agents-hosting");
|
||||
}
|
||||
|
||||
export function buildMSTeamsAuthConfig(
|
||||
creds: MSTeamsCredentials,
|
||||
sdk: MSTeamsSdk,
|
||||
): MSTeamsAuthConfig {
|
||||
return sdk.getAuthConfigWithDefaults({
|
||||
clientId: creds.appId,
|
||||
clientSecret: creds.appPassword,
|
||||
tenantId: creds.tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMSTeamsAdapter(
|
||||
authConfig: MSTeamsAuthConfig,
|
||||
sdk: MSTeamsSdk,
|
||||
): MSTeamsAdapter {
|
||||
return new sdk.CloudAdapter(authConfig) as unknown as MSTeamsAdapter;
|
||||
}
|
||||
|
||||
export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) {
|
||||
const sdk = await loadMSTeamsSdk();
|
||||
const authConfig = buildMSTeamsAuthConfig(creds, sdk);
|
||||
return { sdk, authConfig };
|
||||
}
|
||||
164
openclaw/extensions/msteams/src/send-context.ts
Normal file
164
openclaw/extensions/msteams/src/send-context.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
resolveChannelMediaMaxBytes,
|
||||
type OpenClawConfig,
|
||||
type PluginRuntime,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
StoredConversationReference,
|
||||
} from "./conversation-store.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
export type MSTeamsConversationType = "personal" | "groupChat" | "channel";
|
||||
|
||||
export type MSTeamsProactiveContext = {
|
||||
appId: string;
|
||||
conversationId: string;
|
||||
ref: StoredConversationReference;
|
||||
adapter: MSTeamsAdapter;
|
||||
log: ReturnType<PluginRuntime["logging"]["getChildLogger"]>;
|
||||
/** The type of conversation: personal (1:1), groupChat, or channel */
|
||||
conversationType: MSTeamsConversationType;
|
||||
/** Token provider for Graph API / OneDrive operations */
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
/** SharePoint site ID for file uploads in group chats/channels */
|
||||
sharePointSiteId?: string;
|
||||
/** Resolved media max bytes from config (default: 100MB) */
|
||||
mediaMaxBytes?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the target value into a conversation reference lookup key.
|
||||
* Supported formats:
|
||||
* - conversation:19:abc@thread.tacv2 → lookup by conversation ID
|
||||
* - user:aad-object-id → lookup by user AAD object ID
|
||||
* - 19:abc@thread.tacv2 → direct conversation ID
|
||||
*/
|
||||
function parseRecipient(to: string): {
|
||||
type: "conversation" | "user";
|
||||
id: string;
|
||||
} {
|
||||
const trimmed = to.trim();
|
||||
const finalize = (type: "conversation" | "user", id: string) => {
|
||||
const normalized = id.trim();
|
||||
if (!normalized) {
|
||||
throw new Error(`Invalid target value: missing ${type} id`);
|
||||
}
|
||||
return { type, id: normalized };
|
||||
};
|
||||
if (trimmed.startsWith("conversation:")) {
|
||||
return finalize("conversation", trimmed.slice("conversation:".length));
|
||||
}
|
||||
if (trimmed.startsWith("user:")) {
|
||||
return finalize("user", trimmed.slice("user:".length));
|
||||
}
|
||||
// Assume it's a conversation ID if it looks like one
|
||||
if (trimmed.startsWith("19:") || trimmed.includes("@thread")) {
|
||||
return finalize("conversation", trimmed);
|
||||
}
|
||||
// Otherwise treat as user ID
|
||||
return finalize("user", trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a stored conversation reference for the given recipient.
|
||||
*/
|
||||
async function findConversationReference(recipient: {
|
||||
type: "conversation" | "user";
|
||||
id: string;
|
||||
store: MSTeamsConversationStore;
|
||||
}): Promise<{
|
||||
conversationId: string;
|
||||
ref: StoredConversationReference;
|
||||
} | null> {
|
||||
if (recipient.type === "conversation") {
|
||||
const ref = await recipient.store.get(recipient.id);
|
||||
if (ref) {
|
||||
return { conversationId: recipient.id, ref };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = await recipient.store.findByUserId(recipient.id);
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return { conversationId: found.conversationId, ref: found.reference };
|
||||
}
|
||||
|
||||
export async function resolveMSTeamsSendContext(params: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
}): Promise<MSTeamsProactiveContext> {
|
||||
const msteamsCfg = params.cfg.channels?.msteams;
|
||||
|
||||
if (!msteamsCfg?.enabled) {
|
||||
throw new Error("msteams provider is not enabled");
|
||||
}
|
||||
|
||||
const creds = resolveMSTeamsCredentials(msteamsCfg);
|
||||
if (!creds) {
|
||||
throw new Error("msteams credentials not configured");
|
||||
}
|
||||
|
||||
const store = createMSTeamsConversationStoreFs();
|
||||
|
||||
// Parse recipient and find conversation reference
|
||||
const recipient = parseRecipient(params.to);
|
||||
const found = await findConversationReference({ ...recipient, store });
|
||||
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`No conversation reference found for ${recipient.type}:${recipient.id}. ` +
|
||||
`The bot must receive a message from this conversation before it can send proactively.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { conversationId, ref } = found;
|
||||
const core = getMSTeamsRuntime();
|
||||
const log = core.logging.getChildLogger({ name: "msteams:send" });
|
||||
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const adapter = createMSTeamsAdapter(authConfig, sdk);
|
||||
|
||||
// Create token provider for Graph API / OneDrive operations
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig) as MSTeamsAccessTokenProvider;
|
||||
|
||||
// Determine conversation type from stored reference
|
||||
const storedConversationType = ref.conversation?.conversationType?.toLowerCase() ?? "";
|
||||
let conversationType: MSTeamsConversationType;
|
||||
if (storedConversationType === "personal") {
|
||||
conversationType = "personal";
|
||||
} else if (storedConversationType === "channel") {
|
||||
conversationType = "channel";
|
||||
} else {
|
||||
// groupChat, or unknown defaults to groupChat behavior
|
||||
conversationType = "groupChat";
|
||||
}
|
||||
|
||||
// Get SharePoint site ID from config (required for file uploads in group chats/channels)
|
||||
const sharePointSiteId = msteamsCfg.sharePointSiteId;
|
||||
|
||||
// Resolve media max bytes from config
|
||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg: params.cfg,
|
||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||
});
|
||||
|
||||
return {
|
||||
appId: creds.appId,
|
||||
conversationId,
|
||||
ref,
|
||||
adapter: adapter as unknown as MSTeamsAdapter,
|
||||
log,
|
||||
conversationType,
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
};
|
||||
}
|
||||
530
openclaw/extensions/msteams/src/send.ts
Normal file
530
openclaw/extensions/msteams/src/send.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { loadWebMedia, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import {
|
||||
classifyMSTeamsSendError,
|
||||
formatMSTeamsSendErrorHint,
|
||||
formatUnknownError,
|
||||
} from "./errors.js";
|
||||
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
||||
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
||||
import {
|
||||
getDriveItemProperties,
|
||||
uploadAndShareOneDrive,
|
||||
uploadAndShareSharePoint,
|
||||
} from "./graph-upload.js";
|
||||
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
||||
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
|
||||
import { buildMSTeamsPollCard } from "./polls.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
||||
|
||||
export type SendMSTeamsMessageParams = {
|
||||
/** Full config (for credentials) */
|
||||
cfg: OpenClawConfig;
|
||||
/** Conversation ID or user ID to send to */
|
||||
to: string;
|
||||
/** Message text */
|
||||
text: string;
|
||||
/** Optional media URL */
|
||||
mediaUrl?: string;
|
||||
};
|
||||
|
||||
export type SendMSTeamsMessageResult = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
/** If a FileConsentCard was sent instead of the file, this contains the upload ID */
|
||||
pendingUploadId?: string;
|
||||
};
|
||||
|
||||
/** Threshold for large files that require FileConsentCard flow in personal chats */
|
||||
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024; // 4MB
|
||||
|
||||
/**
|
||||
* MSTeams-specific media size limit (100MB).
|
||||
* Higher than the default because OneDrive upload handles large files well.
|
||||
*/
|
||||
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export type SendMSTeamsPollParams = {
|
||||
/** Full config (for credentials) */
|
||||
cfg: OpenClawConfig;
|
||||
/** Conversation ID or user ID to send to */
|
||||
to: string;
|
||||
/** Poll question */
|
||||
question: string;
|
||||
/** Poll options */
|
||||
options: string[];
|
||||
/** Max selections (defaults to 1) */
|
||||
maxSelections?: number;
|
||||
};
|
||||
|
||||
export type SendMSTeamsPollResult = {
|
||||
pollId: string;
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
export type SendMSTeamsCardParams = {
|
||||
/** Full config (for credentials) */
|
||||
cfg: OpenClawConfig;
|
||||
/** Conversation ID or user ID to send to */
|
||||
to: string;
|
||||
/** Adaptive Card JSON object */
|
||||
card: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SendMSTeamsCardResult = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to a Teams conversation or user.
|
||||
*
|
||||
* Uses the stored ConversationReference from previous interactions.
|
||||
* The bot must have received at least one message from the conversation
|
||||
* before proactive messaging works.
|
||||
*
|
||||
* File handling by conversation type:
|
||||
* - Personal (1:1) chats: small images (<4MB) use base64, large files and non-images use FileConsentCard
|
||||
* - Group chats / channels: files are uploaded to OneDrive and shared via link
|
||||
*/
|
||||
export async function sendMessageMSTeams(
|
||||
params: SendMSTeamsMessageParams,
|
||||
): Promise<SendMSTeamsMessageResult> {
|
||||
const { cfg, to, text, mediaUrl } = params;
|
||||
const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
});
|
||||
const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
||||
const {
|
||||
adapter,
|
||||
appId,
|
||||
conversationId,
|
||||
ref,
|
||||
log,
|
||||
conversationType,
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
} = ctx;
|
||||
|
||||
log.debug?.("sending proactive message", {
|
||||
conversationId,
|
||||
conversationType,
|
||||
textLength: messageText.length,
|
||||
hasMedia: Boolean(mediaUrl),
|
||||
});
|
||||
|
||||
// Handle media if present
|
||||
if (mediaUrl) {
|
||||
const mediaMaxBytes =
|
||||
resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||
}) ?? MSTEAMS_MAX_MEDIA_BYTES;
|
||||
const media = await loadWebMedia(mediaUrl, mediaMaxBytes);
|
||||
const isLargeFile = media.buffer.length >= FILE_CONSENT_THRESHOLD_BYTES;
|
||||
const isImage = media.contentType?.startsWith("image/") ?? false;
|
||||
const fallbackFileName = await extractFilename(mediaUrl);
|
||||
const fileName = media.fileName ?? fallbackFileName;
|
||||
|
||||
log.debug?.("processing media", {
|
||||
fileName,
|
||||
contentType: media.contentType,
|
||||
size: media.buffer.length,
|
||||
isLargeFile,
|
||||
isImage,
|
||||
conversationType,
|
||||
});
|
||||
|
||||
// Personal chats: base64 only works for images; use FileConsentCard for large files or non-images
|
||||
if (
|
||||
requiresFileConsent({
|
||||
conversationType,
|
||||
contentType: media.contentType,
|
||||
bufferSize: media.buffer.length,
|
||||
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
|
||||
})
|
||||
) {
|
||||
const { activity, uploadId } = prepareFileConsentActivity({
|
||||
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
|
||||
conversationId,
|
||||
description: messageText || undefined,
|
||||
});
|
||||
|
||||
log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
let messageId = "unknown";
|
||||
try {
|
||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
||||
const response = await turnCtx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`msteams consent card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
log.info("sent file consent card", { conversationId, messageId, uploadId });
|
||||
|
||||
return {
|
||||
messageId,
|
||||
conversationId,
|
||||
pendingUploadId: uploadId,
|
||||
};
|
||||
}
|
||||
|
||||
// Personal chat with small image: use base64 (only works for images)
|
||||
if (conversationType === "personal") {
|
||||
// Small image in personal chat: use base64 (only works for images)
|
||||
const base64 = media.buffer.toString("base64");
|
||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||
|
||||
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
||||
}
|
||||
|
||||
if (isImage && !sharePointSiteId) {
|
||||
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
|
||||
const base64 = media.buffer.toString("base64");
|
||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
||||
}
|
||||
|
||||
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
|
||||
try {
|
||||
if (sharePointSiteId) {
|
||||
// Use SharePoint upload + Graph API for native file card
|
||||
log.debug?.("uploading to SharePoint for native file card", {
|
||||
fileName,
|
||||
conversationType,
|
||||
siteId: sharePointSiteId,
|
||||
});
|
||||
|
||||
const uploaded = await uploadAndShareSharePoint({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType: media.contentType,
|
||||
tokenProvider,
|
||||
siteId: sharePointSiteId,
|
||||
chatId: conversationId,
|
||||
usePerUserSharing: conversationType === "groupChat",
|
||||
});
|
||||
|
||||
log.debug?.("SharePoint upload complete", {
|
||||
itemId: uploaded.itemId,
|
||||
shareUrl: uploaded.shareUrl,
|
||||
});
|
||||
|
||||
// Get driveItem properties needed for native file card
|
||||
const driveItem = await getDriveItemProperties({
|
||||
siteId: sharePointSiteId,
|
||||
itemId: uploaded.itemId,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
log.debug?.("driveItem properties retrieved", {
|
||||
eTag: driveItem.eTag,
|
||||
webDavUrl: driveItem.webDavUrl,
|
||||
});
|
||||
|
||||
// Build native Teams file card attachment and send via Bot Framework
|
||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: messageText || undefined,
|
||||
attachments: [fileCardAttachment],
|
||||
};
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
let messageId = "unknown";
|
||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
||||
const response = await turnCtx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
|
||||
log.info("sent native file card", {
|
||||
conversationId,
|
||||
messageId,
|
||||
fileName: driveItem.name,
|
||||
});
|
||||
|
||||
return { messageId, conversationId };
|
||||
}
|
||||
|
||||
// Fallback: no SharePoint site configured, use OneDrive with markdown link
|
||||
log.debug?.("uploading to OneDrive (no SharePoint site configured)", {
|
||||
fileName,
|
||||
conversationType,
|
||||
});
|
||||
|
||||
const uploaded = await uploadAndShareOneDrive({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType: media.contentType,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
log.debug?.("OneDrive upload complete", {
|
||||
itemId: uploaded.itemId,
|
||||
shareUrl: uploaded.shareUrl,
|
||||
});
|
||||
|
||||
// Send message with file link (Bot Framework doesn't support "reference" attachment type for sending)
|
||||
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
||||
};
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
let messageId = "unknown";
|
||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
||||
const response = await turnCtx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
|
||||
log.info("sent message with OneDrive file link", {
|
||||
conversationId,
|
||||
messageId,
|
||||
shareUrl: uploaded.shareUrl,
|
||||
});
|
||||
|
||||
return { messageId, conversationId };
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`msteams file send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No media: send text only
|
||||
return sendTextWithMedia(ctx, messageText, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message with optional base64 media URL.
|
||||
*/
|
||||
async function sendTextWithMedia(
|
||||
ctx: MSTeamsProactiveContext,
|
||||
text: string,
|
||||
mediaUrl: string | undefined,
|
||||
): Promise<SendMSTeamsMessageResult> {
|
||||
const {
|
||||
adapter,
|
||||
appId,
|
||||
conversationId,
|
||||
ref,
|
||||
log,
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
} = ctx;
|
||||
|
||||
let messageIds: string[];
|
||||
try {
|
||||
messageIds = await sendMSTeamsMessages({
|
||||
replyStyle: "top-level",
|
||||
adapter,
|
||||
appId,
|
||||
conversationRef: ref,
|
||||
messages: [{ text: text || undefined, mediaUrl }],
|
||||
retry: {},
|
||||
onRetry: (event) => {
|
||||
log.debug?.("retrying send", { conversationId, ...event });
|
||||
},
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
const messageId = messageIds[0] ?? "unknown";
|
||||
log.info("sent proactive message", { conversationId, messageId });
|
||||
|
||||
return {
|
||||
messageId,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
type ProactiveActivityParams = {
|
||||
adapter: MSTeamsProactiveContext["adapter"];
|
||||
appId: string;
|
||||
ref: MSTeamsProactiveContext["ref"];
|
||||
activity: Record<string, unknown>;
|
||||
errorPrefix: string;
|
||||
};
|
||||
|
||||
async function sendProactiveActivity({
|
||||
adapter,
|
||||
appId,
|
||||
ref,
|
||||
activity,
|
||||
errorPrefix,
|
||||
}: ProactiveActivityParams): Promise<string> {
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
|
||||
let messageId = "unknown";
|
||||
try {
|
||||
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
|
||||
const response = await ctx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
return messageId;
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`${errorPrefix} failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a poll (Adaptive Card) to a Teams conversation or user.
|
||||
*/
|
||||
export async function sendPollMSTeams(
|
||||
params: SendMSTeamsPollParams,
|
||||
): Promise<SendMSTeamsPollResult> {
|
||||
const { cfg, to, question, options, maxSelections } = params;
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
|
||||
const pollCard = buildMSTeamsPollCard({
|
||||
question,
|
||||
options,
|
||||
maxSelections,
|
||||
});
|
||||
|
||||
log.debug?.("sending poll", {
|
||||
conversationId,
|
||||
pollId: pollCard.pollId,
|
||||
optionCount: pollCard.options.length,
|
||||
});
|
||||
|
||||
const activity = {
|
||||
type: "message",
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
content: pollCard.card,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Send poll via proactive conversation (Adaptive Cards require direct activity send)
|
||||
const messageId = await sendProactiveActivity({
|
||||
adapter,
|
||||
appId,
|
||||
ref,
|
||||
activity,
|
||||
errorPrefix: "msteams poll send",
|
||||
});
|
||||
|
||||
log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId });
|
||||
|
||||
return {
|
||||
pollId: pollCard.pollId,
|
||||
messageId,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an arbitrary Adaptive Card to a Teams conversation or user.
|
||||
*/
|
||||
export async function sendAdaptiveCardMSTeams(
|
||||
params: SendMSTeamsCardParams,
|
||||
): Promise<SendMSTeamsCardResult> {
|
||||
const { cfg, to, card } = params;
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
|
||||
log.debug?.("sending adaptive card", {
|
||||
conversationId,
|
||||
cardType: card.type,
|
||||
cardVersion: card.version,
|
||||
});
|
||||
|
||||
const activity = {
|
||||
type: "message",
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
content: card,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Send card via proactive conversation
|
||||
const messageId = await sendProactiveActivity({
|
||||
adapter,
|
||||
appId,
|
||||
ref,
|
||||
activity,
|
||||
errorPrefix: "msteams card send",
|
||||
});
|
||||
|
||||
log.info("sent adaptive card", { conversationId, messageId });
|
||||
|
||||
return {
|
||||
messageId,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all known conversation references (for debugging/CLI).
|
||||
*/
|
||||
export async function listMSTeamsConversations(): Promise<
|
||||
Array<{
|
||||
conversationId: string;
|
||||
userName?: string;
|
||||
conversationType?: string;
|
||||
}>
|
||||
> {
|
||||
const store = createMSTeamsConversationStoreFs();
|
||||
const all = await store.list();
|
||||
return all.map(({ conversationId, reference }) => ({
|
||||
conversationId,
|
||||
userName: reference.user?.name,
|
||||
conversationType: reference.conversation?.conversationType,
|
||||
}));
|
||||
}
|
||||
15
openclaw/extensions/msteams/src/sent-message-cache.test.ts
Normal file
15
openclaw/extensions/msteams/src/sent-message-cache.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearMSTeamsSentMessageCache,
|
||||
recordMSTeamsSentMessage,
|
||||
wasMSTeamsMessageSent,
|
||||
} from "./sent-message-cache.js";
|
||||
|
||||
describe("msteams sent message cache", () => {
|
||||
it("records and resolves sent message ids", () => {
|
||||
clearMSTeamsSentMessageCache();
|
||||
recordMSTeamsSentMessage("conv-1", "msg-1");
|
||||
expect(wasMSTeamsMessageSent("conv-1", "msg-1")).toBe(true);
|
||||
expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(false);
|
||||
});
|
||||
});
|
||||
44
openclaw/extensions/msteams/src/sent-message-cache.ts
Normal file
44
openclaw/extensions/msteams/src/sent-message-cache.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
type CacheEntry = {
|
||||
timestamps: Map<string, number>;
|
||||
};
|
||||
|
||||
const sentMessages = new Map<string, CacheEntry>();
|
||||
|
||||
function cleanupExpired(entry: CacheEntry): void {
|
||||
const now = Date.now();
|
||||
for (const [msgId, timestamp] of entry.timestamps) {
|
||||
if (now - timestamp > TTL_MS) {
|
||||
entry.timestamps.delete(msgId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void {
|
||||
if (!conversationId || !messageId) {
|
||||
return;
|
||||
}
|
||||
let entry = sentMessages.get(conversationId);
|
||||
if (!entry) {
|
||||
entry = { timestamps: new Map() };
|
||||
sentMessages.set(conversationId, entry);
|
||||
}
|
||||
entry.timestamps.set(messageId, Date.now());
|
||||
if (entry.timestamps.size > 200) {
|
||||
cleanupExpired(entry);
|
||||
}
|
||||
}
|
||||
|
||||
export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean {
|
||||
const entry = sentMessages.get(conversationId);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
cleanupExpired(entry);
|
||||
return entry.timestamps.has(messageId);
|
||||
}
|
||||
|
||||
export function clearMSTeamsSentMessageCache(): void {
|
||||
sentMessages.clear();
|
||||
}
|
||||
25
openclaw/extensions/msteams/src/storage.ts
Normal file
25
openclaw/extensions/msteams/src/storage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import path from "node:path";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
export type MSTeamsStorePathOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
stateDir?: string;
|
||||
storePath?: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
export function resolveMSTeamsStorePath(params: MSTeamsStorePathOptions): string {
|
||||
if (params.storePath) {
|
||||
return params.storePath;
|
||||
}
|
||||
if (params.stateDir) {
|
||||
return path.join(params.stateDir, params.filename);
|
||||
}
|
||||
|
||||
const env = params.env ?? process.env;
|
||||
const stateDir = params.homedir
|
||||
? getMSTeamsRuntime().state.resolveStateDir(env, params.homedir)
|
||||
: getMSTeamsRuntime().state.resolveStateDir(env);
|
||||
return path.join(stateDir, params.filename);
|
||||
}
|
||||
44
openclaw/extensions/msteams/src/store-fs.ts
Normal file
44
openclaw/extensions/msteams/src/store-fs.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk";
|
||||
import { withFileLock as withPathLock } from "./file-lock.js";
|
||||
|
||||
const STORE_LOCK_OPTIONS = {
|
||||
retries: {
|
||||
retries: 10,
|
||||
factor: 2,
|
||||
minTimeout: 100,
|
||||
maxTimeout: 10_000,
|
||||
randomize: true,
|
||||
},
|
||||
stale: 30_000,
|
||||
} as const;
|
||||
|
||||
export async function readJsonFile<T>(
|
||||
filePath: string,
|
||||
fallback: T,
|
||||
): Promise<{ value: T; exists: boolean }> {
|
||||
return await readJsonFileWithFallback(filePath, fallback);
|
||||
}
|
||||
|
||||
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||
await writeJsonFileAtomically(filePath, value);
|
||||
}
|
||||
|
||||
async function ensureJsonFile(filePath: string, fallback: unknown) {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
} catch {
|
||||
await writeJsonFile(filePath, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withFileLock<T>(
|
||||
filePath: string,
|
||||
fallback: unknown,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
await ensureJsonFile(filePath, fallback);
|
||||
return await withPathLock(filePath, STORE_LOCK_OPTIONS, async () => {
|
||||
return await fn();
|
||||
});
|
||||
}
|
||||
16
openclaw/extensions/msteams/src/test-runtime.ts
Normal file
16
openclaw/extensions/msteams/src/test-runtime.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
export const msteamsRuntimeStub = {
|
||||
state: {
|
||||
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
|
||||
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const resolvedHome = homedir ? homedir() : os.homedir();
|
||||
return path.join(resolvedHome, ".openclaw");
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
23
openclaw/extensions/msteams/src/token-response.test.ts
Normal file
23
openclaw/extensions/msteams/src/token-response.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
|
||||
describe("readAccessToken", () => {
|
||||
it("returns raw string token values", () => {
|
||||
expect(readAccessToken("abc")).toBe("abc");
|
||||
});
|
||||
|
||||
it("returns accessToken from object value", () => {
|
||||
expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token");
|
||||
});
|
||||
|
||||
it("returns token fallback from object value", () => {
|
||||
expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token");
|
||||
});
|
||||
|
||||
it("returns null for unsupported values", () => {
|
||||
expect(readAccessToken({ accessToken: 123 })).toBeNull();
|
||||
expect(readAccessToken({ token: false })).toBeNull();
|
||||
expect(readAccessToken(null)).toBeNull();
|
||||
expect(readAccessToken(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
11
openclaw/extensions/msteams/src/token-response.ts
Normal file
11
openclaw/extensions/msteams/src/token-response.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function readAccessToken(value: unknown): string | null {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const token =
|
||||
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
|
||||
return typeof token === "string" ? token : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
19
openclaw/extensions/msteams/src/token.ts
Normal file
19
openclaw/extensions/msteams/src/token.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { MSTeamsConfig } from "openclaw/plugin-sdk";
|
||||
|
||||
export type MSTeamsCredentials = {
|
||||
appId: string;
|
||||
appPassword: string;
|
||||
tenantId: string;
|
||||
};
|
||||
|
||||
export function resolveMSTeamsCredentials(cfg?: MSTeamsConfig): MSTeamsCredentials | undefined {
|
||||
const appId = cfg?.appId?.trim() || process.env.MSTEAMS_APP_ID?.trim();
|
||||
const appPassword = cfg?.appPassword?.trim() || process.env.MSTEAMS_APP_PASSWORD?.trim();
|
||||
const tenantId = cfg?.tenantId?.trim() || process.env.MSTEAMS_TENANT_ID?.trim();
|
||||
|
||||
if (!appId || !appPassword || !tenantId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { appId, appPassword, tenantId };
|
||||
}
|
||||
Reference in New Issue
Block a user