feat: finalize msteams polls + outbound parity
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
|
||||||
- Commands: accept /models as an alias for /model.
|
- Commands: accept /models as an alias for /model.
|
||||||
- Debugging: add raw model stream logging flags and document gateway watch mode.
|
- 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).
|
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ read_when:
|
|||||||
## Supported providers
|
## Supported providers
|
||||||
- WhatsApp (web provider)
|
- WhatsApp (web provider)
|
||||||
- Discord
|
- Discord
|
||||||
|
- MS Teams (Adaptive Cards)
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ clawdbot message poll --provider discord --to channel:123456789 \
|
|||||||
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
|
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
|
||||||
clawdbot message poll --provider discord --to channel:123456789 \
|
clawdbot message poll --provider discord --to channel:123456789 \
|
||||||
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
|
--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:
|
Options:
|
||||||
@@ -48,8 +53,11 @@ Params:
|
|||||||
## Provider differences
|
## Provider differences
|
||||||
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
|
- 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.
|
- 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)
|
## Agent tool (Message)
|
||||||
Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `provider`).
|
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.
|
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`
|
# `clawdbot message`
|
||||||
|
|
||||||
Single outbound command for sending messages and provider actions
|
Single outbound command for sending messages and provider actions
|
||||||
(Discord/Slack/Telegram/WhatsApp/Signal/iMessage).
|
(Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ clawdbot message <subcommand> [flags]
|
|||||||
Provider selection:
|
Provider selection:
|
||||||
- `--provider` required if more than one provider is configured.
|
- `--provider` required if more than one provider is configured.
|
||||||
- If exactly one provider is configured, it becomes the default.
|
- 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`):
|
Target formats (`--to`):
|
||||||
- WhatsApp: E.164 or group JID
|
- WhatsApp: E.164 or group JID
|
||||||
@@ -27,6 +27,7 @@ Target formats (`--to`):
|
|||||||
- Discord/Slack: `channel:<id>` or `user:<id>` (raw id ok)
|
- Discord/Slack: `channel:<id>` or `user:<id>` (raw id ok)
|
||||||
- Signal: E.164, `group:<id>`, or `signal:+E.164`
|
- Signal: E.164, `group:<id>`, or `signal:+E.164`
|
||||||
- iMessage: handle or `chat_id:<id>`
|
- iMessage: handle or `chat_id:<id>`
|
||||||
|
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
||||||
|
|
||||||
## Common flags
|
## Common flags
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ read_when:
|
|||||||
|
|
||||||
Updated: 2026-01-08
|
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
|
## Goals
|
||||||
- Talk to Clawdbot via Teams DMs, group chats, or channels.
|
- 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:
|
Teams markdown is more limited than Slack or Discord:
|
||||||
- Basic formatting works: **bold**, *italic*, `code`, links
|
- Basic formatting works: **bold**, *italic*, `code`, links
|
||||||
- Complex markdown (tables, nested lists) may not render correctly
|
- 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
|
## Configuration
|
||||||
Key settings (see `/gateway/configuration` for shared provider patterns):
|
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.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||||
- `msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs).
|
- `msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs).
|
||||||
- `msteams.textChunkLimit`: outbound text chunk size.
|
- `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.requireMention`: require @mention in channels/groups (default true).
|
||||||
- `msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
- `msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||||
- `msteams.teams.<teamId>.replyStyle`: per-team override.
|
- `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.
|
- **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).
|
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 messaging
|
||||||
- Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point.
|
- 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",
|
label: "Message",
|
||||||
name: "message",
|
name: "message",
|
||||||
description:
|
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,
|
parameters: MessageToolSchema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
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) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -494,6 +498,7 @@ export async function messageCommand(
|
|||||||
options: result.options,
|
options: result.options,
|
||||||
maxSelections: result.maxSelections,
|
maxSelections: result.maxSelections,
|
||||||
durationHours: result.durationHours,
|
durationHours: result.durationHours,
|
||||||
|
pollId,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { loadConfig } from "../../config/config.js";
|
|||||||
import { sendMessageDiscord, sendPollDiscord } from "../../discord/index.js";
|
import { sendMessageDiscord, sendPollDiscord } from "../../discord/index.js";
|
||||||
import { shouldLogVerbose } from "../../globals.js";
|
import { shouldLogVerbose } from "../../globals.js";
|
||||||
import { sendMessageIMessage } from "../../imessage/index.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 { sendMessageSignal } from "../../signal/index.js";
|
||||||
import { sendMessageSlack } from "../../slack/send.js";
|
import { sendMessageSlack } from "../../slack/send.js";
|
||||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||||
@@ -231,7 +233,11 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
const to = request.to.trim();
|
const to = request.to.trim();
|
||||||
const provider = normalizeMessageProvider(request.provider) ?? "whatsapp";
|
const provider = normalizeMessageProvider(request.provider) ?? "whatsapp";
|
||||||
if (provider !== "whatsapp" && provider !== "discord") {
|
if (
|
||||||
|
provider !== "whatsapp" &&
|
||||||
|
provider !== "discord" &&
|
||||||
|
provider !== "msteams"
|
||||||
|
) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -267,6 +273,40 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
respond(true, payload, undefined, { provider });
|
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 {
|
} else {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const accountId =
|
const accountId =
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type OutboundDeliveryJson = {
|
|||||||
mediaUrl: string | null;
|
mediaUrl: string | null;
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
conversationId?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
toJid?: string;
|
toJid?: string;
|
||||||
};
|
};
|
||||||
@@ -16,6 +17,7 @@ type OutboundDeliveryMeta = {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
conversationId?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
toJid?: string;
|
toJid?: string;
|
||||||
};
|
};
|
||||||
@@ -36,6 +38,8 @@ export function formatOutboundDeliverySummary(
|
|||||||
|
|
||||||
if ("chatId" in result) return `${base} (chat ${result.chatId})`;
|
if ("chatId" in result) return `${base} (chat ${result.chatId})`;
|
||||||
if ("channelId" in result) return `${base} (channel ${result.channelId})`;
|
if ("channelId" in result) return `${base} (channel ${result.channelId})`;
|
||||||
|
if ("conversationId" in result)
|
||||||
|
return `${base} (conversation ${result.conversationId})`;
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +66,13 @@ export function buildOutboundDeliveryJson(params: {
|
|||||||
if (result && "channelId" in result && result.channelId !== undefined) {
|
if (result && "channelId" in result && result.channelId !== undefined) {
|
||||||
payload.channelId = result.channelId;
|
payload.channelId = result.channelId;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
result &&
|
||||||
|
"conversationId" in result &&
|
||||||
|
result.conversationId !== undefined
|
||||||
|
) {
|
||||||
|
payload.conversationId = result.conversationId;
|
||||||
|
}
|
||||||
if (result && "timestamp" in result && result.timestamp !== undefined) {
|
if (result && "timestamp" in result && result.timestamp !== undefined) {
|
||||||
payload.timestamp = result.timestamp;
|
payload.timestamp = result.timestamp;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export type MessagePollResult = {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
toJid?: string;
|
toJid?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
pollId?: string;
|
||||||
};
|
};
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
};
|
};
|
||||||
@@ -108,7 +110,8 @@ export async function sendMessage(
|
|||||||
provider === "discord" ||
|
provider === "discord" ||
|
||||||
provider === "slack" ||
|
provider === "slack" ||
|
||||||
provider === "signal" ||
|
provider === "signal" ||
|
||||||
provider === "imessage"
|
provider === "imessage" ||
|
||||||
|
provider === "msteams"
|
||||||
) {
|
) {
|
||||||
const resolvedTarget = resolveOutboundTarget({
|
const resolvedTarget = resolveOutboundTarget({
|
||||||
provider,
|
provider,
|
||||||
@@ -167,7 +170,11 @@ export async function sendPoll(
|
|||||||
params: MessagePollParams,
|
params: MessagePollParams,
|
||||||
): Promise<MessagePollResult> {
|
): Promise<MessagePollResult> {
|
||||||
const provider = (params.provider ?? "whatsapp").toLowerCase();
|
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}`);
|
throw new Error(`Unsupported poll provider: ${provider}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { listEnabledDiscordAccounts } from "../../discord/accounts.js";
|
import { listEnabledDiscordAccounts } from "../../discord/accounts.js";
|
||||||
import { listEnabledIMessageAccounts } from "../../imessage/accounts.js";
|
import { listEnabledIMessageAccounts } from "../../imessage/accounts.js";
|
||||||
|
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
|
||||||
import { listEnabledSignalAccounts } from "../../signal/accounts.js";
|
import { listEnabledSignalAccounts } from "../../signal/accounts.js";
|
||||||
import { listEnabledSlackAccounts } from "../../slack/accounts.js";
|
import { listEnabledSlackAccounts } from "../../slack/accounts.js";
|
||||||
import { listEnabledTelegramAccounts } from "../../telegram/accounts.js";
|
import { listEnabledTelegramAccounts } from "../../telegram/accounts.js";
|
||||||
@@ -17,7 +18,8 @@ export type MessageProviderId =
|
|||||||
| "discord"
|
| "discord"
|
||||||
| "slack"
|
| "slack"
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage";
|
| "imessage"
|
||||||
|
| "msteams";
|
||||||
|
|
||||||
const MESSAGE_PROVIDERS: MessageProviderId[] = [
|
const MESSAGE_PROVIDERS: MessageProviderId[] = [
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
@@ -26,6 +28,7 @@ const MESSAGE_PROVIDERS: MessageProviderId[] = [
|
|||||||
"slack",
|
"slack",
|
||||||
"signal",
|
"signal",
|
||||||
"imessage",
|
"imessage",
|
||||||
|
"msteams",
|
||||||
];
|
];
|
||||||
|
|
||||||
function isKnownProvider(value: string): value is MessageProviderId {
|
function isKnownProvider(value: string): value is MessageProviderId {
|
||||||
@@ -70,6 +73,11 @@ function isIMessageConfigured(cfg: ClawdbotConfig): boolean {
|
|||||||
return listEnabledIMessageAccounts(cfg).some((account) => account.configured);
|
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(
|
export async function listConfiguredMessageProviders(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
): Promise<MessageProviderId[]> {
|
): Promise<MessageProviderId[]> {
|
||||||
@@ -80,6 +88,7 @@ export async function listConfiguredMessageProviders(
|
|||||||
if (isSlackConfigured(cfg)) providers.push("slack");
|
if (isSlackConfigured(cfg)) providers.push("slack");
|
||||||
if (isSignalConfigured(cfg)) providers.push("signal");
|
if (isSignalConfigured(cfg)) providers.push("signal");
|
||||||
if (isIMessageConfigured(cfg)) providers.push("imessage");
|
if (isIMessageConfigured(cfg)) providers.push("imessage");
|
||||||
|
if (isMSTeamsConfigured(cfg)) providers.push("msteams");
|
||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ describe("msteams attachments", () => {
|
|||||||
{ contentType: "image/png", contentUrl: "https://x/img" },
|
{ contentType: "image/png", contentUrl: "https://x/img" },
|
||||||
],
|
],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
|
allowHosts: ["x"],
|
||||||
fetchFn: fetchMock as unknown as typeof fetch,
|
fetchFn: fetchMock as unknown as typeof fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@ describe("msteams attachments", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
|
allowHosts: ["x"],
|
||||||
fetchFn: fetchMock as unknown as typeof fetch,
|
fetchFn: fetchMock as unknown as typeof fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,6 +158,7 @@ describe("msteams attachments", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
|
allowHosts: ["x"],
|
||||||
fetchFn: fetchMock as unknown as typeof fetch,
|
fetchFn: fetchMock as unknown as typeof fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +176,7 @@ describe("msteams attachments", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
|
allowHosts: ["x"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(media).toHaveLength(1);
|
expect(media).toHaveLength(1);
|
||||||
@@ -202,6 +206,7 @@ describe("msteams attachments", () => {
|
|||||||
],
|
],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||||
|
allowHosts: ["x"],
|
||||||
fetchFn: fetchMock as unknown as typeof fetch,
|
fetchFn: fetchMock as unknown as typeof fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,6 +214,21 @@ describe("msteams attachments", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
it("ignores non-image attachments", async () => {
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const media = await downloadMSTeamsImageAttachments({
|
const media = await downloadMSTeamsImageAttachments({
|
||||||
@@ -216,6 +236,7 @@ describe("msteams attachments", () => {
|
|||||||
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
|
{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" },
|
||||||
],
|
],
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
|
allowHosts: ["x"],
|
||||||
fetchFn: fetchMock as unknown as typeof fetch,
|
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 IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||||
const ATTACHMENT_TAG_RE = /<attachment[^>]+id=["']([^"']+)["'][^>]*>/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 = {
|
export type MSTeamsHtmlAttachmentSummary = {
|
||||||
htmlAttachments: number;
|
htmlAttachments: number;
|
||||||
imgTags: 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(
|
export function summarizeMSTeamsHtmlAttachments(
|
||||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||||
): MSTeamsHtmlAttachmentSummary | undefined {
|
): MSTeamsHtmlAttachmentSummary | undefined {
|
||||||
@@ -456,11 +509,13 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
messageUrl?: string | null;
|
messageUrl?: string | null;
|
||||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
|
allowHosts?: string[];
|
||||||
fetchFn?: typeof fetch;
|
fetchFn?: typeof fetch;
|
||||||
}): Promise<MSTeamsGraphMediaResult> {
|
}): Promise<MSTeamsGraphMediaResult> {
|
||||||
if (!params.messageUrl || !params.tokenProvider) {
|
if (!params.messageUrl || !params.tokenProvider) {
|
||||||
return { media: [] };
|
return { media: [] };
|
||||||
}
|
}
|
||||||
|
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||||
const messageUrl = params.messageUrl;
|
const messageUrl = params.messageUrl;
|
||||||
let accessToken: string;
|
let accessToken: string;
|
||||||
try {
|
try {
|
||||||
@@ -489,6 +544,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
attachments: normalizedAttachments,
|
attachments: normalizedAttachments,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
tokenProvider: params.tokenProvider,
|
tokenProvider: params.tokenProvider,
|
||||||
|
allowHosts,
|
||||||
fetchFn: params.fetchFn,
|
fetchFn: params.fetchFn,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -629,10 +685,12 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
attachments: MSTeamsAttachmentLike[] | undefined;
|
attachments: MSTeamsAttachmentLike[] | undefined;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||||
|
allowHosts?: string[];
|
||||||
fetchFn?: typeof fetch;
|
fetchFn?: typeof fetch;
|
||||||
}): Promise<MSTeamsInboundMedia[]> {
|
}): Promise<MSTeamsInboundMedia[]> {
|
||||||
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
||||||
if (list.length === 0) return [];
|
if (list.length === 0) return [];
|
||||||
|
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||||
|
|
||||||
const candidates: DownloadCandidate[] = list
|
const candidates: DownloadCandidate[] = list
|
||||||
.filter(isLikelyImageAttachment)
|
.filter(isLikelyImageAttachment)
|
||||||
@@ -643,6 +701,9 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
for (const inline of inlineCandidates) {
|
for (const inline of inlineCandidates) {
|
||||||
if (inline.kind === "url") {
|
if (inline.kind === "url") {
|
||||||
|
if (!isUrlAllowed(inline.url, allowHosts)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (seenUrls.has(inline.url)) continue;
|
if (seenUrls.has(inline.url)) continue;
|
||||||
seenUrls.add(inline.url);
|
seenUrls.add(inline.url);
|
||||||
candidates.push({
|
candidates.push({
|
||||||
@@ -677,6 +738,7 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
|
if (!isUrlAllowed(candidate.url, allowHosts)) continue;
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuthFallback({
|
const res = await fetchWithAuthFallback({
|
||||||
url: candidate.url,
|
url: candidate.url,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type StoredConversationReference = {
|
|||||||
bot?: { id?: string; name?: string };
|
bot?: { id?: string; name?: string };
|
||||||
/** Conversation details */
|
/** Conversation details */
|
||||||
conversation?: { id?: string; conversationType?: string; tenantId?: string };
|
conversation?: { id?: string; conversationType?: string; tenantId?: string };
|
||||||
|
/** Team ID for channel messages (when available). */
|
||||||
|
teamId?: string;
|
||||||
/** Channel ID (usually "msteams") */
|
/** Channel ID (usually "msteams") */
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
/** Service URL for sending messages back */
|
/** Service URL for sending messages back */
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ describe("msteams inbound", () => {
|
|||||||
expect(stripMSTeamsMentionTags("<at>Bot</at> hi")).toBe("hi");
|
expect(stripMSTeamsMentionTags("<at>Bot</at> hi")).toBe("hi");
|
||||||
expect(stripMSTeamsMentionTags("hi <at>Bot</at>")).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", () => {
|
describe("normalizeMSTeamsConversationId", () => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function parseMSTeamsActivityTimestamp(
|
|||||||
|
|
||||||
export function stripMSTeamsMentionTags(text: string): string {
|
export function stripMSTeamsMentionTags(text: string): string {
|
||||||
// Teams wraps mentions in <at>...</at> tags
|
// 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 {
|
export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { monitorMSTeamsProvider } from "./monitor.js";
|
export { monitorMSTeamsProvider } from "./monitor.js";
|
||||||
export { probeMSTeams } from "./probe.js";
|
export { probeMSTeams } from "./probe.js";
|
||||||
export { sendMessageMSTeams } from "./send.js";
|
export { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
|
||||||
export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
|
export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js";
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type SendContext = {
|
|||||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConversationReference = {
|
export type MSTeamsConversationReference = {
|
||||||
activityId?: string;
|
activityId?: string;
|
||||||
user?: { id?: string; name?: string; aadObjectId?: string };
|
user?: { id?: string; name?: string; aadObjectId?: string };
|
||||||
agent?: { id?: string; name?: string; aadObjectId?: string } | null;
|
agent?: { id?: string; name?: string; aadObjectId?: string } | null;
|
||||||
@@ -22,7 +22,7 @@ type ConversationReference = {
|
|||||||
export type MSTeamsAdapter = {
|
export type MSTeamsAdapter = {
|
||||||
continueConversation: (
|
continueConversation: (
|
||||||
appId: string,
|
appId: string,
|
||||||
reference: ConversationReference,
|
reference: MSTeamsConversationReference,
|
||||||
logic: (context: SendContext) => Promise<void>,
|
logic: (context: SendContext) => Promise<void>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -52,9 +52,9 @@ function normalizeConversationId(rawId: string): string {
|
|||||||
return rawId.split(";")[0] ?? rawId;
|
return rawId.split(";")[0] ?? rawId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConversationReference(
|
export function buildConversationReference(
|
||||||
ref: StoredConversationReference,
|
ref: StoredConversationReference,
|
||||||
): ConversationReference {
|
): MSTeamsConversationReference {
|
||||||
const conversationId = ref.conversation?.id?.trim();
|
const conversationId = ref.conversation?.id?.trim();
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
throw new Error("Invalid stored reference: missing conversation.id");
|
throw new Error("Invalid stored reference: missing conversation.id");
|
||||||
@@ -275,7 +275,7 @@ export async function sendMSTeamsMessages(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseRef = buildConversationReference(params.conversationRef);
|
const baseRef = buildConversationReference(params.conversationRef);
|
||||||
const proactiveRef: ConversationReference = {
|
const proactiveRef: MSTeamsConversationReference = {
|
||||||
...baseRef,
|
...baseRef,
|
||||||
activityId: undefined,
|
activityId: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ import {
|
|||||||
resolveMSTeamsReplyPolicy,
|
resolveMSTeamsReplyPolicy,
|
||||||
resolveMSTeamsRouteConfig,
|
resolveMSTeamsRouteConfig,
|
||||||
} from "./policy.js";
|
} from "./policy.js";
|
||||||
|
import {
|
||||||
|
createMSTeamsPollStoreFs,
|
||||||
|
extractMSTeamsPollVote,
|
||||||
|
type MSTeamsPollStore,
|
||||||
|
} from "./polls.js";
|
||||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||||
import { resolveMSTeamsCredentials } from "./token.js";
|
import { resolveMSTeamsCredentials } from "./token.js";
|
||||||
|
|
||||||
@@ -58,6 +63,7 @@ export type MonitorMSTeamsOpts = {
|
|||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
conversationStore?: MSTeamsConversationStore;
|
conversationStore?: MSTeamsConversationStore;
|
||||||
|
pollStore?: MSTeamsPollStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MonitorMSTeamsResult = {
|
export type MonitorMSTeamsResult = {
|
||||||
@@ -99,6 +105,7 @@ export async function monitorMSTeamsProvider(
|
|||||||
: 8 * MB;
|
: 8 * MB;
|
||||||
const conversationStore =
|
const conversationStore =
|
||||||
opts.conversationStore ?? createMSTeamsConversationStoreFs();
|
opts.conversationStore ?? createMSTeamsConversationStoreFs();
|
||||||
|
const pollStore = opts.pollStore ?? createMSTeamsPollStoreFs();
|
||||||
|
|
||||||
log.info(`starting provider (port ${port})`);
|
log.info(`starting provider (port ${port})`);
|
||||||
|
|
||||||
@@ -157,10 +164,6 @@ export async function monitorMSTeamsProvider(
|
|||||||
log.debug("html attachment summary", htmlSummary);
|
log.debug("html attachment summary", htmlSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rawBody) {
|
|
||||||
log.debug("skipping empty message after stripping mentions");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!from?.id) {
|
if (!from?.id) {
|
||||||
log.debug("skipping message without from.id");
|
log.debug("skipping message without from.id");
|
||||||
return;
|
return;
|
||||||
@@ -180,63 +183,6 @@ export async function monitorMSTeamsProvider(
|
|||||||
const senderName = from.name ?? from.id;
|
const senderName = from.name ?? from.id;
|
||||||
const senderId = from.aadObjectId ?? 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
|
// Check DM policy for direct messages
|
||||||
if (isDirectMessage && msteamsCfg) {
|
if (isDirectMessage && msteamsCfg) {
|
||||||
const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
|
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 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 channelId = conversationId;
|
||||||
const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({
|
const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({
|
||||||
cfg: msteamsCfg,
|
cfg: msteamsCfg,
|
||||||
@@ -318,6 +355,7 @@ export async function monitorMSTeamsProvider(
|
|||||||
tokenProvider: {
|
tokenProvider: {
|
||||||
getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
|
getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
|
||||||
},
|
},
|
||||||
|
allowHosts: msteamsCfg?.mediaAllowHosts,
|
||||||
});
|
});
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
const onlyHtmlAttachments =
|
const onlyHtmlAttachments =
|
||||||
@@ -357,6 +395,7 @@ export async function monitorMSTeamsProvider(
|
|||||||
getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
|
getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
|
||||||
},
|
},
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
|
allowHosts: msteamsCfg?.mediaAllowHosts,
|
||||||
});
|
});
|
||||||
attempts.push({
|
attempts.push({
|
||||||
url: messageUrl,
|
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,
|
formatMSTeamsSendErrorHint,
|
||||||
formatUnknownError,
|
formatUnknownError,
|
||||||
} from "./errors.js";
|
} 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";
|
import { resolveMSTeamsCredentials } from "./token.js";
|
||||||
|
|
||||||
let _log: ReturnType<typeof getChildLoggerFn> | undefined;
|
let _log: ReturnType<typeof getChildLoggerFn> | undefined;
|
||||||
@@ -37,6 +42,25 @@ export type SendMSTeamsMessageResult = {
|
|||||||
conversationId: string;
|
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.
|
* Parse the --to argument into a conversation reference lookup key.
|
||||||
* Supported formats:
|
* Supported formats:
|
||||||
@@ -85,6 +109,37 @@ async function findConversationReference(recipient: {
|
|||||||
return { conversationId: found.conversationId, ref: found.reference };
|
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.
|
* 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).
|
* List all known conversation references (for debugging/CLI).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ export function normalizeMessageProvider(
|
|||||||
): string | undefined {
|
): string | undefined {
|
||||||
const normalized = raw?.trim().toLowerCase();
|
const normalized = raw?.trim().toLowerCase();
|
||||||
if (!normalized) return undefined;
|
if (!normalized) return undefined;
|
||||||
return normalized === "imsg" ? "imessage" : normalized;
|
if (normalized === "imsg") return "imessage";
|
||||||
|
if (normalized === "teams") return "msteams";
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMessageProvider(
|
export function resolveMessageProvider(
|
||||||
|
|||||||
Reference in New Issue
Block a user