diff --git a/src/msteams/conversation-store-fs.ts b/src/msteams/conversation-store-fs.ts index f1891fa3a..3b1b3bc93 100644 --- a/src/msteams/conversation-store-fs.ts +++ b/src/msteams/conversation-store-fs.ts @@ -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(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: {} }; diff --git a/src/msteams/monitor-handler.ts b/src/msteams/monitor-handler.ts index 72b524f3f..05476f700 100644 --- a/src/msteams/monitor-handler.ts +++ b/src/msteams/monitor-handler.ts @@ -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) => void; - info: (message: string, meta?: Record) => void; - error: (message: string, meta?: Record) => void; -}; - export type MSTeamsAccessTokenProvider = { getAccessToken: (scope: string) => Promise; }; @@ -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 diff --git a/src/msteams/monitor-types.ts b/src/msteams/monitor-types.ts new file mode 100644 index 000000000..014081ffd --- /dev/null +++ b/src/msteams/monitor-types.ts @@ -0,0 +1,5 @@ +export type MSTeamsMonitorLogger = { + debug: (message: string, meta?: Record) => void; + info: (message: string, meta?: Record) => void; + error: (message: string, meta?: Record) => void; +}; diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts index b00de330a..f859daaa2 100644 --- a/src/msteams/monitor.ts +++ b/src/msteams/monitor.ts @@ -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, diff --git a/src/msteams/polls-store-memory.test.ts b/src/msteams/polls-store-memory.test.ts deleted file mode 100644 index ba1f9cf69..000000000 --- a/src/msteams/polls-store-memory.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/src/msteams/polls-store.test.ts b/src/msteams/polls-store.test.ts new file mode 100644 index 000000000..554067ffd --- /dev/null +++ b/src/msteams/polls-store.test.ts @@ -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"]); + }); +}); diff --git a/src/msteams/polls.ts b/src/msteams/polls.ts index 3ad65c41d..9a7c9dc6e 100644 --- a/src/msteams/polls.ts +++ b/src/msteams/polls.ts @@ -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(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 => { diff --git a/src/msteams/probe.ts b/src/msteams/probe.ts index 44c36287a..887eef688 100644 --- a/src/msteams/probe.ts +++ b/src/msteams/probe.ts @@ -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) { diff --git a/src/msteams/reply-dispatcher.ts b/src/msteams/reply-dispatcher.ts new file mode 100644 index 000000000..bf0300461 --- /dev/null +++ b/src/msteams/reply-dispatcher.ts @@ -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, + }); +} diff --git a/src/msteams/sdk.ts b/src/msteams/sdk.ts new file mode 100644 index 000000000..2d0f7a959 --- /dev/null +++ b/src/msteams/sdk.ts @@ -0,0 +1,34 @@ +import type { MSTeamsAdapter } from "./messenger.js"; +import type { MSTeamsCredentials } from "./token.js"; + +export type MSTeamsSdk = Awaited< + ReturnType +>; + +export async function loadMSTeamsSdk(): Promise { + 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 }; +} diff --git a/src/msteams/send.ts b/src/msteams/send.ts index 88f2138ac..dde3b3945 100644 --- a/src/msteams/send.ts +++ b/src/msteams/send.ts @@ -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 | 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, diff --git a/src/msteams/storage.ts b/src/msteams/storage.ts new file mode 100644 index 000000000..9b625d4b8 --- /dev/null +++ b/src/msteams/storage.ts @@ -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); +}