refactor: adopt chat plugin builder in msteams

This commit is contained in:
Peter Steinberger 2026-03-22 22:24:03 +00:00
parent 7709aa33d8
commit ad5e3f0cd5
1 changed files with 375 additions and 372 deletions

View File

@ -6,14 +6,12 @@ import type {
ChannelMessageActionAdapter,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import {
createPairingPrefixStripper,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-pairing";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
createAllowlistProviderGroupPolicyWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
@ -137,381 +135,386 @@ function describeMSTeamsMessageTool({
};
}
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
id: "msteams",
meta: {
...meta,
aliases: [...meta.aliases],
},
setupWizard: msteamsSetupWizard,
pairing: createTextPairingAdapter({
idLabel: "msteamsUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(msteams|user):/i),
notify: async ({ cfg, id, message }) => {
const { sendMessageMSTeams } = await loadMSTeamsChannelRuntime();
await sendMessageMSTeams({
cfg,
to: id,
text: message,
});
},
}),
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
threads: true,
media: true,
},
agentPrompt: {
messageToolHints: () => [
"- Adaptive Cards supported. Use `action=send` with `card={type,version,body}` to send rich cards.",
"- MSTeams targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:ID` or `user:Display Name` (requires Graph API) for DMs, `conversation:19:...@thread.tacv2` for groups/channels. Prefer IDs over display names for speed.",
],
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
groups: {
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
},
reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: {
...msteamsConfigAdapter,
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
}),
},
security: {
collectWarnings: projectWarningCollector(
({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }),
collectMSTeamsSecurityWarnings,
),
},
setup: msteamsSetupAdapter,
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^conversation:/i.test(trimmed)) {
return true;
}
if (/^user:/i.test(trimmed)) {
// Only treat as ID if the value after user: looks like a UUID
const id = trimmed.slice("user:".length).trim();
return /^[0-9a-fA-F-]{16,}$/.test(id);
}
return trimmed.includes("@thread");
export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsResult> =
createChatChannelPlugin({
base: {
id: "msteams",
meta: {
...meta,
aliases: [...meta.aliases],
},
hint: "<conversationId|user:ID|conversation:ID>",
},
},
directory: createChannelDirectoryAdapter({
self: async ({ cfg }) => {
const creds = resolveMSTeamsCredentials(cfg.channels?.msteams);
if (!creds) {
return null;
}
return { kind: "user" as const, id: creds.appId, name: creds.appId };
},
listPeers: async ({ cfg, query, limit }) =>
listDirectoryEntriesFromSources({
kind: "user",
sources: [
cfg.channels?.msteams?.allowFrom ?? [],
Object.keys(cfg.channels?.msteams?.dms ?? {}),
setupWizard: msteamsSetupWizard,
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
threads: true,
media: true,
},
agentPrompt: {
messageToolHints: () => [
"- Adaptive Cards supported. Use `action=send` with `card={type,version,body}` to send rich cards.",
"- MSTeams targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:ID` or `user:Display Name` (requires Graph API) for DMs, `conversation:19:...@thread.tacv2` for groups/channels. Prefer IDs over display names for speed.",
],
query,
limit,
normalizeId: (raw) => {
const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw;
const lowered = normalized.toLowerCase();
if (lowered.startsWith("user:") || lowered.startsWith("conversation:")) {
return normalized;
}
return `user:${normalized}`;
},
groups: {
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
},
reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: {
...msteamsConfigAdapter,
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: account.configured,
}),
},
setup: msteamsSetupAdapter,
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^conversation:/i.test(trimmed)) {
return true;
}
if (/^user:/i.test(trimmed)) {
// Only treat as ID if the value after user: looks like a UUID
const id = trimmed.slice("user:".length).trim();
return /^[0-9a-fA-F-]{16,}$/.test(id);
}
return trimmed.includes("@thread");
},
hint: "<conversationId|user:ID|conversation:ID>",
},
}),
listGroups: async ({ cfg, query, limit }) =>
listDirectoryEntriesFromSources({
kind: "group",
sources: [
Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) =>
Object.keys(team.channels ?? {}),
),
],
query,
limit,
normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`,
}),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadMSTeamsChannelRuntime,
listPeersLive: (runtime) => runtime.listMSTeamsDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listMSTeamsDirectoryGroupsLive,
}),
}),
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) => {
const results = inputs.map((input) => ({
input,
resolved: false,
id: undefined as string | undefined,
name: undefined as string | undefined,
note: undefined as string | undefined,
}));
type ResolveTargetResultEntry = (typeof results)[number];
type PendingTargetEntry = { input: string; query: string; index: number };
const stripPrefix = (value: string) => normalizeMSTeamsUserInput(value);
const markPendingLookupFailed = (pending: PendingTargetEntry[]) => {
pending.forEach(({ index }) => {
const entry = results[index];
if (entry) {
entry.note = "lookup failed";
},
directory: createChannelDirectoryAdapter({
self: async ({ cfg }) => {
const creds = resolveMSTeamsCredentials(cfg.channels?.msteams);
if (!creds) {
return null;
}
});
};
const resolvePending = async <T>(
pending: PendingTargetEntry[],
resolveEntries: (entries: string[]) => Promise<T[]>,
applyResolvedEntry: (target: ResolveTargetResultEntry, entry: T) => void,
) => {
if (pending.length === 0) {
return;
}
try {
const resolved = await resolveEntries(pending.map((entry) => entry.query));
resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1];
if (!target) {
return { kind: "user" as const, id: creds.appId, name: creds.appId };
},
listPeers: async ({ cfg, query, limit }) =>
listDirectoryEntriesFromSources({
kind: "user",
sources: [
cfg.channels?.msteams?.allowFrom ?? [],
Object.keys(cfg.channels?.msteams?.dms ?? {}),
],
query,
limit,
normalizeId: (raw) => {
const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw;
const lowered = normalized.toLowerCase();
if (lowered.startsWith("user:") || lowered.startsWith("conversation:")) {
return normalized;
}
return `user:${normalized}`;
},
}),
listGroups: async ({ cfg, query, limit }) =>
listDirectoryEntriesFromSources({
kind: "group",
sources: [
Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) =>
Object.keys(team.channels ?? {}),
),
],
query,
limit,
normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`,
}),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadMSTeamsChannelRuntime,
listPeersLive: (runtime) => runtime.listMSTeamsDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listMSTeamsDirectoryGroupsLive,
}),
}),
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) => {
const results = inputs.map((input) => ({
input,
resolved: false,
id: undefined as string | undefined,
name: undefined as string | undefined,
note: undefined as string | undefined,
}));
type ResolveTargetResultEntry = (typeof results)[number];
type PendingTargetEntry = { input: string; query: string; index: number };
const stripPrefix = (value: string) => normalizeMSTeamsUserInput(value);
const markPendingLookupFailed = (pending: PendingTargetEntry[]) => {
pending.forEach(({ index }) => {
const entry = results[index];
if (entry) {
entry.note = "lookup failed";
}
});
};
const resolvePending = async <T>(
pending: PendingTargetEntry[],
resolveEntries: (entries: string[]) => Promise<T[]>,
applyResolvedEntry: (target: ResolveTargetResultEntry, entry: T) => void,
) => {
if (pending.length === 0) {
return;
}
applyResolvedEntry(target, entry);
});
} catch (err) {
runtime.error?.(`msteams resolve failed: ${String(err)}`);
markPendingLookupFailed(pending);
}
};
if (kind === "user") {
const pending: PendingTargetEntry[] = [];
results.forEach((entry, index) => {
const trimmed = entry.input.trim();
if (!trimmed) {
entry.note = "empty input";
return;
}
const cleaned = stripPrefix(trimmed);
if (/^[0-9a-fA-F-]{16,}$/.test(cleaned) || cleaned.includes("@")) {
entry.resolved = true;
entry.id = cleaned;
return;
}
pending.push({ input: entry.input, query: cleaned, index });
});
await resolvePending(
pending,
(entries) => resolveMSTeamsUserAllowlist({ cfg, entries }),
(target, entry) => {
target.resolved = entry.resolved;
target.id = entry.id;
target.name = entry.name;
target.note = entry.note;
},
);
return results;
}
const pending: PendingTargetEntry[] = [];
results.forEach((entry, index) => {
const trimmed = entry.input.trim();
if (!trimmed) {
entry.note = "empty input";
return;
}
const conversationId = parseMSTeamsConversationId(trimmed);
if (conversationId !== null) {
entry.resolved = Boolean(conversationId);
entry.id = conversationId || undefined;
entry.note = conversationId ? "conversation id" : "empty conversation id";
return;
}
const parsed = parseMSTeamsTeamChannelInput(trimmed);
if (!parsed.team) {
entry.note = "missing team";
return;
}
const query = parsed.channel ? `${parsed.team}/${parsed.channel}` : parsed.team;
pending.push({ input: entry.input, query, index });
});
await resolvePending(
pending,
(entries) => resolveMSTeamsChannelAllowlist({ cfg, entries }),
(target, entry) => {
if (!entry.resolved || !entry.teamId) {
target.resolved = false;
target.note = entry.note;
return;
}
target.resolved = true;
if (entry.channelId) {
target.id = `${entry.teamId}/${entry.channelId}`;
target.name =
entry.channelName && entry.teamName
? `${entry.teamName}/${entry.channelName}`
: (entry.channelName ?? entry.teamName);
} else {
target.id = entry.teamId;
target.name = entry.teamName;
target.note = "team id";
}
if (entry.note) {
target.note = entry.note;
}
},
);
return results;
},
},
actions: {
describeMessageTool: describeMSTeamsMessageTool,
handleAction: async (ctx) => {
// Handle send action with card parameter
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: "";
if (!to) {
return {
isError: true,
content: [{ type: "text" as const, text: "Card send requires a target (to)." }],
details: { error: "Card send requires a target (to)." },
try {
const resolved = await resolveEntries(pending.map((entry) => entry.query));
resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1];
if (!target) {
return;
}
applyResolvedEntry(target, entry);
});
} catch (err) {
runtime.error?.(`msteams resolve failed: ${String(err)}`);
markPendingLookupFailed(pending);
}
};
}
const { sendAdaptiveCardMSTeams } = await loadMSTeamsChannelRuntime();
const result = await sendAdaptiveCardMSTeams({
cfg: ctx.cfg,
to,
card,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
channel: "msteams",
messageId: result.messageId,
conversationId: result.conversationId,
}),
if (kind === "user") {
const pending: PendingTargetEntry[] = [];
results.forEach((entry, index) => {
const trimmed = entry.input.trim();
if (!trimmed) {
entry.note = "empty input";
return;
}
const cleaned = stripPrefix(trimmed);
if (/^[0-9a-fA-F-]{16,}$/.test(cleaned) || cleaned.includes("@")) {
entry.resolved = true;
entry.id = cleaned;
return;
}
pending.push({ input: entry.input, query: cleaned, index });
});
await resolvePending(
pending,
(entries) => resolveMSTeamsUserAllowlist({ cfg, entries }),
(target, entry) => {
target.resolved = entry.resolved;
target.id = entry.id;
target.name = entry.name;
target.note = entry.note;
},
);
return results;
}
const pending: PendingTargetEntry[] = [];
results.forEach((entry, index) => {
const trimmed = entry.input.trim();
if (!trimmed) {
entry.note = "empty input";
return;
}
const conversationId = parseMSTeamsConversationId(trimmed);
if (conversationId !== null) {
entry.resolved = Boolean(conversationId);
entry.id = conversationId || undefined;
entry.note = conversationId ? "conversation id" : "empty conversation id";
return;
}
const parsed = parseMSTeamsTeamChannelInput(trimmed);
if (!parsed.team) {
entry.note = "missing team";
return;
}
const query = parsed.channel ? `${parsed.team}/${parsed.channel}` : parsed.team;
pending.push({ input: entry.input, query, index });
});
await resolvePending(
pending,
(entries) => resolveMSTeamsChannelAllowlist({ cfg, entries }),
(target, entry) => {
if (!entry.resolved || !entry.teamId) {
target.resolved = false;
target.note = entry.note;
return;
}
target.resolved = true;
if (entry.channelId) {
target.id = `${entry.teamId}/${entry.channelId}`;
target.name =
entry.channelName && entry.teamName
? `${entry.teamName}/${entry.channelName}`
: (entry.channelName ?? entry.teamName);
} else {
target.id = entry.teamId;
target.name = entry.teamName;
target.note = "team id";
}
if (entry.note) {
target.note = entry.note;
}
},
],
details: { ok: true, channel: "msteams", messageId: result.messageId },
};
}
// Return null to fall through to default handler
return null as never;
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 12,
...createRuntimeOutboundDelegates({
getRuntime: loadMSTeamsChannelRuntime,
sendText: { resolve: (runtime) => runtime.msteamsOutbound.sendText },
sendMedia: { resolve: (runtime) => runtime.msteamsOutbound.sendMedia },
sendPoll: { resolve: (runtime) => runtime.msteamsOutbound.sendPoll },
}),
},
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, {
port: snapshot.port ?? null,
}),
probeAccount: async ({ cfg }) =>
await (await loadMSTeamsChannelRuntime()).probeMSTeams(cfg.channels?.msteams),
formatCapabilitiesProbe: ({ probe }) => {
const teamsProbe = probe as ProbeMSTeamsResult | undefined;
const lines: Array<{ text: string; tone?: "error" }> = [];
const appId = typeof teamsProbe?.appId === "string" ? teamsProbe.appId.trim() : "";
if (appId) {
lines.push({ text: `App: ${appId}` });
}
const graph = teamsProbe?.graph;
if (graph) {
const roles = Array.isArray(graph.roles)
? graph.roles.map((role) => String(role).trim()).filter(Boolean)
: [];
const scopes = Array.isArray(graph.scopes)
? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean)
: [];
const formatPermission = (permission: string) => {
const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission];
return hint ? `${permission} (${hint})` : permission;
};
if (graph.ok === false) {
lines.push({ text: `Graph: ${graph.error ?? "failed"}`, tone: "error" });
} else if (roles.length > 0 || scopes.length > 0) {
if (roles.length > 0) {
lines.push({ text: `Graph roles: ${roles.map(formatPermission).join(", ")}` });
}
if (scopes.length > 0) {
lines.push({ text: `Graph scopes: ${scopes.map(formatPermission).join(", ")}` });
}
} else if (graph.ok === true) {
lines.push({ text: "Graph: ok" });
}
}
return lines;
},
buildAccountSnapshot: ({ account, runtime, probe }) =>
buildRuntimeAccountStatusSnapshot(
{ runtime, probe },
{
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
port: runtime?.port ?? null,
);
return results;
},
),
},
gateway: {
startAccount: async (ctx) => {
const { monitorMSTeamsProvider } = await import("./index.js");
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(`starting provider (port ${port})`);
return monitorMSTeamsProvider({
cfg: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
actions: {
describeMessageTool: describeMSTeamsMessageTool,
handleAction: async (ctx) => {
// Handle send action with card parameter
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: "";
if (!to) {
return {
isError: true,
content: [{ type: "text" as const, text: "Card send requires a target (to)." }],
details: { error: "Card send requires a target (to)." },
};
}
const { sendAdaptiveCardMSTeams } = await loadMSTeamsChannelRuntime();
const result = await sendAdaptiveCardMSTeams({
cfg: ctx.cfg,
to,
card,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
channel: "msteams",
messageId: result.messageId,
conversationId: result.conversationId,
}),
},
],
details: { ok: true, channel: "msteams", messageId: result.messageId },
};
}
// Return null to fall through to default handler
return null as never;
},
},
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, {
port: snapshot.port ?? null,
}),
probeAccount: async ({ cfg }) =>
await (await loadMSTeamsChannelRuntime()).probeMSTeams(cfg.channels?.msteams),
formatCapabilitiesProbe: ({ probe }) => {
const teamsProbe = probe as ProbeMSTeamsResult | undefined;
const lines: Array<{ text: string; tone?: "error" }> = [];
const appId = typeof teamsProbe?.appId === "string" ? teamsProbe.appId.trim() : "";
if (appId) {
lines.push({ text: `App: ${appId}` });
}
const graph = teamsProbe?.graph;
if (graph) {
const roles = Array.isArray(graph.roles)
? graph.roles.map((role) => String(role).trim()).filter(Boolean)
: [];
const scopes = Array.isArray(graph.scopes)
? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean)
: [];
const formatPermission = (permission: string) => {
const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission];
return hint ? `${permission} (${hint})` : permission;
};
if (graph.ok === false) {
lines.push({ text: `Graph: ${graph.error ?? "failed"}`, tone: "error" });
} else if (roles.length > 0 || scopes.length > 0) {
if (roles.length > 0) {
lines.push({ text: `Graph roles: ${roles.map(formatPermission).join(", ")}` });
}
if (scopes.length > 0) {
lines.push({ text: `Graph scopes: ${scopes.map(formatPermission).join(", ")}` });
}
} else if (graph.ok === true) {
lines.push({ text: "Graph: ok" });
}
}
return lines;
},
buildAccountSnapshot: ({ account, runtime, probe }) =>
buildRuntimeAccountStatusSnapshot(
{ runtime, probe },
{
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
port: runtime?.port ?? null,
},
),
},
gateway: {
startAccount: async (ctx) => {
const { monitorMSTeamsProvider } = await import("./index.js");
const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978;
ctx.setStatus({ accountId: ctx.accountId, port });
ctx.log?.info(`starting provider (port ${port})`);
return monitorMSTeamsProvider({
cfg: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
});
},
},
},
},
};
security: {
collectWarnings: projectWarningCollector(
({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }),
collectMSTeamsSecurityWarnings,
),
},
pairing: {
text: {
idLabel: "msteamsUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(msteams|user):/i),
notify: async ({ cfg, id, message }) => {
const { sendMessageMSTeams } = await loadMSTeamsChannelRuntime();
await sendMessageMSTeams({
cfg,
to: id,
text: message,
});
},
},
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 12,
...createRuntimeOutboundDelegates({
getRuntime: loadMSTeamsChannelRuntime,
sendText: { resolve: (runtime) => runtime.msteamsOutbound.sendText },
sendMedia: { resolve: (runtime) => runtime.msteamsOutbound.sendMedia },
sendPoll: { resolve: (runtime) => runtime.msteamsOutbound.sendPoll },
}),
},
});