feat!: move msteams to plugin

This commit is contained in:
Peter Steinberger
2026-01-16 02:58:08 +00:00
parent dae34f3a61
commit d9f9e93dee
73 changed files with 711 additions and 243 deletions

View File

@@ -0,0 +1,6 @@
# Changelog
## 2026.1.15
### Features
- Microsoft Teams channel plugin (Bot Framework) with polls, media, threads, and gateway monitor.

View File

@@ -0,0 +1,14 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import { msteamsPlugin } from "./src/channel.js";
const plugin = {
id: "msteams",
name: "Microsoft Teams",
description: "Microsoft Teams channel plugin (Bot Framework)",
register(api: ClawdbotPluginApi) {
api.registerChannel({ plugin: msteamsPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,16 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.15",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
},
"dependencies": {
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
"express": "^5.2.1",
"proper-lockfile": "^4.1.2"
}
}

View File

@@ -0,0 +1,344 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const detectMimeMock = vi.fn(async () => "image/png");
const saveMediaBufferMock = vi.fn(async () => ({
path: "/tmp/saved.png",
contentType: "image/png",
}));
const modulePaths = vi.hoisted(() => {
const downloadModuleUrl = new URL("./attachments/download.js", import.meta.url);
return {
mimeModulePath: new URL("../../../../src/media/mime.js", downloadModuleUrl).pathname,
storeModulePath: new URL("../../../../src/media/store.js", downloadModuleUrl).pathname,
};
});
vi.mock(modulePaths.mimeModulePath, () => ({
detectMime: (...args: unknown[]) => detectMimeMock(...args),
}));
vi.mock(modulePaths.storeModulePath, () => ({
saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args),
}));
describe("msteams attachments", () => {
const load = async () => {
return await import("./attachments.js");
};
beforeEach(() => {
detectMimeMock.mockClear();
saveMediaBufferMock.mockClear();
});
describe("buildMSTeamsAttachmentPlaceholder", () => {
it("returns empty string when no attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe("");
expect(buildMSTeamsAttachmentPlaceholder([])).toBe("");
});
it("returns image placeholder for image attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "image/png", contentUrl: "https://x/img.png" },
]),
).toBe("<media:image>");
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "image/png", contentUrl: "https://x/1.png" },
{ contentType: "image/jpeg", contentUrl: "https://x/2.jpg" },
]),
).toBe("<media:image> (2 images)");
});
it("treats Teams file.download.info image attachments as images", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{
contentType: "application/vnd.microsoft.teams.file.download.info",
content: { downloadUrl: "https://x/dl", fileType: "png" },
},
]),
).toBe("<media:image>");
});
it("returns document placeholder for non-image attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
]),
).toBe("<media:document>");
expect(
buildMSTeamsAttachmentPlaceholder([
{ contentType: "application/pdf", contentUrl: "https://x/1.pdf" },
{ contentType: "application/pdf", contentUrl: "https://x/2.pdf" },
]),
).toBe("<media:document> (2 files)");
});
it("counts inline images in text/html attachments", async () => {
const { buildMSTeamsAttachmentPlaceholder } = await load();
expect(
buildMSTeamsAttachmentPlaceholder([
{
contentType: "text/html",
content: '<p>hi</p><img src="https://x/a.png" />',
},
]),
).toBe("<media:image>");
expect(
buildMSTeamsAttachmentPlaceholder([
{
contentType: "text/html",
content: '<img src="https://x/a.png" /><img src="https://x/b.png" />',
},
]),
).toBe("<media:image> (2 images)");
});
});
describe("downloadMSTeamsImageAttachments", () => {
it("downloads and stores image contentUrl attachments", async () => {
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsImageAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(fetchMock).toHaveBeenCalledWith("https://x/img");
expect(saveMediaBufferMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
expect(media[0]?.path).toBe("/tmp/saved.png");
});
it("supports Teams file.download.info downloadUrl attachments", async () => {
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsImageAttachments({
attachments: [
{
contentType: "application/vnd.microsoft.teams.file.download.info",
content: { downloadUrl: "https://x/dl", fileType: "png" },
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(fetchMock).toHaveBeenCalledWith("https://x/dl");
expect(media).toHaveLength(1);
});
it("downloads inline image URLs from html attachments", async () => {
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsImageAttachments({
attachments: [
{
contentType: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith("https://x/inline.png");
});
it("stores inline data:image base64 payloads", async () => {
const { downloadMSTeamsImageAttachments } = await load();
const base64 = Buffer.from("png").toString("base64");
const media = await downloadMSTeamsImageAttachments({
attachments: [
{
contentType: "text/html",
content: `<img src="data:image/png;base64,${base64}" />`,
},
],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
});
expect(media).toHaveLength(1);
expect(saveMediaBufferMock).toHaveBeenCalled();
});
it("retries with auth when the first request is unauthorized", async () => {
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const hasAuth = Boolean(
opts &&
typeof opts === "object" &&
"headers" in opts &&
(opts.headers as Record<string, string>)?.Authorization,
);
if (!hasAuth) {
return new Response("unauthorized", { status: 401 });
}
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsImageAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(fetchMock).toHaveBeenCalled();
expect(media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("skips urls outside the allowlist", async () => {
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn();
const media = await downloadMSTeamsImageAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(media).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled();
});
it("ignores non-image attachments", async () => {
const { downloadMSTeamsImageAttachments } = await load();
const fetchMock = vi.fn();
const media = await downloadMSTeamsImageAttachments({
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(media).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("buildMSTeamsGraphMessageUrls", () => {
it("builds channel message urls", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
conversationId: "19:thread@thread.tacv2",
messageId: "123",
channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
});
expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123");
});
it("builds channel reply urls when replyToId is present", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
messageId: "reply-id",
replyToId: "root-id",
channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
});
expect(urls[0]).toContain(
"/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id",
);
});
it("builds chat message urls", async () => {
const { buildMSTeamsGraphMessageUrls } = await load();
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "groupChat",
conversationId: "19:chat@thread.v2",
messageId: "456",
});
expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456");
});
});
describe("downloadMSTeamsGraphMedia", () => {
it("downloads hostedContents images", async () => {
const { downloadMSTeamsGraphMedia } = await load();
const base64 = Buffer.from("png").toString("base64");
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("/hostedContents")) {
return new Response(
JSON.stringify({
value: [
{
id: "1",
contentType: "image/png",
contentBytes: base64,
},
],
}),
{ status: 200 },
);
}
if (url.endsWith("/attachments")) {
return new Response(JSON.stringify({ value: [] }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(media.media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalled();
expect(saveMediaBufferMock).toHaveBeenCalled();
});
});
describe("buildMSTeamsMediaPayload", () => {
it("returns single and multi-file fields", async () => {
const { buildMSTeamsMediaPayload } = await load();
const payload = buildMSTeamsMediaPayload([
{ path: "/tmp/a.png", contentType: "image/png" },
{ path: "/tmp/b.png", contentType: "image/png" },
]);
expect(payload.MediaPath).toBe("/tmp/a.png");
expect(payload.MediaUrl).toBe("/tmp/a.png");
expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]);
expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]);
expect(payload.MediaTypes).toEqual(["image/png", "image/png"]);
});
});
});

View File

@@ -0,0 +1,14 @@
export { 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";

View File

@@ -0,0 +1,192 @@
import { detectMime } from "../../../../src/media/mime.js";
import { saveMediaBuffer } from "../../../../src/media/store.js";
import {
extractInlineImageCandidates,
inferPlaceholder,
isLikelyImageAttachment,
isRecord,
isUrlAllowed,
normalizeContentType,
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/.default", "https://api.botframework.com/.default"]
: ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
} catch {
return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
}
}
async function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch;
const firstAttempt = await fetchFn(params.url);
if (firstAttempt.ok) return firstAttempt;
if (!params.tokenProvider) return firstAttempt;
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) return firstAttempt;
const scopes = scopeCandidatesForUrl(params.url);
for (const scope of scopes) {
try {
const token = await params.tokenProvider.getAccessToken(scope);
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) return res;
} catch {
// Try the next scope.
}
}
return firstAttempt;
}
export async function downloadMSTeamsImageAttachments(params: {
attachments: MSTeamsAttachmentLike[] | undefined;
maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[];
fetchFn?: typeof fetch;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) return [];
const allowHosts = resolveAllowedHosts(params.allowHosts);
const candidates: DownloadCandidate[] = list
.filter(isLikelyImageAttachment)
.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 {
const saved = await 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 res = await fetchWithAuthFallback({
url: candidate.url,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
if (!res.ok) continue;
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.byteLength > params.maxBytes) continue;
const mime = await detectMime({
buffer,
headerMime: res.headers.get("content-type"),
filePath: candidate.fileHint ?? candidate.url,
});
const saved = await saveMediaBuffer(
buffer,
mime ?? candidate.contentTypeHint,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: candidate.placeholder,
});
} catch {
// Ignore download failures and continue.
}
}
return out;
}

View File

@@ -0,0 +1,230 @@
import { detectMime } from "../../../../src/media/mime.js";
import { saveMediaBuffer } from "../../../../src/media/store.js";
import { downloadMSTeamsImageAttachments } from "./download.js";
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
import type {
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;
}): Promise<{ status: number; items: T[] }> {
const fetchFn = params.fetchFn ?? fetch;
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${params.accessToken}` },
});
const status = res.status;
if (!res.ok) return { status, items: [] };
try {
const data = (await res.json()) as { value?: T[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
}
}
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,
};
}
async function downloadGraphHostedImages(params: {
accessToken: string;
messageUrl: string;
maxBytes: number;
fetchFn?: typeof fetch;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = await fetchGraphCollection<GraphHostedContent>({
url: `${params.messageUrl}/hostedContents`,
accessToken: params.accessToken,
fetchFn: params.fetchFn,
});
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 detectMime({
buffer,
headerMime: item.contentType ?? undefined,
});
if (mime && !mime.startsWith("image/")) continue;
try {
const saved = await saveMediaBuffer(
buffer,
mime ?? item.contentType ?? undefined,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
} 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[];
fetchFn?: typeof fetch;
}): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
const allowHosts = resolveAllowedHosts(params.allowHosts);
const messageUrl = params.messageUrl;
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
} catch {
return { media: [], messageUrl, tokenError: true };
}
const hosted = await downloadGraphHostedImages({
accessToken,
messageUrl,
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
});
const attachments = await fetchGraphCollection<GraphAttachment>({
url: `${messageUrl}/attachments`,
accessToken,
fetchFn: params.fetchFn,
});
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
const attachmentMedia = await downloadMSTeamsImageAttachments({
attachments: normalizedAttachments,
maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider,
allowHosts,
fetchFn: params.fetchFn,
});
return {
media: [...hosted.media, ...attachmentMedia],
hostedCount: hosted.count,
attachmentCount: attachments.items.length,
hostedStatus: hosted.status,
attachmentStatus: attachments.status,
messageUrl,
};
}

View File

@@ -0,0 +1,76 @@
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)` : ""}`;
}

View File

@@ -0,0 +1,22 @@
export function buildMSTeamsMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType ?? "");
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined,
};
}

View File

@@ -0,0 +1,202 @@
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",
] 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 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;
}
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";
}
}
function normalizeAllowHost(value: string): string {
const trimmed = value.trim().toLowerCase();
if (!trimmed) return "";
if (trimmed === "*") return "*";
return trimmed.replace(/^\*\.?/, "");
}
export function resolveAllowedHosts(input?: string[]): string[] {
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) return ["*"];
return normalized;
}
function isHostAllowed(host: string, allowlist: string[]): boolean {
if (allowlist.includes("*")) return true;
const normalized = host.toLowerCase();
return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
}
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
return isHostAllowed(parsed.hostname, allowlist);
} catch {
return false;
}
}

View 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;
};

View File

@@ -0,0 +1,169 @@
import type { ClawdbotConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { PAIRING_APPROVED_MESSAGE } from "../../../src/channels/plugins/pairing-message.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../../src/channels/plugins/types.js";
import { msteamsOnboardingAdapter } from "./onboarding.js";
import { msteamsOutbound } from "./outbound.js";
import { sendMessageMSTeams } from "./send.js";
import { resolveMSTeamsCredentials } from "./token.js";
type ResolvedMSTeamsAccount = {
accountId: string;
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,
},
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,
},
reload: { configPrefixes: ["channels.msteams"] },
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 ClawdbotConfig;
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()),
},
security: {
collectWarnings: ({ cfg }) => {
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? "allowlist";
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,
},
},
}),
},
actions: {
listActions: ({ cfg }) => {
const enabled =
cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
if (!enabled) return [];
return ["poll"] satisfies ChannelMessageActionName[];
},
},
outbound: msteamsOutbound,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
port: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
buildAccountSnapshot: ({ account, runtime }) => ({
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,
}),
},
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,
});
},
},
};

View File

@@ -0,0 +1,71 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { StoredConversationReference } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
describe("msteams conversation store (fs)", () => {
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));
const env: NodeJS.ProcessEnv = {
...process.env,
CLAWDBOT_STATE_DIR: stateDir,
};
const store = createMSTeamsConversationStoreFs({ env, ttlMs: 1_000 });
const ref: StoredConversationReference = {
conversation: { id: "19:active@thread.tacv2" },
channelId: "msteams",
serviceUrl: "https://service.example.com",
user: { id: "u1", aadObjectId: "aad1" },
};
await store.upsert("19:active@thread.tacv2", ref);
const filePath = path.join(stateDir, "msteams-conversations.json");
const raw = await fs.promises.readFile(filePath, "utf-8");
const json = JSON.parse(raw) as {
version: number;
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>;
};
json.conversations["19:old@thread.tacv2"] = {
...ref,
conversation: { id: "19:old@thread.tacv2" },
lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
};
// Legacy entry without lastSeenAt should be preserved.
json.conversations["19:legacy@thread.tacv2"] = {
...ref,
conversation: { id: "19:legacy@thread.tacv2" },
};
await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
const list = await store.list();
const ids = list.map((e) => e.conversationId).sort();
expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
expect(await store.get("19:old@thread.tacv2")).toBeNull();
expect(await store.get("19:legacy@thread.tacv2")).not.toBeNull();
await store.upsert("19:new@thread.tacv2", {
...ref,
conversation: { id: "19:new@thread.tacv2" },
});
const rawAfter = await fs.promises.readFile(filePath, "utf-8");
const jsonAfter = JSON.parse(rawAfter) as typeof json;
expect(Object.keys(jsonAfter.conversations).sort()).toEqual([
"19:active@thread.tacv2",
"19:legacy@thread.tacv2",
"19:new@thread.tacv2",
]);
});
});

View File

@@ -0,0 +1,155 @@
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 };
}

View File

@@ -0,0 +1,45 @@
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;
},
};
}

View 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>;
};

View File

@@ -0,0 +1,46 @@
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");
});
});

View File

@@ -0,0 +1,158 @@
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;
}

View File

@@ -0,0 +1,67 @@
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);
});
});
});

View File

@@ -0,0 +1,38 @@
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);
}

View 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";

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import { SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
import type { StoredConversationReference } from "./conversation-store.js";
import {
type MSTeamsAdapter,
renderReplyPayloadsToMessages,
sendMSTeamsMessages,
} from "./messenger.js";
describe("msteams messenger", () => {
describe("renderReplyPayloadsToMessages", () => {
it("filters silent replies", () => {
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
textChunkLimit: 4000,
});
expect(messages).toEqual([]);
});
it("filters silent reply prefixes", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
{ textChunkLimit: 4000 },
);
expect(messages).toEqual([]);
});
it("splits media into separate messages by default", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000 },
);
expect(messages).toEqual(["hi", "https://example.com/a.png"]);
});
it("supports inline media mode", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, mediaMode: "inline" },
);
expect(messages).toEqual(["hi\n\nhttps://example.com/a.png"]);
});
it("chunks long text when enabled", () => {
const long = "hello ".repeat(200);
const messages = renderReplyPayloadsToMessages([{ text: long }], {
textChunkLimit: 50,
});
expect(messages.length).toBeGreaterThan(1);
});
});
describe("sendMSTeamsMessages", () => {
const baseRef: StoredConversationReference = {
activityId: "activity123",
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: async (activity: unknown) => {
const { text } = activity as { text?: string };
sent.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
};
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: baseRef,
context: ctx,
messages: ["one", "two"],
});
expect(sent).toEqual(["one", "two"]);
expect(ids).toEqual(["id:one", "id:two"]);
});
it("sends top-level messages via continueConversation and strips activityId", async () => {
const seen: { reference?: unknown; texts: string[] } = { texts: [] };
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, reference, logic) => {
seen.reference = reference;
await logic({
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
seen.texts.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
});
},
};
const ids = await sendMSTeamsMessages({
replyStyle: "top-level",
adapter,
appId: "app123",
conversationRef: baseRef,
messages: ["hello"],
});
expect(seen.texts).toEqual(["hello"]);
expect(ids).toEqual(["id:hello"]);
const ref = seen.reference as {
activityId?: string;
conversation?: { id?: string };
};
expect(ref.activityId).toBeUndefined();
expect(ref.conversation?.id).toBe("19:abc@thread.tacv2");
});
it("retries thread sends on throttling (429)", async () => {
const attempts: string[] = [];
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
const ctx = {
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
attempts.push(text ?? "");
if (attempts.length === 1) {
throw Object.assign(new Error("throttled"), { statusCode: 429 });
}
return { id: `id:${text ?? ""}` };
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
};
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: baseRef,
context: ctx,
messages: ["one"],
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
});
expect(attempts).toEqual(["one", "one"]);
expect(ids).toEqual(["id:one"]);
expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
});
it("does not retry thread sends on client errors (4xx)", async () => {
const ctx = {
sendActivity: async () => {
throw Object.assign(new Error("bad request"), { statusCode: 400 });
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async () => {},
};
await expect(
sendMSTeamsMessages({
replyStyle: "thread",
adapter,
appId: "app123",
conversationRef: baseRef,
context: ctx,
messages: ["one"],
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
}),
).rejects.toMatchObject({ statusCode: 400 });
});
it("retries top-level sends on transient (5xx)", async () => {
const attempts: string[] = [];
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: async (activity: unknown) => {
const { text } = activity as { text?: string };
attempts.push(text ?? "");
if (attempts.length === 1) {
throw Object.assign(new Error("server error"), {
statusCode: 503,
});
}
return { id: `id:${text ?? ""}` };
},
});
},
};
const ids = await sendMSTeamsMessages({
replyStyle: "top-level",
adapter,
appId: "app123",
conversationRef: baseRef,
messages: ["hello"],
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
});
expect(attempts).toEqual(["hello", "hello"]);
expect(ids).toEqual(["id:hello"]);
});
});
});

View File

@@ -0,0 +1,291 @@
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../src/auto-reply/tokens.js";
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import type { MSTeamsReplyStyle } from "../../../src/config/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { classifyMSTeamsSendError } from "./errors.js";
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";
};
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 extractMessageId(response: unknown): string | null {
if (!response || typeof response !== "object") return null;
if (!("id" in response)) return null;
const { id } = response as { id?: unknown };
if (typeof id !== "string" || !id) return null;
return id;
}
function pushTextMessages(
out: string[],
text: string,
opts: {
chunkText: boolean;
chunkLimit: number;
},
) {
if (!text) return;
if (opts.chunkText) {
for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push(trimmed);
}
return;
}
const trimmed = text.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
out.push(trimmed);
}
function clampMs(value: number, maxMs: number): number {
if (!Number.isFinite(value) || value < 0) return 0;
return Math.min(value, maxMs);
}
async function sleep(ms: number): Promise<void> {
const delay = Math.max(0, ms);
if (delay === 0) return;
await new Promise<void>((resolve) => {
setTimeout(resolve, delay);
});
}
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,
): string[] {
const out: string[] = [];
const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split";
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
pushTextMessages(out, text, { chunkText, chunkLimit });
continue;
}
if (mediaMode === "inline") {
const combined = text ? `${text}\n\n${mediaList.join("\n")}` : mediaList.join("\n");
pushTextMessages(out, combined, { chunkText, chunkLimit });
continue;
}
// mediaMode === "split"
pushTextMessages(out, text, { chunkText, chunkLimit });
for (const mediaUrl of mediaList) {
if (!mediaUrl) continue;
out.push(mediaUrl);
}
}
return out;
}
export async function sendMSTeamsMessages(params: {
replyStyle: MSTeamsReplyStyle;
adapter: MSTeamsAdapter;
appId: string;
conversationRef: StoredConversationReference;
context?: SendContext;
messages: string[];
retry?: false | MSTeamsSendRetryOptions;
onRetry?: (event: MSTeamsSendRetryEvent) => void;
}): Promise<string[]> {
const messages = params.messages
.map((m) => (typeof m === "string" ? m : String(m)))
.filter((m) => m.trim().length > 0);
if (messages.length === 0) return [];
const retryOptions = resolveRetryOptions(params.retry);
const sendWithRetry = async (
sendOnce: () => Promise<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;
}
}
};
if (params.replyStyle === "thread") {
const ctx = params.context;
if (!ctx) {
throw new Error("Missing context for replyStyle=thread");
}
const messageIds: string[] = [];
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity({
type: "message",
text: message,
}),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");
}
return messageIds;
}
const baseRef = buildConversationReference(params.conversationRef);
const proactiveRef: MSTeamsConversationReference = {
...baseRef,
activityId: undefined,
};
const messageIds: string[] = [];
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry(
async () =>
await ctx.sendActivity({
type: "message",
text: message,
}),
{ messageIndex: idx, messageCount: messages.length },
);
messageIds.push(extractMessageId(response) ?? "unknown");
}
});
return messageIds;
}

View File

@@ -0,0 +1,63 @@
import type { ClawdbotConfig } from "../../../src/config/types.js";
import { danger } from "../../../src/globals.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.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;
};
export type MSTeamsMessageHandlerDeps = {
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
appId: string;
adapter: MSTeamsAdapter;
tokenProvider: MSTeamsAccessTokenProvider;
textLimit: number;
mediaMaxBytes: number;
conversationStore: MSTeamsConversationStore;
pollStore: MSTeamsPollStore;
log: MSTeamsMonitorLogger;
};
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
handler: T,
deps: MSTeamsMessageHandlerDeps,
): T {
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
handler.onMessage(async (context, next) => {
try {
await handleTeamsMessage(context as MSTeamsTurnContext);
} catch (err) {
deps.runtime.error?.(danger(`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;
}

View File

@@ -0,0 +1,118 @@
import {
buildMSTeamsGraphMessageUrls,
downloadMSTeamsGraphMedia,
downloadMSTeamsImageAttachments,
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[];
tokenProvider: MSTeamsAccessTokenProvider;
conversationType: string;
conversationId: string;
conversationMessageId?: string;
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
log: MSTeamsLogger;
}): Promise<MSTeamsInboundMedia[]> {
const {
attachments,
htmlSummary,
maxBytes,
tokenProvider,
allowHosts,
conversationType,
conversationId,
conversationMessageId,
activity,
log,
} = params;
let mediaList = await downloadMSTeamsImageAttachments({
attachments,
maxBytes,
tokenProvider,
allowHosts,
});
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,
});
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 image 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;
}

View File

@@ -0,0 +1,507 @@
import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../../../../src/auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../../../src/auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "../../../../src/auto-reply/reply/history.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../../../src/pairing/pairing-store.js";
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
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,
resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig,
} from "../policy.js";
import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import type { MSTeamsTurnContext } from "../sdk-types.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 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 = resolveInboundDebounceMs({ cfg, channel: "msteams" });
type MSTeamsDebounceEntry = {
context: MSTeamsTurnContext;
rawText: string;
text: string;
attachments: MSTeamsAttachmentLike[];
wasMentioned: 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 storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []);
// Check DM policy for direct messages.
if (isDirectMessage && msteamsCfg) {
const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
const allowFrom = msteamsCfg.allowFrom ?? [];
if (dmPolicy === "disabled") {
log.debug("dropping dm (dms disabled)");
return;
}
if (dmPolicy !== "open") {
const effectiveAllowFrom = [
...allowFrom.map((v) => String(v).toLowerCase()),
...storedAllowFrom,
];
const senderLower = senderId.toLowerCase();
const senderNameLower = senderName.toLowerCase();
const allowed =
effectiveAllowFrom.includes("*") ||
effectiveAllowFrom.includes(senderLower) ||
effectiveAllowFrom.includes(senderNameLower);
if (!allowed) {
if (dmPolicy === "pairing") {
const request = await upsertChannelPairingRequest({
channel: "msteams",
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,
});
return;
}
}
}
if (!isDirectMessage && msteamsCfg) {
const groupPolicy = msteamsCfg.groupPolicy ?? "allowlist";
const groupAllowFrom =
msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : []);
const effectiveGroupAllowFrom = [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom];
if (groupPolicy === "disabled") {
log.debug("dropping group message (groupPolicy: disabled)", {
conversationId,
});
return;
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
log.debug("dropping group message (groupPolicy: allowlist, no groupAllowFrom)", {
conversationId,
});
return;
}
const allowed = isMSTeamsGroupAllowed({
groupPolicy,
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
});
if (!allowed) {
log.debug("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
});
return;
}
}
}
// Build conversation reference for proactive replies.
const agent = activity.recipient;
const teamId = activity.channelData?.team?.id;
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 = resolveAgentRoute({
cfg,
channel: "msteams",
peer: {
kind: isDirectMessage ? "dm" : 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}`;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
});
const channelId = conversationId;
const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({
cfg: msteamsCfg,
teamId,
conversationId: channelId,
});
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
isDirectMessage,
globalConfig: msteamsCfg,
teamConfig,
channelConfig,
});
if (!isDirectMessage) {
const mentioned = params.wasMentioned;
if (requireMention && !mentioned) {
log.debug("skipping message (mention required)", {
teamId,
channelId,
requireMention,
mentioned,
});
return;
}
}
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
const mediaList = await resolveMSTeamsInboundMedia({
attachments,
htmlSummary: htmlSummary ?? undefined,
maxBytes: mediaMaxBytes,
tokenProvider,
allowHosts: msteamsCfg?.mediaAllowHosts,
conversationType,
conversationId,
conversationMessageId: conversationMessageId ?? undefined,
activity: {
id: activity.id,
replyToId: activity.replyToId,
channelData: activity.channelData,
},
log,
});
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const body = formatAgentEnvelope({
channel: "Teams",
from: senderName,
timestamp,
body: rawBody,
});
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
}),
});
}
const ctxPayload = {
Body: combinedBody,
RawBody: rawBody,
CommandBody: rawBody,
From: teamsFrom,
To: teamsTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isChannel ? "room" : "group",
GroupSubject: !isDirectMessage ? conversationType : undefined,
SenderName: senderName,
SenderId: senderId,
Provider: "msteams" as const,
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned,
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
...mediaPayload,
};
if (shouldLogVerbose()) {
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
}
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
cfg,
agentId: route.agentId,
runtime,
log,
adapter,
appId,
conversationRef,
context,
replyStyle,
textLimit,
});
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
});
markDispatchIdle();
log.info("dispatch complete", { queuedFinal, counts });
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({
historyMap: conversationHistories,
historyKey,
});
}
return;
}
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
}
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
try {
await context.sendActivity(
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
);
} catch {
// Best effort.
}
}
};
const inboundDebouncer = 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 !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);
await handleTeamsMessageNow({
context: last.context,
rawText: combinedRawText,
text: combinedText,
attachments: [],
wasMentioned,
});
},
onError: (err) => {
runtime.error?.(danger(`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);
await inboundDebouncer.enqueue({ context, rawText, text, attachments, wasMentioned });
};
}

View 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;
};

View File

@@ -0,0 +1,147 @@
import type { Request, Response } from "express";
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
import type { ClawdbotConfig } from "../../../src/config/types.js";
import { getChildLogger } from "../../../src/logging.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import { formatUnknownError } from "./errors.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { registerMSTeamsHandlers } from "./monitor-handler.js";
import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
const log = getChildLogger({ name: "msteams" });
export type MonitorMSTeamsOpts = {
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
conversationStore?: MSTeamsConversationStore;
pollStore?: MSTeamsPollStore;
};
export type MonitorMSTeamsResult = {
app: unknown;
shutdown: () => Promise<void>;
};
export async function monitorMSTeamsProvider(
opts: MonitorMSTeamsOpts,
): Promise<MonitorMSTeamsResult> {
const cfg = opts.cfg;
const 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}`);
},
};
const port = msteamsCfg.webhook?.port ?? 3978;
const textLimit = 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(), {
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());
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) => {
type HandlerContext = Parameters<(typeof handler)["run"]>[0];
void adapter
.process(req, res, (context: unknown) => handler.run(context as HandlerContext))
.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 };
}

View File

@@ -0,0 +1,197 @@
import type { ClawdbotConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
} from "../../../src/channels/plugins/onboarding-types.js";
import { addWildcardAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js";
import { resolveMSTeamsCredentials } from "./token.js";
const channel = "msteams" as const;
function setMSTeamsDmPolicy(cfg: ClawdbotConfig, 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 } : {}),
},
},
};
}
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",
);
}
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),
};
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 = String(
await prompter.text({
message: "Enter MS Teams App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appPassword = String(
await prompter.text({
message: "Enter MS Teams App Password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
tenantId = String(
await prompter.text({
message: "Enter MS Teams Tenant ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigCreds) {
const keep = await prompter.confirm({
message: "MS Teams credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
appId = String(
await prompter.text({
message: "Enter MS Teams App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appPassword = String(
await prompter.text({
message: "Enter MS Teams App Password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
tenantId = String(
await prompter.text({
message: "Enter MS Teams Tenant ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
appId = String(
await prompter.text({
message: "Enter MS Teams App ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
appPassword = String(
await prompter.text({
message: "Enter MS Teams App Password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
tenantId = String(
await prompter.text({
message: "Enter MS Teams Tenant ID",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (appId && appPassword && tenantId) {
next = {
...next,
channels: {
...next.channels,
msteams: {
...next.channels?.msteams,
enabled: true,
appId,
appPassword,
tenantId,
},
},
};
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
msteams: { ...cfg.channels?.msteams, enabled: false },
},
}),
};

View File

@@ -0,0 +1,58 @@
import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import { createMSTeamsPollStoreFs } from "./polls.js";
import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkMarkdownText,
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, deps }) => {
const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text);
return { channel: "msteams", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => {
const send =
deps?.sendMSTeams ??
((to, text, opts) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }));
const result = await send(to, text, { mediaUrl });
return { channel: "msteams", ...result };
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;
const result = await sendPollMSTeams({
cfg,
to,
question: poll.question,
options: poll.options,
maxSelections,
});
const pollStore = createMSTeamsPollStoreFs();
await pollStore.createPoll({
id: result.pollId,
question: poll.question,
options: poll.options,
maxSelections,
createdAt: new Date().toISOString(),
conversationId: result.conversationId,
messageId: result.messageId,
votes: {},
});
return result;
},
};

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import type { MSTeamsConfig } from "../../../src/config/types.js";
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);
});
it("returns undefined configs when teamId is missing", () => {
const cfg: MSTeamsConfig = {
teams: { team123: { requireMention: false } },
};
const res = resolveMSTeamsRouteConfig({
cfg,
teamId: undefined,
conversationId: "chan",
});
expect(res.teamConfig).toBeUndefined();
expect(res.channelConfig).toBeUndefined();
});
});
describe("resolveMSTeamsReplyPolicy", () => {
it("forces thread replies for direct messages", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: true,
globalConfig: { replyStyle: "top-level", requireMention: false },
});
expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
});
it("defaults to requireMention=true and replyStyle=thread", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
globalConfig: {},
});
expect(policy).toEqual({ requireMention: true, replyStyle: "thread" });
});
it("defaults replyStyle to top-level when requireMention=false", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
globalConfig: { requireMention: false },
});
expect(policy).toEqual({
requireMention: false,
replyStyle: "top-level",
});
});
it("prefers channel overrides over team and global defaults", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
globalConfig: { requireMention: true },
teamConfig: { requireMention: true },
channelConfig: { requireMention: false },
});
// requireMention from channel -> false, and replyStyle defaults from requireMention -> top-level
expect(policy).toEqual({
requireMention: false,
replyStyle: "top-level",
});
});
it("uses explicit replyStyle even when requireMention defaults would differ", () => {
const policy = resolveMSTeamsReplyPolicy({
isDirectMessage: false,
globalConfig: { requireMention: false, replyStyle: "thread" },
});
expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
});
});
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("allows allowlist when sender name matches", () => {
expect(
isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["user"],
senderId: "other",
senderName: "User",
}),
).toBe(true);
});
it("allows allowlist wildcard", () => {
expect(
isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["*"],
senderId: "other",
senderName: "User",
}),
).toBe(true);
});
});
});

View File

@@ -0,0 +1,76 @@
import type {
GroupPolicy,
MSTeamsChannelConfig,
MSTeamsConfig,
MSTeamsReplyStyle,
MSTeamsTeamConfig,
} from "../../../src/config/types.js";
export type MSTeamsResolvedRouteConfig = {
teamConfig?: MSTeamsTeamConfig;
channelConfig?: MSTeamsChannelConfig;
};
export function resolveMSTeamsRouteConfig(params: {
cfg?: MSTeamsConfig;
teamId?: string | null | undefined;
conversationId?: string | null | undefined;
}): MSTeamsResolvedRouteConfig {
const teamId = params.teamId?.trim();
const conversationId = params.conversationId?.trim();
const teamConfig = teamId ? params.cfg?.teams?.[teamId] : undefined;
const channelConfig =
teamConfig && conversationId ? teamConfig.channels?.[conversationId] : undefined;
return { teamConfig, channelConfig };
}
export type MSTeamsReplyPolicy = {
requireMention: boolean;
replyStyle: MSTeamsReplyStyle;
};
export function resolveMSTeamsReplyPolicy(params: {
isDirectMessage: boolean;
globalConfig?: MSTeamsConfig;
teamConfig?: MSTeamsTeamConfig;
channelConfig?: MSTeamsChannelConfig;
}): MSTeamsReplyPolicy {
if (params.isDirectMessage) {
return { requireMention: false, replyStyle: "thread" };
}
const requireMention =
params.channelConfig?.requireMention ??
params.teamConfig?.requireMention ??
params.globalConfig?.requireMention ??
true;
const explicitReplyStyle =
params.channelConfig?.replyStyle ??
params.teamConfig?.replyStyle ??
params.globalConfig?.replyStyle;
const replyStyle: MSTeamsReplyStyle =
explicitReplyStyle ?? (requireMention ? "thread" : "top-level");
return { requireMention, replyStyle };
}
export function isMSTeamsGroupAllowed(params: {
groupPolicy: GroupPolicy;
allowFrom: Array<string | number>;
senderId: string;
senderName?: string | null;
}): boolean {
const { groupPolicy } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
const allowFrom = params.allowFrom
.map((entry) => String(entry).trim().toLowerCase())
.filter(Boolean);
if (allowFrom.length === 0) return false;
if (allowFrom.includes("*")) return true;
const senderId = params.senderId.toLowerCase();
const senderName = params.senderName?.toLowerCase();
return allowFrom.includes(senderId) || (senderName ? allowFrom.includes(senderName) : false);
}

View File

@@ -0,0 +1,30 @@
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 };
}

View File

@@ -0,0 +1,40 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createMSTeamsPollStoreFs } from "./polls.js";
import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js";
const createFsStore = async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-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"]);
});
});

View File

@@ -0,0 +1,55 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
describe("msteams polls", () => {
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: {
clawdbotPollId: "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(), "clawdbot-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"]);
});
});

View File

@@ -0,0 +1,299 @@
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, ["clawdbotPollId"]) ??
readNestedString(value, ["pollId"]) ??
readNestedString(value, ["clawdbot", "pollId"]) ??
readNestedString(value, ["clawdbot", "poll", "id"]) ??
readNestedString(value, ["data", "clawdbotPollId"]) ??
readNestedString(value, ["data", "pollId"]) ??
readNestedString(value, ["data", "clawdbot", "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: {
clawdbotPollId: pollId,
},
msteams: {
type: "messageBack",
text: "clawdbot poll vote",
displayText: "Vote recorded",
value: { clawdbotPollId: 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 };
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import type { MSTeamsConfig } from "../../../src/config/types.js";
const hostMockState = vi.hoisted(() => ({
tokenError: null as Error | null,
}));
vi.mock("@microsoft/agents-hosting", () => ({
getAuthConfigWithDefaults: (cfg: unknown) => cfg,
MsalTokenProvider: class {
async getAccessToken() {
if (hostMockState.tokenError) throw hostMockState.tokenError;
return "token";
}
},
}));
import { probeMSTeams } from "./probe.js";
describe("msteams probe", () => {
it("returns an error when credentials are missing", async () => {
const cfg = { enabled: true } as unknown as MSTeamsConfig;
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
ok: false,
});
});
it("validates credentials by acquiring a token", async () => {
hostMockState.tokenError = null;
const cfg = {
enabled: true,
appId: "app",
appPassword: "pw",
tenantId: "tenant",
} as unknown as MSTeamsConfig;
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
ok: true,
appId: "app",
});
});
it("returns a helpful error when token acquisition fails", async () => {
hostMockState.tokenError = new Error("bad creds");
const cfg = {
enabled: true,
appId: "app",
appPassword: "pw",
tenantId: "tenant",
} as unknown as MSTeamsConfig;
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
ok: false,
appId: "app",
error: "bad creds",
});
});
});

View File

@@ -0,0 +1,33 @@
import type { MSTeamsConfig } from "../../../src/config/types.js";
import { formatUnknownError } from "./errors.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
export type ProbeMSTeamsResult = {
ok: boolean;
error?: string;
appId?: string;
};
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/.default");
return { ok: true, appId: creds.appId };
} catch (err) {
return {
ok: false,
appId: creds.appId,
error: formatUnknownError(err),
};
}
}

View File

@@ -0,0 +1,82 @@
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../../src/agents/identity.js";
import { createReplyDispatcherWithTyping } from "../../../src/auto-reply/reply/reply-dispatcher.js";
import type { ClawdbotConfig, MSTeamsReplyStyle } from "../../../src/config/types.js";
import { danger } from "../../../src/globals.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
import type { StoredConversationReference } from "./conversation-store.js";
import {
classifyMSTeamsSendError,
formatMSTeamsSendErrorHint,
formatUnknownError,
} from "./errors.js";
import {
type MSTeamsAdapter,
renderReplyPayloadsToMessages,
sendMSTeamsMessages,
} from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
export function createMSTeamsReplyDispatcher(params: {
cfg: ClawdbotConfig;
agentId: string;
runtime: RuntimeEnv;
log: MSTeamsMonitorLogger;
adapter: MSTeamsAdapter;
appId: string;
conversationRef: StoredConversationReference;
context: MSTeamsTurnContext;
replyStyle: MSTeamsReplyStyle;
textLimit: number;
}) {
const sendTypingIndicator = async () => {
try {
await params.context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort.
}
};
return createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId).responsePrefix,
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
});
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,
});
},
});
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.(
danger(`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`),
);
params.log.error("reply failed", {
kind: info.kind,
error: errMsg,
classification,
hint,
});
},
onReplyStart: sendTypingIndicator,
});
}

View 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>;
};

View 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 };
}

View File

@@ -0,0 +1,124 @@
import type { ClawdbotConfig } from "../../../src/config/types.js";
import type { getChildLogger as getChildLoggerFn } from "../../../src/logging.js";
import type {
MSTeamsConversationStore,
StoredConversationReference,
} from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
let _log: ReturnType<typeof getChildLoggerFn> | undefined;
const getLog = async (): Promise<ReturnType<typeof getChildLoggerFn>> => {
if (_log) return _log;
const { getChildLogger } = await import("../logging.js");
_log = getChildLogger({ name: "msteams:send" });
return _log;
};
export type MSTeamsProactiveContext = {
appId: string;
conversationId: string;
ref: StoredConversationReference;
adapter: MSTeamsAdapter;
log: Awaited<ReturnType<typeof getLog>>;
};
/**
* Parse the --to argument into a conversation reference lookup key.
* Supported formats:
* - conversation:19:abc@thread.tacv2 → lookup by conversation ID
* - user:aad-object-id → lookup by user AAD object ID
* - 19:abc@thread.tacv2 → direct conversation ID
*/
function parseRecipient(to: string): {
type: "conversation" | "user";
id: string;
} {
const trimmed = to.trim();
const finalize = (type: "conversation" | "user", id: string) => {
const normalized = id.trim();
if (!normalized) {
throw new Error(`Invalid --to 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: ClawdbotConfig;
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 log = await getLog();
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const adapter = createMSTeamsAdapter(authConfig, sdk);
return {
appId: creds.appId,
conversationId,
ref,
adapter: adapter as unknown as MSTeamsAdapter,
log,
};
}

View File

@@ -0,0 +1,212 @@
import type { ClawdbotConfig } from "../../../src/config/types.js";
import type { StoredConversationReference } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import {
classifyMSTeamsSendError,
formatMSTeamsSendErrorHint,
formatUnknownError,
} from "./errors.js";
import {
buildConversationReference,
type MSTeamsAdapter,
sendMSTeamsMessages,
} from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js";
import { resolveMSTeamsSendContext } from "./send-context.js";
export type SendMSTeamsMessageParams = {
/** Full config (for credentials) */
cfg: ClawdbotConfig;
/** 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;
};
export type SendMSTeamsPollParams = {
/** Full config (for credentials) */
cfg: ClawdbotConfig;
/** 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;
};
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;
}
async function sendMSTeamsActivity(params: {
adapter: MSTeamsAdapter;
appId: string;
conversationRef: StoredConversationReference;
activity: Record<string, unknown>;
}): Promise<string> {
const baseRef = buildConversationReference(params.conversationRef);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(params.activity);
messageId = extractMessageId(response) ?? "unknown";
});
return messageId;
}
/**
* Send a message to a Teams conversation or user.
*
* Uses the stored ConversationReference from previous interactions.
* The bot must have received at least one message from the conversation
* before proactive messaging works.
*/
export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl } = params;
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
cfg,
to,
});
log.debug("sending proactive message", {
conversationId,
textLength: text.length,
hasMedia: Boolean(mediaUrl),
});
const message = mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text;
let messageIds: string[];
try {
messageIds = await sendMSTeamsMessages({
replyStyle: "top-level",
adapter,
appId,
conversationRef: ref,
messages: [message],
// Enable default retry/backoff for throttling/transient failures.
retry: {},
onRetry: (event) => {
log.debug("retrying send", { conversationId, ...event });
},
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
const messageId = messageIds[0] ?? "unknown";
log.info("sent proactive message", { conversationId, messageId });
return {
messageId,
conversationId,
};
}
/**
* 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",
text: pollCard.fallbackText,
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: pollCard.card,
},
],
};
let messageId: string;
try {
messageId = await sendMSTeamsActivity({
adapter,
appId,
conversationRef: ref,
activity,
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams poll send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId });
return {
pollId: pollCard.pollId,
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,
}));
}

View File

@@ -0,0 +1,20 @@
import path from "node:path";
import { resolveStateDir } from "../../../src/config/paths.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 ? resolveStateDir(env, params.homedir) : resolveStateDir(env);
return path.join(stateDir, params.filename);
}

View File

@@ -0,0 +1,80 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import lockfile from "proper-lockfile";
const STORE_LOCK_OPTIONS = {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 10_000,
randomize: true,
},
stale: 30_000,
} as const;
function safeParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export async function readJsonFile<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(raw);
if (parsed == null) return { value: fallback, exists: true };
return { value: parsed, exists: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") return { value: fallback, exists: false };
return { value: fallback, exists: false };
}
}
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
const dir = path.dirname(filePath);
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf-8",
});
await fs.promises.chmod(tmp, 0o600);
await fs.promises.rename(tmp, filePath);
}
async function ensureJsonFile(filePath: string, fallback: unknown) {
try {
await fs.promises.access(filePath);
} catch {
await writeJsonFile(filePath, fallback);
}
}
export async function withFileLock<T>(
filePath: string,
fallback: unknown,
fn: () => Promise<T>,
): Promise<T> {
await ensureJsonFile(filePath, fallback);
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(filePath, STORE_LOCK_OPTIONS);
return await fn();
} finally {
if (release) {
try {
await release();
} catch {
// ignore unlock errors
}
}
}
}

View File

@@ -0,0 +1,19 @@
import type { MSTeamsConfig } from "../../../src/config/types.js";
export type MSTeamsCredentials = {
appId: string;
appPassword: string;
tenantId: string;
};
export function resolveMSTeamsCredentials(cfg?: MSTeamsConfig): MSTeamsCredentials | undefined {
const appId = cfg?.appId?.trim() || process.env.MSTEAMS_APP_ID?.trim();
const appPassword = cfg?.appPassword?.trim() || process.env.MSTEAMS_APP_PASSWORD?.trim();
const tenantId = cfg?.tenantId?.trim() || process.env.MSTEAMS_TENANT_ID?.trim();
if (!appId || !appPassword || !tenantId) {
return undefined;
}
return { appId, appPassword, tenantId };
}