feat: finalize msteams polls + outbound parity
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
61
src/msteams/polls.test.ts
Normal 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
400
src/msteams/polls.ts
Normal 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 };
|
||||
}
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user