msteams: add message edit and delete support (#49925)

- Add edit/delete action handlers with toolContext.currentChannelId
  fallback for in-thread edits/deletes without explicit target
- Add editMessageMSTeams/deleteMessageMSTeams to channel runtime
- Add updateActivity/deleteActivity to SendContext and MSTeamsTurnContext
- Extend content param with text/content/message fallback chain
- Update test mocks for new SendContext shape

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sudie-codes 2026-03-23 21:42:04 -07:00 committed by GitHub
parent 9f5d286caf
commit 6e970010f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 435 additions and 3 deletions

View File

@ -5,10 +5,14 @@ import {
import { msteamsOutbound as msteamsOutboundImpl } from "./outbound.js";
import { probeMSTeams as probeMSTeamsImpl } from "./probe.js";
import {
deleteMessageMSTeams as deleteMessageMSTeamsImpl,
editMessageMSTeams as editMessageMSTeamsImpl,
sendAdaptiveCardMSTeams as sendAdaptiveCardMSTeamsImpl,
sendMessageMSTeams as sendMessageMSTeamsImpl,
} from "./send.js";
export const msTeamsChannelRuntime = {
deleteMessageMSTeams: deleteMessageMSTeamsImpl,
editMessageMSTeams: editMessageMSTeamsImpl,
listMSTeamsDirectoryGroupsLive: listMSTeamsDirectoryGroupsLiveImpl,
listMSTeamsDirectoryPeersLive: listMSTeamsDirectoryPeersLiveImpl,
msteamsOutbound: { ...msteamsOutboundImpl },

View File

@ -123,7 +123,7 @@ function describeMSTeamsMessageTool({
cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
return {
actions: enabled ? (["poll"] satisfies ChannelMessageActionName[]) : [],
actions: enabled ? (["poll", "edit", "delete"] satisfies ChannelMessageActionName[]) : [],
capabilities: enabled ? ["cards"] : [],
schema: enabled
? {
@ -405,6 +405,106 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
details: { ok: true, channel: "msteams", messageId: result.messageId },
};
}
if (ctx.action === "edit") {
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
const messageId =
typeof ctx.params.messageId === "string" ? ctx.params.messageId.trim() : "";
const content =
typeof ctx.params.text === "string"
? ctx.params.text
: typeof ctx.params.content === "string"
? ctx.params.content
: typeof ctx.params.message === "string"
? ctx.params.message
: "";
if (!to || !messageId) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "Edit requires a target (to) and messageId.",
},
],
details: { error: "Edit requires a target (to) and messageId." },
};
}
if (!content) {
return {
isError: true,
content: [{ type: "text" as const, text: "Edit requires content." }],
details: { error: "Edit requires content." },
};
}
const { editMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await editMessageMSTeams({
cfg: ctx.cfg,
to,
activityId: messageId,
text: content,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
channel: "msteams",
conversationId: result.conversationId,
}),
},
],
details: { ok: true, channel: "msteams" },
};
}
if (ctx.action === "delete") {
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: (ctx.toolContext?.currentChannelId?.trim() ?? "");
const messageId =
typeof ctx.params.messageId === "string" ? ctx.params.messageId.trim() : "";
if (!to || !messageId) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "Delete requires a target (to) and messageId.",
},
],
details: { error: "Delete requires a target (to) and messageId." },
};
}
const { deleteMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await deleteMessageMSTeams({
cfg: ctx.cfg,
to,
activityId: messageId,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
channel: "msteams",
conversationId: result.conversationId,
}),
},
],
details: { ok: true, channel: "msteams" },
};
}
// Return null to fall through to default handler
return null as never;
},

View File

@ -99,6 +99,8 @@ const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
@ -175,6 +177,8 @@ describe("msteams messenger", () => {
}
throw new TypeError(REVOCATION_ERROR);
},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
}
@ -191,6 +195,8 @@ describe("msteams messenger", () => {
const sent: string[] = [];
const ctx = {
sendActivity: createRecordedSendActivity(sent),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
const adapter = createNoopAdapter();
@ -215,6 +221,8 @@ describe("msteams messenger", () => {
seen.reference = reference;
await logic({
sendActivity: createRecordedSendActivity(seen.texts),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
@ -253,6 +261,8 @@ describe("msteams messenger", () => {
sent.push(activity as { text?: string; entities?: unknown[] });
return { id: "id:one" };
},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
const adapter = createNoopAdapter();
@ -304,6 +314,8 @@ describe("msteams messenger", () => {
const ctx = {
sendActivity: createRecordedSendActivity(attempts, 429),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
const adapter = createNoopAdapter();
@ -328,6 +340,8 @@ describe("msteams messenger", () => {
sendActivity: async () => {
throw Object.assign(new Error("bad request"), { statusCode: 400 });
},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
};
const adapter = createNoopAdapter();
@ -389,7 +403,11 @@ describe("msteams messenger", () => {
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
await logic({
sendActivity: createRecordedSendActivity(attempts, 503),
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
},
process: async () => {},
updateActivity: noopUpdateActivity,
@ -425,6 +443,8 @@ describe("msteams messenger", () => {
batchTexts.push(text ?? "");
return { id: `id:${text ?? ""}` };
},
updateActivity: noopUpdateActivity,
deleteActivity: noopDeleteActivity,
});
conversationCallTexts.push(batchTexts);
},

View File

@ -38,6 +38,8 @@ const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024;
type SendContext = {
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
updateActivity: (activity: object) => Promise<{ id?: string } | void>;
deleteActivity: (activityId: string) => Promise<void>;
};
export type MSTeamsConversationReference = {

View File

@ -16,4 +16,6 @@ export type MSTeamsTurnContext = {
sendActivities: (
activities: Array<{ type: string } & Record<string, unknown>>,
) => Promise<unknown>;
updateActivity: (activity: object) => Promise<{ id?: string } | void>;
deleteActivity: (activityId: string) => Promise<void>;
};

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { sendMessageMSTeams } from "./send.js";
import { deleteMessageMSTeams, editMessageMSTeams, sendMessageMSTeams } from "./send.js";
const mockState = vi.hoisted(() => ({
loadOutboundMediaFromUrl: vi.fn(),
@ -258,3 +258,197 @@ describe("sendMessageMSTeams", () => {
);
});
});
describe("editMessageMSTeams", () => {
beforeEach(() => {
mockState.resolveMSTeamsSendContext.mockReset();
});
it("calls continueConversation and updateActivity with correct params", async () => {
const mockUpdateActivity = vi.fn();
const mockContinueConversation = vi.fn(
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
await logic({
sendActivity: vi.fn(),
updateActivity: mockUpdateActivity,
deleteActivity: vi.fn(),
});
},
);
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: { continueConversation: mockContinueConversation },
appId: "app-id",
conversationId: "19:conversation@thread.tacv2",
ref: {
user: { id: "user-1" },
agent: { id: "agent-1" },
conversation: { id: "19:conversation@thread.tacv2", conversationType: "personal" },
channelId: "msteams",
},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "personal",
tokenProvider: {},
});
const result = await editMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "conversation:19:conversation@thread.tacv2",
activityId: "activity-123",
text: "Updated message text",
});
expect(result.conversationId).toBe("19:conversation@thread.tacv2");
expect(mockContinueConversation).toHaveBeenCalledTimes(1);
expect(mockContinueConversation).toHaveBeenCalledWith(
"app-id",
expect.objectContaining({ activityId: undefined }),
expect.any(Function),
);
expect(mockUpdateActivity).toHaveBeenCalledWith({
type: "message",
id: "activity-123",
text: "Updated message text",
});
});
it("throws a descriptive error when continueConversation fails", async () => {
const mockContinueConversation = vi.fn().mockRejectedValue(new Error("Service unavailable"));
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: { continueConversation: mockContinueConversation },
appId: "app-id",
conversationId: "19:conversation@thread.tacv2",
ref: {
user: { id: "user-1" },
agent: { id: "agent-1" },
conversation: { id: "19:conversation@thread.tacv2" },
channelId: "msteams",
},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "personal",
tokenProvider: {},
});
await expect(
editMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "conversation:19:conversation@thread.tacv2",
activityId: "activity-123",
text: "Updated text",
}),
).rejects.toThrow("msteams edit failed");
});
});
describe("deleteMessageMSTeams", () => {
beforeEach(() => {
mockState.resolveMSTeamsSendContext.mockReset();
});
it("calls continueConversation and deleteActivity with correct activityId", async () => {
const mockDeleteActivity = vi.fn();
const mockContinueConversation = vi.fn(
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
await logic({
sendActivity: vi.fn(),
updateActivity: vi.fn(),
deleteActivity: mockDeleteActivity,
});
},
);
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: { continueConversation: mockContinueConversation },
appId: "app-id",
conversationId: "19:conversation@thread.tacv2",
ref: {
user: { id: "user-1" },
agent: { id: "agent-1" },
conversation: { id: "19:conversation@thread.tacv2", conversationType: "groupChat" },
channelId: "msteams",
},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "groupChat",
tokenProvider: {},
});
const result = await deleteMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "conversation:19:conversation@thread.tacv2",
activityId: "activity-456",
});
expect(result.conversationId).toBe("19:conversation@thread.tacv2");
expect(mockContinueConversation).toHaveBeenCalledTimes(1);
expect(mockContinueConversation).toHaveBeenCalledWith(
"app-id",
expect.objectContaining({ activityId: undefined }),
expect.any(Function),
);
expect(mockDeleteActivity).toHaveBeenCalledWith("activity-456");
});
it("throws a descriptive error when continueConversation fails", async () => {
const mockContinueConversation = vi.fn().mockRejectedValue(new Error("Not found"));
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: { continueConversation: mockContinueConversation },
appId: "app-id",
conversationId: "19:conversation@thread.tacv2",
ref: {
user: { id: "user-1" },
agent: { id: "agent-1" },
conversation: { id: "19:conversation@thread.tacv2" },
channelId: "msteams",
},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "personal",
tokenProvider: {},
});
await expect(
deleteMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "conversation:19:conversation@thread.tacv2",
activityId: "activity-456",
}),
).rejects.toThrow("msteams delete failed");
});
it("passes the appId and proactive ref to continueConversation", async () => {
const mockContinueConversation = vi.fn(
async (_appId: string, _ref: unknown, logic: (ctx: unknown) => Promise<void>) => {
await logic({
sendActivity: vi.fn(),
updateActivity: vi.fn(),
deleteActivity: vi.fn(),
});
},
);
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: { continueConversation: mockContinueConversation },
appId: "my-app-id",
conversationId: "19:conv@thread.tacv2",
ref: {
activityId: "original-activity",
user: { id: "user-1" },
agent: { id: "agent-1" },
conversation: { id: "19:conv@thread.tacv2" },
channelId: "msteams",
},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "personal",
tokenProvider: {},
});
await deleteMessageMSTeams({
cfg: {} as OpenClawConfig,
to: "conversation:19:conv@thread.tacv2",
activityId: "activity-789",
});
// appId should be forwarded correctly
expect(mockContinueConversation.mock.calls[0]?.[0]).toBe("my-app-id");
// activityId on the proactive ref should be cleared (undefined) — proactive pattern
expect(mockContinueConversation.mock.calls[0]?.[1]).toMatchObject({
activityId: undefined,
});
});
});

View File

@ -511,6 +511,116 @@ export async function sendAdaptiveCardMSTeams(
};
}
export type EditMSTeamsMessageParams = {
/** Full config (for credentials) */
cfg: OpenClawConfig;
/** Conversation ID or user ID */
to: string;
/** Activity ID of the message to edit */
activityId: string;
/** New message text */
text: string;
};
export type EditMSTeamsMessageResult = {
conversationId: string;
};
export type DeleteMSTeamsMessageParams = {
/** Full config (for credentials) */
cfg: OpenClawConfig;
/** Conversation ID or user ID */
to: string;
/** Activity ID of the message to delete */
activityId: string;
};
export type DeleteMSTeamsMessageResult = {
conversationId: string;
};
/**
* Edit (update) a previously sent message in a Teams conversation.
*
* Uses the Bot Framework `continueConversation` `updateActivity` flow
* for proactive edits outside of the original turn context.
*/
export async function editMessageMSTeams(
params: EditMSTeamsMessageParams,
): Promise<EditMSTeamsMessageResult> {
const { cfg, to, activityId, text } = params;
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
cfg,
to,
});
log.debug?.("editing proactive message", { conversationId, activityId, textLength: text.length });
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
await ctx.updateActivity({
type: "message",
id: activityId,
text,
});
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams edit failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
log.info("edited proactive message", { conversationId, activityId });
return { conversationId };
}
/**
* Delete a previously sent message in a Teams conversation.
*
* Uses the Bot Framework `continueConversation` `deleteActivity` flow
* for proactive deletes outside of the original turn context.
*/
export async function deleteMessageMSTeams(
params: DeleteMSTeamsMessageParams,
): Promise<DeleteMSTeamsMessageResult> {
const { cfg, to, activityId } = params;
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
cfg,
to,
});
log.debug?.("deleting proactive message", { conversationId, activityId });
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
await ctx.deleteActivity(activityId);
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams delete failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
log.info("deleted proactive message", { conversationId, activityId });
return { conversationId };
}
/**
* List all known conversation references (for debugging/CLI).
*/