feat!: move msteams to plugin
This commit is contained in:
6
extensions/msteams/CHANGELOG.md
Normal file
6
extensions/msteams/CHANGELOG.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Features
|
||||
- Microsoft Teams channel plugin (Bot Framework) with polls, media, threads, and gateway monitor.
|
||||
14
extensions/msteams/index.ts
Normal file
14
extensions/msteams/index.ts
Normal 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;
|
||||
16
extensions/msteams/package.json
Normal file
16
extensions/msteams/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
344
extensions/msteams/src/attachments.test.ts
Normal file
344
extensions/msteams/src/attachments.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
extensions/msteams/src/attachments.ts
Normal file
14
extensions/msteams/src/attachments.ts
Normal 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";
|
||||
192
extensions/msteams/src/attachments/download.ts
Normal file
192
extensions/msteams/src/attachments/download.ts
Normal 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;
|
||||
}
|
||||
230
extensions/msteams/src/attachments/graph.ts
Normal file
230
extensions/msteams/src/attachments/graph.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
76
extensions/msteams/src/attachments/html.ts
Normal file
76
extensions/msteams/src/attachments/html.ts
Normal 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)` : ""}`;
|
||||
}
|
||||
22
extensions/msteams/src/attachments/payload.ts
Normal file
22
extensions/msteams/src/attachments/payload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
202
extensions/msteams/src/attachments/shared.ts
Normal file
202
extensions/msteams/src/attachments/shared.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
extensions/msteams/src/attachments/types.ts
Normal file
37
extensions/msteams/src/attachments/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type MSTeamsAttachmentLike = {
|
||||
contentType?: string | null;
|
||||
contentUrl?: string | null;
|
||||
name?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export type MSTeamsAccessTokenProvider = {
|
||||
getAccessToken: (scope: string) => Promise<string>;
|
||||
};
|
||||
|
||||
export type MSTeamsInboundMedia = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export type MSTeamsHtmlAttachmentSummary = {
|
||||
htmlAttachments: number;
|
||||
imgTags: number;
|
||||
dataImages: number;
|
||||
cidImages: number;
|
||||
srcHosts: string[];
|
||||
attachmentTags: number;
|
||||
attachmentIds: string[];
|
||||
};
|
||||
|
||||
export type MSTeamsGraphMediaResult = {
|
||||
media: MSTeamsInboundMedia[];
|
||||
hostedCount?: number;
|
||||
attachmentCount?: number;
|
||||
hostedStatus?: number;
|
||||
attachmentStatus?: number;
|
||||
messageUrl?: string;
|
||||
tokenError?: boolean;
|
||||
};
|
||||
169
extensions/msteams/src/channel.ts
Normal file
169
extensions/msteams/src/channel.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
71
extensions/msteams/src/conversation-store-fs.test.ts
Normal file
71
extensions/msteams/src/conversation-store-fs.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
155
extensions/msteams/src/conversation-store-fs.ts
Normal file
155
extensions/msteams/src/conversation-store-fs.ts
Normal 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 };
|
||||
}
|
||||
45
extensions/msteams/src/conversation-store-memory.ts
Normal file
45
extensions/msteams/src/conversation-store-memory.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
41
extensions/msteams/src/conversation-store.ts
Normal file
41
extensions/msteams/src/conversation-store.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Conversation store for MS Teams proactive messaging.
|
||||
*
|
||||
* Stores ConversationReference-like objects keyed by conversation ID so we can
|
||||
* send proactive messages later (after the webhook turn has completed).
|
||||
*/
|
||||
|
||||
/** Minimal ConversationReference shape for proactive messaging */
|
||||
export type StoredConversationReference = {
|
||||
/** Activity ID from the last message */
|
||||
activityId?: string;
|
||||
/** User who sent the message */
|
||||
user?: { id?: string; name?: string; aadObjectId?: string };
|
||||
/** Agent/bot that received the message */
|
||||
agent?: { id?: string; name?: string; aadObjectId?: string } | null;
|
||||
/** @deprecated legacy field (pre-Agents SDK). Prefer `agent`. */
|
||||
bot?: { id?: string; name?: string };
|
||||
/** Conversation details */
|
||||
conversation?: { id?: string; conversationType?: string; tenantId?: string };
|
||||
/** Team ID for channel messages (when available). */
|
||||
teamId?: string;
|
||||
/** Channel ID (usually "msteams") */
|
||||
channelId?: string;
|
||||
/** Service URL for sending messages back */
|
||||
serviceUrl?: string;
|
||||
/** Locale */
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsConversationStoreEntry = {
|
||||
conversationId: string;
|
||||
reference: StoredConversationReference;
|
||||
};
|
||||
|
||||
export type MSTeamsConversationStore = {
|
||||
upsert: (conversationId: string, reference: StoredConversationReference) => Promise<void>;
|
||||
get: (conversationId: string) => Promise<StoredConversationReference | null>;
|
||||
list: () => Promise<MSTeamsConversationStoreEntry[]>;
|
||||
remove: (conversationId: string) => Promise<boolean>;
|
||||
findByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
|
||||
};
|
||||
46
extensions/msteams/src/errors.test.ts
Normal file
46
extensions/msteams/src/errors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
158
extensions/msteams/src/errors.ts
Normal file
158
extensions/msteams/src/errors.ts
Normal 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;
|
||||
}
|
||||
67
extensions/msteams/src/inbound.test.ts
Normal file
67
extensions/msteams/src/inbound.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
38
extensions/msteams/src/inbound.ts
Normal file
38
extensions/msteams/src/inbound.ts
Normal 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);
|
||||
}
|
||||
4
extensions/msteams/src/index.ts
Normal file
4
extensions/msteams/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { monitorMSTeamsProvider } from "./monitor.js";
|
||||
export { probeMSTeams } from "./probe.js";
|
||||
export { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||
export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
|
||||
217
extensions/msteams/src/messenger.test.ts
Normal file
217
extensions/msteams/src/messenger.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
291
extensions/msteams/src/messenger.ts
Normal file
291
extensions/msteams/src/messenger.ts
Normal 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;
|
||||
}
|
||||
63
extensions/msteams/src/monitor-handler.ts
Normal file
63
extensions/msteams/src/monitor-handler.ts
Normal 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;
|
||||
}
|
||||
118
extensions/msteams/src/monitor-handler/inbound-media.ts
Normal file
118
extensions/msteams/src/monitor-handler/inbound-media.ts
Normal 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;
|
||||
}
|
||||
507
extensions/msteams/src/monitor-handler/message-handler.ts
Normal file
507
extensions/msteams/src/monitor-handler/message-handler.ts
Normal 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 });
|
||||
};
|
||||
}
|
||||
5
extensions/msteams/src/monitor-types.ts
Normal file
5
extensions/msteams/src/monitor-types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type MSTeamsMonitorLogger = {
|
||||
debug: (message: string, meta?: Record<string, unknown>) => void;
|
||||
info: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
147
extensions/msteams/src/monitor.ts
Normal file
147
extensions/msteams/src/monitor.ts
Normal 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 };
|
||||
}
|
||||
197
extensions/msteams/src/onboarding.ts
Normal file
197
extensions/msteams/src/onboarding.ts
Normal 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 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
58
extensions/msteams/src/outbound.ts
Normal file
58
extensions/msteams/src/outbound.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
168
extensions/msteams/src/policy.test.ts
Normal file
168
extensions/msteams/src/policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
76
extensions/msteams/src/policy.ts
Normal file
76
extensions/msteams/src/policy.ts
Normal 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);
|
||||
}
|
||||
30
extensions/msteams/src/polls-store-memory.ts
Normal file
30
extensions/msteams/src/polls-store-memory.ts
Normal 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 };
|
||||
}
|
||||
40
extensions/msteams/src/polls-store.test.ts
Normal file
40
extensions/msteams/src/polls-store.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
55
extensions/msteams/src/polls.test.ts
Normal file
55
extensions/msteams/src/polls.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
299
extensions/msteams/src/polls.ts
Normal file
299
extensions/msteams/src/polls.ts
Normal 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 };
|
||||
}
|
||||
57
extensions/msteams/src/probe.test.ts
Normal file
57
extensions/msteams/src/probe.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
33
extensions/msteams/src/probe.ts
Normal file
33
extensions/msteams/src/probe.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
82
extensions/msteams/src/reply-dispatcher.ts
Normal file
82
extensions/msteams/src/reply-dispatcher.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
19
extensions/msteams/src/sdk-types.ts
Normal file
19
extensions/msteams/src/sdk-types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { TurnContext } from "@microsoft/agents-hosting";
|
||||
|
||||
/**
|
||||
* Minimal public surface we depend on from the Microsoft SDK types.
|
||||
*
|
||||
* Note: we intentionally avoid coupling to SDK classes with private members
|
||||
* (like TurnContext) in our own public signatures. The SDK's TS surface is also
|
||||
* stricter than what the runtime accepts (e.g. it allows plain activity-like
|
||||
* objects), so we model the minimal structural shape we rely on.
|
||||
*/
|
||||
export type MSTeamsActivity = TurnContext["activity"];
|
||||
|
||||
export type MSTeamsTurnContext = {
|
||||
activity: MSTeamsActivity;
|
||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||
sendActivities: (
|
||||
activities: Array<{ type: string } & Record<string, unknown>>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
33
extensions/msteams/src/sdk.ts
Normal file
33
extensions/msteams/src/sdk.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import type { MSTeamsCredentials } from "./token.js";
|
||||
|
||||
export type MSTeamsSdk = typeof import("@microsoft/agents-hosting");
|
||||
export type MSTeamsAuthConfig = ReturnType<MSTeamsSdk["getAuthConfigWithDefaults"]>;
|
||||
|
||||
export async function loadMSTeamsSdk(): Promise<MSTeamsSdk> {
|
||||
return await import("@microsoft/agents-hosting");
|
||||
}
|
||||
|
||||
export function buildMSTeamsAuthConfig(
|
||||
creds: MSTeamsCredentials,
|
||||
sdk: MSTeamsSdk,
|
||||
): MSTeamsAuthConfig {
|
||||
return sdk.getAuthConfigWithDefaults({
|
||||
clientId: creds.appId,
|
||||
clientSecret: creds.appPassword,
|
||||
tenantId: creds.tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMSTeamsAdapter(
|
||||
authConfig: MSTeamsAuthConfig,
|
||||
sdk: MSTeamsSdk,
|
||||
): MSTeamsAdapter {
|
||||
return new sdk.CloudAdapter(authConfig) as unknown as MSTeamsAdapter;
|
||||
}
|
||||
|
||||
export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) {
|
||||
const sdk = await loadMSTeamsSdk();
|
||||
const authConfig = buildMSTeamsAuthConfig(creds, sdk);
|
||||
return { sdk, authConfig };
|
||||
}
|
||||
124
extensions/msteams/src/send-context.ts
Normal file
124
extensions/msteams/src/send-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
212
extensions/msteams/src/send.ts
Normal file
212
extensions/msteams/src/send.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
20
extensions/msteams/src/storage.ts
Normal file
20
extensions/msteams/src/storage.ts
Normal 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);
|
||||
}
|
||||
80
extensions/msteams/src/store-fs.ts
Normal file
80
extensions/msteams/src/store-fs.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
extensions/msteams/src/token.ts
Normal file
19
extensions/msteams/src/token.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user