feat(msteams): add outbound sends and fix reply delivery
- Add sendMessageMSTeams for proactive messaging via CLI/gateway - Wire msteams into outbound delivery, heartbeat targets, and gateway send - Fix reply delivery to use SDK's getConversationReference() for proper bot info, avoiding "Activity Recipient undefined" errors - Use proactive messaging for replies to post as top-level messages (not threaded) by omitting activityId from conversation reference - Add lazy logger in send.ts to avoid test initialization issues
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { sendMessageDiscord } from "../discord/send.js";
|
import { sendMessageDiscord } from "../discord/send.js";
|
||||||
import { sendMessageIMessage } from "../imessage/send.js";
|
import { sendMessageIMessage } from "../imessage/send.js";
|
||||||
|
import { sendMessageMSTeams } from "../msteams/send.js";
|
||||||
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
|
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
|
||||||
import { sendMessageSignal } from "../signal/send.js";
|
import { sendMessageSignal } from "../signal/send.js";
|
||||||
import { sendMessageSlack } from "../slack/send.js";
|
import { sendMessageSlack } from "../slack/send.js";
|
||||||
@@ -12,6 +13,7 @@ export type CliDeps = {
|
|||||||
sendMessageSlack: typeof sendMessageSlack;
|
sendMessageSlack: typeof sendMessageSlack;
|
||||||
sendMessageSignal: typeof sendMessageSignal;
|
sendMessageSignal: typeof sendMessageSignal;
|
||||||
sendMessageIMessage: typeof sendMessageIMessage;
|
sendMessageIMessage: typeof sendMessageIMessage;
|
||||||
|
sendMessageMSTeams: typeof sendMessageMSTeams;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDefaultDeps(): CliDeps {
|
export function createDefaultDeps(): CliDeps {
|
||||||
@@ -22,6 +24,7 @@ export function createDefaultDeps(): CliDeps {
|
|||||||
sendMessageSlack,
|
sendMessageSlack,
|
||||||
sendMessageSignal,
|
sendMessageSignal,
|
||||||
sendMessageIMessage,
|
sendMessageIMessage,
|
||||||
|
sendMessageMSTeams,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { 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";
|
||||||
@@ -141,6 +142,26 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
respond(true, payload, undefined, { provider });
|
respond(true, payload, undefined, { provider });
|
||||||
|
} else if (provider === "msteams") {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const result = await sendMessageMSTeams({
|
||||||
|
cfg,
|
||||||
|
to,
|
||||||
|
text: message,
|
||||||
|
mediaUrl: request.mediaUrl,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
runId: idem,
|
||||||
|
messageId: result.messageId,
|
||||||
|
conversationId: result.conversationId,
|
||||||
|
provider,
|
||||||
|
};
|
||||||
|
context.dedupe.set(`send:${idem}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: true,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
respond(true, payload, undefined, { provider });
|
||||||
} else {
|
} else {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const targetAccountId =
|
const targetAccountId =
|
||||||
|
|||||||
@@ -1964,6 +1964,13 @@ export async function startGatewayServer(
|
|||||||
startIMessageProvider,
|
startIMessageProvider,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (plan.restartProviders.has("msteams")) {
|
||||||
|
await restartProvider(
|
||||||
|
"msteams",
|
||||||
|
stopMSTeamsProvider,
|
||||||
|
startMSTeamsProvider,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { sendMessageDiscord } from "../../discord/send.js";
|
import { sendMessageDiscord } from "../../discord/send.js";
|
||||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||||
|
import { sendMessageMSTeams } from "../../msteams/send.js";
|
||||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import { sendMessageSignal } from "../../signal/send.js";
|
import { sendMessageSignal } from "../../signal/send.js";
|
||||||
import { sendMessageSlack } from "../../slack/send.js";
|
import { sendMessageSlack } from "../../slack/send.js";
|
||||||
@@ -28,6 +29,11 @@ export type OutboundSendDeps = {
|
|||||||
sendSlack?: typeof sendMessageSlack;
|
sendSlack?: typeof sendMessageSlack;
|
||||||
sendSignal?: typeof sendMessageSignal;
|
sendSignal?: typeof sendMessageSignal;
|
||||||
sendIMessage?: typeof sendMessageIMessage;
|
sendIMessage?: typeof sendMessageIMessage;
|
||||||
|
sendMSTeams?: (
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
opts?: { mediaUrl?: string },
|
||||||
|
) => Promise<{ messageId: string; conversationId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OutboundDeliveryResult =
|
export type OutboundDeliveryResult =
|
||||||
@@ -36,7 +42,8 @@ export type OutboundDeliveryResult =
|
|||||||
| { provider: "discord"; messageId: string; channelId: string }
|
| { provider: "discord"; messageId: string; channelId: string }
|
||||||
| { provider: "slack"; messageId: string; channelId: string }
|
| { provider: "slack"; messageId: string; channelId: string }
|
||||||
| { provider: "signal"; messageId: string; timestamp?: number }
|
| { provider: "signal"; messageId: string; timestamp?: number }
|
||||||
| { provider: "imessage"; messageId: string };
|
| { provider: "imessage"; messageId: string }
|
||||||
|
| { provider: "msteams"; messageId: string; conversationId: string };
|
||||||
|
|
||||||
type Chunker = (text: string, limit: number) => string[];
|
type Chunker = (text: string, limit: number) => string[];
|
||||||
|
|
||||||
@@ -50,6 +57,7 @@ const providerCaps: Record<
|
|||||||
slack: { chunker: null },
|
slack: { chunker: null },
|
||||||
signal: { chunker: chunkText },
|
signal: { chunker: chunkText },
|
||||||
imessage: { chunker: chunkText },
|
imessage: { chunker: chunkText },
|
||||||
|
msteams: { chunker: chunkMarkdownText },
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProviderHandler = {
|
type ProviderHandler = {
|
||||||
@@ -204,6 +212,17 @@ function createProviderHandler(params: {
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
msteams: {
|
||||||
|
chunker: providerCaps.msteams.chunker,
|
||||||
|
sendText: async (text) => ({
|
||||||
|
provider: "msteams",
|
||||||
|
...(await deps.sendMSTeams(to, text)),
|
||||||
|
}),
|
||||||
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
|
provider: "msteams",
|
||||||
|
...(await deps.sendMSTeams(to, caption, { mediaUrl })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return handlers[params.provider];
|
return handlers[params.provider];
|
||||||
@@ -222,6 +241,11 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
}): Promise<OutboundDeliveryResult[]> {
|
}): Promise<OutboundDeliveryResult[]> {
|
||||||
const { cfg, provider, to, payloads } = params;
|
const { cfg, provider, to, payloads } = params;
|
||||||
const accountId = params.accountId;
|
const accountId = params.accountId;
|
||||||
|
const defaultSendMSTeams = async (
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
opts?: { mediaUrl?: string },
|
||||||
|
) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl });
|
||||||
const deps = {
|
const deps = {
|
||||||
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
||||||
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
|
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
|
||||||
@@ -229,6 +253,7 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
sendSlack: params.deps?.sendSlack ?? sendMessageSlack,
|
sendSlack: params.deps?.sendSlack ?? sendMessageSlack,
|
||||||
sendSignal: params.deps?.sendSignal ?? sendMessageSignal,
|
sendSignal: params.deps?.sendSignal ?? sendMessageSignal,
|
||||||
sendIMessage: params.deps?.sendIMessage ?? sendMessageIMessage,
|
sendIMessage: params.deps?.sendIMessage ?? sendMessageIMessage,
|
||||||
|
sendMSTeams: params.deps?.sendMSTeams ?? defaultSendMSTeams,
|
||||||
};
|
};
|
||||||
const results: OutboundDeliveryResult[] = [];
|
const results: OutboundDeliveryResult[] = [];
|
||||||
const handler = createProviderHandler({
|
const handler = createProviderHandler({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type OutboundProvider =
|
|||||||
| "slack"
|
| "slack"
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage"
|
| "imessage"
|
||||||
|
| "msteams"
|
||||||
| "none";
|
| "none";
|
||||||
|
|
||||||
export type HeartbeatTarget = OutboundProvider | "last";
|
export type HeartbeatTarget = OutboundProvider | "last";
|
||||||
@@ -31,6 +32,7 @@ export function resolveOutboundTarget(params: {
|
|||||||
| "slack"
|
| "slack"
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage"
|
| "imessage"
|
||||||
|
| "msteams"
|
||||||
| "webchat";
|
| "webchat";
|
||||||
to?: string;
|
to?: string;
|
||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
@@ -104,6 +106,17 @@ export function resolveOutboundTarget(params: {
|
|||||||
}
|
}
|
||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
}
|
}
|
||||||
|
if (params.provider === "msteams") {
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(
|
||||||
|
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, to: trimmed };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: new Error(
|
error: new Error(
|
||||||
@@ -125,6 +138,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
rawTarget === "slack" ||
|
rawTarget === "slack" ||
|
||||||
rawTarget === "signal" ||
|
rawTarget === "signal" ||
|
||||||
rawTarget === "imessage" ||
|
rawTarget === "imessage" ||
|
||||||
|
rawTarget === "msteams" ||
|
||||||
rawTarget === "none" ||
|
rawTarget === "none" ||
|
||||||
rawTarget === "last"
|
rawTarget === "last"
|
||||||
? rawTarget
|
? rawTarget
|
||||||
@@ -152,6 +166,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
| "slack"
|
| "slack"
|
||||||
| "signal"
|
| "signal"
|
||||||
| "imessage"
|
| "imessage"
|
||||||
|
| "msteams"
|
||||||
| undefined =
|
| undefined =
|
||||||
target === "last"
|
target === "last"
|
||||||
? lastProvider
|
? lastProvider
|
||||||
@@ -160,7 +175,8 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
target === "discord" ||
|
target === "discord" ||
|
||||||
target === "slack" ||
|
target === "slack" ||
|
||||||
target === "signal" ||
|
target === "signal" ||
|
||||||
target === "imessage"
|
target === "imessage" ||
|
||||||
|
target === "msteams"
|
||||||
? target
|
? target
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export async function monitorMSTeamsProvider(
|
|||||||
log.error("msteams credentials not configured");
|
log.error("msteams credentials not configured");
|
||||||
return { app: null, shutdown: async () => {} };
|
return { app: null, shutdown: async () => {} };
|
||||||
}
|
}
|
||||||
|
const appId = creds.appId; // Extract for use in closures
|
||||||
|
|
||||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
@@ -117,34 +118,74 @@ export async function monitorMSTeamsProvider(
|
|||||||
const { ActivityHandler, CloudAdapter, authorizeJWT, getAuthConfigWithDefaults } =
|
const { ActivityHandler, CloudAdapter, authorizeJWT, getAuthConfigWithDefaults } =
|
||||||
agentsHosting;
|
agentsHosting;
|
||||||
|
|
||||||
// Helper to deliver replies via Teams SDK
|
// Auth configuration - create early so adapter is available for deliverReplies
|
||||||
|
const authConfig = getAuthConfigWithDefaults({
|
||||||
|
clientId: creds.appId,
|
||||||
|
clientSecret: creds.appPassword,
|
||||||
|
tenantId: creds.tenantId,
|
||||||
|
});
|
||||||
|
const adapter = new CloudAdapter(authConfig);
|
||||||
|
|
||||||
|
// Helper to deliver replies as top-level messages (not threaded)
|
||||||
|
// We use proactive messaging to avoid threading to the original message
|
||||||
async function deliverReplies(params: {
|
async function deliverReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
context: TeamsTurnContext;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
context: any; // TurnContext from SDK - has activity.getConversationReference()
|
||||||
|
adapter: InstanceType<typeof CloudAdapter>;
|
||||||
|
appId: string;
|
||||||
}) {
|
}) {
|
||||||
const chunkLimit = Math.min(textLimit, 4000);
|
const chunkLimit = Math.min(textLimit, 4000);
|
||||||
|
|
||||||
|
// Get conversation reference from SDK's activity (includes proper bot info)
|
||||||
|
// Then remove activityId to avoid threading
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const fullRef = params.context.activity.getConversationReference() as any;
|
||||||
|
const conversationRef = {
|
||||||
|
...fullRef,
|
||||||
|
activityId: undefined, // Remove to post as top-level message, not thread
|
||||||
|
};
|
||||||
|
// Also strip the messageid suffix from conversation.id if present
|
||||||
|
if (conversationRef.conversation?.id) {
|
||||||
|
conversationRef.conversation = {
|
||||||
|
...conversationRef.conversation,
|
||||||
|
id: conversationRef.conversation.id.split(";")[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (const payload of params.replies) {
|
for (const payload of params.replies) {
|
||||||
const mediaList =
|
const mediaList =
|
||||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
|
|
||||||
|
const sendMessage = async (message: string) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (params.adapter as any).continueConversation(
|
||||||
|
params.appId,
|
||||||
|
conversationRef,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async (ctx: any) => {
|
||||||
|
await ctx.sendActivity({ type: "message", text: message });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
|
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
|
||||||
await params.context.sendActivity(trimmed);
|
await sendMessage(trimmed);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For media, send text first then media URLs as separate messages
|
// For media, send text first then media URLs as separate messages
|
||||||
if (text.trim() && text.trim() !== SILENT_REPLY_TOKEN) {
|
if (text.trim() && text.trim() !== SILENT_REPLY_TOKEN) {
|
||||||
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
||||||
await params.context.sendActivity(chunk);
|
await sendMessage(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
// Teams supports adaptive cards for rich media, but for now just send URL
|
await sendMessage(mediaUrl);
|
||||||
await params.context.sendActivity(mediaUrl);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,6 +418,8 @@ export async function monitorMSTeamsProvider(
|
|||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
context,
|
context,
|
||||||
|
adapter,
|
||||||
|
appId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
@@ -450,16 +493,7 @@ export async function monitorMSTeamsProvider(
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth configuration - use SDK's defaults merger
|
// Create Express server
|
||||||
const authConfig = getAuthConfigWithDefaults({
|
|
||||||
clientId: creds.appId,
|
|
||||||
clientSecret: creds.appPassword,
|
|
||||||
tenantId: creds.tenantId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create our own Express server (instead of using startServer) so we can control shutdown
|
|
||||||
// Pass authConfig to CloudAdapter so it can authenticate outbound calls
|
|
||||||
const adapter = new CloudAdapter(authConfig);
|
|
||||||
const expressApp = express.default();
|
const expressApp = express.default();
|
||||||
expressApp.use(express.json());
|
expressApp.use(express.json());
|
||||||
expressApp.use(authorizeJWT(authConfig));
|
expressApp.use(authorizeJWT(authConfig));
|
||||||
|
|||||||
@@ -1,25 +1,226 @@
|
|||||||
import type { MSTeamsConfig } from "../config/types.js";
|
import type { ClawdbotConfig } from "../config/types.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import type { getChildLogger as getChildLoggerFn } from "../logging.js";
|
||||||
|
import {
|
||||||
|
getConversationReference,
|
||||||
|
listConversationReferences,
|
||||||
|
type StoredConversationReference,
|
||||||
|
} from "./conversation-store.js";
|
||||||
|
import { resolveMSTeamsCredentials } from "./token.js";
|
||||||
|
|
||||||
const log = getChildLogger({ name: "msteams:send" });
|
// Lazy logger to avoid initialization order issues in tests
|
||||||
|
let _log: ReturnType<typeof getChildLoggerFn> | undefined;
|
||||||
|
const getLog = (): ReturnType<typeof getChildLoggerFn> => {
|
||||||
|
if (!_log) {
|
||||||
|
// Dynamic import to defer initialization
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const { getChildLogger } = require("../logging.js") as {
|
||||||
|
getChildLogger: typeof getChildLoggerFn;
|
||||||
|
};
|
||||||
|
_log = getChildLogger({ name: "msteams:send" });
|
||||||
|
}
|
||||||
|
return _log;
|
||||||
|
};
|
||||||
|
|
||||||
export type SendMSTeamsMessageParams = {
|
export type SendMSTeamsMessageParams = {
|
||||||
cfg: MSTeamsConfig;
|
/** Full config (for credentials) */
|
||||||
conversationId: string;
|
cfg: ClawdbotConfig;
|
||||||
|
/** Conversation ID or user ID to send to */
|
||||||
|
to: string;
|
||||||
|
/** Message text */
|
||||||
text: string;
|
text: string;
|
||||||
serviceUrl: string;
|
/** Optional media URL */
|
||||||
|
mediaUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SendMSTeamsMessageResult = {
|
export type SendMSTeamsMessageResult = {
|
||||||
ok: boolean;
|
messageId: string;
|
||||||
messageId?: string;
|
conversationId: string;
|
||||||
error?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function sendMessageMSTeams(
|
/**
|
||||||
_params: SendMSTeamsMessageParams,
|
* Parse the --to argument into a conversation reference lookup key.
|
||||||
): Promise<SendMSTeamsMessageResult> {
|
* Supported formats:
|
||||||
// TODO: Implement using CloudAdapter.continueConversationAsync
|
* - conversation:19:abc@thread.tacv2 → lookup by conversation ID
|
||||||
log.warn("sendMessageMSTeams not yet implemented");
|
* - user:aad-object-id → lookup by user AAD object ID
|
||||||
return { ok: false, error: "not implemented" };
|
* - 19:abc@thread.tacv2 → direct conversation ID
|
||||||
|
*/
|
||||||
|
function parseRecipient(to: string): {
|
||||||
|
type: "conversation" | "user";
|
||||||
|
id: string;
|
||||||
|
} {
|
||||||
|
const trimmed = to.trim();
|
||||||
|
if (trimmed.startsWith("conversation:")) {
|
||||||
|
return { type: "conversation", id: trimmed.slice("conversation:".length) };
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("user:")) {
|
||||||
|
return { type: "user", id: trimmed.slice("user:".length) };
|
||||||
|
}
|
||||||
|
// Assume it's a conversation ID if it looks like one
|
||||||
|
if (trimmed.startsWith("19:") || trimmed.includes("@thread")) {
|
||||||
|
return { type: "conversation", id: trimmed };
|
||||||
|
}
|
||||||
|
// Otherwise treat as user ID
|
||||||
|
return { type: "user", id: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a stored conversation reference for the given recipient.
|
||||||
|
*/
|
||||||
|
async function findConversationReference(
|
||||||
|
recipient: { type: "conversation" | "user"; id: string },
|
||||||
|
): Promise<{ conversationId: string; ref: StoredConversationReference } | null> {
|
||||||
|
if (recipient.type === "conversation") {
|
||||||
|
const ref = await getConversationReference(recipient.id);
|
||||||
|
if (ref) return { conversationId: recipient.id, ref };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search by user AAD object ID
|
||||||
|
const all = await listConversationReferences();
|
||||||
|
for (const { conversationId, reference } of all) {
|
||||||
|
if (reference.user?.aadObjectId === recipient.id) {
|
||||||
|
return { conversationId, ref: reference };
|
||||||
|
}
|
||||||
|
if (reference.user?.id === recipient.id) {
|
||||||
|
return { conversationId, ref: reference };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type matching @microsoft/agents-activity ConversationReference
|
||||||
|
type ConversationReferenceShape = {
|
||||||
|
activityId?: string;
|
||||||
|
user?: { id: string; name?: string };
|
||||||
|
bot?: { id: string; name?: string };
|
||||||
|
conversation: { id: string; conversationType?: string; tenantId?: string };
|
||||||
|
channelId: string;
|
||||||
|
serviceUrl?: string;
|
||||||
|
locale?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Bot Framework ConversationReference from our stored format.
|
||||||
|
* Note: activityId is intentionally omitted so proactive messages post as
|
||||||
|
* top-level messages rather than replies/threads.
|
||||||
|
*/
|
||||||
|
function buildConversationReference(
|
||||||
|
ref: StoredConversationReference,
|
||||||
|
): ConversationReferenceShape {
|
||||||
|
if (!ref.conversation?.id) {
|
||||||
|
throw new Error("Invalid stored reference: missing conversation.id");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
// activityId omitted to avoid creating reply threads
|
||||||
|
user: ref.user?.id ? { id: ref.user.id, name: ref.user.name } : undefined,
|
||||||
|
bot: ref.bot?.id ? { id: ref.bot.id, name: ref.bot.name } : undefined,
|
||||||
|
conversation: {
|
||||||
|
id: ref.conversation.id,
|
||||||
|
conversationType: ref.conversation.conversationType,
|
||||||
|
tenantId: ref.conversation.tenantId,
|
||||||
|
},
|
||||||
|
channelId: ref.channelId ?? "msteams",
|
||||||
|
serviceUrl: ref.serviceUrl,
|
||||||
|
locale: ref.locale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a Teams conversation or user.
|
||||||
|
*
|
||||||
|
* Uses the stored ConversationReference from previous interactions.
|
||||||
|
* The bot must have received at least one message from the conversation
|
||||||
|
* before proactive messaging works.
|
||||||
|
*/
|
||||||
|
export async function sendMessageMSTeams(
|
||||||
|
params: SendMSTeamsMessageParams,
|
||||||
|
): Promise<SendMSTeamsMessageResult> {
|
||||||
|
const { cfg, to, text, mediaUrl } = 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse recipient and find conversation reference
|
||||||
|
const recipient = parseRecipient(to);
|
||||||
|
const found = await findConversationReference(recipient);
|
||||||
|
|
||||||
|
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 conversationRef = buildConversationReference(ref);
|
||||||
|
|
||||||
|
getLog().debug("sending proactive message", {
|
||||||
|
conversationId,
|
||||||
|
textLength: text.length,
|
||||||
|
hasMedia: Boolean(mediaUrl),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic import to avoid loading SDK when not needed
|
||||||
|
const agentsHosting = await import("@microsoft/agents-hosting");
|
||||||
|
const { CloudAdapter, getAuthConfigWithDefaults } = agentsHosting;
|
||||||
|
|
||||||
|
const authConfig = getAuthConfigWithDefaults({
|
||||||
|
clientId: creds.appId,
|
||||||
|
clientSecret: creds.appPassword,
|
||||||
|
tenantId: creds.tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapter = new CloudAdapter(authConfig);
|
||||||
|
|
||||||
|
let messageId = "unknown";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (adapter as any).continueConversation(
|
||||||
|
creds.appId,
|
||||||
|
conversationRef,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async (context: any) => {
|
||||||
|
// Build the activity
|
||||||
|
const activity = {
|
||||||
|
type: "message",
|
||||||
|
text: mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text,
|
||||||
|
};
|
||||||
|
const response = await context.sendActivity(activity);
|
||||||
|
if (response?.id) {
|
||||||
|
messageId = response.id;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
getLog().info("sent proactive message", { conversationId, messageId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all known conversation references (for debugging/CLI).
|
||||||
|
*/
|
||||||
|
export async function listMSTeamsConversations(): Promise<
|
||||||
|
Array<{
|
||||||
|
conversationId: string;
|
||||||
|
userName?: string;
|
||||||
|
conversationType?: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const all = await listConversationReferences();
|
||||||
|
return all.map(({ conversationId, reference }) => ({
|
||||||
|
conversationId,
|
||||||
|
userName: reference.user?.name,
|
||||||
|
conversationType: reference.conversation?.conversationType,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user