mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 17:01:53 +03:00
MS Teams: add user mention support
- Add mention parsing and validation logic - Handle mention entities with proper whitespace - Validate mention IDs to prevent false positives from code snippets - Use fake placeholders in tests for privacy
This commit is contained in:
committed by
Peter Steinberger
parent
edfdd12d37
commit
7c6d6ce06f
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildMentionEntities, formatMentionText, parseMentions } from "./mentions.js";
|
||||||
|
|
||||||
|
describe("parseMentions", () => {
|
||||||
|
it("parses single mention", () => {
|
||||||
|
const result = parseMentions("Hello @[John Doe](28:a1b2c3-d4e5f6)!");
|
||||||
|
|
||||||
|
expect(result.text).toBe("Hello <at>John Doe</at>!");
|
||||||
|
expect(result.entities).toHaveLength(1);
|
||||||
|
expect(result.entities[0]).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
text: "<at>John Doe</at>",
|
||||||
|
mentioned: {
|
||||||
|
id: "28:a1b2c3-d4e5f6",
|
||||||
|
name: "John Doe",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses multiple mentions", () => {
|
||||||
|
const result = parseMentions("Hey @[Alice](28:aaa) and @[Bob](28:bbb), can you review this?");
|
||||||
|
|
||||||
|
expect(result.text).toBe("Hey <at>Alice</at> and <at>Bob</at>, can you review this?");
|
||||||
|
expect(result.entities).toHaveLength(2);
|
||||||
|
expect(result.entities[0]).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
text: "<at>Alice</at>",
|
||||||
|
mentioned: {
|
||||||
|
id: "28:aaa",
|
||||||
|
name: "Alice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.entities[1]).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
text: "<at>Bob</at>",
|
||||||
|
mentioned: {
|
||||||
|
id: "28:bbb",
|
||||||
|
name: "Bob",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles text without mentions", () => {
|
||||||
|
const result = parseMentions("Hello world!");
|
||||||
|
|
||||||
|
expect(result.text).toBe("Hello world!");
|
||||||
|
expect(result.entities).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty text", () => {
|
||||||
|
const result = parseMentions("");
|
||||||
|
|
||||||
|
expect(result.text).toBe("");
|
||||||
|
expect(result.entities).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mention with spaces in name", () => {
|
||||||
|
const result = parseMentions("@[John Peter Smith](28:a1b2c3)");
|
||||||
|
|
||||||
|
expect(result.text).toBe("<at>John Peter Smith</at>");
|
||||||
|
expect(result.entities[0]?.mentioned.name).toBe("John Peter Smith");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from id and name", () => {
|
||||||
|
const result = parseMentions("@[ John Doe ]( 28:a1b2c3 )");
|
||||||
|
|
||||||
|
expect(result.entities[0]).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
text: "<at>John Doe</at>",
|
||||||
|
mentioned: {
|
||||||
|
id: "28:a1b2c3",
|
||||||
|
name: "John Doe",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Japanese characters in mention at start of message", () => {
|
||||||
|
const input = "@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!";
|
||||||
|
const result = parseMentions(input);
|
||||||
|
|
||||||
|
expect(result.text).toBe("<at>タナカ タロウ</at> スキル化完了しました!");
|
||||||
|
expect(result.entities).toHaveLength(1);
|
||||||
|
expect(result.entities[0]).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
text: "<at>タナカ タロウ</at>",
|
||||||
|
mentioned: {
|
||||||
|
id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
name: "タナカ タロウ",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify entity text exactly matches what's in the formatted text
|
||||||
|
const entityText = result.entities[0]?.text;
|
||||||
|
expect(result.text).toContain(entityText);
|
||||||
|
expect(result.text.indexOf(entityText)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips mention-like patterns with non-Teams IDs (e.g. in code blocks)", () => {
|
||||||
|
// This reproduces the actual failing payload: the message contains a real mention
|
||||||
|
// plus `@[表示名](ユーザーID)` as documentation text inside backticks.
|
||||||
|
const input =
|
||||||
|
"@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!📋\n\n" +
|
||||||
|
"**作成したスキル:** `teams-mention`\n" +
|
||||||
|
"- 機能: Teamsでのメンション形式 `@[表示名](ユーザーID)`\n\n" +
|
||||||
|
"**追加対応:**\n" +
|
||||||
|
"- ユーザーのID `a1b2c3d4-e5f6-7890-abcd-ef1234567890` を登録済み";
|
||||||
|
const result = parseMentions(input);
|
||||||
|
|
||||||
|
// Only the real mention should be parsed; the documentation example should be left as-is
|
||||||
|
expect(result.entities).toHaveLength(1);
|
||||||
|
expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||||
|
expect(result.entities[0]?.mentioned.name).toBe("タナカ タロウ");
|
||||||
|
|
||||||
|
// The documentation pattern must remain untouched in the text
|
||||||
|
expect(result.text).toContain("`@[表示名](ユーザーID)`");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts Bot Framework IDs (28:xxx)", () => {
|
||||||
|
const result = parseMentions("@[Bot](28:abc-123)");
|
||||||
|
expect(result.entities).toHaveLength(1);
|
||||||
|
expect(result.entities[0]?.mentioned.id).toBe("28:abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts AAD object IDs (UUIDs)", () => {
|
||||||
|
const result = parseMentions("@[User](a1b2c3d4-e5f6-7890-abcd-ef1234567890)");
|
||||||
|
expect(result.entities).toHaveLength(1);
|
||||||
|
expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-ID strings as mention targets", () => {
|
||||||
|
const result = parseMentions("See @[docs](https://example.com) for details");
|
||||||
|
expect(result.entities).toHaveLength(0);
|
||||||
|
// Original text preserved
|
||||||
|
expect(result.text).toBe("See @[docs](https://example.com) for details");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildMentionEntities", () => {
|
||||||
|
it("builds entities from mention info", () => {
|
||||||
|
const mentions = [
|
||||||
|
{ id: "28:aaa", name: "Alice" },
|
||||||
|
{ id: "28:bbb", name: "Bob" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const entities = buildMentionEntities(mentions);
|
||||||
|
|
||||||
|
expect(entities).toHaveLength(2);
|
||||||
|
expect(entities[0]).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
text: "<at>Alice</at>",
|
||||||
|
mentioned: {
|
||||||
|
id: "28:aaa",
|
||||||
|
name: "Alice",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(entities[1]).toEqual({
|
||||||
|
type: "mention",
|
||||||
|
text: "<at>Bob</at>",
|
||||||
|
mentioned: {
|
||||||
|
id: "28:bbb",
|
||||||
|
name: "Bob",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty list", () => {
|
||||||
|
const entities = buildMentionEntities([]);
|
||||||
|
expect(entities).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatMentionText", () => {
|
||||||
|
it("formats text with single mention", () => {
|
||||||
|
const text = "Hello @John!";
|
||||||
|
const mentions = [{ id: "28:xxx", name: "John" }];
|
||||||
|
|
||||||
|
const result = formatMentionText(text, mentions);
|
||||||
|
|
||||||
|
expect(result).toBe("Hello <at>John</at>!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats text with multiple mentions", () => {
|
||||||
|
const text = "Hey @Alice and @Bob";
|
||||||
|
const mentions = [
|
||||||
|
{ id: "28:aaa", name: "Alice" },
|
||||||
|
{ id: "28:bbb", name: "Bob" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = formatMentionText(text, mentions);
|
||||||
|
|
||||||
|
expect(result).toBe("Hey <at>Alice</at> and <at>Bob</at>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles case-insensitive matching", () => {
|
||||||
|
const text = "Hey @alice and @ALICE";
|
||||||
|
const mentions = [{ id: "28:aaa", name: "Alice" }];
|
||||||
|
|
||||||
|
const result = formatMentionText(text, mentions);
|
||||||
|
|
||||||
|
expect(result).toBe("Hey <at>Alice</at> and <at>Alice</at>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles text without mentions", () => {
|
||||||
|
const text = "Hello world";
|
||||||
|
const mentions = [{ id: "28:xxx", name: "John" }];
|
||||||
|
|
||||||
|
const result = formatMentionText(text, mentions);
|
||||||
|
|
||||||
|
expect(result).toBe("Hello world");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* MS Teams mention handling utilities.
|
||||||
|
*
|
||||||
|
* Mentions in Teams require:
|
||||||
|
* 1. Text containing <at>Name</at> tags
|
||||||
|
* 2. entities array with mention metadata
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MentionEntity = {
|
||||||
|
type: "mention";
|
||||||
|
text: string;
|
||||||
|
mentioned: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MentionInfo = {
|
||||||
|
/** User/bot ID (e.g., "28:xxx" or AAD object ID) */
|
||||||
|
id: string;
|
||||||
|
/** Display name */
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an ID looks like a valid Teams user/bot identifier.
|
||||||
|
* Accepts:
|
||||||
|
* - Bot Framework IDs: "28:xxx..." or "29:xxx..."
|
||||||
|
* - AAD object IDs (UUIDs): "d5318c29-33ac-4e6b-bd42-57b8b793908f"
|
||||||
|
*
|
||||||
|
* This prevents false positives from text like `@[表示名](ユーザーID)`
|
||||||
|
* that appears in code snippets or documentation within messages.
|
||||||
|
*/
|
||||||
|
const TEAMS_ID_PATTERN =
|
||||||
|
/^(?:\d+:[a-f0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i;
|
||||||
|
|
||||||
|
function isValidTeamsId(id: string): boolean {
|
||||||
|
return TEAMS_ID_PATTERN.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse mentions from text in the format @[Name](id).
|
||||||
|
* Example: "Hello @[John Doe](28:xxx-yyy-zzz)!"
|
||||||
|
*
|
||||||
|
* Only matches where the id looks like a real Teams user/bot ID are treated
|
||||||
|
* as mentions. This avoids false positives from documentation or code samples
|
||||||
|
* embedded in the message (e.g. `@[表示名](ユーザーID)` in backticks).
|
||||||
|
*
|
||||||
|
* Returns both the formatted text with <at> tags and the entities array.
|
||||||
|
*/
|
||||||
|
export function parseMentions(text: string): {
|
||||||
|
text: string;
|
||||||
|
entities: MentionEntity[];
|
||||||
|
} {
|
||||||
|
const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
const entities: MentionEntity[] = [];
|
||||||
|
|
||||||
|
// Replace @[Name](id) with <at>Name</at> only for valid Teams IDs
|
||||||
|
const formattedText = text.replace(mentionPattern, (match, name, id) => {
|
||||||
|
const trimmedId = id.trim();
|
||||||
|
|
||||||
|
// Skip matches where the id doesn't look like a real Teams identifier
|
||||||
|
if (!isValidTeamsId(trimmedId)) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const mentionTag = `<at>${trimmedName}</at>`;
|
||||||
|
entities.push({
|
||||||
|
type: "mention",
|
||||||
|
text: mentionTag,
|
||||||
|
mentioned: {
|
||||||
|
id: trimmedId,
|
||||||
|
name: trimmedName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mentionTag;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: formattedText,
|
||||||
|
entities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build mention entities array from a list of mentions.
|
||||||
|
* Use this when you already have the mention info and formatted text.
|
||||||
|
*/
|
||||||
|
export function buildMentionEntities(mentions: MentionInfo[]): MentionEntity[] {
|
||||||
|
return mentions.map((mention) => ({
|
||||||
|
type: "mention",
|
||||||
|
text: `<at>${mention.name}</at>`,
|
||||||
|
mentioned: {
|
||||||
|
id: mention.id,
|
||||||
|
name: mention.name,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format text with mentions using <at> tags.
|
||||||
|
* This is a convenience function when you want to manually format mentions.
|
||||||
|
*/
|
||||||
|
export function formatMentionText(text: string, mentions: MentionInfo[]): string {
|
||||||
|
let formatted = text;
|
||||||
|
for (const mention of mentions) {
|
||||||
|
// Replace @Name or @name with <at>Name</at>
|
||||||
|
const namePattern = new RegExp(`@${mention.name}`, "gi");
|
||||||
|
formatted = formatted.replace(namePattern, `<at>${mention.name}</at>`);
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
uploadAndShareSharePoint,
|
uploadAndShareSharePoint,
|
||||||
} from "./graph-upload.js";
|
} from "./graph-upload.js";
|
||||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
||||||
|
import { parseMentions } from "./mentions.js";
|
||||||
import { getMSTeamsRuntime } from "./runtime.js";
|
import { getMSTeamsRuntime } from "./runtime.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,7 +270,14 @@ async function buildActivity(
|
|||||||
const activity: Record<string, unknown> = { type: "message" };
|
const activity: Record<string, unknown> = { type: "message" };
|
||||||
|
|
||||||
if (msg.text) {
|
if (msg.text) {
|
||||||
activity.text = msg.text;
|
// Parse mentions from text (format: @[Name](id))
|
||||||
|
const { text: formattedText, entities } = parseMentions(msg.text);
|
||||||
|
activity.text = formattedText;
|
||||||
|
|
||||||
|
// Add mention entities if any mentions were found
|
||||||
|
if (entities.length > 0) {
|
||||||
|
activity.entities = entities;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.mediaUrl) {
|
if (msg.mediaUrl) {
|
||||||
|
|||||||
Reference in New Issue
Block a user