chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View File

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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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"] = {

View File

@@ -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);
});

View File

@@ -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>;

View File

@@ -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");
});
});

View File

@@ -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);

View File

@@ -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",
);
});
});

View File

@@ -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);
}

View File

@@ -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"]);

View File

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

View File

@@ -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.
}

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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) });
});

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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 });
};

View File

@@ -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",

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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");

View File

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

View File

@@ -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);
}

View File

@@ -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",
});

View File

@@ -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;