diff --git a/extensions/msteams/src/channel.runtime.ts b/extensions/msteams/src/channel.runtime.ts index bc6c36a101b..8547493fee0 100644 --- a/extensions/msteams/src/channel.runtime.ts +++ b/extensions/msteams/src/channel.runtime.ts @@ -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 }, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 4d80c7232ce..c6d5b7d2c7c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -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 ({ 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); }, diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 331760adfce..e9540e3dfea 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -38,6 +38,8 @@ const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024; type SendContext = { sendActivity: (textOrActivity: string | object) => Promise; + updateActivity: (activity: object) => Promise<{ id?: string } | void>; + deleteActivity: (activityId: string) => Promise; }; export type MSTeamsConversationReference = { diff --git a/extensions/msteams/src/sdk-types.ts b/extensions/msteams/src/sdk-types.ts index 0901848a3ba..3a7e00a7b05 100644 --- a/extensions/msteams/src/sdk-types.ts +++ b/extensions/msteams/src/sdk-types.ts @@ -16,4 +16,6 @@ export type MSTeamsTurnContext = { sendActivities: ( activities: Array<{ type: string } & Record>, ) => Promise; + updateActivity: (activity: object) => Promise<{ id?: string } | void>; + deleteActivity: (activityId: string) => Promise; }; diff --git a/extensions/msteams/src/send.test.ts b/extensions/msteams/src/send.test.ts index 0c15cc87f28..29dd3017c4d 100644 --- a/extensions/msteams/src/send.test.ts +++ b/extensions/msteams/src/send.test.ts @@ -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) => { + 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) => { + 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) => { + 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, + }); + }); +}); diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index 2471b6f3c86..60b22a0d7eb 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -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 { + 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 { + 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). */