Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/automation/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ openclaw hooks info session-memory
| `message:transcribed` | After audio transcription completes |
| `message:preprocessed` | After media and link preprocessing completes or is skipped |
| `message:sent` | Outbound message delivered |
| `message:feedback` | Channel-native reaction or written feedback |

## Writing hooks

Expand Down Expand Up @@ -131,9 +132,11 @@ reply channel and ignore pushed messages.

**Command events** (`command:new`, `command:reset`): `context.sessionEntry`, `context.previousSessionEntry`, `context.commandSource`, `context.workspaceDir`, `context.cfg`.

**Message events** (`message:received`): `context.from`, `context.content`, `context.channelId`, `context.metadata` (provider-specific data including `senderId`, `senderName`, `guildId`). `context.content` prefers a nonblank command body for command-like messages, then falls back to the raw inbound body and generic body; it does not include agent-only enrichment such as thread history or link summaries.
**Message events** (`message:received`): `context.from`, `context.content`, `context.channelId`, `context.senderId`, `context.replyToId`, `context.metadata` (provider-specific data including `senderName`, `guildId`). `context.content` prefers a nonblank command body for command-like messages, then falls back to the raw inbound body and generic body; it does not include agent-only enrichment such as thread history or link summaries.

**Message events** (`message:sent`): `context.to`, `context.content`, `context.success`, `context.channelId`.
**Message events** (`message:sent`): `context.to`, `context.content`, `context.success`, `context.channelId`, and, when the channel can bind delivery to an agent turn, `context.runId`.

**Message events** (`message:feedback`): `context.kind`, `context.providerEventId`, `context.providerActivityId`, `context.providerConversationId`, `context.providerTargetActivityId`, `context.actorId`, and optional `context.reaction` or `context.content`. `providerEventId` distinguishes logical facts when one provider activity carries both a reaction and comment. These provider fields are untrusted input; hooks must validate, bound, and redact them before persistence or command use. The event is emitted only by channels with native feedback support.

**Message events** (`message:transcribed`): `context.transcript`, `context.from`, `context.channelId`, `context.mediaPath`.

Expand Down
35 changes: 35 additions & 0 deletions extensions/msteams/src/feedback-invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coe
import { formatUnknownError } from "./errors.js";
import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js";
import { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js";
import { emitMSTeamsFeedbackHook, normalizeMSTeamsHookTimestamp } from "./message-hooks.js";
import { isFeedbackInvokeAuthorized } from "./monitor-handler.js";
import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js";
import { getMSTeamsRuntime } from "./runtime.js";
Expand Down Expand Up @@ -131,6 +132,40 @@ export async function runMSTeamsFeedbackInvokeHandler(
hasComment: Boolean(userComment),
});

const exactFeedbackIds =
activity.id &&
conversationId !== "unknown" &&
senderId !== "unknown" &&
messageId !== "unknown";
if (exactFeedbackIds) {
emitMSTeamsFeedbackHook({
sessionKey: route.sessionKey,
accountId: route.accountId,
kind: "reaction_added",
occurredAt: normalizeMSTeamsHookTimestamp(activity.timestamp),
providerEventId: `${activity.id}:reaction:${reaction}`,
providerActivityId: activity.id,
providerConversationId: conversationId,
providerTargetActivityId: messageId,
actorId: senderId,
reaction,
});
if (userComment) {
emitMSTeamsFeedbackHook({
sessionKey: route.sessionKey,
accountId: route.accountId,
kind: "feedback_comment",
occurredAt: normalizeMSTeamsHookTimestamp(activity.timestamp),
providerEventId: `${activity.id}:comment`,
providerActivityId: activity.id,
providerConversationId: conversationId,
providerTargetActivityId: messageId,
actorId: senderId,
content: userComment,
});
}
}

// Write feedback event to session transcript
try {
const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, {
Expand Down
116 changes: 116 additions & 0 deletions extensions/msteams/src/message-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Msteams tests cover canonical message and feedback hook emission.
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
runMessageSent: vi.fn(async () => undefined),
triggerInternalHook: vi.fn(async () => undefined),
}));

vi.mock("openclaw/plugin-sdk/hook-runtime", () => ({
buildCanonicalSentMessageHookContext: (params: Record<string, unknown>) => params,
createInternalHookEvent: (
type: string,
action: string,
sessionKey: string,
context: Record<string, unknown>,
) => ({ type, action, sessionKey, context }),
fireAndForgetHook: (promise: Promise<unknown>) => void promise,
toInternalMessageSentContext: (canonical: Record<string, unknown>) => canonical,
toPluginMessageContext: (canonical: Record<string, unknown>) => canonical,
toPluginMessageSentEvent: (canonical: Record<string, unknown>) => canonical,
triggerInternalHook: mocks.triggerInternalHook,
}));

vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
getGlobalHookRunner: () => ({
hasHooks: (name: string) => name === "message_sent",
runMessageSent: mocks.runMessageSent,
}),
}));

import { emitMSTeamsFeedbackHook, emitMSTeamsMessageSentHooks } from "./message-hooks.js";

describe("msteams message hooks", () => {
beforeEach(() => {
mocks.runMessageSent.mockClear();
mocks.triggerInternalHook.mockClear();
});

it("binds an exact provider response id to the agent run", async () => {
emitMSTeamsMessageSentHooks({
sessionKey: "agent:ops:msteams:direct:user",
runId: "run-123",
to: "user:user",
accountId: "ops",
conversationId: "conversation-123",
content: "Response",
messageId: "activity-456",
isGroup: false,
});
await vi.waitFor(() => expect(mocks.runMessageSent).toHaveBeenCalledOnce());

expect(mocks.runMessageSent.mock.calls[0]?.[0]).toMatchObject({
channelId: "msteams",
conversationId: "conversation-123",
messageId: "activity-456",
runId: "run-123",
});
expect(mocks.triggerInternalHook).toHaveBeenCalledWith(
expect.objectContaining({
action: "sent",
sessionKey: "agent:ops:msteams:direct:user",
}),
);
});

it("marks provider feedback fields and content as untrusted", async () => {
emitMSTeamsFeedbackHook({
sessionKey: "agent:ops:msteams:direct:user",
kind: "feedback_comment",
providerEventId: "feedback-123:comment",
providerActivityId: "feedback-123",
providerConversationId: "conversation-123",
providerTargetActivityId: "activity-456",
actorId: "00000000-0000-0000-0000-000000000001",
content: "<script>untrusted</script>",
});
await vi.waitFor(() => expect(mocks.triggerInternalHook).toHaveBeenCalledOnce());

expect(mocks.triggerInternalHook).toHaveBeenCalledWith(
expect.objectContaining({
action: "feedback",
context: expect.objectContaining({
providerActivityId: "feedback-123",
providerEventId: "feedback-123:comment",
providerTargetActivityId: "activity-456",
content: "<script>untrusted</script>",
untrusted: true,
}),
}),
);
});

it("fails closed when exact response or feedback identifiers are absent", () => {
emitMSTeamsMessageSentHooks({
sessionKey: "agent:ops:msteams:direct:user",
to: "user:user",
conversationId: "conversation-123",
content: "Response",
messageId: "unknown",
isGroup: false,
});
emitMSTeamsFeedbackHook({
sessionKey: "agent:ops:msteams:direct:user",
kind: "feedback_comment",
providerEventId: "feedback-123:comment",
providerActivityId: "feedback-123",
providerConversationId: "conversation-123",
providerTargetActivityId: "unknown",
actorId: "00000000-0000-0000-0000-000000000001",
content: "ambiguous",
});

expect(mocks.runMessageSent).not.toHaveBeenCalled();
expect(mocks.triggerInternalHook).not.toHaveBeenCalled();
});
});
125 changes: 125 additions & 0 deletions extensions/msteams/src/message-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Msteams plugin module emits canonical outbound and feedback hook facts.
import {
buildCanonicalSentMessageHookContext,
createInternalHookEvent,
fireAndForgetHook,
toInternalMessageSentContext,
toPluginMessageContext,
toPluginMessageSentEvent,
triggerInternalHook,
} from "openclaw/plugin-sdk/hook-runtime";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";

export type MSTeamsMessageSentHookParams = {
sessionKey: string;
runId?: string;
to: string;
accountId?: string;
conversationId: string;
content: string;
messageId: string;
isGroup: boolean;
groupId?: string;
};

export function emitMSTeamsMessageSentHooks(params: MSTeamsMessageSentHookParams): void {
if (
!params.runId?.trim() ||
!params.messageId.trim() ||
params.messageId === "unknown" ||
!params.conversationId.trim()
) {
return;
}
const canonical = buildCanonicalSentMessageHookContext({
...params,
channelId: "msteams",
success: true,
});
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_sent")) {
fireAndForgetHook(
Promise.resolve(
hookRunner.runMessageSent(
toPluginMessageSentEvent(canonical),
toPluginMessageContext(canonical),
),
),
"msteams: message_sent plugin hook failed",
);
}
fireAndForgetHook(
triggerInternalHook(
createInternalHookEvent(
"message",
"sent",
params.sessionKey,
toInternalMessageSentContext(canonical),
),
),
"msteams: message:sent internal hook failed",
);
}

export type MSTeamsFeedbackHookKind = "reaction_added" | "reaction_removed" | "feedback_comment";

export type MSTeamsFeedbackHookParams = {
sessionKey: string;
accountId?: string;
kind: MSTeamsFeedbackHookKind;
occurredAt?: string;
/** Stable id for this logical sub-event; distinct from its provider activity id. */
providerEventId: string;
providerActivityId: string;
providerConversationId: string;
providerTargetActivityId: string;
actorId: string;
reaction?: string;
content?: string;
};

export function normalizeMSTeamsHookTimestamp(value: unknown): string | undefined {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString();
}
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) {
return value;
}
return undefined;
}

/**
* Emit an untrusted provider fact. Consumers must validate, bound, and redact it.
* Missing provider activity ids are preserved as absent rather than invented.
*/
export function emitMSTeamsFeedbackHook(params: MSTeamsFeedbackHookParams): void {
const exactReferences = [
params.providerEventId,
params.providerActivityId,
params.providerConversationId,
params.providerTargetActivityId,
params.actorId,
];
if (exactReferences.some((value) => !value?.trim() || value === "unknown")) {
return;
}
fireAndForgetHook(
triggerInternalHook(
createInternalHookEvent("message", "feedback", params.sessionKey, {
channelId: "msteams",
accountId: params.accountId,
kind: params.kind,
occurredAt: params.occurredAt,
providerEventId: params.providerEventId,
providerActivityId: params.providerActivityId,
providerConversationId: params.providerConversationId,
providerTargetActivityId: params.providerTargetActivityId,
actorId: params.actorId,
reaction: params.reaction,
content: params.content,
untrusted: true,
}),
),
"msteams: message:feedback internal hook failed",
);
}
25 changes: 25 additions & 0 deletions extensions/msteams/src/messenger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ describe("msteams messenger", () => {

it("sends thread messages via the provided context", async () => {
const sent: string[] = [];
const onSent = vi.fn();
const ctx = {
sendActivity: createRecordedSendActivity(sent),
};
Expand All @@ -313,10 +314,34 @@ describe("msteams messenger", () => {
conversationRef: baseRef,
context: ctx,
messages: [{ text: "one" }, { text: "two" }],
onSent,
});

expect(sent).toEqual(["one", "two"]);
expect(ids).toEqual(["id:one", "id:two"]);
expect(onSent.mock.calls).toEqual([
[{ messageId: "id:one", message: { text: "one" } }],
[{ messageId: "id:two", message: { text: "two" } }],
]);
});

it("does not retry a confirmed send when an observation callback throws", async () => {
const sendActivity = vi.fn(async () => ({ id: "id:one" }));

const ids = await sendMSTeamsMessages({
replyStyle: "thread",
app: createMockApp(),
appId: "app123",
conversationRef: baseRef,
context: { sendActivity },
messages: [{ text: "one" }],
onSent: () => {
throw new Error("observer failed");
},
});

expect(ids).toEqual(["id:one"]);
expect(sendActivity).toHaveBeenCalledOnce();
});

it("sends top-level messages via proactive send context", async () => {
Expand Down
10 changes: 10 additions & 0 deletions extensions/msteams/src/messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,8 @@ export async function sendMSTeamsMessages(params: {
/** Enable the Teams feedback loop (thumbs up/down) on sent messages. */
feedbackLoopEnabled?: boolean;
serviceUrlBoundary?: MSTeamsSdkCloudOptions;
/** Observe each provider-confirmed activity without changing the return contract. */
onSent?: (sent: { messageId: string; message: MSTeamsRenderedMessage }) => void;
}): Promise<string[]> {
const messages = params.messages.filter(
(m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
Expand Down Expand Up @@ -507,6 +509,14 @@ export async function sendMSTeamsMessages(params: {
},
);
const messageId = extractMessageId(response) ?? "unknown";
if (messageId !== "unknown") {
try {
params.onSent?.({ messageId, message });
} catch {
// Observability callbacks must never turn a provider-confirmed send into
// a retry, which could duplicate the user-visible message.
}
}

// Store the activity ID so the accept handler can replace the consent card in-place
if (pendingUploadId && messageId !== "unknown") {
Expand Down
Loading
Loading