From e55358c65d31fa7192e6029e96aa3f09baca39b9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 09:56:36 +0100 Subject: [PATCH] feat: finalize msteams polls + outbound parity --- CHANGELOG.md | 1 + docs/automation/poll.md | 8 + docs/cli/message.md | 5 +- docs/providers/msteams.md | 14 +- src/agents/tools/message-tool.ts | 2 +- src/commands/message.ts | 5 + src/gateway/server-methods/send.ts | 44 ++- src/infra/outbound/format.ts | 11 + src/infra/outbound/message.ts | 11 +- src/infra/outbound/provider-selection.ts | 11 +- src/msteams/attachments.test.ts | 21 ++ src/msteams/attachments.ts | 62 ++++ src/msteams/conversation-store.ts | 2 + src/msteams/inbound.test.ts | 5 + src/msteams/inbound.ts | 2 +- src/msteams/index.ts | 2 +- src/msteams/messenger.ts | 10 +- src/msteams/monitor.ts | 163 +++++---- src/msteams/polls.test.ts | 61 ++++ src/msteams/polls.ts | 400 +++++++++++++++++++++++ src/msteams/send.ts | 150 ++++++++- src/utils/message-provider.ts | 4 +- 22 files changed, 913 insertions(+), 81 deletions(-) create mode 100644 src/msteams/polls.test.ts create mode 100644 src/msteams/polls.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7a5f896..ee33af2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Commands: accept /models as an alias for /model. - Debugging: add raw model stream logging flags and document gateway watch mode. - Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled). diff --git a/docs/automation/poll.md b/docs/automation/poll.md index 39307f946..12aef2dd5 100644 --- a/docs/automation/poll.md +++ b/docs/automation/poll.md @@ -10,6 +10,7 @@ read_when: ## Supported providers - WhatsApp (web provider) - Discord +- MS Teams (Adaptive Cards) ## CLI @@ -25,6 +26,10 @@ clawdbot message poll --provider discord --to channel:123456789 \ --poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi" clawdbot message poll --provider discord --to channel:123456789 \ --poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48 + +# MS Teams +clawdbot message poll --provider msteams --to conversation:19:abc@thread.tacv2 \ + --poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi" ``` Options: @@ -48,8 +53,11 @@ Params: ## Provider differences - WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`. - Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count. +- MS Teams: Adaptive Card polls (Clawdbot-managed). No native poll API; `durationHours` is ignored. ## Agent tool (Message) Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `provider`). Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select. +Teams polls are rendered as Adaptive Cards and require the gateway to stay online +to record votes in `~/.clawdbot/msteams-polls.json`. diff --git a/docs/cli/message.md b/docs/cli/message.md index 47aa67320..cec9bec6c 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -8,7 +8,7 @@ read_when: # `clawdbot message` Single outbound command for sending messages and provider actions -(Discord/Slack/Telegram/WhatsApp/Signal/iMessage). +(Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams). ## Usage @@ -19,7 +19,7 @@ clawdbot message [flags] Provider selection: - `--provider` required if more than one provider is configured. - If exactly one provider is configured, it becomes the default. -- Values: `whatsapp|telegram|discord|slack|signal|imessage` +- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams` Target formats (`--to`): - WhatsApp: E.164 or group JID @@ -27,6 +27,7 @@ Target formats (`--to`): - Discord/Slack: `channel:` or `user:` (raw id ok) - Signal: E.164, `group:`, or `signal:+E.164` - iMessage: handle or `chat_id:` +- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:` or `user:` ## Common flags diff --git a/docs/providers/msteams.md b/docs/providers/msteams.md index 126aba525..1a7e6fd59 100644 --- a/docs/providers/msteams.md +++ b/docs/providers/msteams.md @@ -10,7 +10,7 @@ read_when: Updated: 2026-01-08 -Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. +Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards. ## Goals - Talk to Clawdbot via Teams DMs, group chats, or channels. @@ -288,7 +288,7 @@ Clawdbot handles this by returning quickly and sending replies proactively, but Teams markdown is more limited than Slack or Discord: - Basic formatting works: **bold**, *italic*, `code`, links - Complex markdown (tables, nested lists) may not render correctly -- Adaptive Cards are not yet supported (plain text + links for now) +- Adaptive Cards are used for polls; other card types are not yet supported ## Configuration Key settings (see `/gateway/configuration` for shared provider patterns): @@ -300,6 +300,7 @@ Key settings (see `/gateway/configuration` for shared provider patterns): - `msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) - `msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs). - `msteams.textChunkLimit`: outbound text chunk size. +- `msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). - `msteams.requireMention`: require @mention in channels/groups (default true). - `msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)). - `msteams.teams..replyStyle`: per-team override. @@ -352,6 +353,15 @@ Teams recently introduced two channel UI styles over the same underlying data mo - **Channels/groups:** Attachments live in M365 storage (SharePoint/OneDrive). The webhook payload only includes an HTML stub, not the actual file bytes. **Graph API permissions are required** to download channel attachments. Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot). +By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Override with `msteams.mediaAllowHosts` (use `["*"]` to allow any host). + +## Polls (Adaptive Cards) +Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API). + +- CLI: `clawdbot message poll --provider msteams --to conversation: ...` +- Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`. +- The gateway must stay online to record votes. +- Polls do not auto-post result summaries yet (inspect the store file if needed). ## Proactive messaging - Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point. diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index a4d54b2e9..9875a54d9 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -130,7 +130,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { label: "Message", name: "message", description: - "Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage).", + "Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).", parameters: MessageToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; diff --git a/src/commands/message.ts b/src/commands/message.ts index 1ea310e8c..731d452a3 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -477,6 +477,10 @@ export async function messageCommand( }), ), ); + const pollId = (result.result as { pollId?: string } | undefined)?.pollId; + if (pollId) { + runtime.log(success(`Poll id: ${pollId}`)); + } if (opts.json) { runtime.log( JSON.stringify( @@ -494,6 +498,7 @@ export async function messageCommand( options: result.options, maxSelections: result.maxSelections, durationHours: result.durationHours, + pollId, }, null, 2, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index ce0509122..059e0452e 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -2,7 +2,9 @@ import { loadConfig } from "../../config/config.js"; import { sendMessageDiscord, sendPollDiscord } from "../../discord/index.js"; import { shouldLogVerbose } from "../../globals.js"; import { sendMessageIMessage } from "../../imessage/index.js"; -import { sendMessageMSTeams } from "../../msteams/send.js"; +import { createMSTeamsPollStoreFs } from "../../msteams/polls.js"; +import { sendMessageMSTeams, sendPollMSTeams } from "../../msteams/send.js"; +import { normalizePollInput } from "../../polls.js"; import { sendMessageSignal } from "../../signal/index.js"; import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; @@ -231,7 +233,11 @@ export const sendHandlers: GatewayRequestHandlers = { } const to = request.to.trim(); const provider = normalizeMessageProvider(request.provider) ?? "whatsapp"; - if (provider !== "whatsapp" && provider !== "discord") { + if ( + provider !== "whatsapp" && + provider !== "discord" && + provider !== "msteams" + ) { respond( false, undefined, @@ -267,6 +273,40 @@ export const sendHandlers: GatewayRequestHandlers = { payload, }); respond(true, payload, undefined, { provider }); + } else if (provider === "msteams") { + const cfg = loadConfig(); + const normalized = normalizePollInput(poll, { maxOptions: 12 }); + const result = await sendPollMSTeams({ + cfg, + to, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + }); + const pollStore = createMSTeamsPollStoreFs(); + await pollStore.createPoll({ + id: result.pollId, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + createdAt: new Date().toISOString(), + conversationId: result.conversationId, + messageId: result.messageId, + votes: {}, + }); + const payload = { + runId: idem, + messageId: result.messageId, + conversationId: result.conversationId, + pollId: result.pollId, + provider, + }; + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); } else { const cfg = loadConfig(); const accountId = diff --git a/src/infra/outbound/format.ts b/src/infra/outbound/format.ts index e3133e65f..4c02307bf 100644 --- a/src/infra/outbound/format.ts +++ b/src/infra/outbound/format.ts @@ -8,6 +8,7 @@ export type OutboundDeliveryJson = { mediaUrl: string | null; chatId?: string; channelId?: string; + conversationId?: string; timestamp?: number; toJid?: string; }; @@ -16,6 +17,7 @@ type OutboundDeliveryMeta = { messageId?: string; chatId?: string; channelId?: string; + conversationId?: string; timestamp?: number; toJid?: string; }; @@ -36,6 +38,8 @@ export function formatOutboundDeliverySummary( if ("chatId" in result) return `${base} (chat ${result.chatId})`; if ("channelId" in result) return `${base} (channel ${result.channelId})`; + if ("conversationId" in result) + return `${base} (conversation ${result.conversationId})`; return base; } @@ -62,6 +66,13 @@ export function buildOutboundDeliveryJson(params: { if (result && "channelId" in result && result.channelId !== undefined) { payload.channelId = result.channelId; } + if ( + result && + "conversationId" in result && + result.conversationId !== undefined + ) { + payload.conversationId = result.conversationId; + } if (result && "timestamp" in result && result.timestamp !== undefined) { payload.timestamp = result.timestamp; } diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index e283a59be..17880720a 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -70,6 +70,8 @@ export type MessagePollResult = { messageId: string; toJid?: string; channelId?: string; + conversationId?: string; + pollId?: string; }; dryRun?: boolean; }; @@ -108,7 +110,8 @@ export async function sendMessage( provider === "discord" || provider === "slack" || provider === "signal" || - provider === "imessage" + provider === "imessage" || + provider === "msteams" ) { const resolvedTarget = resolveOutboundTarget({ provider, @@ -167,7 +170,11 @@ export async function sendPoll( params: MessagePollParams, ): Promise { const provider = (params.provider ?? "whatsapp").toLowerCase(); - if (provider !== "whatsapp" && provider !== "discord") { + if ( + provider !== "whatsapp" && + provider !== "discord" && + provider !== "msteams" + ) { throw new Error(`Unsupported poll provider: ${provider}`); } diff --git a/src/infra/outbound/provider-selection.ts b/src/infra/outbound/provider-selection.ts index b969c0585..b63d9c752 100644 --- a/src/infra/outbound/provider-selection.ts +++ b/src/infra/outbound/provider-selection.ts @@ -1,6 +1,7 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { listEnabledDiscordAccounts } from "../../discord/accounts.js"; import { listEnabledIMessageAccounts } from "../../imessage/accounts.js"; +import { resolveMSTeamsCredentials } from "../../msteams/token.js"; import { listEnabledSignalAccounts } from "../../signal/accounts.js"; import { listEnabledSlackAccounts } from "../../slack/accounts.js"; import { listEnabledTelegramAccounts } from "../../telegram/accounts.js"; @@ -17,7 +18,8 @@ export type MessageProviderId = | "discord" | "slack" | "signal" - | "imessage"; + | "imessage" + | "msteams"; const MESSAGE_PROVIDERS: MessageProviderId[] = [ "whatsapp", @@ -26,6 +28,7 @@ const MESSAGE_PROVIDERS: MessageProviderId[] = [ "slack", "signal", "imessage", + "msteams", ]; function isKnownProvider(value: string): value is MessageProviderId { @@ -70,6 +73,11 @@ function isIMessageConfigured(cfg: ClawdbotConfig): boolean { return listEnabledIMessageAccounts(cfg).some((account) => account.configured); } +function isMSTeamsConfigured(cfg: ClawdbotConfig): boolean { + if (!cfg.msteams || cfg.msteams.enabled === false) return false; + return Boolean(resolveMSTeamsCredentials(cfg.msteams)); +} + export async function listConfiguredMessageProviders( cfg: ClawdbotConfig, ): Promise { @@ -80,6 +88,7 @@ export async function listConfiguredMessageProviders( if (isSlackConfigured(cfg)) providers.push("slack"); if (isSignalConfigured(cfg)) providers.push("signal"); if (isIMessageConfigured(cfg)) providers.push("imessage"); + if (isMSTeamsConfigured(cfg)) providers.push("msteams"); return providers; } diff --git a/src/msteams/attachments.test.ts b/src/msteams/attachments.test.ts index d1f92a33e..b0dc4aef5 100644 --- a/src/msteams/attachments.test.ts +++ b/src/msteams/attachments.test.ts @@ -108,6 +108,7 @@ describe("msteams attachments", () => { { contentType: "image/png", contentUrl: "https://x/img" }, ], maxBytes: 1024 * 1024, + allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, }); @@ -133,6 +134,7 @@ describe("msteams attachments", () => { }, ], maxBytes: 1024 * 1024, + allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, }); @@ -156,6 +158,7 @@ describe("msteams attachments", () => { }, ], maxBytes: 1024 * 1024, + allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, }); @@ -173,6 +176,7 @@ describe("msteams attachments", () => { }, ], maxBytes: 1024 * 1024, + allowHosts: ["x"], }); expect(media).toHaveLength(1); @@ -202,6 +206,7 @@ describe("msteams attachments", () => { ], maxBytes: 1024 * 1024, tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, }); @@ -209,6 +214,21 @@ describe("msteams attachments", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("skips urls outside the allowlist", async () => { + const fetchMock = vi.fn(); + const media = await downloadMSTeamsImageAttachments({ + attachments: [ + { contentType: "image/png", contentUrl: "https://evil.test/img" }, + ], + maxBytes: 1024 * 1024, + allowHosts: ["graph.microsoft.com"], + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(media).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("ignores non-image attachments", async () => { const fetchMock = vi.fn(); const media = await downloadMSTeamsImageAttachments({ @@ -216,6 +236,7 @@ describe("msteams attachments", () => { { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, ], maxBytes: 1024 * 1024, + allowHosts: ["x"], fetchFn: fetchMock as unknown as typeof fetch, }); diff --git a/src/msteams/attachments.ts b/src/msteams/attachments.ts index e426899a9..bb1b63f77 100644 --- a/src/msteams/attachments.ts +++ b/src/msteams/attachments.ts @@ -46,6 +46,25 @@ const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i; const IMG_SRC_RE = /]+src=["']([^"']+)["'][^>]*>/gi; const ATTACHMENT_TAG_RE = /]+id=["']([^"']+)["'][^>]*>/gi; +const DEFAULT_MEDIA_HOST_ALLOWLIST = [ + "graph.microsoft.com", + "graph.microsoft.us", + "graph.microsoft.de", + "graph.microsoft.cn", + "sharepoint.com", + "sharepoint.us", + "sharepoint.de", + "sharepoint.cn", + "sharepoint-df.com", + "1drv.ms", + "onedrive.com", + "teams.microsoft.com", + "teams.cdn.office.net", + "statics.teams.cdn.office.net", + "office.com", + "office.net", +]; + export type MSTeamsHtmlAttachmentSummary = { htmlAttachments: number; imgTags: number; @@ -222,6 +241,40 @@ function safeHostForUrl(url: string): string { } } +function normalizeAllowHost(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) return ""; + if (trimmed === "*") return "*"; + return trimmed.replace(/^\*\.?/, ""); +} + +function resolveAllowedHosts(input?: string[]): string[] { + if (!Array.isArray(input) || input.length === 0) { + return DEFAULT_MEDIA_HOST_ALLOWLIST.slice(); + } + const normalized = input.map(normalizeAllowHost).filter(Boolean); + if (normalized.includes("*")) return ["*"]; + return normalized; +} + +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}`), + ); +} + +function isUrlAllowed(url: string, allowlist: string[]): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") return false; + return isHostAllowed(parsed.hostname, allowlist); + } catch { + return false; + } +} + export function summarizeMSTeamsHtmlAttachments( attachments: MSTeamsAttachmentLike[] | undefined, ): MSTeamsHtmlAttachmentSummary | undefined { @@ -456,11 +509,13 @@ export async function downloadMSTeamsGraphMedia(params: { messageUrl?: string | null; tokenProvider?: MSTeamsAccessTokenProvider; maxBytes: number; + allowHosts?: string[]; fetchFn?: typeof fetch; }): Promise { if (!params.messageUrl || !params.tokenProvider) { return { media: [] }; } + const allowHosts = resolveAllowedHosts(params.allowHosts); const messageUrl = params.messageUrl; let accessToken: string; try { @@ -489,6 +544,7 @@ export async function downloadMSTeamsGraphMedia(params: { attachments: normalizedAttachments, maxBytes: params.maxBytes, tokenProvider: params.tokenProvider, + allowHosts, fetchFn: params.fetchFn, }); @@ -629,10 +685,12 @@ export async function downloadMSTeamsImageAttachments(params: { attachments: MSTeamsAttachmentLike[] | undefined; maxBytes: number; tokenProvider?: MSTeamsAccessTokenProvider; + allowHosts?: string[]; fetchFn?: typeof fetch; }): Promise { const list = Array.isArray(params.attachments) ? params.attachments : []; if (list.length === 0) return []; + const allowHosts = resolveAllowedHosts(params.allowHosts); const candidates: DownloadCandidate[] = list .filter(isLikelyImageAttachment) @@ -643,6 +701,9 @@ export async function downloadMSTeamsImageAttachments(params: { const seenUrls = new Set(); for (const inline of inlineCandidates) { if (inline.kind === "url") { + if (!isUrlAllowed(inline.url, allowHosts)) { + continue; + } if (seenUrls.has(inline.url)) continue; seenUrls.add(inline.url); candidates.push({ @@ -677,6 +738,7 @@ export async function downloadMSTeamsImageAttachments(params: { } } for (const candidate of candidates) { + if (!isUrlAllowed(candidate.url, allowHosts)) continue; try { const res = await fetchWithAuthFallback({ url: candidate.url, diff --git a/src/msteams/conversation-store.ts b/src/msteams/conversation-store.ts index a76b4d3f2..f16e00fd1 100644 --- a/src/msteams/conversation-store.ts +++ b/src/msteams/conversation-store.ts @@ -17,6 +17,8 @@ export type StoredConversationReference = { bot?: { id?: string; name?: string }; /** Conversation details */ conversation?: { id?: string; conversationType?: string; tenantId?: string }; + /** Team ID for channel messages (when available). */ + teamId?: string; /** Channel ID (usually "msteams") */ channelId?: string; /** Service URL for sending messages back */ diff --git a/src/msteams/inbound.test.ts b/src/msteams/inbound.test.ts index 98c9b2df4..05a5d8d67 100644 --- a/src/msteams/inbound.test.ts +++ b/src/msteams/inbound.test.ts @@ -13,6 +13,11 @@ describe("msteams inbound", () => { expect(stripMSTeamsMentionTags("Bot hi")).toBe("hi"); expect(stripMSTeamsMentionTags("hi Bot")).toBe("hi"); }); + + it("removes tags with attributes", () => { + expect(stripMSTeamsMentionTags('Bot hi')).toBe("hi"); + expect(stripMSTeamsMentionTags('hi Bot')).toBe("hi"); + }); }); describe("normalizeMSTeamsConversationId", () => { diff --git a/src/msteams/inbound.ts b/src/msteams/inbound.ts index 9f308deb8..a704ee87f 100644 --- a/src/msteams/inbound.ts +++ b/src/msteams/inbound.ts @@ -31,7 +31,7 @@ export function parseMSTeamsActivityTimestamp( export function stripMSTeamsMentionTags(text: string): string { // Teams wraps mentions in ... tags - return text.replace(/.*?<\/at>/gi, "").trim(); + return text.replace(/]*>.*?<\/at>/gi, "").trim(); } export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean { diff --git a/src/msteams/index.ts b/src/msteams/index.ts index b24578cc9..375a2bbd7 100644 --- a/src/msteams/index.ts +++ b/src/msteams/index.ts @@ -1,4 +1,4 @@ export { monitorMSTeamsProvider } from "./monitor.js"; export { probeMSTeams } from "./probe.js"; -export { sendMessageMSTeams } from "./send.js"; +export { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js"; diff --git a/src/msteams/messenger.ts b/src/msteams/messenger.ts index b33db42f2..7b45993cf 100644 --- a/src/msteams/messenger.ts +++ b/src/msteams/messenger.ts @@ -9,7 +9,7 @@ type SendContext = { sendActivity: (textOrActivity: string | object) => Promise; }; -type ConversationReference = { +export type MSTeamsConversationReference = { activityId?: string; user?: { id?: string; name?: string; aadObjectId?: string }; agent?: { id?: string; name?: string; aadObjectId?: string } | null; @@ -22,7 +22,7 @@ type ConversationReference = { export type MSTeamsAdapter = { continueConversation: ( appId: string, - reference: ConversationReference, + reference: MSTeamsConversationReference, logic: (context: SendContext) => Promise, ) => Promise; }; @@ -52,9 +52,9 @@ function normalizeConversationId(rawId: string): string { return rawId.split(";")[0] ?? rawId; } -function buildConversationReference( +export function buildConversationReference( ref: StoredConversationReference, -): ConversationReference { +): MSTeamsConversationReference { const conversationId = ref.conversation?.id?.trim(); if (!conversationId) { throw new Error("Invalid stored reference: missing conversation.id"); @@ -275,7 +275,7 @@ export async function sendMSTeamsMessages(params: { } const baseRef = buildConversationReference(params.conversationRef); - const proactiveRef: ConversationReference = { + const proactiveRef: MSTeamsConversationReference = { ...baseRef, activityId: undefined, }; diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts index 7270d63be..df0eaf6c6 100644 --- a/src/msteams/monitor.ts +++ b/src/msteams/monitor.ts @@ -48,6 +48,11 @@ import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig, } from "./policy.js"; +import { + createMSTeamsPollStoreFs, + extractMSTeamsPollVote, + type MSTeamsPollStore, +} from "./polls.js"; import type { MSTeamsTurnContext } from "./sdk-types.js"; import { resolveMSTeamsCredentials } from "./token.js"; @@ -58,6 +63,7 @@ export type MonitorMSTeamsOpts = { runtime?: RuntimeEnv; abortSignal?: AbortSignal; conversationStore?: MSTeamsConversationStore; + pollStore?: MSTeamsPollStore; }; export type MonitorMSTeamsResult = { @@ -99,6 +105,7 @@ export async function monitorMSTeamsProvider( : 8 * MB; const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs(); + const pollStore = opts.pollStore ?? createMSTeamsPollStoreFs(); log.info(`starting provider (port ${port})`); @@ -157,10 +164,6 @@ export async function monitorMSTeamsProvider( log.debug("html attachment summary", htmlSummary); } - if (!rawBody) { - log.debug("skipping empty message after stripping mentions"); - return; - } if (!from?.id) { log.debug("skipping message without from.id"); return; @@ -180,63 +183,6 @@ export async function monitorMSTeamsProvider( const senderName = from.name ?? from.id; const senderId = from.aadObjectId ?? from.id; - // Save conversation reference for proactive messaging - const agent = activity.recipient - ? { - id: activity.recipient.id, - name: activity.recipient.name, - aadObjectId: activity.recipient.aadObjectId, - } - : undefined; - const conversationRef: StoredConversationReference = { - activityId: activity.id, - user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId }, - agent, - bot: agent ? { id: agent.id, name: agent.name } : undefined, - conversation: { - id: conversationId, - conversationType, - tenantId: conversation?.tenantId, - }, - channelId: activity.channelId, - serviceUrl: activity.serviceUrl, - }; - conversationStore.upsert(conversationId, conversationRef).catch((err) => { - log.debug("failed to save conversation reference", { - error: formatUnknownError(err), - }); - }); - - // Build Teams-specific identifiers - const teamsFrom = isDirectMessage - ? `msteams:${senderId}` - : isChannel - ? `msteams:channel:${conversationId}` - : `msteams:group:${conversationId}`; - const teamsTo = isDirectMessage - ? `user:${senderId}` - : `conversation:${conversationId}`; - - // Resolve routing - const route = resolveAgentRoute({ - cfg, - provider: "msteams", - peer: { - kind: isDirectMessage ? "dm" : isChannel ? "channel" : "group", - id: isDirectMessage ? senderId : conversationId, - }, - }); - - const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); - const inboundLabel = isDirectMessage - ? `Teams DM from ${senderName}` - : `Teams message in ${conversationType} from ${senderName}`; - - enqueueSystemEvent(`${inboundLabel}: ${preview}`, { - sessionKey: route.sessionKey, - contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`, - }); - // Check DM policy for direct messages if (isDirectMessage && msteamsCfg) { const dmPolicy = msteamsCfg.dmPolicy ?? "pairing"; @@ -280,8 +226,99 @@ export async function monitorMSTeamsProvider( } } - // Resolve team/channel config for channels and group chats + // Save conversation reference for proactive messaging + const agent = activity.recipient + ? { + id: activity.recipient.id, + name: activity.recipient.name, + aadObjectId: activity.recipient.aadObjectId, + } + : undefined; const teamId = activity.channelData?.team?.id; + const conversationRef: StoredConversationReference = { + activityId: activity.id, + user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId }, + agent, + bot: agent ? { id: agent.id, name: agent.name } : undefined, + conversation: { + id: conversationId, + conversationType, + tenantId: conversation?.tenantId, + }, + teamId, + channelId: activity.channelId, + serviceUrl: activity.serviceUrl, + }; + conversationStore.upsert(conversationId, conversationRef).catch((err) => { + log.debug("failed to save conversation reference", { + error: formatUnknownError(err), + }); + }); + + const pollVote = extractMSTeamsPollVote(activity); + if (pollVote) { + try { + const poll = await pollStore.recordVote({ + pollId: pollVote.pollId, + voterId: senderId, + selections: pollVote.selections, + }); + if (!poll) { + log.debug("poll vote ignored (poll not found)", { + pollId: pollVote.pollId, + }); + } else { + log.info("recorded poll vote", { + pollId: pollVote.pollId, + voter: senderId, + selections: pollVote.selections, + }); + } + } catch (err) { + log.error("failed to record poll vote", { + pollId: pollVote.pollId, + error: formatUnknownError(err), + }); + } + return; + } + + if (!rawBody) { + log.debug("skipping empty message after stripping mentions"); + return; + } + + // Build Teams-specific identifiers + const teamsFrom = isDirectMessage + ? `msteams:${senderId}` + : isChannel + ? `msteams:channel:${conversationId}` + : `msteams:group:${conversationId}`; + const teamsTo = isDirectMessage + ? `user:${senderId}` + : `conversation:${conversationId}`; + + // Resolve routing + const route = resolveAgentRoute({ + cfg, + provider: "msteams", + peer: { + kind: isDirectMessage ? "dm" : isChannel ? "channel" : "group", + id: isDirectMessage ? senderId : conversationId, + }, + }); + + const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); + const inboundLabel = isDirectMessage + ? `Teams DM from ${senderName}` + : `Teams message in ${conversationType} from ${senderName}`; + + enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + sessionKey: route.sessionKey, + contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`, + }); + + // Resolve team/channel config for channels and group chats const channelId = conversationId; const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({ cfg: msteamsCfg, @@ -318,6 +355,7 @@ export async function monitorMSTeamsProvider( tokenProvider: { getAccessToken: (scope) => tokenProvider.getAccessToken(scope), }, + allowHosts: msteamsCfg?.mediaAllowHosts, }); if (mediaList.length === 0) { const onlyHtmlAttachments = @@ -357,6 +395,7 @@ export async function monitorMSTeamsProvider( getAccessToken: (scope) => tokenProvider.getAccessToken(scope), }, maxBytes: mediaMaxBytes, + allowHosts: msteamsCfg?.mediaAllowHosts, }); attempts.push({ url: messageUrl, diff --git a/src/msteams/polls.test.ts b/src/msteams/polls.test.ts new file mode 100644 index 000000000..02ca43e0d --- /dev/null +++ b/src/msteams/polls.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + buildMSTeamsPollCard, + createMSTeamsPollStoreFs, + extractMSTeamsPollVote, +} from "./polls.js"; + +describe("msteams polls", () => { + it("builds poll cards with fallback text", () => { + const card = buildMSTeamsPollCard({ + question: "Lunch?", + options: ["Pizza", "Sushi"], + }); + + expect(card.pollId).toBeTruthy(); + expect(card.fallbackText).toContain("Poll: Lunch?"); + expect(card.fallbackText).toContain("1. Pizza"); + expect(card.fallbackText).toContain("2. Sushi"); + }); + + it("extracts poll votes from activity values", () => { + const vote = extractMSTeamsPollVote({ + value: { + clawdbotPollId: "poll-1", + choices: "0,1", + }, + }); + + expect(vote).toEqual({ + pollId: "poll-1", + selections: ["0", "1"], + }); + }); + + it("stores and records poll votes", async () => { + const home = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "clawdbot-msteams-polls-"), + ); + const store = createMSTeamsPollStoreFs({ homedir: () => home }); + await store.createPoll({ + id: "poll-2", + question: "Pick one", + options: ["A", "B"], + maxSelections: 1, + createdAt: new Date().toISOString(), + votes: {}, + }); + await store.recordVote({ + pollId: "poll-2", + voterId: "user-1", + selections: ["0", "1"], + }); + const stored = await store.getPoll("poll-2"); + expect(stored?.votes["user-1"]).toEqual(["0"]); + }); +}); diff --git a/src/msteams/polls.ts b/src/msteams/polls.ts new file mode 100644 index 000000000..55c15c728 --- /dev/null +++ b/src/msteams/polls.ts @@ -0,0 +1,400 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import lockfile from "proper-lockfile"; + +import { resolveStateDir } from "../config/paths.js"; + +export type MSTeamsPollVote = { + pollId: string; + selections: string[]; +}; + +export type MSTeamsPoll = { + id: string; + question: string; + options: string[]; + maxSelections: number; + createdAt: string; + updatedAt?: string; + conversationId?: string; + messageId?: string; + votes: Record; +}; + +export type MSTeamsPollStore = { + createPoll: (poll: MSTeamsPoll) => Promise; + getPoll: (pollId: string) => Promise; + recordVote: (params: { + pollId: string; + voterId: string; + selections: string[]; + }) => Promise; +}; + +export type MSTeamsPollCard = { + pollId: string; + question: string; + options: string[]; + maxSelections: number; + card: Record; + fallbackText: string; +}; + +type PollStoreData = { + version: 1; + polls: Record; +}; + +const STORE_FILENAME = "msteams-polls.json"; +const MAX_POLLS = 1000; +const POLL_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const STORE_LOCK_OPTIONS = { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10_000, + randomize: true, + }, + stale: 30_000, +} as const; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeChoiceValue(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return null; +} + +function extractSelections(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map(normalizeChoiceValue) + .filter((entry): entry is string => Boolean(entry)); + } + const normalized = normalizeChoiceValue(value); + if (!normalized) return []; + if (normalized.includes(",")) { + return normalized + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return [normalized]; +} + +function readNestedValue( + value: unknown, + keys: Array, +): unknown { + let current: unknown = value; + for (const key of keys) { + if (!isRecord(current)) return undefined; + current = current[key as keyof typeof current]; + } + return current; +} + +function readNestedString( + value: unknown, + keys: Array, +): string | undefined { + const found = readNestedValue(value, keys); + return typeof found === "string" && found.trim() ? found.trim() : undefined; +} + +export function extractMSTeamsPollVote( + activity: { value?: unknown } | undefined, +): MSTeamsPollVote | null { + const value = activity?.value; + if (!value || !isRecord(value)) return null; + const pollId = + readNestedString(value, ["clawdbotPollId"]) ?? + readNestedString(value, ["pollId"]) ?? + readNestedString(value, ["clawdbot", "pollId"]) ?? + readNestedString(value, ["clawdbot", "poll", "id"]) ?? + readNestedString(value, ["data", "clawdbotPollId"]) ?? + readNestedString(value, ["data", "pollId"]) ?? + readNestedString(value, ["data", "clawdbot", "pollId"]); + if (!pollId) return null; + + const directSelections = extractSelections(value.choices); + const nestedSelections = extractSelections( + readNestedValue(value, ["choices"]), + ); + const dataSelections = extractSelections( + readNestedValue(value, ["data", "choices"]), + ); + const selections = + directSelections.length > 0 + ? directSelections + : nestedSelections.length > 0 + ? nestedSelections + : dataSelections; + + if (selections.length === 0) return null; + + return { + pollId, + selections, + }; +} + +export function buildMSTeamsPollCard(params: { + question: string; + options: string[]; + maxSelections?: number; + pollId?: string; +}): MSTeamsPollCard { + const pollId = params.pollId ?? crypto.randomUUID(); + const maxSelections = + typeof params.maxSelections === "number" && params.maxSelections > 1 + ? Math.floor(params.maxSelections) + : 1; + const cappedMaxSelections = Math.min( + Math.max(1, maxSelections), + params.options.length, + ); + const choices = params.options.map((option, index) => ({ + title: option, + value: String(index), + })); + const hint = + cappedMaxSelections > 1 + ? `Select up to ${cappedMaxSelections} option${cappedMaxSelections === 1 ? "" : "s"}.` + : "Select one option."; + + const card = { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "TextBlock", + text: params.question, + wrap: true, + weight: "Bolder", + size: "Medium", + }, + { + type: "Input.ChoiceSet", + id: "choices", + isMultiSelect: cappedMaxSelections > 1, + style: "expanded", + choices, + }, + { + type: "TextBlock", + text: hint, + wrap: true, + isSubtle: true, + spacing: "Small", + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Vote", + data: { + clawdbotPollId: pollId, + }, + msteams: { + type: "messageBack", + text: "clawdbot poll vote", + displayText: "Vote recorded", + value: { clawdbotPollId: pollId }, + }, + }, + ], + }; + + const fallbackLines = [ + `Poll: ${params.question}`, + ...params.options.map((option, index) => `${index + 1}. ${option}`), + ]; + + return { + pollId, + question: params.question, + options: params.options, + maxSelections: cappedMaxSelections, + card, + fallbackText: fallbackLines.join("\n"), + }; +} + +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; + } catch { + return null; + } +} + +async function readJsonFile( + filePath: string, + fallback: T, +): Promise<{ value: T; exists: boolean }> { + try { + const raw = await fs.promises.readFile(filePath, "utf-8"); + const parsed = safeParseJson(raw); + if (parsed == null) return { value: fallback, exists: true }; + return { value: parsed, exists: true }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") return { value: fallback, exists: false }; + return { value: fallback, exists: false }; + } +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + 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`, + ); + await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf-8", + }); + await fs.promises.chmod(tmp, 0o600); + await fs.promises.rename(tmp, filePath); +} + +async function ensureJsonFile(filePath: string, fallback: unknown) { + try { + await fs.promises.access(filePath); + } catch { + await writeJsonFile(filePath, fallback); + } +} + +async function withFileLock( + filePath: string, + fallback: unknown, + fn: () => Promise, +): Promise { + await ensureJsonFile(filePath, fallback); + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(filePath, STORE_LOCK_OPTIONS); + return await fn(); + } finally { + if (release) { + try { + await release(); + } catch { + // ignore unlock errors + } + } + } +} + +function parseTimestamp(value?: string): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function pruneExpired(polls: Record) { + const cutoff = Date.now() - POLL_TTL_MS; + const entries = Object.entries(polls).filter(([, poll]) => { + const ts = parseTimestamp(poll.updatedAt ?? poll.createdAt) ?? 0; + return ts >= cutoff; + }); + return Object.fromEntries(entries); +} + +function pruneToLimit(polls: Record) { + const entries = Object.entries(polls); + if (entries.length <= MAX_POLLS) return polls; + entries.sort((a, b) => { + const aTs = parseTimestamp(a[1].updatedAt ?? a[1].createdAt) ?? 0; + const bTs = parseTimestamp(b[1].updatedAt ?? b[1].createdAt) ?? 0; + return aTs - bTs; + }); + const keep = entries.slice(entries.length - MAX_POLLS); + return Object.fromEntries(keep); +} + +function normalizePollSelections(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); + return Array.from(new Set(limited)); +} + +export function createMSTeamsPollStoreFs(params?: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; +}): MSTeamsPollStore { + const filePath = resolveStorePath(params?.env, params?.homedir); + const empty: PollStoreData = { version: 1, polls: {} }; + + const readStore = async (): Promise => { + const { value } = await readJsonFile(filePath, empty); + const pruned = pruneToLimit(pruneExpired(value.polls ?? {})); + return { version: 1, polls: pruned }; + }; + + const writeStore = async (data: PollStoreData) => { + await writeJsonFile(filePath, data); + }; + + const createPoll = async (poll: MSTeamsPoll) => { + await withFileLock(filePath, empty, async () => { + const data = await readStore(); + data.polls[poll.id] = poll; + await writeStore({ version: 1, polls: pruneToLimit(data.polls) }); + }); + }; + + const getPoll = async (pollId: string) => + await withFileLock(filePath, empty, async () => { + const data = await readStore(); + return data.polls[pollId] ?? null; + }); + + 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 = normalizePollSelections(poll, params.selections); + poll.votes[params.voterId] = normalized; + poll.updatedAt = new Date().toISOString(); + data.polls[poll.id] = poll; + await writeStore({ version: 1, polls: pruneToLimit(data.polls) }); + return poll; + }); + + return { createPoll, getPoll, recordVote }; +} diff --git a/src/msteams/send.ts b/src/msteams/send.ts index 46192d913..2371447dd 100644 --- a/src/msteams/send.ts +++ b/src/msteams/send.ts @@ -10,7 +10,12 @@ import { formatMSTeamsSendErrorHint, formatUnknownError, } from "./errors.js"; -import { type MSTeamsAdapter, sendMSTeamsMessages } from "./messenger.js"; +import { + buildConversationReference, + type MSTeamsAdapter, + sendMSTeamsMessages, +} from "./messenger.js"; +import { buildMSTeamsPollCard } from "./polls.js"; import { resolveMSTeamsCredentials } from "./token.js"; let _log: ReturnType | undefined; @@ -37,6 +42,25 @@ export type SendMSTeamsMessageResult = { conversationId: string; }; +export type SendMSTeamsPollParams = { + /** Full config (for credentials) */ + cfg: ClawdbotConfig; + /** Conversation ID or user ID to send to */ + to: string; + /** Poll question */ + question: string; + /** Poll options */ + options: string[]; + /** Max selections (defaults to 1) */ + maxSelections?: number; +}; + +export type SendMSTeamsPollResult = { + pollId: string; + messageId: string; + conversationId: string; +}; + /** * Parse the --to argument into a conversation reference lookup key. * Supported formats: @@ -85,6 +109,37 @@ async function findConversationReference(recipient: { return { conversationId: found.conversationId, ref: found.reference }; } +function extractMessageId(response: unknown): string | null { + if (!response || typeof response !== "object") return null; + if (!("id" in response)) return null; + const { id } = response as { id?: unknown }; + if (typeof id !== "string" || !id) return null; + return id; +} + +async function sendMSTeamsActivity(params: { + adapter: MSTeamsAdapter; + appId: string; + conversationRef: StoredConversationReference; + activity: Record; +}): Promise { + const baseRef = buildConversationReference(params.conversationRef); + const proactiveRef = { + ...baseRef, + 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"; + }, + ); + return messageId; +} + /** * Send a message to a Teams conversation or user. * @@ -181,6 +236,99 @@ export async function sendMessageMSTeams( }; } +/** + * Send a poll (Adaptive Card) to a Teams conversation or user. + */ +export async function sendPollMSTeams( + params: SendMSTeamsPollParams, +): Promise { + const { cfg, to, question, options, maxSelections } = params; + const msteamsCfg = cfg.msteams; + + if (!msteamsCfg?.enabled) { + throw new Error("msteams provider is not enabled"); + } + + const creds = resolveMSTeamsCredentials(msteamsCfg); + if (!creds) { + throw new Error("msteams credentials not configured"); + } + + const store = createMSTeamsConversationStoreFs(); + const recipient = parseRecipient(to); + const found = await findConversationReference({ ...recipient, store }); + + if (!found) { + throw new Error( + `No conversation reference found for ${recipient.type}:${recipient.id}. ` + + `The bot must receive a message from this conversation before it can send proactively.`, + ); + } + + const { conversationId, ref } = found; + const log = await getLog(); + + const pollCard = buildMSTeamsPollCard({ + question, + options, + maxSelections, + }); + + log.debug("sending poll", { + conversationId, + pollId: pollCard.pollId, + optionCount: pollCard.options.length, + }); + + 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 activity = { + type: "message", + text: pollCard.fallbackText, + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: pollCard.card, + }, + ], + }; + + let messageId: string; + try { + messageId = await sendMSTeamsActivity({ + adapter: adapter as unknown as MSTeamsAdapter, + appId: creds.appId, + conversationRef: ref, + activity, + }); + } catch (err) { + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + const status = classification.statusCode + ? ` (HTTP ${classification.statusCode})` + : ""; + throw new Error( + `msteams poll send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, + ); + } + + log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId }); + + return { + pollId: pollCard.pollId, + messageId, + conversationId, + }; +} + /** * List all known conversation references (for debugging/CLI). */ diff --git a/src/utils/message-provider.ts b/src/utils/message-provider.ts index 2a1c7ab02..6420d53dd 100644 --- a/src/utils/message-provider.ts +++ b/src/utils/message-provider.ts @@ -3,7 +3,9 @@ export function normalizeMessageProvider( ): string | undefined { const normalized = raw?.trim().toLowerCase(); if (!normalized) return undefined; - return normalized === "imsg" ? "imessage" : normalized; + if (normalized === "imsg") return "imessage"; + if (normalized === "teams") return "msteams"; + return normalized; } export function resolveMessageProvider(