Skip to content

Commit 763dd83

Browse files
committed
feat(msteams): respond to channel messages by keyword without an @mention
The MS Teams channel is the only group-capable channel that ignores messages.groupChat.mentionPatterns: Discord, Slack and WhatsApp all expose a per-provider mentionPatterns policy that scopes those patterns and treats a text match as an implicit mention, but msteams drops every non-@mention group message at the requireMention gate. This adds the same seam to msteams. - config: channels.msteams.mentionPatterns (MentionPatternsPolicyConfig), matching the discord/slack/whatsapp shape; scopes the global messages.groupChat.mentionPatterns to selected Teams conversations. - handler: build the mention regexes and OR a match into the wasMentioned fact fed to resolveInboundMentionDecision, so a named-without-@ message dispatches while non-matching messages still hit the requireMention skip (no extra model turn). Self-echo guarded so the bot does not trigger on its own posts (with RSC ChannelMessage.Read.Group the bot also receives its own channel messages). - isMSTeamsKeywordMention helper (mirrors discord's mention-state resolution), unit-tested for match/case-insensitive/no-keyword/self-echo/DM/already- mentioned/empty-patterns/empty-text.
1 parent 6cf06e8 commit 763dd83

5 files changed

Lines changed: 113 additions & 3 deletions

File tree

extensions/msteams/src/mentions.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Msteams tests cover mentions plugin behavior.
22
import { describe, expect, it } from "vitest";
3-
import { buildMentionEntities, formatMentionText, parseMentions } from "./mentions.js";
3+
import {
4+
buildMentionEntities,
5+
formatMentionText,
6+
isMSTeamsKeywordMention,
7+
parseMentions,
8+
} from "./mentions.js";
49

510
function requireFirstEntity(result: ReturnType<typeof parseMentions>) {
611
const entity = result.entities[0];
@@ -253,3 +258,49 @@ describe("formatMentionText", () => {
253258
expect(result).toBe("Hey <at>John(Test)</at> and <at>Alice.Smith</at>");
254259
});
255260
});
261+
262+
describe("isMSTeamsKeywordMention", () => {
263+
const regexes = [/\bradar\b/i];
264+
const base = {
265+
isDirectMessage: false,
266+
alreadyMentioned: false,
267+
text: "hey radar, you there?",
268+
fromId: "29:user",
269+
recipientId: "28:bot",
270+
mentionRegexes: regexes,
271+
};
272+
273+
it("treats a keyword match in a channel message as an implicit mention", () => {
274+
expect(isMSTeamsKeywordMention(base)).toBe(true);
275+
});
276+
277+
it("is case-insensitive (regex carries the flag)", () => {
278+
expect(isMSTeamsKeywordMention({ ...base, text: "RADAR, status?" })).toBe(true);
279+
});
280+
281+
it("does not trigger when the keyword is absent", () => {
282+
expect(isMSTeamsKeywordMention({ ...base, text: "the weather is nice today" })).toBe(false);
283+
});
284+
285+
it("does not trigger on the bot's own posts (self-echo guard)", () => {
286+
expect(isMSTeamsKeywordMention({ ...base, fromId: "28:bot", recipientId: "28:bot" })).toBe(
287+
false,
288+
);
289+
});
290+
291+
it("skips direct messages (handled by the normal DM path)", () => {
292+
expect(isMSTeamsKeywordMention({ ...base, isDirectMessage: true })).toBe(false);
293+
});
294+
295+
it("skips messages already @mentioned (handled normally)", () => {
296+
expect(isMSTeamsKeywordMention({ ...base, alreadyMentioned: true })).toBe(false);
297+
});
298+
299+
it("no-ops when no patterns are configured", () => {
300+
expect(isMSTeamsKeywordMention({ ...base, mentionRegexes: [] })).toBe(false);
301+
});
302+
303+
it("no-ops on empty text", () => {
304+
expect(isMSTeamsKeywordMention({ ...base, text: undefined })).toBe(false);
305+
});
306+
});

extensions/msteams/src/mentions.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* 1. Text containing <at>Name</at> tags
66
* 2. entities array with mention metadata
77
*/
8+
import { matchesMentionPatterns } from "openclaw/plugin-sdk/channel-inbound";
89

910
type MentionEntity = {
1011
type: "mention";
@@ -112,3 +113,35 @@ export function formatMentionText(text: string, mentions: MentionInfo[]): string
112113
}
113114
return formatted;
114115
}
116+
117+
/**
118+
* Whether a channel/group message should be treated as an implicit mention because
119+
* its text matches a configured mention pattern (e.g. the agent's name). This lets
120+
* the agent answer when named without an `@` — the message-handler ORs the result
121+
* into the `wasMentioned` fact so the existing requireMention gate still drops
122+
* everything else (no extra model turn).
123+
*
124+
* Guards, in order: never for DMs or already-@mentioned messages (handled by the
125+
* normal path); nothing to match without text or patterns; and never for the bot's
126+
* own posts — with RSC the bot also receives its own channel messages, and a reply
127+
* that happens to contain the keyword would otherwise re-trigger itself.
128+
*/
129+
export function isMSTeamsKeywordMention(params: {
130+
isDirectMessage: boolean;
131+
alreadyMentioned: boolean;
132+
text: string | undefined;
133+
fromId: string | undefined;
134+
recipientId: string | undefined;
135+
mentionRegexes: RegExp[];
136+
}): boolean {
137+
if (params.isDirectMessage || params.alreadyMentioned) {
138+
return false;
139+
}
140+
if (!params.text || params.mentionRegexes.length === 0) {
141+
return false;
142+
}
143+
if (params.fromId && params.fromId === params.recipientId) {
144+
return false;
145+
}
146+
return matchesMentionPatterns(params.text, params.mentionRegexes);
147+
}

extensions/msteams/src/monitor-handler/message-handler.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
33
import {
44
buildChannelInboundEventContext,
5+
buildMentionRegexes,
56
logInboundDrop,
67
resolveInboundMentionDecision,
78
resolveInboundSessionEnvelopeContext,
@@ -37,6 +38,7 @@ import {
3738
type GraphThreadMessage,
3839
} from "../graph-thread.js";
3940
import { resolveGraphChatId } from "../graph-upload.js";
41+
import { isMSTeamsKeywordMention } from "../mentions.js";
4042
import {
4143
extractMSTeamsConversationMessageId,
4244
extractMSTeamsQuoteInfo,
@@ -522,10 +524,26 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
522524
channelConfig,
523525
});
524526
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
527+
// A non-@mention channel message that matches a configured mention pattern
528+
// (e.g. the agent's name) counts as an implicit mention, so the agent answers
529+
// when named without an `@`. Non-matching messages fall through to the
530+
// requireMention gate below and are dropped before any model turn.
531+
const keywordMention = isMSTeamsKeywordMention({
532+
isDirectMessage,
533+
alreadyMentioned: params.wasMentioned,
534+
text,
535+
fromId: activity.from?.id,
536+
recipientId: activity.recipient?.id,
537+
mentionRegexes: buildMentionRegexes(cfg, route.agentId, {
538+
provider: "msteams",
539+
conversationId,
540+
providerPolicy: msteamsCfg?.mentionPatterns,
541+
}),
542+
});
525543
const mentionDecision = resolveInboundMentionDecision({
526544
facts: {
527545
canDetectMention: true,
528-
wasMentioned: params.wasMentioned,
546+
wasMentioned: params.wasMentioned || keywordMention,
529547
implicitMentionKinds: params.implicitMentionKinds,
530548
},
531549
policy: {

src/config/types.msteams.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
ChannelHealthMonitorConfig,
1212
ChannelHeartbeatVisibilityConfig,
1313
} from "./types.channel-health.js";
14-
import type { DmConfig } from "./types.messages.js";
14+
import type { DmConfig, MentionPatternsPolicyConfig } from "./types.messages.js";
1515
import type { SecretInput } from "./types.secrets.js";
1616
import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js";
1717

@@ -163,6 +163,13 @@ export type MSTeamsConfig = {
163163
mediaAuthAllowHosts?: Array<string>;
164164
/** Default: require @mention to respond in channels/groups. */
165165
requireMention?: boolean;
166+
/**
167+
* Scope the global `messages.groupChat.mentionPatterns` to selected Teams
168+
* conversations. A pattern match makes a non-@mention channel message count
169+
* as a mention, so the agent answers when named without an @ (e.g. "Radar, …").
170+
* Same seam as the Discord/Slack/WhatsApp channels.
171+
*/
172+
mentionPatterns?: MentionPatternsPolicyConfig;
166173
/** Max group/channel messages to keep as history context (0 disables). */
167174
historyLimit?: number;
168175
/** Max DM turns to keep as history context. */

src/config/zod-schema.providers-core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,6 +1609,7 @@ export const MSTeamsConfigSchema = z
16091609
mediaAllowHosts: z.array(z.string()).optional(),
16101610
mediaAuthAllowHosts: z.array(z.string()).optional(),
16111611
requireMention: z.boolean().optional(),
1612+
mentionPatterns: MentionPatternsPolicySchema.optional(),
16121613
historyLimit: z.number().int().min(0).optional(),
16131614
dmHistoryLimit: z.number().int().min(0).optional(),
16141615
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),

0 commit comments

Comments
 (0)