Skip to content

Commit 9f5d286

Browse files
authored
msteams: extract structured quote/reply context (#51647)
* msteams: extract structured quote/reply context from Teams HTML attachments * msteams: address PR #51647 review feedback
1 parent 8c89d0e commit 9f5d286

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

extensions/msteams/src/inbound.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
decodeHtmlEntities,
4+
extractMSTeamsQuoteInfo,
5+
htmlToPlainText,
36
normalizeMSTeamsConversationId,
47
parseMSTeamsActivityTimestamp,
58
stripMSTeamsMentionTags,
@@ -66,4 +69,153 @@ describe("msteams inbound", () => {
6669
).toBe(false);
6770
});
6871
});
72+
73+
describe("decodeHtmlEntities", () => {
74+
it("decodes common entities", () => {
75+
expect(decodeHtmlEntities("&amp;&lt;&gt;&quot;&#39;&#x27;&nbsp;")).toBe("&<>\"'' ");
76+
});
77+
78+
it("leaves plain text unchanged", () => {
79+
expect(decodeHtmlEntities("hello world")).toBe("hello world");
80+
});
81+
82+
it("prevents double-decoding: &amp;lt; should become &lt; not <", () => {
83+
// If &amp; were decoded first, &amp;lt; → &lt; → < (wrong).
84+
// With &amp; decoded last, &amp;lt; stays as &lt; (correct).
85+
expect(decodeHtmlEntities("&amp;lt;b&amp;gt;")).toBe("&lt;b&gt;");
86+
});
87+
});
88+
89+
describe("htmlToPlainText", () => {
90+
it("strips tags and decodes entities", () => {
91+
expect(htmlToPlainText("<strong>Hello &amp; world</strong>")).toBe("Hello & world");
92+
});
93+
94+
it("collapses whitespace from tag removal", () => {
95+
expect(htmlToPlainText("<p>foo</p><p>bar</p>")).toBe("foo bar");
96+
});
97+
98+
it("trims leading and trailing whitespace", () => {
99+
expect(htmlToPlainText(" <span>hi</span> ")).toBe("hi");
100+
});
101+
});
102+
103+
describe("extractMSTeamsQuoteInfo", () => {
104+
const replyAttachment = (overrides?: { content?: string; contentType?: string }) => ({
105+
contentType: overrides?.contentType ?? "text/html",
106+
content:
107+
overrides?.content ??
108+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
109+
'<strong itemprop="mri">Alice</strong>' +
110+
'<p itemprop="copy">Hello world</p>' +
111+
"</blockquote>",
112+
});
113+
114+
it("extracts sender and body from a Teams reply attachment", () => {
115+
const result = extractMSTeamsQuoteInfo([replyAttachment()]);
116+
expect(result).toEqual({ sender: "Alice", body: "Hello world" });
117+
});
118+
119+
it("returns undefined for empty attachments array", () => {
120+
expect(extractMSTeamsQuoteInfo([])).toBeUndefined();
121+
});
122+
123+
it("returns undefined when no reply blockquote is present", () => {
124+
expect(
125+
extractMSTeamsQuoteInfo([{ contentType: "text/html", content: "<p>just a message</p>" }]),
126+
).toBeUndefined();
127+
});
128+
129+
it("uses 'unknown' as sender when sender element is absent", () => {
130+
const result = extractMSTeamsQuoteInfo([
131+
{
132+
contentType: "text/html",
133+
content:
134+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
135+
'<p itemprop="copy">quoted text</p>' +
136+
"</blockquote>",
137+
},
138+
]);
139+
expect(result).toEqual({ sender: "unknown", body: "quoted text" });
140+
});
141+
142+
it("returns undefined when body element is absent", () => {
143+
const result = extractMSTeamsQuoteInfo([
144+
{
145+
contentType: "text/html",
146+
content:
147+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
148+
'<strong itemprop="mri">Alice</strong>' +
149+
"</blockquote>",
150+
},
151+
]);
152+
expect(result).toBeUndefined();
153+
});
154+
155+
it("decodes HTML entities in body text", () => {
156+
const result = extractMSTeamsQuoteInfo([
157+
{
158+
contentType: "text/html",
159+
content:
160+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
161+
'<strong itemprop="mri">Bob</strong>' +
162+
'<p itemprop="copy">2 &lt; 3 &amp; 4 &gt; 1</p>' +
163+
"</blockquote>",
164+
},
165+
]);
166+
expect(result).toEqual({ sender: "Bob", body: "2 < 3 & 4 > 1" });
167+
});
168+
169+
it("handles multiline body by collapsing whitespace", () => {
170+
const result = extractMSTeamsQuoteInfo([
171+
{
172+
contentType: "text/html",
173+
content:
174+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
175+
'<strong itemprop="mri">Carol</strong>' +
176+
'<p itemprop="copy">line one\nline two</p>' +
177+
"</blockquote>",
178+
},
179+
]);
180+
expect(result?.body).toBe("line one line two");
181+
});
182+
183+
it("skips non-string content values", () => {
184+
expect(
185+
extractMSTeamsQuoteInfo([{ contentType: "application/json", content: { foo: "bar" } }]),
186+
).toBeUndefined();
187+
});
188+
189+
it("handles object content with .text property containing the reply HTML", () => {
190+
const htmlContent =
191+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
192+
'<strong itemprop="mri">Dave</strong>' +
193+
'<p itemprop="copy">hello from object</p>' +
194+
"</blockquote>";
195+
const result = extractMSTeamsQuoteInfo([
196+
{ contentType: "text/html", content: { text: htmlContent } },
197+
]);
198+
expect(result).toEqual({ sender: "Dave", body: "hello from object" });
199+
});
200+
201+
it("handles object content with .body property containing the reply HTML", () => {
202+
const htmlContent =
203+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
204+
'<strong itemprop="mri">Eve</strong>' +
205+
'<p itemprop="copy">hello from body field</p>' +
206+
"</blockquote>";
207+
const result = extractMSTeamsQuoteInfo([
208+
{ contentType: "text/html", content: { body: htmlContent } },
209+
]);
210+
expect(result).toEqual({ sender: "Eve", body: "hello from body field" });
211+
});
212+
213+
it("finds quote in second attachment when first has no quote", () => {
214+
const result = extractMSTeamsQuoteInfo([
215+
{ contentType: "text/plain", content: "plain text" },
216+
replyAttachment(),
217+
]);
218+
expect(result).toEqual({ sender: "Alice", body: "Hello world" });
219+
});
220+
});
69221
});

extensions/msteams/src/inbound.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,73 @@
1+
export type MSTeamsQuoteInfo = {
2+
sender: string;
3+
body: string;
4+
};
5+
6+
/**
7+
* Decode common HTML entities to plain text.
8+
*/
9+
export function decodeHtmlEntities(html: string): string {
10+
return html
11+
.replace(/&lt;/g, "<")
12+
.replace(/&gt;/g, ">")
13+
.replace(/&quot;/g, '"')
14+
.replace(/&#39;/g, "'")
15+
.replace(/&#x27;/g, "'")
16+
.replace(/&nbsp;/g, " ")
17+
.replace(/&amp;/g, "&"); // must be last to prevent double-decoding (e.g. &amp;lt; → &lt; not <)
18+
}
19+
20+
/**
21+
* Strip HTML tags, preserving text content.
22+
*/
23+
export function htmlToPlainText(html: string): string {
24+
return decodeHtmlEntities(
25+
html
26+
.replace(/<[^>]*>/g, " ")
27+
.replace(/\s+/g, " ")
28+
.trim(),
29+
);
30+
}
31+
32+
/**
33+
* Extract quote info from MS Teams HTML reply attachments.
34+
* Teams wraps quoted content in a blockquote with itemtype="http://schema.skype.com/Reply".
35+
*/
36+
export function extractMSTeamsQuoteInfo(
37+
attachments: Array<{ contentType?: string | null; content?: unknown }>,
38+
): MSTeamsQuoteInfo | undefined {
39+
for (const att of attachments) {
40+
// Content may be a plain string or an object with .text/.body (e.g. Adaptive Card payloads).
41+
const content =
42+
typeof att.content === "string"
43+
? att.content
44+
: typeof att.content === "object" && att.content !== null
45+
? String(
46+
(att.content as Record<string, unknown>).text ??
47+
(att.content as Record<string, unknown>).body ??
48+
"",
49+
)
50+
: "";
51+
if (!content) continue;
52+
53+
// Look for the Skype Reply schema blockquote.
54+
if (!content.includes("http://schema.skype.com/Reply")) continue;
55+
56+
// Extract sender from <strong itemprop="mri">.
57+
const senderMatch = /<strong[^>]*itemprop=["']mri["'][^>]*>(.*?)<\/strong>/i.exec(content);
58+
const sender = senderMatch?.[1] ? htmlToPlainText(senderMatch[1]) : undefined;
59+
60+
// Extract body from <p itemprop="copy">.
61+
const bodyMatch = /<p[^>]*itemprop=["']copy["'][^>]*>(.*?)<\/p>/is.exec(content);
62+
const body = bodyMatch?.[1] ? htmlToPlainText(bodyMatch[1]) : undefined;
63+
64+
if (body) {
65+
return { sender: sender ?? "unknown", body };
66+
}
67+
}
68+
return undefined;
69+
}
70+
171
export type MentionableActivity = {
272
recipient?: { id?: string } | null;
373
entities?: Array<{

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { StoredConversationReference } from "../conversation-store.js";
3030
import { formatUnknownError } from "../errors.js";
3131
import {
3232
extractMSTeamsConversationMessageId,
33+
extractMSTeamsQuoteInfo,
3334
normalizeMSTeamsConversationId,
3435
parseMSTeamsActivityTimestamp,
3536
stripMSTeamsMentionTags,
@@ -103,6 +104,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
103104
const attachments = params.attachments;
104105
const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments);
105106
const rawBody = text || attachmentPlaceholder;
107+
const quoteInfo = extractMSTeamsQuoteInfo(attachments);
106108
const from = activity.from;
107109
const conversation = activity.conversation;
108110

@@ -533,6 +535,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
533535
CommandAuthorized: commandAuthorized,
534536
OriginatingChannel: "msteams" as const,
535537
OriginatingTo: teamsTo,
538+
ReplyToId: activity.replyToId ?? undefined,
539+
ReplyToBody: quoteInfo?.body,
540+
ReplyToSender: quoteInfo?.sender,
541+
ReplyToIsQuote: quoteInfo ? true : undefined,
536542
...mediaPayload,
537543
});
538544

0 commit comments

Comments
 (0)