refactor(msteams): extract sdk + storage helpers
This commit is contained in:
@@ -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: {} };
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
5
src/msteams/monitor-types.ts
Normal file
5
src/msteams/monitor-types.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
42
src/msteams/polls-store.test.ts
Normal file
42
src/msteams/polls-store.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
81
src/msteams/reply-dispatcher.ts
Normal file
81
src/msteams/reply-dispatcher.ts
Normal 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
34
src/msteams/sdk.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
24
src/msteams/storage.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user