feat(msteams): add Teams reaction support#37853
feat(msteams): add Teams reaction support#37853bradgcoza wants to merge 7 commits intoopenclaw:mainfrom
Conversation
…moved) Add handler for Bot Framework reaction events in the MSTeams extension. When a user reacts to a message in Teams (thumbs up, heart, etc.), the extension now: - Extracts reaction type from activity.reactionsAdded/reactionsRemoved - Resolves sender identity and runs allowlist authorization - Routes to the correct agent via resolveAgentRoute() - Fires a system event with emoji-mapped summary (e.g. 'Teams DM: Bradley reacted 👍 on message 1772788362699') Reaction type mapping: like→👍, heart→❤️, laugh→😆, surprised→😮, sad→😢, angry→😡 Files changed: - extensions/msteams/src/monitor-handler.ts: Extended MSTeamsActivityHandler type with onReactionsAdded/onReactionsRemoved, registered handlers - extensions/msteams/src/monitor-handler/reaction-handler.ts: New file implementing reaction event handling with auth, routing, and dispatch
Greptile SummaryThis PR adds Teams reaction event support (
Confidence Score: 2/5
Last reviewed commit: 652d7d0 |
| const senderGroupPolicy = | ||
| effectiveGroupAllowFrom.length > 0 ? "allowlist" : ("open" as const); |
There was a problem hiding this comment.
groupPolicy: "disabled" is not respected for group chats
senderGroupPolicy is always computed as "allowlist" or "open" — it never becomes "disabled". This means reactions from group chats will still be processed and fire system events even when the user has configured msteamsCfg.groupPolicy === "disabled".
In message-handler.ts, the equivalent computation correctly derives "disabled" by first evaluating the configured groupPolicy:
// message-handler.ts (correct approach)
const groupPolicy = !isDirectMessage && msteamsCfg
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
: "disabled";
const senderGroupPolicy =
groupPolicy === "disabled" ? "disabled"
: effectiveGroupAllowFrom.length > 0 ? "allowlist"
: "open";The reaction handler needs the same gate. Without it a groupPolicy: "disabled" config silences regular messages but still leaks reaction events to the agent.
| const senderGroupPolicy = | |
| effectiveGroupAllowFrom.length > 0 ? "allowlist" : ("open" as const); | |
| const configuredGroupPolicy = | |
| !isDirectMessage && msteamsCfg | |
| ? (msteamsCfg.groupPolicy ?? "allowlist") | |
| : "disabled"; | |
| const senderGroupPolicy = | |
| configuredGroupPolicy === "disabled" ? "disabled" : effectiveGroupAllowFrom.length > 0 ? "allowlist" : ("open" as const); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/monitor-handler/reaction-handler.ts
Line: 93-94
Comment:
**`groupPolicy: "disabled"` is not respected for group chats**
`senderGroupPolicy` is always computed as `"allowlist"` or `"open"` — it never becomes `"disabled"`. This means reactions from group chats will still be processed and fire system events even when the user has configured `msteamsCfg.groupPolicy === "disabled"`.
In `message-handler.ts`, the equivalent computation correctly derives `"disabled"` by first evaluating the configured `groupPolicy`:
```ts
// message-handler.ts (correct approach)
const groupPolicy = !isDirectMessage && msteamsCfg
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
: "disabled";
const senderGroupPolicy =
groupPolicy === "disabled" ? "disabled"
: effectiveGroupAllowFrom.length > 0 ? "allowlist"
: "open";
```
The reaction handler needs the same gate. Without it a `groupPolicy: "disabled"` config silences regular messages but still leaks reaction events to the agent.
```suggestion
const configuredGroupPolicy =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupPolicy ?? "allowlist")
: "disabled";
const senderGroupPolicy =
configuredGroupPolicy === "disabled" ? "disabled" : effectiveGroupAllowFrom.length > 0 ? "allowlist" : ("open" as const);
```
How can I resolve this? If you propose a fix, please make it concise.| // Resolve agent route | ||
| const route = core.channel.routing.resolveAgentRoute({ | ||
| cfg, | ||
| channel: "msteams", | ||
| peer: { | ||
| kind: isDirectMessage ? "direct" : isChannel ? "channel" : "group", | ||
| id: isDirectMessage ? senderId : conversationId, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Channel/team-level allowlist is not enforced for reactions
message-handler.ts calls resolveMSTeamsRouteConfig and then explicitly drops events when a team or channel is not on the configured allowlist:
// message-handler.ts
const channelGate = resolveMSTeamsRouteConfig({ cfg: msteamsCfg, teamId, teamName, conversationId, channelName });
// ...
if (groupPolicy === "allowlist") {
if (channelGate.allowlistConfigured && !channelGate.allowed) {
// drop: not in team/channel allowlist
return;
}
if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) {
// drop: allowlist policy but nothing configured
return;
}
}The reaction handler skips resolveMSTeamsRouteConfig entirely. As a result, reactions arriving from a channel/team that is explicitly blocked in config will still reach the agent via enqueueSystemEvent. This is inconsistent with message handling and can surface information from channels the operator intended to block.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/monitor-handler/reaction-handler.ts
Line: 122-130
Comment:
**Channel/team-level allowlist is not enforced for reactions**
`message-handler.ts` calls `resolveMSTeamsRouteConfig` and then explicitly drops events when a team or channel is not on the configured allowlist:
```ts
// message-handler.ts
const channelGate = resolveMSTeamsRouteConfig({ cfg: msteamsCfg, teamId, teamName, conversationId, channelName });
// ...
if (groupPolicy === "allowlist") {
if (channelGate.allowlistConfigured && !channelGate.allowed) {
// drop: not in team/channel allowlist
return;
}
if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) {
// drop: allowlist policy but nothing configured
return;
}
}
```
The reaction handler skips `resolveMSTeamsRouteConfig` entirely. As a result, reactions arriving from a channel/team that is explicitly blocked in config will still reach the agent via `enqueueSystemEvent`. This is inconsistent with message handling and can surface information from channels the operator intended to block.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Pull request overview
Adds Microsoft Teams “reaction” (reactionsAdded/reactionsRemoved) handling to the MSTeams monitor so reactions can be routed to the correct agent and surfaced as system events.
Changes:
- Added a new reaction handler that authorizes the sender, resolves routing, and enqueues a system event summary.
- Extended the MSTeams activity handler surface and registered reaction-added/removed callbacks.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| extensions/msteams/src/monitor-handler/reaction-handler.ts | Implements reaction event parsing, authz, routing, and system-event dispatch. |
| extensions/msteams/src/monitor-handler.ts | Extends handler type and wires reaction handlers into the MSTeams ActivityHandler pipeline. |
| // Authorization — reuse the same allowlist logic as message-handler | ||
| const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing"; | ||
| const storedAllowFrom = await readStoreAllowFromForDmPolicy({ | ||
| provider: "msteams", | ||
| accountId: pairing.accountId, | ||
| dmPolicy, | ||
| readStore: pairing.readStoreForDmPolicy, | ||
| }); | ||
|
|
||
| const dmAllowFrom = msteamsCfg?.allowFrom ?? []; | ||
| const configuredDmAllowFrom = dmAllowFrom.map((v) => String(v)); | ||
| const groupAllowFrom = msteamsCfg?.groupAllowFrom; | ||
| const resolvedAllowFromLists = resolveEffectiveAllowFromLists({ | ||
| allowFrom: configuredDmAllowFrom, | ||
| groupAllowFrom, | ||
| storeAllowFrom: storedAllowFrom, | ||
| dmPolicy, | ||
| }); | ||
| const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom; | ||
| const senderGroupPolicy = | ||
| effectiveGroupAllowFrom.length > 0 ? "allowlist" : ("open" as const); | ||
| const access = resolveDmGroupAccessWithLists({ | ||
| isGroup: !isDirectMessage, | ||
| dmPolicy, | ||
| groupPolicy: isDirectMessage ? dmPolicy : senderGroupPolicy, | ||
| allowFrom: configuredDmAllowFrom, | ||
| groupAllowFrom, | ||
| storeAllowFrom: storedAllowFrom, | ||
| groupAllowFromFallbackToAllowFrom: false, | ||
| isSenderAllowed: (allowFrom) => | ||
| resolveMSTeamsAllowlistMatch({ | ||
| allowFrom, | ||
| senderId, | ||
| senderName, | ||
| allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), | ||
| }).allowed, | ||
| }); |
There was a problem hiding this comment.
Reaction authorization is not aligned with the message handler: this logic ignores msteamsCfg.groupPolicy (including disabled) and the team/channel allowlist gate (resolveMSTeamsRouteConfig). As written, reactions in group/channel conversations can be enqueued even when group messaging is disabled or restricted by team/channel allowlist. Please reuse the same groupPolicy + channelGate checks as message-handler.ts before allowing reactions in non-DM conversations.
| core.system.enqueueSystemEvent(summary, { | ||
| sessionKey: route.sessionKey, | ||
| contextKey: `msteams:reaction:${conversationId}:${direction}:${activity.id ?? Date.now()}`, |
There was a problem hiding this comment.
contextKey falls back to Date.now() when activity.id is missing. This makes reaction events non-deterministic across retries/replays and can create duplicate system events. Consider using a stable fallback (e.g. "unknown") and/or incorporating stable fields like replyToId, senderId, direction, and reaction types, similar to how the message handler uses activity.id ?? "unknown".
| core.system.enqueueSystemEvent(summary, { | |
| sessionKey: route.sessionKey, | |
| contextKey: `msteams:reaction:${conversationId}:${direction}:${activity.id ?? Date.now()}`, | |
| const rawReactionTypes = reactions.map((r) => r.type).sort().join(","); | |
| core.system.enqueueSystemEvent(summary, { | |
| sessionKey: route.sessionKey, | |
| contextKey: `msteams:reaction:${conversationId}:${direction}:${replyToId ?? "none"}:${senderId}:${rawReactionTypes}:${activity.id ?? "unknown"}`, |
| export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) { | ||
| const { cfg, log } = deps; | ||
| const core = getMSTeamsRuntime(); | ||
| const pairing = createScopedPairingAccess({ | ||
| core, | ||
| channel: "msteams", | ||
| accountId: DEFAULT_ACCOUNT_ID, | ||
| }); | ||
| const msteamsCfg = cfg.channels?.msteams; | ||
|
|
||
| return async function handleTeamsReaction( | ||
| context: MSTeamsTurnContext, | ||
| direction: "added" | "removed", | ||
| ) { |
There was a problem hiding this comment.
This handler is newly introduced but there are currently no MSTeams tests exercising reaction events (added/removed), authorization decisions (dmPolicy/groupPolicy/allowlists), and the resulting enqueueSystemEvent payload. Adding a small Vitest suite for createMSTeamsReactionHandler (similar to existing message-handler.*.test.ts) would help prevent regressions.
| const REACTION_EMOJI: Record<string, string> = { | ||
| like: "👍", | ||
| heart: "❤️", | ||
| laugh: "😆", | ||
| surprised: "😮", | ||
| sad: "😢", | ||
| angry: "😡", | ||
| }; |
There was a problem hiding this comment.
PR description says reaction summaries are emoji-mapped using colon codes like :thumbsup:, but the implementation emits raw Unicode emoji (e.g. 👍). Please either update the PR description/docs to match the actual output or change the mapping/formatter to emit the documented :name: form for consistency with the documented behavior.
| onReactionsAdded: ( | ||
| handler: (context: unknown, next: () => Promise<void>) => Promise<void>, | ||
| ) => MSTeamsActivityHandler; | ||
| onReactionsRemoved: ( |
There was a problem hiding this comment.
MSTeamsActivityHandler now requires onReactionsAdded/onReactionsRemoved. This breaks existing MSTeams tests that construct minimal handler stubs (e.g. monitor-handler.file-consent.test.ts's createActivityHandler()), and will also throw at runtime if a stub/older SDK handler lacks these hooks. Either update the stubs/tests to implement these methods, or make the new hooks optional and guard the registrations in registerMSTeamsHandlers.
| onReactionsAdded: ( | |
| handler: (context: unknown, next: () => Promise<void>) => Promise<void>, | |
| ) => MSTeamsActivityHandler; | |
| onReactionsRemoved: ( | |
| onReactionsAdded?: ( | |
| handler: (context: unknown, next: () => Promise<void>) => Promise<void>, | |
| ) => MSTeamsActivityHandler; | |
| onReactionsRemoved?: ( |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 652d7d0ffc
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const senderGroupPolicy = | ||
| effectiveGroupAllowFrom.length > 0 ? "allowlist" : ("open" as const); |
There was a problem hiding this comment.
Honor configured groupPolicy in reaction access checks
senderGroupPolicy is computed solely from whether effectiveGroupAllowFrom is empty, which forces group reactions into open when no list exists and never honors channels.msteams.groupPolicy (including disabled). Message intake explicitly resolves and enforces group policy in extensions/msteams/src/monitor-handler/message-handler.ts:159-236, so this regression allows group/channel reactions to be enqueued even when group traffic is configured to be blocked.
Useful? React with 👍 / 👎.
| const access = resolveDmGroupAccessWithLists({ | ||
| isGroup: !isDirectMessage, | ||
| dmPolicy, | ||
| groupPolicy: isDirectMessage ? dmPolicy : senderGroupPolicy, | ||
| allowFrom: configuredDmAllowFrom, |
There was a problem hiding this comment.
Enforce team/channel allowlist gate for reactions
This authorization path checks sender allowlists but never applies the team/channel route allowlist (teams/channels) that message handling enforces via resolveMSTeamsRouteConfig in extensions/msteams/src/monitor-handler/message-handler.ts:168-247. In deployments that scope MSTeams ingestion to specific teams/channels, reactions from excluded conversations will still pass here and be delivered to enqueueSystemEvent.
Useful? React with 👍 / 👎.
Address Greptile review findings: - Respect groupPolicy: disabled for group chat reactions - Add resolveMSTeamsRouteConfig channelGate check - Propagate disabled through senderGroupPolicy - Add explicit group gating block mirroring message-handler.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e55eb51b7e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| const handleReaction = createMSTeamsReactionHandler(deps); | ||
|
|
||
| handler.onReactionsAdded(async (context, next) => { |
There was a problem hiding this comment.
Guard reaction hook registration against missing SDK methods
registerMSTeamsHandlers now calls handler.onReactionsAdded(...) and handler.onReactionsRemoved(...) unconditionally, but the previous handler contract only required onMessage/onMembersAdded, and existing in-repo stubs (for example in extensions/msteams/src/monitor-handler.file-consent.test.ts) still omit reaction hooks. In any environment where the provided handler object lacks these methods (custom wrappers, older SDK surfaces, or test doubles), initialization throws a TypeError and the Teams monitor cannot start.
Useful? React with 👍 / 👎.
Add onReactionsAdded and onReactionsRemoved to createActivityHandler() mock in file-consent test to satisfy MSTeamsActivityHandler type.
Replace openclaw/plugin-sdk with openclaw/plugin-sdk/msteams in monitor-handler.ts and reaction-handler.ts to satisfy the no-monolithic-plugin-sdk-entry-imports lint rule.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: efaf060b20
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| }).allowed, | ||
| }); | ||
|
|
||
| if (access.decision !== "allow") { |
There was a problem hiding this comment.
Respect open groupPolicy in reaction authorization
createMSTeamsReactionHandler unconditionally returns when access.decision !== "allow", but access is computed with senderGroupPolicy that becomes "allowlist" whenever effectiveGroupAllowFrom is non-empty. In a configuration with channels.msteams.groupPolicy: "open" plus a populated groupAllowFrom, group reactions from users outside that list are incorrectly dropped, even though open policy should allow them; createMSTeamsMessageHandler only applies this pre-check to DMs (message-handler.ts), so this is a reaction-only regression.
Useful? React with 👍 / 👎.
The import used "openclaw/plugin-sdk/msteams" which doesn't exist as a subpath export. Changed to "openclaw/plugin-sdk" to match every other file in the extension.
|
Hi @bradgcoza — thanks for the submission. I’m the new Microsoft Teams maintainer for OpenClaw. Please give me a day or two to work through the open Teams backlog. Also, join the Twitter community for daily MS Teams feedback + updates: https://x.com/i/communities/2031170403607974228 |
|
Closing as superseded by #51646, which implements the same Teams reaction support feature with an updated approach. Thank you for the initial work on this. |
Summary
Add handler for Bot Framework reaction events (
reactionsAdded/reactionsRemoved) in the MSTeams extension.What it does
When a user reacts to a message in Teams (thumbs up, heart, etc.), the extension now:
activity.reactionsAdded/activity.reactionsRemovedresolveAgentRoute()Reaction mapping
Files changed
extensions/msteams/src/monitor-handler.ts— ExtendedMSTeamsActivityHandlertype withonReactionsAdded/onReactionsRemoved, registered handlersextensions/msteams/src/monitor-handler/reaction-handler.ts— New file implementing reaction event handling with auth, routing, and dispatch