refactor(msteams): extract sdk + storage helpers

This commit is contained in:
Peter Steinberger
2026-01-09 11:03:10 +01:00
parent 8875dbd449
commit 6d223303eb
12 changed files with 228 additions and 158 deletions

View File

@@ -1,16 +1,15 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import lockfile from "proper-lockfile";
import { resolveStateDir } from "../config/paths.js";
import type {
MSTeamsConversationStore,
MSTeamsConversationStoreEntry,
StoredConversationReference,
} from "./conversation-store.js";
import { resolveMSTeamsStorePath } from "./storage.js";
type ConversationStoreData = {
version: 1;
@@ -34,16 +33,6 @@ const STORE_LOCK_OPTIONS = {
stale: 30_000,
} as const;
function resolveStorePath(
env: NodeJS.ProcessEnv = process.env,
homedir?: () => string,
): string {
const stateDir = homedir
? resolveStateDir(env, homedir)
: resolveStateDir(env);
return path.join(stateDir, STORE_FILENAME);
}
function safeParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
@@ -167,11 +156,17 @@ export function createMSTeamsConversationStoreFs(params?: {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
ttlMs?: number;
stateDir?: string;
storePath?: string;
}): MSTeamsConversationStore {
const env = params?.env ?? process.env;
const homedir = params?.homedir ?? os.homedir;
const ttlMs = params?.ttlMs ?? CONVERSATION_TTL_MS;
const filePath = resolveStorePath(env, homedir);
const filePath = resolveMSTeamsStorePath({
filename: STORE_FILENAME,
env: params?.env,
homedir: params?.homedir,
stateDir: params?.stateDir,
storePath: params?.storePath,
});
const empty: ConversationStoreData = { version: 1, conversations: {} };

View File

@@ -1,6 +1,5 @@
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import type { ClawdbotConfig } from "../config/types.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
@@ -23,11 +22,7 @@ import type {
MSTeamsConversationStore,
StoredConversationReference,
} from "./conversation-store.js";
import {
classifyMSTeamsSendError,
formatMSTeamsSendErrorHint,
formatUnknownError,
} from "./errors.js";
import { formatUnknownError } from "./errors.js";
import {
extractMSTeamsConversationMessageId,
normalizeMSTeamsConversationId,
@@ -35,24 +30,16 @@ import {
stripMSTeamsMentionTags,
wasMSTeamsBotMentioned,
} from "./inbound.js";
import {
type MSTeamsAdapter,
renderReplyPayloadsToMessages,
sendMSTeamsMessages,
} from "./messenger.js";
import type { MSTeamsAdapter } from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import {
resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig,
} from "./policy.js";
import { extractMSTeamsPollVote, type MSTeamsPollStore } from "./polls.js";
import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
export type MSTeamsMonitorLogger = {
debug: (message: string, meta?: Record<string, unknown>) => void;
info: (message: string, meta?: Record<string, unknown>) => void;
error: (message: string, meta?: Record<string, unknown>) => void;
};
export type MSTeamsAccessTokenProvider = {
getAccessToken: (scope: string) => Promise<string>;
};
@@ -456,59 +443,18 @@ function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
);
}
// Send typing indicator
const sendTypingIndicator = async () => {
try {
await context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort.
}
};
// Create reply dispatcher
const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({
responsePrefix: cfg.messages?.responsePrefix,
deliver: async (payload) => {
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: textLimit,
chunkText: true,
mediaMode: "split",
});
await sendMSTeamsMessages({
replyStyle,
adapter,
appId,
conversationRef,
context,
messages,
// Enable default retry/backoff for throttling/transient failures.
retry: {},
onRetry: (event) => {
log.debug("retrying send", {
replyStyle,
...event,
});
},
});
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
runtime.error?.(
danger(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
),
);
log.error("reply failed", {
kind: info.kind,
error: errMsg,
classification,
hint,
});
},
onReplyStart: sendTypingIndicator,
createMSTeamsReplyDispatcher({
cfg,
runtime,
log,
adapter,
appId,
conversationRef,
context,
replyStyle,
textLimit,
});
// Dispatch to agent

View File

@@ -0,0 +1,5 @@
export type MSTeamsMonitorLogger = {
debug: (message: string, meta?: Record<string, unknown>) => void;
info: (message: string, meta?: Record<string, unknown>) => void;
error: (message: string, meta?: Record<string, unknown>) => void;
};

View File

@@ -9,6 +9,7 @@ import { formatUnknownError } from "./errors.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { registerMSTeamsHandlers } from "./monitor-handler.js";
import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
const log = getChildLogger({ name: "msteams" });
@@ -65,25 +66,14 @@ export async function monitorMSTeamsProvider(
log.info(`starting provider (port ${port})`);
// Dynamic import to avoid loading SDK when provider is disabled
const agentsHosting = await import("@microsoft/agents-hosting");
const express = await import("express");
const {
ActivityHandler,
CloudAdapter,
MsalTokenProvider,
authorizeJWT,
getAuthConfigWithDefaults,
} = agentsHosting;
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const { ActivityHandler, MsalTokenProvider, authorizeJWT } = sdk;
// Auth configuration - create early so adapter is available for deliverReplies
const authConfig = getAuthConfigWithDefaults({
clientId: creds.appId,
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
});
const tokenProvider = new MsalTokenProvider(authConfig);
const adapter = new CloudAdapter(authConfig);
const adapter = createMSTeamsAdapter(authConfig, sdk);
const handler = registerMSTeamsHandlers(new ActivityHandler(), {
cfg,

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js";
describe("msteams poll memory store", () => {
it("stores polls and records normalized votes", async () => {
const store = createMSTeamsPollStoreMemory();
await store.createPoll({
id: "poll-1",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
createdAt: new Date().toISOString(),
votes: {},
});
const poll = await store.recordVote({
pollId: "poll-1",
voterId: "user-1",
selections: ["0", "1"],
});
expect(poll?.votes["user-1"]).toEqual(["0"]);
});
});

View File

@@ -0,0 +1,42 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createMSTeamsPollStoreFs } from "./polls.js";
import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js";
const createFsStore = async () => {
const stateDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "clawdbot-msteams-polls-"),
);
return createMSTeamsPollStoreFs({ stateDir });
};
const createMemoryStore = () => createMSTeamsPollStoreMemory();
describe.each([
{ name: "memory", createStore: createMemoryStore },
{ name: "fs", createStore: createFsStore },
])("$name poll store", ({ createStore }) => {
it("stores polls and records normalized votes", async () => {
const store = await createStore();
await store.createPoll({
id: "poll-1",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
createdAt: new Date().toISOString(),
votes: {},
});
const poll = await store.recordVote({
pollId: "poll-1",
voterId: "user-1",
selections: ["0", "1"],
});
expect(poll?.votes["user-1"]).toEqual(["0"]);
});
});

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import lockfile from "proper-lockfile";
import { resolveStateDir } from "../config/paths.js";
import { resolveMSTeamsStorePath } from "./storage.js";
export type MSTeamsPollVote = {
pollId: string;
@@ -239,19 +239,6 @@ export type MSTeamsPollStoreFsOptions = {
storePath?: string;
};
function resolveStorePath(params?: MSTeamsPollStoreFsOptions): string {
if (params?.storePath) {
return params.storePath;
}
if (params?.stateDir) {
return path.join(params.stateDir, STORE_FILENAME);
}
const stateDir = params?.homedir
? resolveStateDir(params.env ?? process.env, params.homedir)
: resolveStateDir(params?.env ?? process.env);
return path.join(stateDir, STORE_FILENAME);
}
function safeParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
@@ -364,7 +351,13 @@ export function normalizeMSTeamsPollSelections(
export function createMSTeamsPollStoreFs(
params?: MSTeamsPollStoreFsOptions,
): MSTeamsPollStore {
const filePath = resolveStorePath(params);
const filePath = resolveMSTeamsStorePath({
filename: STORE_FILENAME,
env: params?.env,
homedir: params?.homedir,
stateDir: params?.stateDir,
storePath: params?.storePath,
});
const empty: PollStoreData = { version: 1, polls: {} };
const readStore = async (): Promise<PollStoreData> => {

View File

@@ -1,5 +1,6 @@
import type { MSTeamsConfig } from "../config/types.js";
import { formatUnknownError } from "./errors.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
export type ProbeMSTeamsResult = {
@@ -20,16 +21,8 @@ export async function probeMSTeams(
}
try {
const { MsalTokenProvider, getAuthConfigWithDefaults } = await import(
"@microsoft/agents-hosting"
);
const authConfig = getAuthConfigWithDefaults({
clientId: creds.appId,
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
});
const tokenProvider = new MsalTokenProvider(authConfig);
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
return { ok: true, appId: creds.appId };
} catch (err) {

View File

@@ -0,0 +1,81 @@
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import type { ClawdbotConfig, MSTeamsReplyStyle } from "../config/types.js";
import { danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import type { StoredConversationReference } from "./conversation-store.js";
import {
classifyMSTeamsSendError,
formatMSTeamsSendErrorHint,
formatUnknownError,
} from "./errors.js";
import {
type MSTeamsAdapter,
renderReplyPayloadsToMessages,
sendMSTeamsMessages,
} from "./messenger.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
export function createMSTeamsReplyDispatcher(params: {
cfg: ClawdbotConfig;
runtime: RuntimeEnv;
log: MSTeamsMonitorLogger;
adapter: MSTeamsAdapter;
appId: string;
conversationRef: StoredConversationReference;
context: MSTeamsTurnContext;
replyStyle: MSTeamsReplyStyle;
textLimit: number;
}) {
const sendTypingIndicator = async () => {
try {
await params.context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort.
}
};
return createReplyDispatcherWithTyping({
responsePrefix: params.cfg.messages?.responsePrefix,
deliver: async (payload) => {
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
});
await sendMSTeamsMessages({
replyStyle: params.replyStyle,
adapter: params.adapter,
appId: params.appId,
conversationRef: params.conversationRef,
context: params.context,
messages,
// Enable default retry/backoff for throttling/transient failures.
retry: {},
onRetry: (event) => {
params.log.debug("retrying send", {
replyStyle: params.replyStyle,
...event,
});
},
});
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.(
danger(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
),
);
params.log.error("reply failed", {
kind: info.kind,
error: errMsg,
classification,
hint,
});
},
onReplyStart: sendTypingIndicator,
});
}

34
src/msteams/sdk.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { MSTeamsAdapter } from "./messenger.js";
import type { MSTeamsCredentials } from "./token.js";
export type MSTeamsSdk = Awaited<
ReturnType<typeof import("@microsoft/agents-hosting")>
>;
export async function loadMSTeamsSdk(): Promise<MSTeamsSdk> {
return await import("@microsoft/agents-hosting");
}
export function buildMSTeamsAuthConfig(
creds: MSTeamsCredentials,
sdk: MSTeamsSdk,
) {
return sdk.getAuthConfigWithDefaults({
clientId: creds.appId,
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
});
}
export function createMSTeamsAdapter(
authConfig: unknown,
sdk: MSTeamsSdk,
): MSTeamsAdapter {
return new sdk.CloudAdapter(authConfig) as unknown as MSTeamsAdapter;
}
export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) {
const sdk = await loadMSTeamsSdk();
const authConfig = buildMSTeamsAuthConfig(creds, sdk);
return { sdk, authConfig };
}

View File

@@ -16,6 +16,7 @@ import {
sendMSTeamsMessages,
} from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js";
let _log: ReturnType<typeof getChildLoggerFn> | undefined;
@@ -156,17 +157,8 @@ async function resolveMSTeamsSendContext(params: {
const { conversationId, ref } = found;
const log = await getLog();
// Dynamic import to avoid loading SDK when not needed
const agentsHosting = await import("@microsoft/agents-hosting");
const { CloudAdapter, getAuthConfigWithDefaults } = agentsHosting;
const authConfig = getAuthConfigWithDefaults({
clientId: creds.appId,
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
});
const adapter = new CloudAdapter(authConfig);
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const adapter = createMSTeamsAdapter(authConfig, sdk);
return {
appId: creds.appId,

24
src/msteams/storage.ts Normal file
View File

@@ -0,0 +1,24 @@
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
export type MSTeamsStorePathOptions = {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
stateDir?: string;
storePath?: string;
filename: string;
};
export function resolveMSTeamsStorePath(
params: MSTeamsStorePathOptions,
): string {
if (params.storePath) return params.storePath;
if (params.stateDir) return path.join(params.stateDir, params.filename);
const env = params.env ?? process.env;
const stateDir = params.homedir
? resolveStateDir(env, params.homedir)
: resolveStateDir(env);
return path.join(stateDir, params.filename);
}