feat: finalize msteams polls + outbound parity

This commit is contained in:
Peter Steinberger
2026-01-09 09:56:36 +01:00
parent a2bba7ef51
commit e55358c65d
22 changed files with 913 additions and 81 deletions

View File

@@ -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).

View File

@@ -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`.

View File

@@ -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 <subcommand> [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:<id>` or `user:<id>` (raw id ok)
- Signal: E.164, `group:<id>`, or `signal:+E.164`
- iMessage: handle or `chat_id:<id>`
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
## Common flags

View File

@@ -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.<teamId>.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:<id> ...`
- 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.

View File

@@ -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<string, unknown>;

View File

@@ -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,

View File

@@ -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 =

View File

@@ -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;
}

View File

@@ -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<MessagePollResult> {
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}`);
}

View File

@@ -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<MessageProviderId[]> {
@@ -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;
}

View File

@@ -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,
});

View File

@@ -46,6 +46,25 @@ const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
const ATTACHMENT_TAG_RE = /<attachment[^>]+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<MSTeamsGraphMediaResult> {
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<MSTeamsInboundMedia[]> {
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<string>();
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,

View File

@@ -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 */

View File

@@ -13,6 +13,11 @@ describe("msteams inbound", () => {
expect(stripMSTeamsMentionTags("<at>Bot</at> hi")).toBe("hi");
expect(stripMSTeamsMentionTags("hi <at>Bot</at>")).toBe("hi");
});
it("removes <at ...> tags with attributes", () => {
expect(stripMSTeamsMentionTags('<at id="1">Bot</at> hi')).toBe("hi");
expect(stripMSTeamsMentionTags('hi <at itemid="2">Bot</at>')).toBe("hi");
});
});
describe("normalizeMSTeamsConversationId", () => {

View File

@@ -31,7 +31,7 @@ export function parseMSTeamsActivityTimestamp(
export function stripMSTeamsMentionTags(text: string): string {
// Teams wraps mentions in <at>...</at> tags
return text.replace(/<at>.*?<\/at>/gi, "").trim();
return text.replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
}
export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean {

View File

@@ -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";

View File

@@ -9,7 +9,7 @@ type SendContext = {
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
};
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<void>,
) => Promise<void>;
};
@@ -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,
};

View File

@@ -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,

61
src/msteams/polls.test.ts Normal file
View File

@@ -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"]);
});
});

400
src/msteams/polls.ts Normal file
View File

@@ -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<string, string[]>;
};
export type MSTeamsPollStore = {
createPoll: (poll: MSTeamsPoll) => Promise<void>;
getPoll: (pollId: string) => Promise<MSTeamsPoll | null>;
recordVote: (params: {
pollId: string;
voterId: string;
selections: string[];
}) => Promise<MSTeamsPoll | null>;
};
export type MSTeamsPollCard = {
pollId: string;
question: string;
options: string[];
maxSelections: number;
card: Record<string, unknown>;
fallbackText: string;
};
type PollStoreData = {
version: 1;
polls: Record<string, MSTeamsPoll>;
};
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<string, unknown> {
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<string | number>,
): 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 | number>,
): 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<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function readJsonFile<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(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<void> {
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<T>(
filePath: string,
fallback: unknown,
fn: () => Promise<T>,
): Promise<T> {
await ensureJsonFile(filePath, fallback);
let release: (() => Promise<void>) | 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<string, MSTeamsPoll>) {
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<string, MSTeamsPoll>) {
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<PollStoreData> => {
const { value } = await readJsonFile<PollStoreData>(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 };
}

View File

@@ -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<typeof getChildLoggerFn> | 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<string, unknown>;
}): Promise<string> {
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<SendMSTeamsPollResult> {
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).
*/

View File

@@ -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(