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

View File

@@ -1,6 +1,5 @@
import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.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 type { ClawdbotConfig } from "../config/types.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
@@ -23,11 +22,7 @@ import type {
MSTeamsConversationStore, MSTeamsConversationStore,
StoredConversationReference, StoredConversationReference,
} from "./conversation-store.js"; } from "./conversation-store.js";
import { import { formatUnknownError } from "./errors.js";
classifyMSTeamsSendError,
formatMSTeamsSendErrorHint,
formatUnknownError,
} from "./errors.js";
import { import {
extractMSTeamsConversationMessageId, extractMSTeamsConversationMessageId,
normalizeMSTeamsConversationId, normalizeMSTeamsConversationId,
@@ -35,24 +30,16 @@ import {
stripMSTeamsMentionTags, stripMSTeamsMentionTags,
wasMSTeamsBotMentioned, wasMSTeamsBotMentioned,
} from "./inbound.js"; } from "./inbound.js";
import { import type { MSTeamsAdapter } from "./messenger.js";
type MSTeamsAdapter, import type { MSTeamsMonitorLogger } from "./monitor-types.js";
renderReplyPayloadsToMessages,
sendMSTeamsMessages,
} from "./messenger.js";
import { import {
resolveMSTeamsReplyPolicy, resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig, resolveMSTeamsRouteConfig,
} from "./policy.js"; } from "./policy.js";
import { extractMSTeamsPollVote, type MSTeamsPollStore } from "./polls.js"; import { extractMSTeamsPollVote, type MSTeamsPollStore } from "./polls.js";
import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js";
import type { MSTeamsTurnContext } from "./sdk-types.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 = { export type MSTeamsAccessTokenProvider = {
getAccessToken: (scope: string) => Promise<string>; 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 // Create reply dispatcher
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({ createMSTeamsReplyDispatcher({
responsePrefix: cfg.messages?.responsePrefix, cfg,
deliver: async (payload) => { runtime,
const messages = renderReplyPayloadsToMessages([payload], { log,
textChunkLimit: textLimit, adapter,
chunkText: true, appId,
mediaMode: "split", conversationRef,
}); context,
await sendMSTeamsMessages({ replyStyle,
replyStyle, textLimit,
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,
}); });
// Dispatch to agent // 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 type { MSTeamsAdapter } from "./messenger.js";
import { registerMSTeamsHandlers } from "./monitor-handler.js"; import { registerMSTeamsHandlers } from "./monitor-handler.js";
import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js"; import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js"; import { resolveMSTeamsCredentials } from "./token.js";
const log = getChildLogger({ name: "msteams" }); const log = getChildLogger({ name: "msteams" });
@@ -65,25 +66,14 @@ export async function monitorMSTeamsProvider(
log.info(`starting provider (port ${port})`); log.info(`starting provider (port ${port})`);
// Dynamic import to avoid loading SDK when provider is disabled // Dynamic import to avoid loading SDK when provider is disabled
const agentsHosting = await import("@microsoft/agents-hosting");
const express = await import("express"); const express = await import("express");
const { const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
ActivityHandler, const { ActivityHandler, MsalTokenProvider, authorizeJWT } = sdk;
CloudAdapter,
MsalTokenProvider,
authorizeJWT,
getAuthConfigWithDefaults,
} = agentsHosting;
// Auth configuration - create early so adapter is available for deliverReplies // 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 tokenProvider = new MsalTokenProvider(authConfig);
const adapter = new CloudAdapter(authConfig); const adapter = createMSTeamsAdapter(authConfig, sdk);
const handler = registerMSTeamsHandlers(new ActivityHandler(), { const handler = registerMSTeamsHandlers(new ActivityHandler(), {
cfg, 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 lockfile from "proper-lockfile";
import { resolveStateDir } from "../config/paths.js"; import { resolveMSTeamsStorePath } from "./storage.js";
export type MSTeamsPollVote = { export type MSTeamsPollVote = {
pollId: string; pollId: string;
@@ -239,19 +239,6 @@ export type MSTeamsPollStoreFsOptions = {
storePath?: string; 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 { function safeParseJson<T>(raw: string): T | null {
try { try {
return JSON.parse(raw) as T; return JSON.parse(raw) as T;
@@ -364,7 +351,13 @@ export function normalizeMSTeamsPollSelections(
export function createMSTeamsPollStoreFs( export function createMSTeamsPollStoreFs(
params?: MSTeamsPollStoreFsOptions, params?: MSTeamsPollStoreFsOptions,
): MSTeamsPollStore { ): 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 empty: PollStoreData = { version: 1, polls: {} };
const readStore = async (): Promise<PollStoreData> => { const readStore = async (): Promise<PollStoreData> => {

View File

@@ -1,5 +1,6 @@
import type { MSTeamsConfig } from "../config/types.js"; import type { MSTeamsConfig } from "../config/types.js";
import { formatUnknownError } from "./errors.js"; import { formatUnknownError } from "./errors.js";
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js"; import { resolveMSTeamsCredentials } from "./token.js";
export type ProbeMSTeamsResult = { export type ProbeMSTeamsResult = {
@@ -20,16 +21,8 @@ export async function probeMSTeams(
} }
try { try {
const { MsalTokenProvider, getAuthConfigWithDefaults } = await import( const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
"@microsoft/agents-hosting" const tokenProvider = new sdk.MsalTokenProvider(authConfig);
);
const authConfig = getAuthConfigWithDefaults({
clientId: creds.appId,
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
});
const tokenProvider = new MsalTokenProvider(authConfig);
await tokenProvider.getAccessToken("https://api.botframework.com/.default"); await tokenProvider.getAccessToken("https://api.botframework.com/.default");
return { ok: true, appId: creds.appId }; return { ok: true, appId: creds.appId };
} catch (err) { } 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, sendMSTeamsMessages,
} from "./messenger.js"; } from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js"; import { buildMSTeamsPollCard } from "./polls.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js"; import { resolveMSTeamsCredentials } from "./token.js";
let _log: ReturnType<typeof getChildLoggerFn> | undefined; let _log: ReturnType<typeof getChildLoggerFn> | undefined;
@@ -156,17 +157,8 @@ async function resolveMSTeamsSendContext(params: {
const { conversationId, ref } = found; const { conversationId, ref } = found;
const log = await getLog(); const log = await getLog();
// Dynamic import to avoid loading SDK when not needed const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const agentsHosting = await import("@microsoft/agents-hosting"); const adapter = createMSTeamsAdapter(authConfig, sdk);
const { CloudAdapter, getAuthConfigWithDefaults } = agentsHosting;
const authConfig = getAuthConfigWithDefaults({
clientId: creds.appId,
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
});
const adapter = new CloudAdapter(authConfig);
return { return {
appId: creds.appId, 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);
}