mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 20:28:29 +00:00
chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
@@ -87,8 +87,7 @@ describe("msteams attachments", () => {
|
||||
buildMSTeamsAttachmentPlaceholder([
|
||||
{
|
||||
contentType: "text/html",
|
||||
content:
|
||||
'<img src="https://x/a.png" /><img src="https://x/b.png" />',
|
||||
content: '<img src="https://x/a.png" /><img src="https://x/b.png" />',
|
||||
},
|
||||
]),
|
||||
).toBe("<media:image> (2 images)");
|
||||
@@ -106,9 +105,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
attachments: [
|
||||
{ contentType: "image/png", contentUrl: "https://x/img" },
|
||||
],
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
@@ -193,9 +190,9 @@ describe("msteams attachments", () => {
|
||||
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,
|
||||
typeof opts === "object" &&
|
||||
"headers" in opts &&
|
||||
(opts.headers as Record<string, string>)?.Authorization,
|
||||
);
|
||||
if (!hasAuth) {
|
||||
return new Response("unauthorized", { status: 401 });
|
||||
@@ -207,9 +204,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
attachments: [
|
||||
{ contentType: "image/png", contentUrl: "https://x/img" },
|
||||
],
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
allowHosts: ["x"],
|
||||
@@ -225,9 +220,7 @@ describe("msteams attachments", () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const fetchMock = vi.fn();
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
attachments: [
|
||||
{ contentType: "image/png", contentUrl: "https://evil.test/img" },
|
||||
],
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["graph.microsoft.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
@@ -241,9 +234,7 @@ describe("msteams attachments", () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const fetchMock = vi.fn();
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
attachments: [
|
||||
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
|
||||
],
|
||||
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
@@ -316,8 +307,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsGraphMedia({
|
||||
messageUrl:
|
||||
"https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
|
||||
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,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export { downloadMSTeamsImageAttachments } from "./attachments/download.js";
|
||||
export {
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
downloadMSTeamsGraphMedia,
|
||||
} from "./attachments/graph.js";
|
||||
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
export {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
summarizeMSTeamsHtmlAttachments,
|
||||
|
||||
@@ -22,37 +22,21 @@ type DownloadCandidate = {
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
function resolveDownloadCandidate(
|
||||
att: MSTeamsAttachmentLike,
|
||||
): DownloadCandidate | null {
|
||||
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()
|
||||
: "";
|
||||
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 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}` : "");
|
||||
const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
|
||||
return {
|
||||
url: downloadUrl,
|
||||
fileHint: fileHint || undefined,
|
||||
@@ -65,8 +49,7 @@ function resolveDownloadCandidate(
|
||||
};
|
||||
}
|
||||
|
||||
const contentUrl =
|
||||
typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
|
||||
const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
|
||||
if (!contentUrl) return null;
|
||||
|
||||
return {
|
||||
@@ -86,19 +69,10 @@ function scopeCandidatesForUrl(url: string): string[] {
|
||||
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",
|
||||
];
|
||||
? ["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",
|
||||
];
|
||||
return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +85,7 @@ async function fetchWithAuthFallback(params: {
|
||||
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;
|
||||
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) return firstAttempt;
|
||||
|
||||
const scopes = scopeCandidatesForUrl(params.url);
|
||||
for (const scope of scopes) {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { detectMime } from "../../media/mime.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||
import {
|
||||
GRAPH_ROOT,
|
||||
isRecord,
|
||||
normalizeContentType,
|
||||
resolveAllowedHosts,
|
||||
} from "./shared.js";
|
||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
@@ -29,18 +24,13 @@ type GraphAttachment = {
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
function readNestedString(
|
||||
value: unknown,
|
||||
keys: Array<string | number>,
|
||||
): string | undefined {
|
||||
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;
|
||||
return typeof current === "string" && current.trim() ? current.trim() : undefined;
|
||||
}
|
||||
|
||||
export function buildMSTeamsGraphMessageUrls(params: {
|
||||
@@ -63,8 +53,7 @@ export function buildMSTeamsGraphMessageUrls(params: {
|
||||
pushCandidate(readNestedString(params.channelData, ["messageId"]));
|
||||
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
|
||||
|
||||
const replyToId =
|
||||
typeof params.replyToId === "string" ? params.replyToId.trim() : "";
|
||||
const replyToId = typeof params.replyToId === "string" ? params.replyToId.trim() : "";
|
||||
|
||||
if (conversationType === "channel") {
|
||||
const teamId =
|
||||
@@ -84,8 +73,7 @@ export function buildMSTeamsGraphMessageUrls(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messageIdCandidates.size === 0 && replyToId)
|
||||
messageIdCandidates.add(replyToId);
|
||||
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)}`,
|
||||
@@ -94,12 +82,9 @@ export function buildMSTeamsGraphMessageUrls(params: {
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
const chatId =
|
||||
params.conversationId?.trim() ||
|
||||
readNestedString(params.channelData, ["chatId"]);
|
||||
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
|
||||
if (!chatId) return [];
|
||||
if (messageIdCandidates.size === 0 && replyToId)
|
||||
messageIdCandidates.add(replyToId);
|
||||
if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
|
||||
const urls = Array.from(messageIdCandidates).map(
|
||||
(candidate) =>
|
||||
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
|
||||
@@ -161,8 +146,7 @@ async function downloadGraphHostedImages(params: {
|
||||
|
||||
const out: MSTeamsInboundMedia[] = [];
|
||||
for (const item of hosted.items) {
|
||||
const contentBytes =
|
||||
typeof item.contentBytes === "string" ? item.contentBytes : "";
|
||||
const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
|
||||
if (!contentBytes) continue;
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
@@ -208,9 +192,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
const messageUrl = params.messageUrl;
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await params.tokenProvider.getAccessToken(
|
||||
"https://graph.microsoft.com/.default",
|
||||
);
|
||||
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
|
||||
} catch {
|
||||
return { media: [], messageUrl, tokenError: true };
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
isLikelyImageAttachment,
|
||||
safeHostForUrl,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAttachmentLike,
|
||||
MSTeamsHtmlAttachmentSummary,
|
||||
} from "./types.js";
|
||||
import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
|
||||
|
||||
export function summarizeMSTeamsHtmlAttachments(
|
||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||
|
||||
@@ -61,9 +61,7 @@ export function inferPlaceholder(params: {
|
||||
const fileType = params.fileType?.toLowerCase() ?? "";
|
||||
|
||||
const looksLikeImage =
|
||||
mime.startsWith("image/") ||
|
||||
IMAGE_EXT_RE.test(name) ||
|
||||
IMAGE_EXT_RE.test(`x.${fileType}`);
|
||||
mime.startsWith("image/") || IMAGE_EXT_RE.test(name) || IMAGE_EXT_RE.test(`x.${fileType}`);
|
||||
|
||||
return looksLikeImage ? "<media:image>" : "<media:document>";
|
||||
}
|
||||
@@ -78,11 +76,9 @@ export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
||||
isRecord(att.content)
|
||||
) {
|
||||
const fileType =
|
||||
typeof att.content.fileType === "string" ? att.content.fileType : "";
|
||||
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 : "";
|
||||
const fileName = typeof att.content.fileName === "string" ? att.content.fileName : "";
|
||||
if (fileName && IMAGE_EXT_RE.test(fileName)) return true;
|
||||
}
|
||||
|
||||
@@ -94,9 +90,7 @@ function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
return contentType.startsWith("text/html");
|
||||
}
|
||||
|
||||
export function extractHtmlFromAttachment(
|
||||
att: MSTeamsAttachmentLike,
|
||||
): string | undefined {
|
||||
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;
|
||||
@@ -194,9 +188,7 @@ export function resolveAllowedHosts(input?: string[]): string[] {
|
||||
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}`),
|
||||
);
|
||||
return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
|
||||
}
|
||||
|
||||
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
||||
|
||||
@@ -9,9 +9,7 @@ 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 stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-store-"));
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
@@ -33,10 +31,7 @@ describe("msteams conversation store (fs)", () => {
|
||||
const raw = await fs.promises.readFile(filePath, "utf-8");
|
||||
const json = JSON.parse(raw) as {
|
||||
version: number;
|
||||
conversations: Record<
|
||||
string,
|
||||
StoredConversationReference & { lastSeenAt?: string }
|
||||
>;
|
||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>;
|
||||
};
|
||||
|
||||
json.conversations["19:old@thread.tacv2"] = {
|
||||
|
||||
@@ -8,10 +8,7 @@ import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js";
|
||||
|
||||
type ConversationStoreData = {
|
||||
version: 1;
|
||||
conversations: Record<
|
||||
string,
|
||||
StoredConversationReference & { lastSeenAt?: string }
|
||||
>;
|
||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>;
|
||||
};
|
||||
|
||||
const STORE_FILENAME = "msteams-conversations.json";
|
||||
@@ -26,10 +23,7 @@ function parseTimestamp(value: string | undefined): number | null {
|
||||
}
|
||||
|
||||
function pruneToLimit(
|
||||
conversations: Record<
|
||||
string,
|
||||
StoredConversationReference & { lastSeenAt?: string }
|
||||
>,
|
||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
||||
) {
|
||||
const entries = Object.entries(conversations);
|
||||
if (entries.length <= MAX_CONVERSATIONS) return conversations;
|
||||
@@ -45,10 +39,7 @@ function pruneToLimit(
|
||||
}
|
||||
|
||||
function pruneExpired(
|
||||
conversations: Record<
|
||||
string,
|
||||
StoredConversationReference & { lastSeenAt?: string }
|
||||
>,
|
||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
||||
nowMs: number,
|
||||
ttlMs: number,
|
||||
) {
|
||||
@@ -89,10 +80,7 @@ export function createMSTeamsConversationStoreFs(params?: {
|
||||
const empty: ConversationStoreData = { version: 1, conversations: {} };
|
||||
|
||||
const readStore = async (): Promise<ConversationStoreData> => {
|
||||
const { value } = await readJsonFile<ConversationStoreData>(
|
||||
filePath,
|
||||
empty,
|
||||
);
|
||||
const { value } = await readJsonFile<ConversationStoreData>(filePath, empty);
|
||||
if (
|
||||
value.version !== 1 ||
|
||||
!value.conversations ||
|
||||
@@ -102,34 +90,24 @@ export function createMSTeamsConversationStoreFs(params?: {
|
||||
return empty;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const pruned = pruneExpired(
|
||||
value.conversations,
|
||||
nowMs,
|
||||
ttlMs,
|
||||
).conversations;
|
||||
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,
|
||||
}),
|
||||
);
|
||||
return Object.entries(store.conversations).map(([conversationId, reference]) => ({
|
||||
conversationId,
|
||||
reference,
|
||||
}));
|
||||
};
|
||||
|
||||
const get = async (
|
||||
conversationId: string,
|
||||
): Promise<StoredConversationReference | null> => {
|
||||
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 findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => {
|
||||
const target = id.trim();
|
||||
if (!target) return null;
|
||||
for (const entry of await list()) {
|
||||
@@ -156,11 +134,7 @@ export function createMSTeamsConversationStoreFs(params?: {
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
};
|
||||
const nowMs = Date.now();
|
||||
store.conversations = pruneExpired(
|
||||
store.conversations,
|
||||
nowMs,
|
||||
ttlMs,
|
||||
).conversations;
|
||||
store.conversations = pruneExpired(store.conversations, nowMs, ttlMs).conversations;
|
||||
store.conversations = pruneToLimit(store.conversations);
|
||||
await writeJsonFile(filePath, store);
|
||||
});
|
||||
|
||||
@@ -33,10 +33,7 @@ export type MSTeamsConversationStoreEntry = {
|
||||
};
|
||||
|
||||
export type MSTeamsConversationStore = {
|
||||
upsert: (
|
||||
conversationId: string,
|
||||
reference: StoredConversationReference,
|
||||
) => Promise<void>;
|
||||
upsert: (conversationId: string, reference: StoredConversationReference) => Promise<void>;
|
||||
get: (conversationId: string) => Promise<StoredConversationReference | null>;
|
||||
list: () => Promise<MSTeamsConversationStoreEntry[]>;
|
||||
remove: (conversationId: string) => Promise<boolean>;
|
||||
|
||||
@@ -18,9 +18,7 @@ describe("msteams errors", () => {
|
||||
});
|
||||
|
||||
it("classifies throttling errors and parses retry-after", () => {
|
||||
expect(
|
||||
classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5" }),
|
||||
).toMatchObject({
|
||||
expect(classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5" })).toMatchObject({
|
||||
kind: "throttled",
|
||||
statusCode: 429,
|
||||
retryAfterMs: 1500,
|
||||
@@ -43,8 +41,6 @@ describe("msteams errors", () => {
|
||||
|
||||
it("provides actionable hints for common cases", () => {
|
||||
expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams");
|
||||
expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain(
|
||||
"throttled",
|
||||
);
|
||||
expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,7 @@ export function formatUnknownError(err: unknown): string {
|
||||
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"
|
||||
) {
|
||||
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
|
||||
return String(err);
|
||||
}
|
||||
if (typeof err === "symbol") return err.description ?? err.toString();
|
||||
@@ -85,9 +81,7 @@ function extractRetryAfterMs(err: unknown): number | null {
|
||||
"get" in headers &&
|
||||
typeof (headers as { get?: unknown }).get === "function"
|
||||
) {
|
||||
const raw = (headers as { get: (name: string) => string | null }).get(
|
||||
"retry-after",
|
||||
);
|
||||
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;
|
||||
@@ -97,12 +91,7 @@ function extractRetryAfterMs(err: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export type MSTeamsSendErrorKind =
|
||||
| "auth"
|
||||
| "throttled"
|
||||
| "transient"
|
||||
| "permanent"
|
||||
| "unknown";
|
||||
export type MSTeamsSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown";
|
||||
|
||||
export type MSTeamsSendErrorClassification = {
|
||||
kind: MSTeamsSendErrorKind;
|
||||
@@ -118,9 +107,7 @@ export type MSTeamsSendErrorClassification = {
|
||||
* 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 {
|
||||
export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassification {
|
||||
const statusCode = extractStatusCode(err);
|
||||
const retryAfterMs = extractRetryAfterMs(err);
|
||||
|
||||
|
||||
@@ -22,11 +22,9 @@ describe("msteams inbound", () => {
|
||||
|
||||
describe("normalizeMSTeamsConversationId", () => {
|
||||
it("strips the ;messageid suffix", () => {
|
||||
expect(
|
||||
normalizeMSTeamsConversationId(
|
||||
"19:abc@thread.tacv2;messageid=deadbeef",
|
||||
),
|
||||
).toBe("19:abc@thread.tacv2");
|
||||
expect(normalizeMSTeamsConversationId("19:abc@thread.tacv2;messageid=deadbeef")).toBe(
|
||||
"19:abc@thread.tacv2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,18 +10,14 @@ export function normalizeMSTeamsConversationId(raw: string): string {
|
||||
return raw.split(";")[0] ?? raw;
|
||||
}
|
||||
|
||||
export function extractMSTeamsConversationMessageId(
|
||||
raw: string,
|
||||
): string | undefined {
|
||||
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 {
|
||||
export function parseMSTeamsActivityTimestamp(value: unknown): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value !== "string") return undefined;
|
||||
@@ -38,7 +34,5 @@ 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,
|
||||
);
|
||||
return entities.some((e) => e.type === "mention" && e.mentioned?.id === botId);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
describe("msteams messenger", () => {
|
||||
describe("renderReplyPayloadsToMessages", () => {
|
||||
it("filters silent replies", () => {
|
||||
const messages = renderReplyPayloadsToMessages(
|
||||
[{ text: SILENT_REPLY_TOKEN }],
|
||||
{ textChunkLimit: 4000 },
|
||||
);
|
||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||
textChunkLimit: 4000,
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -150,8 +149,7 @@ describe("msteams messenger", () => {
|
||||
context: ctx,
|
||||
messages: ["one"],
|
||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
onRetry: (e) =>
|
||||
retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
|
||||
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
|
||||
});
|
||||
|
||||
expect(attempts).toEqual(["one", "one"]);
|
||||
|
||||
@@ -157,12 +157,8 @@ function computeRetryDelayMs(
|
||||
return clampMs(exponential, opts.maxDelayMs);
|
||||
}
|
||||
|
||||
function shouldRetry(
|
||||
classification: ReturnType<typeof classifyMSTeamsSendError>,
|
||||
): boolean {
|
||||
return (
|
||||
classification.kind === "throttled" || classification.kind === "transient"
|
||||
);
|
||||
function shouldRetry(classification: ReturnType<typeof classifyMSTeamsSendError>): boolean {
|
||||
return classification.kind === "throttled" || classification.kind === "transient";
|
||||
}
|
||||
|
||||
export function renderReplyPayloadsToMessages(
|
||||
@@ -175,8 +171,7 @@ export function renderReplyPayloadsToMessages(
|
||||
const mediaMode = options.mediaMode ?? "split";
|
||||
|
||||
for (const payload of replies) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
@@ -187,9 +182,7 @@ export function renderReplyPayloadsToMessages(
|
||||
}
|
||||
|
||||
if (mediaMode === "inline") {
|
||||
const combined = text
|
||||
? `${text}\n\n${mediaList.join("\n")}`
|
||||
: mediaList.join("\n");
|
||||
const combined = text ? `${text}\n\n${mediaList.join("\n")}` : mediaList.join("\n");
|
||||
pushTextMessages(out, combined, { chunkText, chunkLimit });
|
||||
continue;
|
||||
}
|
||||
@@ -234,15 +227,10 @@ export async function sendMSTeamsMessages(params: {
|
||||
return await sendOnce();
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const canRetry =
|
||||
attempt < retryOptions.maxAttempts && shouldRetry(classification);
|
||||
const canRetry = attempt < retryOptions.maxAttempts && shouldRetry(classification);
|
||||
if (!canRetry) throw err;
|
||||
|
||||
const delayMs = computeRetryDelayMs(
|
||||
attempt,
|
||||
classification,
|
||||
retryOptions,
|
||||
);
|
||||
const delayMs = computeRetryDelayMs(attempt, classification, retryOptions);
|
||||
const nextAttempt = attempt + 1;
|
||||
params.onRetry?.({
|
||||
messageIndex: meta.messageIndex,
|
||||
@@ -286,22 +274,18 @@ export async function sendMSTeamsMessages(params: {
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
},
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -49,12 +49,9 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
});
|
||||
|
||||
handler.onMembersAdded(async (context, next) => {
|
||||
const membersAdded =
|
||||
(context as MSTeamsTurnContext).activity?.membersAdded ?? [];
|
||||
const membersAdded = (context as MSTeamsTurnContext).activity?.membersAdded ?? [];
|
||||
for (const member of membersAdded) {
|
||||
if (
|
||||
member.id !== (context as MSTeamsTurnContext).activity?.recipient?.id
|
||||
) {
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -22,10 +22,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
conversationType: string;
|
||||
conversationId: string;
|
||||
conversationMessageId?: string;
|
||||
activity: Pick<
|
||||
MSTeamsTurnContext["activity"],
|
||||
"id" | "replyToId" | "channelData"
|
||||
>;
|
||||
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
|
||||
log: MSTeamsLogger;
|
||||
}): Promise<MSTeamsInboundMedia[]> {
|
||||
const {
|
||||
@@ -51,9 +48,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
if (mediaList.length === 0) {
|
||||
const onlyHtmlAttachments =
|
||||
attachments.length > 0 &&
|
||||
attachments.every((att) =>
|
||||
String(att.contentType ?? "").startsWith("text/html"),
|
||||
);
|
||||
attachments.every((att) => String(att.contentType ?? "").startsWith("text/html"));
|
||||
|
||||
if (onlyHtmlAttachments) {
|
||||
const messageUrls = buildMSTeamsGraphMessageUrls({
|
||||
|
||||
@@ -69,16 +69,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
const attachments = Array.isArray(activity.attachments)
|
||||
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
|
||||
: [];
|
||||
const attachmentPlaceholder =
|
||||
buildMSTeamsAttachmentPlaceholder(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,
|
||||
)
|
||||
.map((att) => (typeof att.contentType === "string" ? att.contentType : undefined))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
|
||||
@@ -101,19 +98,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
// 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 conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
|
||||
const conversationType = conversation?.conversationType ?? "personal";
|
||||
const isGroupChat =
|
||||
conversationType === "groupChat" || conversation?.isGroup === true;
|
||||
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(
|
||||
() => [],
|
||||
);
|
||||
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []);
|
||||
|
||||
// Check DM policy for direct messages.
|
||||
if (isDirectMessage && msteamsCfg) {
|
||||
@@ -165,13 +158,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
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,
|
||||
];
|
||||
(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)", {
|
||||
@@ -182,12 +170,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
log.debug(
|
||||
"dropping group message (groupPolicy: allowlist, no groupAllowFrom)",
|
||||
{
|
||||
conversationId,
|
||||
},
|
||||
);
|
||||
log.debug("dropping group message (groupPolicy: allowlist, no groupAllowFrom)", {
|
||||
conversationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const allowed = isMSTeamsGroupAllowed({
|
||||
@@ -268,9 +253,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
: isChannel
|
||||
? `msteams:channel:${conversationId}`
|
||||
: `msteams:group:${conversationId}`;
|
||||
const teamsTo = isDirectMessage
|
||||
? `user:${senderId}`
|
||||
: `conversation:${conversationId}`;
|
||||
const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`;
|
||||
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
@@ -391,24 +374,21 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
};
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`msteams inbound: from=${ctxPayload.From} preview="${preview}"`,
|
||||
);
|
||||
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,
|
||||
});
|
||||
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 {
|
||||
|
||||
@@ -57,12 +57,10 @@ export async function monitorMSTeamsProvider(
|
||||
const MB = 1024 * 1024;
|
||||
const agentDefaults = cfg.agents?.defaults;
|
||||
const mediaMaxBytes =
|
||||
typeof agentDefaults?.mediaMaxMb === "number" &&
|
||||
agentDefaults.mediaMaxMb > 0
|
||||
typeof agentDefaults?.mediaMaxMb === "number" && agentDefaults.mediaMaxMb > 0
|
||||
? Math.floor(agentDefaults.mediaMaxMb * MB)
|
||||
: 8 * MB;
|
||||
const conversationStore =
|
||||
opts.conversationStore ?? createMSTeamsConversationStoreFs();
|
||||
const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs();
|
||||
const pollStore = opts.pollStore ?? createMSTeamsPollStoreFs();
|
||||
|
||||
log.info(`starting provider (port ${port})`);
|
||||
@@ -100,9 +98,7 @@ export async function monitorMSTeamsProvider(
|
||||
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),
|
||||
)
|
||||
.process(req, res, (context: unknown) => handler.run(context as HandlerContext))
|
||||
.catch((err: unknown) => {
|
||||
log.error("msteams webhook failed", { error: formatUnknownError(err) });
|
||||
});
|
||||
|
||||
@@ -20,9 +20,7 @@ export function resolveMSTeamsRouteConfig(params: {
|
||||
const conversationId = params.conversationId?.trim();
|
||||
const teamConfig = teamId ? params.cfg?.teams?.[teamId] : undefined;
|
||||
const channelConfig =
|
||||
teamConfig && conversationId
|
||||
? teamConfig.channels?.[conversationId]
|
||||
: undefined;
|
||||
teamConfig && conversationId ? teamConfig.channels?.[conversationId] : undefined;
|
||||
return { teamConfig, channelConfig };
|
||||
}
|
||||
|
||||
@@ -74,8 +72,5 @@ export function isMSTeamsGroupAllowed(params: {
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const senderId = params.senderId.toLowerCase();
|
||||
const senderName = params.senderName?.toLowerCase();
|
||||
return (
|
||||
allowFrom.includes(senderId) ||
|
||||
(senderName ? allowFrom.includes(senderName) : false)
|
||||
);
|
||||
return allowFrom.includes(senderId) || (senderName ? allowFrom.includes(senderName) : false);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
normalizeMSTeamsPollSelections,
|
||||
} from "./polls.js";
|
||||
|
||||
export function createMSTeamsPollStoreMemory(
|
||||
initial: MSTeamsPoll[] = [],
|
||||
): MSTeamsPollStore {
|
||||
export function createMSTeamsPollStoreMemory(initial: MSTeamsPoll[] = []): MSTeamsPollStore {
|
||||
const polls = new Map<string, MSTeamsPoll>();
|
||||
for (const poll of initial) {
|
||||
polls.set(poll.id, { ...poll });
|
||||
@@ -18,11 +16,7 @@ export function createMSTeamsPollStoreMemory(
|
||||
|
||||
const getPoll = async (pollId: string) => polls.get(pollId) ?? null;
|
||||
|
||||
const recordVote = async (params: {
|
||||
pollId: string;
|
||||
voterId: string;
|
||||
selections: string[];
|
||||
}) => {
|
||||
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);
|
||||
|
||||
@@ -8,9 +8,7 @@ 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-"),
|
||||
);
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-polls-"));
|
||||
return createMSTeamsPollStoreFs({ stateDir });
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@ import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildMSTeamsPollCard,
|
||||
createMSTeamsPollStoreFs,
|
||||
extractMSTeamsPollVote,
|
||||
} from "./polls.js";
|
||||
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
|
||||
|
||||
describe("msteams polls", () => {
|
||||
it("builds poll cards with fallback text", () => {
|
||||
@@ -38,9 +34,7 @@ describe("msteams polls", () => {
|
||||
});
|
||||
|
||||
it("stores and records poll votes", async () => {
|
||||
const home = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-msteams-polls-"),
|
||||
);
|
||||
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-msteams-polls-"));
|
||||
const store = createMSTeamsPollStoreFs({ homedir: () => home });
|
||||
await store.createPoll({
|
||||
id: "poll-2",
|
||||
|
||||
@@ -64,9 +64,7 @@ function normalizeChoiceValue(value: unknown): string | null {
|
||||
|
||||
function extractSelections(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(normalizeChoiceValue)
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return value.map(normalizeChoiceValue).filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
const normalized = normalizeChoiceValue(value);
|
||||
if (!normalized) return [];
|
||||
@@ -79,10 +77,7 @@ function extractSelections(value: unknown): string[] {
|
||||
return [normalized];
|
||||
}
|
||||
|
||||
function readNestedValue(
|
||||
value: unknown,
|
||||
keys: Array<string | number>,
|
||||
): unknown {
|
||||
function readNestedValue(value: unknown, keys: Array<string | number>): unknown {
|
||||
let current: unknown = value;
|
||||
for (const key of keys) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
@@ -91,10 +86,7 @@ function readNestedValue(
|
||||
return current;
|
||||
}
|
||||
|
||||
function readNestedString(
|
||||
value: unknown,
|
||||
keys: Array<string | number>,
|
||||
): string | undefined {
|
||||
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
||||
const found = readNestedValue(value, keys);
|
||||
return typeof found === "string" && found.trim() ? found.trim() : undefined;
|
||||
}
|
||||
@@ -115,12 +107,8 @@ export function extractMSTeamsPollVote(
|
||||
if (!pollId) return null;
|
||||
|
||||
const directSelections = extractSelections(value.choices);
|
||||
const nestedSelections = extractSelections(
|
||||
readNestedValue(value, ["choices"]),
|
||||
);
|
||||
const dataSelections = extractSelections(
|
||||
readNestedValue(value, ["data", "choices"]),
|
||||
);
|
||||
const nestedSelections = extractSelections(readNestedValue(value, ["choices"]));
|
||||
const dataSelections = extractSelections(readNestedValue(value, ["data", "choices"]));
|
||||
const selections =
|
||||
directSelections.length > 0
|
||||
? directSelections
|
||||
@@ -147,10 +135,7 @@ export function buildMSTeamsPollCard(params: {
|
||||
typeof params.maxSelections === "number" && params.maxSelections > 1
|
||||
? Math.floor(params.maxSelections)
|
||||
: 1;
|
||||
const cappedMaxSelections = Math.min(
|
||||
Math.max(1, maxSelections),
|
||||
params.options.length,
|
||||
);
|
||||
const cappedMaxSelections = Math.min(Math.max(1, maxSelections), params.options.length);
|
||||
const choices = params.options.map((option, index) => ({
|
||||
title: option,
|
||||
value: String(index),
|
||||
@@ -252,24 +237,18 @@ function pruneToLimit(polls: Record<string, MSTeamsPoll>) {
|
||||
return Object.fromEntries(keep);
|
||||
}
|
||||
|
||||
export function normalizeMSTeamsPollSelections(
|
||||
poll: MSTeamsPoll,
|
||||
selections: string[],
|
||||
) {
|
||||
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);
|
||||
const limited = maxSelections > 1 ? mapped.slice(0, maxSelections) : mapped.slice(0, 1);
|
||||
return Array.from(new Set(limited));
|
||||
}
|
||||
|
||||
export function createMSTeamsPollStoreFs(
|
||||
params?: MSTeamsPollStoreFsOptions,
|
||||
): MSTeamsPollStore {
|
||||
export function createMSTeamsPollStoreFs(params?: MSTeamsPollStoreFsOptions): MSTeamsPollStore {
|
||||
const filePath = resolveMSTeamsStorePath({
|
||||
filename: STORE_FILENAME,
|
||||
env: params?.env,
|
||||
@@ -303,19 +282,12 @@ export function createMSTeamsPollStoreFs(
|
||||
return data.polls[pollId] ?? null;
|
||||
});
|
||||
|
||||
const recordVote = async (params: {
|
||||
pollId: string;
|
||||
voterId: string;
|
||||
selections: string[];
|
||||
}) =>
|
||||
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,
|
||||
);
|
||||
const normalized = normalizeMSTeamsPollSelections(poll, params.selections);
|
||||
poll.votes[params.voterId] = normalized;
|
||||
poll.updatedAt = new Date().toISOString();
|
||||
data.polls[poll.id] = poll;
|
||||
|
||||
@@ -9,9 +9,7 @@ export type ProbeMSTeamsResult = {
|
||||
appId?: string;
|
||||
};
|
||||
|
||||
export async function probeMSTeams(
|
||||
cfg?: MSTeamsConfig,
|
||||
): Promise<ProbeMSTeamsResult> {
|
||||
export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsResult> {
|
||||
const creds = resolveMSTeamsCredentials(cfg);
|
||||
if (!creds) {
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
} from "../agents/identity.js";
|
||||
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../agents/identity.js";
|
||||
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { ClawdbotConfig, MSTeamsReplyStyle } from "../config/types.js";
|
||||
import { danger } from "../globals.js";
|
||||
@@ -41,8 +38,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
};
|
||||
|
||||
return createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId)
|
||||
.responsePrefix,
|
||||
responsePrefix: resolveEffectiveMessagesConfig(params.cfg, params.agentId).responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
deliver: async (payload) => {
|
||||
const messages = renderReplyPayloadsToMessages([payload], {
|
||||
@@ -72,9 +68,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
params.runtime.error?.(
|
||||
danger(
|
||||
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
|
||||
),
|
||||
danger(`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`),
|
||||
);
|
||||
params.log.error("reply failed", {
|
||||
kind: info.kind,
|
||||
|
||||
@@ -2,9 +2,7 @@ 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 type MSTeamsAuthConfig = ReturnType<MSTeamsSdk["getAuthConfigWithDefaults"]>;
|
||||
|
||||
export async function loadMSTeamsSdk(): Promise<MSTeamsSdk> {
|
||||
return await import("@microsoft/agents-hosting");
|
||||
|
||||
@@ -69,14 +69,10 @@ async function sendMSTeamsActivity(params: {
|
||||
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";
|
||||
},
|
||||
);
|
||||
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
|
||||
const response = await ctx.sendActivity(params.activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
return messageId;
|
||||
}
|
||||
|
||||
@@ -91,11 +87,10 @@ export async function sendMessageMSTeams(
|
||||
params: SendMSTeamsMessageParams,
|
||||
): Promise<SendMSTeamsMessageResult> {
|
||||
const { cfg, to, text, mediaUrl } = params;
|
||||
const { adapter, appId, conversationId, ref, log } =
|
||||
await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
|
||||
log.debug("sending proactive message", {
|
||||
conversationId,
|
||||
@@ -103,11 +98,7 @@ export async function sendMessageMSTeams(
|
||||
hasMedia: Boolean(mediaUrl),
|
||||
});
|
||||
|
||||
const message = mediaUrl
|
||||
? text
|
||||
? `${text}\n\n${mediaUrl}`
|
||||
: mediaUrl
|
||||
: text;
|
||||
const message = mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text;
|
||||
let messageIds: string[];
|
||||
try {
|
||||
messageIds = await sendMSTeamsMessages({
|
||||
@@ -125,9 +116,7 @@ export async function sendMessageMSTeams(
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode
|
||||
? ` (HTTP ${classification.statusCode})`
|
||||
: "";
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
@@ -149,11 +138,10 @@ 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 { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
|
||||
const pollCard = buildMSTeamsPollCard({
|
||||
question,
|
||||
@@ -189,9 +177,7 @@ export async function sendPollMSTeams(
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode
|
||||
? ` (HTTP ${classification.statusCode})`
|
||||
: "";
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`msteams poll send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
|
||||
@@ -10,15 +10,11 @@ export type MSTeamsStorePathOptions = {
|
||||
filename: string;
|
||||
};
|
||||
|
||||
export function resolveMSTeamsStorePath(
|
||||
params: MSTeamsStorePathOptions,
|
||||
): 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);
|
||||
const stateDir = params.homedir ? resolveStateDir(env, params.homedir) : resolveStateDir(env);
|
||||
return path.join(stateDir, params.filename);
|
||||
}
|
||||
|
||||
@@ -39,16 +39,10 @@ export async function readJsonFile<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFile(
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
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`,
|
||||
);
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -6,14 +6,10 @@ export type MSTeamsCredentials = {
|
||||
tenantId: string;
|
||||
};
|
||||
|
||||
export function resolveMSTeamsCredentials(
|
||||
cfg?: MSTeamsConfig,
|
||||
): MSTeamsCredentials | undefined {
|
||||
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();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user