UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

663 lines (662 loc) 23.6 kB
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js"; import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js"; import { _ as sendPayloadMediaSequence, h as resolveTextChunksWithFallback, p as resolvePayloadMediaUrls } from "./reply-payload-D-VMfpYI.js"; import { n as resolveOutboundSendDep } from "./send-deps-DjbvQHZ4.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import { t as chunkTextForOutbound } from "./text-chunking-D45TeeEs.js"; import "./channel-outbound-B3_Zy-kG.js"; import { i as createAttachedChannelResultAdapter, t as attachChannelToResult } from "./channel-send-result-Dn_C6AJS.js"; import "./runtime-api-B9a52KOD.js"; import { a as fetchGraphJson, c as normalizeQuery, d as postGraphJson, f as resolveGraphToken, i as fetchGraphAbsoluteUrl, l as patchGraphJson, n as deleteGraphRequest, o as listChannelsForTeam, r as escapeOData, s as listTeamsByName, t as searchGraphUsers, u as postGraphBetaJson } from "./graph-users-DhhsJ2VT.js"; import { n as buildMSTeamsPresentationCard, t as MSTEAMS_PRESENTATION_CAPABILITIES } from "./presentation-B9NzXv2T.js"; import { l as createMSTeamsPollStoreState, v as createMSTeamsConversationStoreState } from "./polls-CojwMnnP.js"; import { a as sendMessageMSTeams, i as sendAdaptiveCardMSTeams, n as deleteMessageMSTeams, o as sendPollMSTeams, r as editMessageMSTeams, t as probeMSTeams } from "./probe-DhYkw-P_.js"; //#region extensions/msteams/src/directory-live.ts async function listMSTeamsDirectoryPeersLive(params) { const query = normalizeQuery(params.query); if (!query) return []; return (await searchGraphUsers({ token: await resolveGraphToken(params.cfg), query, top: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20 })).map((user) => { const id = user.id?.trim(); if (!id) return null; const name = user.displayName?.trim(); const handle = user.userPrincipalName?.trim() || user.mail?.trim(); return { kind: "user", id: `user:${id}`, name: name || void 0, handle: handle ? `@${handle}` : void 0, raw: user }; }).filter(Boolean); } async function listMSTeamsDirectoryGroupsLive(params) { const rawQuery = normalizeQuery(params.query); if (!rawQuery) return []; const token = await resolveGraphToken(params.cfg); const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; const [teamQuery, channelQuery] = rawQuery.includes("/") ? normalizeStringEntries(rawQuery.split("/", 2)) : [rawQuery, null]; const teams = await listTeamsByName(token, teamQuery); const results = []; for (const team of teams) { const teamId = team.id?.trim(); if (!teamId) continue; const teamName = team.displayName?.trim() || teamQuery; if (!channelQuery) { results.push({ kind: "group", id: `team:${teamId}`, name: teamName, handle: teamName ? `#${teamName}` : void 0, raw: team }); if (results.length >= limit) return results; continue; } const channels = await listChannelsForTeam(token, teamId); for (const channel of channels) { const name = channel.displayName?.trim(); if (!name) continue; if (!normalizeLowercaseStringOrEmpty(name).includes(normalizeLowercaseStringOrEmpty(channelQuery))) continue; results.push({ kind: "group", id: `conversation:${channel.id}`, name: `${teamName}/${name}`, handle: `#${name}`, raw: channel }); if (results.length >= limit) return results; } } return results; } //#endregion //#region extensions/msteams/src/graph-messages.ts /** * Resolve the Graph API path prefix for a conversation. * If `to` contains "/" it's a `teamId/channelId` (channel path), * otherwise it's a chat ID. */ /** * Strip common target prefixes (`conversation:`, `user:`) so raw * conversation IDs can be used directly in Graph paths. */ function stripTargetPrefix(raw) { const trimmed = raw.trim(); if (/^conversation:/i.test(trimmed)) return trimmed.slice(13).trim(); if (/^user:/i.test(trimmed)) return trimmed.slice(5).trim(); return trimmed; } /** * Resolve a target to a Graph-compatible conversation ID. * `user:<aadId>` targets are looked up in the conversation store to find the * actual `19:xxx@thread.*` chat ID that Graph API requires. * Conversation IDs and `teamId/channelId` pairs pass through unchanged. */ async function resolveGraphConversationId(to) { const trimmed = to.trim(); const isUserTarget = /^user:/i.test(trimmed); const cleaned = stripTargetPrefix(trimmed); if (!isUserTarget) return cleaned; const found = await createMSTeamsConversationStoreState().findPreferredDmByUserId(cleaned); if (!found) throw new Error(`No conversation found for user:${cleaned}. The bot must receive a message from this user before Graph API operations work.`); if (found.reference.graphChatId) return found.reference.graphChatId; if (found.conversationId.startsWith("19:")) return found.conversationId; throw new Error(`Conversation for user:${cleaned} uses a Bot Framework ID (${found.conversationId}) that Graph API does not accept. Send a message to this user first so the Graph chat ID is cached.`); } function resolveConversationPath(to) { const cleaned = stripTargetPrefix(to); if (cleaned.includes("/")) { const [teamId, channelId] = cleaned.split("/", 2); return { kind: "channel", basePath: `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}`, teamId, channelId }; } return { kind: "chat", basePath: `/chats/${encodeURIComponent(cleaned)}`, chatId: cleaned }; } /** * Retrieve a single message by ID from a chat or channel via Graph API. */ async function getMessageMSTeams(params) { const token = await resolveGraphToken(params.cfg); const { basePath } = resolveConversationPath(await resolveGraphConversationId(params.to)); const msg = await fetchGraphJson({ token, path: `${basePath}/messages/${encodeURIComponent(params.messageId)}` }); return { id: msg.id ?? params.messageId, text: msg.body?.content, from: msg.from, createdAt: msg.createdDateTime }; } /** * Pin a message in a chat conversation via Graph API. * * Chat pinning uses the v1.0 endpoint: `POST /chats/{chatId}/pinnedMessages`. * * Channel pinning uses `POST /teams/{teamId}/channels/{channelId}/pinnedMessages`. * **Note:** The channel pin endpoint may require the Graph beta API or specific * tenant-level permissions. As of March 2026, general availability is not * confirmed for all tenants. If the call returns 404 or 403, the endpoint may * not be enabled for the target tenant. */ async function pinMessageMSTeams(params) { const token = await resolveGraphToken(params.cfg); const conversationId = await resolveGraphConversationId(params.to); const conv = resolveConversationPath(conversationId); if (conv.kind === "channel") throw new Error("Pin/unpin is not supported for channel messages on Graph v1.0. Only chat conversations support pinned messages."); const body = { "message@odata.bind": `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(params.messageId)}` }; return { ok: true, pinnedMessageId: (await postGraphJson({ token, path: `${conv.basePath}/pinnedMessages`, body })).id }; } /** * Unpin a message in a chat conversation via Graph API. * `pinnedMessageId` is the pinned-message resource ID (from pin or list-pins), * not the underlying chat message ID. * * Channel unpin uses `DELETE /teams/{teamId}/channels/{channelId}/pinnedMessages/{id}`. * See the note on {@link pinMessageMSTeams} regarding beta/GA status. */ async function unpinMessageMSTeams(params) { const token = await resolveGraphToken(params.cfg); const conv = resolveConversationPath(await resolveGraphConversationId(params.to)); if (conv.kind === "channel") throw new Error("Pin/unpin is not supported for channel messages on Graph v1.0. Only chat conversations support pinned messages."); await deleteGraphRequest({ token, path: `${conv.basePath}/pinnedMessages/${encodeURIComponent(params.pinnedMessageId)}` }); return { ok: true }; } /** Maximum number of pagination pages to follow to avoid unbounded loops. */ const LIST_PINS_MAX_PAGES = 10; /** * List all pinned messages in a chat conversation via Graph API. * Follows `@odata.nextLink` pagination to collect the full pin set. * * Channel list-pins uses the same endpoint pattern as channel pin/unpin. * See the note on {@link pinMessageMSTeams} regarding beta/GA status. */ async function listPinsMSTeams(params) { const token = await resolveGraphToken(params.cfg); const conv = resolveConversationPath(await resolveGraphConversationId(params.to)); if (conv.kind === "channel") throw new Error("Listing pinned messages is not supported for channels on Graph v1.0. Only chat conversations support pinned messages."); const path = `${conv.basePath}/pinnedMessages?$expand=message`; const allPins = []; let res = await fetchGraphJson({ token, path }); let pages = 1; while (true) { for (const pin of res.value ?? []) allPins.push({ id: pin.id ?? "", pinnedMessageId: pin.id ?? "", messageId: pin.message?.id, text: pin.message?.body?.content }); const nextLink = res["@odata.nextLink"]; if (!nextLink || pages >= LIST_PINS_MAX_PAGES) break; res = await fetchGraphAbsoluteUrl({ token, url: nextLink }); pages++; } return { pins: allPins }; } const TEAMS_REACTION_TYPES = [ "like", "heart", "laugh", "surprised", "sad", "angry" ]; /** Map well-known reaction type names to representative emoji for CLI display. */ const REACTION_TYPE_EMOJI = { like: "👍", heart: "❤️", laugh: "😆", surprised: "😮", sad: "😢", angry: "😡" }; /** * Normalize a reaction type string. Graph setReaction/unsetReaction accepts * the well-known legacy names (like, heart, laugh, surprised, sad, angry) * as well as Unicode emoji values — so we pass unknown types through rather * than rejecting them. */ function normalizeReactionType(raw) { const normalized = raw.trim(); if (!normalized) throw new Error(`Reaction type is required. Common types: ${TEAMS_REACTION_TYPES.join(", ")}`); const lowered = normalized.toLowerCase(); if (TEAMS_REACTION_TYPES.includes(lowered)) return lowered; return normalized; } /** * Add an emoji reaction to a message via Graph API (beta). * * Writes (setReaction) require a Delegated token, so we pass * `preferDelegated: true`. The resolver falls back to the app-only token when * delegated auth is not configured, preserving today's behavior while letting * delegated-auth-enabled deployments hit the user-scoped endpoint. */ async function reactMessageMSTeams(params) { const reactionType = normalizeReactionType(params.reactionType); const token = await resolveGraphToken(params.cfg, { preferDelegated: true }); const { basePath } = resolveConversationPath(await resolveGraphConversationId(params.to)); await postGraphBetaJson({ token, path: `${basePath}/messages/${encodeURIComponent(params.messageId)}/setReaction`, body: { reactionType } }); return { ok: true }; } /** * Remove an emoji reaction from a message via Graph API (beta). * * Writes (unsetReaction) require a Delegated token, so we pass * `preferDelegated: true`. See `reactMessageMSTeams` for fallback rules. */ async function unreactMessageMSTeams(params) { const reactionType = normalizeReactionType(params.reactionType); const token = await resolveGraphToken(params.cfg, { preferDelegated: true }); const { basePath } = resolveConversationPath(await resolveGraphConversationId(params.to)); await postGraphBetaJson({ token, path: `${basePath}/messages/${encodeURIComponent(params.messageId)}/unsetReaction`, body: { reactionType } }); return { ok: true }; } /** * List reactions on a message, grouped by type. * Uses Graph v1.0 (reactions are included in the message resource). */ async function listReactionsMSTeams(params) { const token = await resolveGraphToken(params.cfg); const { basePath } = resolveConversationPath(await resolveGraphConversationId(params.to)); const msg = await fetchGraphJson({ token, path: `${basePath}/messages/${encodeURIComponent(params.messageId)}` }); const grouped = /* @__PURE__ */ new Map(); for (const reaction of msg.reactions ?? []) { const type = reaction.reactionType ?? "unknown"; if (!grouped.has(type)) grouped.set(type, { count: 0, users: [] }); const group = grouped.get(type); group.count++; if (reaction.user?.id) group.users.push({ id: reaction.user.id, displayName: reaction.user.displayName }); } return { reactions: Array.from(grouped.entries()).map(([type, group]) => ({ reactionType: type, name: type, emoji: REACTION_TYPE_EMOJI[type], count: group.count, users: group.users })) }; } const SEARCH_DEFAULT_LIMIT = 25; const SEARCH_MAX_LIMIT = 50; /** * Search messages in a chat or channel by content via Graph API. * Uses `$search` for full-text body search and optional `$filter` for sender. */ async function searchMessagesMSTeams(params) { const token = await resolveGraphToken(params.cfg); const { basePath } = resolveConversationPath(await resolveGraphConversationId(params.to)); const rawLimit = params.limit ?? SEARCH_DEFAULT_LIMIT; const top = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), SEARCH_MAX_LIMIT) : SEARCH_DEFAULT_LIMIT; const sanitizedQuery = params.query.replace(/"/g, ""); const parts = [`$search=${encodeURIComponent(`"${sanitizedQuery}"`)}`]; parts.push(`$top=${top}`); if (params.from) parts.push(`$filter=${encodeURIComponent(`from/user/displayName eq '${escapeOData(params.from)}'`)}`); return { messages: ((await fetchGraphJson({ token, path: `${basePath}/messages?${parts.join("&")}`, headers: { ConsistencyLevel: "eventual" } })).value ?? []).map((msg) => ({ id: msg.id ?? "", text: msg.body?.content, from: msg.from, createdAt: msg.createdDateTime })) }; } //#endregion //#region extensions/msteams/src/graph-group-management.ts function normalizeConversationMemberRole(role) { const normalized = role?.trim().toLowerCase() ?? ""; if (!normalized) return "member"; if (normalized === "member" || normalized === "owner") return normalized; throw new Error("MS Teams participant role must be \"member\" or \"owner\"."); } /** * Add a user to a chat or channel via Graph API. */ async function addParticipantMSTeams(params) { const token = await resolveGraphToken(params.cfg); const conversationId = await resolveGraphConversationId(params.to); const conv = resolveConversationPath(conversationId); const body = { "@odata.type": "#microsoft.graph.aadUserConversationMember", roles: [normalizeConversationMemberRole(params.role)], "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${escapeOData(params.userId)}')` }; await postGraphJson({ token, path: `${conv.basePath}/members`, body }); return { added: { userId: params.userId, chatId: conversationId } }; } /** * Remove a user from a chat or channel via Graph API. * Lists members first to resolve the membership ID, then deletes. */ async function removeParticipantMSTeams(params) { const token = await resolveGraphToken(params.cfg); const conversationId = await resolveGraphConversationId(params.to); const conv = resolveConversationPath(conversationId); const MAX_PAGES = 10; let nextPath = `${conv.basePath}/members`; let page = 0; let member; while (nextPath && page < MAX_PAGES && !member) { const membersRes = await fetchGraphJson({ token, path: nextPath }); member = (membersRes.value ?? []).find((candidate) => candidate.userId === params.userId); if (member) break; const nextLink = membersRes["@odata.nextLink"]; nextPath = nextLink ? nextLink.replace("https://graph.microsoft.com/v1.0", "") : void 0; page++; } if (!member?.id) throw new Error(`User ${params.userId} is not a member of this conversation`); await deleteGraphRequest({ token, path: `${conv.basePath}/members/${encodeURIComponent(member.id)}` }); return { removed: { userId: params.userId, chatId: conversationId } }; } /** * Rename a chat (topic) or channel (displayName) via Graph API. */ async function renameGroupMSTeams(params) { const token = await resolveGraphToken(params.cfg); const conversationId = await resolveGraphConversationId(params.to); const conv = resolveConversationPath(conversationId); const body = conv.kind === "chat" ? { topic: params.name } : { displayName: params.name }; await patchGraphJson({ token, path: conv.basePath, body }); return { renamed: { chatId: conversationId, newName: params.name } }; } //#endregion //#region extensions/msteams/src/graph-members.ts /** * Fetch a user profile from Microsoft Graph by user ID. */ async function getMemberInfoMSTeams(params) { const user = await fetchGraphJson({ token: await resolveGraphToken(params.cfg), path: `/users/${encodeURIComponent(params.userId)}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation` }); return { user: { id: user.id, displayName: user.displayName, mail: user.mail, jobTitle: user.jobTitle, userPrincipalName: user.userPrincipalName, officeLocation: user.officeLocation } }; } //#endregion //#region extensions/msteams/src/graph-teams.ts /** * List channels in a team via Graph API. * Returns id, displayName, description, and membershipType for each channel. * Follows @odata.nextLink for paginated results (up to 10 pages). */ async function listChannelsMSTeams(params) { const token = await resolveGraphToken(params.cfg); const firstPath = `/teams/${encodeURIComponent(params.teamId)}/channels?$select=id,displayName,description,membershipType`; const collected = []; let nextPath = firstPath; const MAX_PAGES = 10; let page = 0; while (nextPath && page < MAX_PAGES) { const res = await fetchGraphJson({ token, path: nextPath }); collected.push(...res.value ?? []); const nextLink = res["@odata.nextLink"]; nextPath = nextLink ? nextLink.replace("https://graph.microsoft.com/v1.0", "") : void 0; page++; } return { channels: collected.map((ch) => ({ id: ch.id, displayName: ch.displayName, description: ch.description, membershipType: ch.membershipType })), truncated: Boolean(nextPath) }; } /** * Get detailed information about a single channel in a team via Graph API. * Returns id, displayName, description, membershipType, webUrl, and createdDateTime. */ async function getChannelInfoMSTeams(params) { const ch = await fetchGraphJson({ token: await resolveGraphToken(params.cfg), path: `/teams/${encodeURIComponent(params.teamId)}/channels/${encodeURIComponent(params.channelId)}?$select=id,displayName,description,membershipType,webUrl,createdDateTime` }); return { channel: { id: ch.id, displayName: ch.displayName, description: ch.description, membershipType: ch.membershipType, webUrl: ch.webUrl, createdDateTime: ch.createdDateTime } }; } //#endregion //#region extensions/msteams/src/outbound.ts function asObjectRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : void 0; } const MSTEAMS_TEXT_CHUNK_LIMIT = 4e3; function resolveMSTeamsTextSend(params) { return resolveOutboundSendDep(params.deps, "msteams") ?? ((to, text) => sendMessageMSTeams({ cfg: params.cfg, to, text })); } function resolveMSTeamsMediaSend(params) { return resolveOutboundSendDep(params.deps, "msteams") ?? ((to, text, opts) => sendMessageMSTeams({ cfg: params.cfg, to, text, mediaUrl: opts?.mediaUrl, mediaLocalRoots: opts?.mediaLocalRoots, mediaReadFile: opts?.mediaReadFile })); } //#endregion //#region extensions/msteams/src/channel.runtime.ts const msTeamsChannelRuntime = { addParticipantMSTeams, deleteMessageMSTeams, editMessageMSTeams, getChannelInfoMSTeams, getMemberInfoMSTeams, getMessageMSTeams, listChannelsMSTeams, listPinsMSTeams, listReactionsMSTeams, pinMessageMSTeams, reactMessageMSTeams, removeParticipantMSTeams, renameGroupMSTeams, searchMessagesMSTeams, unpinMessageMSTeams, unreactMessageMSTeams, listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive, msteamsOutbound: { deliveryMode: "direct", chunker: chunkTextForOutbound, chunkerMode: "markdown", textChunkLimit: MSTEAMS_TEXT_CHUNK_LIMIT, pollMaxOptions: 12, deliveryCapabilities: { durableFinal: { text: true, media: true, payload: true, messageSendingHooks: true } }, presentationCapabilities: MSTEAMS_PRESENTATION_CAPABILITIES, renderPresentation: ({ payload, presentation }) => { if (payload.mediaUrl || payload.mediaUrls?.length) return null; const card = buildMSTeamsPresentationCard({ presentation, text: payload.text }); const msteamsData = asObjectRecord(payload.channelData?.msteams) ?? {}; return { ...payload, channelData: { ...payload.channelData, msteams: { ...msteamsData, presentationCard: card } } }; }, sendPayload: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, mediaReadFile, payload, deps }) => { const presentationCard = asObjectRecord(payload.channelData?.msteams)?.presentationCard; if (presentationCard && typeof presentationCard === "object" && !Array.isArray(presentationCard)) return attachChannelToResult("msteams", await sendAdaptiveCardMSTeams({ cfg, to, card: presentationCard })); const mediaUrls = normalizeStringEntries(resolvePayloadMediaUrls({ ...payload, mediaUrl: payload.mediaUrl ?? mediaUrl })); if (mediaUrls.length > 0) { const send = resolveMSTeamsMediaSend({ cfg, deps }); const result = await sendPayloadMediaSequence({ text, mediaUrls, send: async ({ text: textLocal, mediaUrl: mediaUrlLocal }) => await send(to, textLocal, { mediaUrl: mediaUrlLocal, mediaLocalRoots, mediaReadFile }) }); if (result) return attachChannelToResult("msteams", result); } if (text.trim()) { const send = resolveMSTeamsTextSend({ cfg, deps }); const chunks = resolveTextChunksWithFallback(text, chunkTextForOutbound(text, MSTEAMS_TEXT_CHUNK_LIMIT)); let result; for (const chunk of chunks) result = await send(to, chunk); return attachChannelToResult("msteams", result); } throw new Error("MS Teams payload send requires text, media, or a presentation card."); }, ...createAttachedChannelResultAdapter({ channel: "msteams", sendText: async ({ cfg, to, text, deps }) => { return await resolveMSTeamsTextSend({ cfg, deps })(to, text); }, sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, mediaReadFile, deps }) => { return await resolveMSTeamsMediaSend({ cfg, deps })(to, text, { mediaUrl, mediaLocalRoots, mediaReadFile }); }, sendPoll: async ({ cfg, to, poll }) => { const maxSelections = poll.maxSelections ?? 1; const result = await sendPollMSTeams({ cfg, to, question: poll.question, options: poll.options, maxSelections }); await createMSTeamsPollStoreState().createPoll({ id: result.pollId, question: poll.question, options: poll.options, maxSelections, createdAt: (/* @__PURE__ */ new Date()).toISOString(), conversationId: result.conversationId, messageId: result.messageId, votes: {} }); return result; } }) }, probeMSTeams, sendAdaptiveCardMSTeams, sendMessageMSTeams }; //#endregion export { msTeamsChannelRuntime };