Skip to content

Commit 354b263

Browse files
committed
msteams: extract structured quote/reply context from Teams HTML attachments
1 parent 11aff6e commit 354b263

File tree

3 files changed

+188
-0
lines changed

3 files changed

+188
-0
lines changed

extensions/msteams/src/inbound.test.ts

Lines changed: 122 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,
@@ -63,4 +66,123 @@ describe("msteams inbound", () => {
6366
).toBe(false);
6467
});
6568
});
69+
70+
describe("decodeHtmlEntities", () => {
71+
it("decodes common entities", () => {
72+
expect(decodeHtmlEntities("&amp;&lt;&gt;&quot;&#39;&#x27;&nbsp;")).toBe("&<>\"'' ");
73+
});
74+
75+
it("leaves plain text unchanged", () => {
76+
expect(decodeHtmlEntities("hello world")).toBe("hello world");
77+
});
78+
});
79+
80+
describe("htmlToPlainText", () => {
81+
it("strips tags and decodes entities", () => {
82+
expect(htmlToPlainText("<strong>Hello &amp; world</strong>")).toBe("Hello & world");
83+
});
84+
85+
it("collapses whitespace from tag removal", () => {
86+
expect(htmlToPlainText("<p>foo</p><p>bar</p>")).toBe("foo bar");
87+
});
88+
89+
it("trims leading and trailing whitespace", () => {
90+
expect(htmlToPlainText(" <span>hi</span> ")).toBe("hi");
91+
});
92+
});
93+
94+
describe("extractMSTeamsQuoteInfo", () => {
95+
const replyAttachment = (overrides?: { content?: string; contentType?: string }) => ({
96+
contentType: overrides?.contentType ?? "text/html",
97+
content:
98+
overrides?.content ??
99+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
100+
'<strong itemprop="mri">Alice</strong>' +
101+
'<p itemprop="copy">Hello world</p>' +
102+
"</blockquote>",
103+
});
104+
105+
it("extracts sender and body from a Teams reply attachment", () => {
106+
const result = extractMSTeamsQuoteInfo([replyAttachment()]);
107+
expect(result).toEqual({ sender: "Alice", body: "Hello world" });
108+
});
109+
110+
it("returns undefined for empty attachments array", () => {
111+
expect(extractMSTeamsQuoteInfo([])).toBeUndefined();
112+
});
113+
114+
it("returns undefined when no reply blockquote is present", () => {
115+
expect(
116+
extractMSTeamsQuoteInfo([{ contentType: "text/html", content: "<p>just a message</p>" }]),
117+
).toBeUndefined();
118+
});
119+
120+
it("uses 'unknown' as sender when sender element is absent", () => {
121+
const result = extractMSTeamsQuoteInfo([
122+
{
123+
contentType: "text/html",
124+
content:
125+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
126+
'<p itemprop="copy">quoted text</p>' +
127+
"</blockquote>",
128+
},
129+
]);
130+
expect(result).toEqual({ sender: "unknown", body: "quoted text" });
131+
});
132+
133+
it("returns undefined when body element is absent", () => {
134+
const result = extractMSTeamsQuoteInfo([
135+
{
136+
contentType: "text/html",
137+
content:
138+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
139+
'<strong itemprop="mri">Alice</strong>' +
140+
"</blockquote>",
141+
},
142+
]);
143+
expect(result).toBeUndefined();
144+
});
145+
146+
it("decodes HTML entities in body text", () => {
147+
const result = extractMSTeamsQuoteInfo([
148+
{
149+
contentType: "text/html",
150+
content:
151+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
152+
'<strong itemprop="mri">Bob</strong>' +
153+
'<p itemprop="copy">2 &lt; 3 &amp; 4 &gt; 1</p>' +
154+
"</blockquote>",
155+
},
156+
]);
157+
expect(result).toEqual({ sender: "Bob", body: "2 < 3 & 4 > 1" });
158+
});
159+
160+
it("handles multiline body by collapsing whitespace", () => {
161+
const result = extractMSTeamsQuoteInfo([
162+
{
163+
contentType: "text/html",
164+
content:
165+
'<blockquote itemtype="http://schema.skype.com/Reply" itemscope>' +
166+
'<strong itemprop="mri">Carol</strong>' +
167+
'<p itemprop="copy">line one\nline two</p>' +
168+
"</blockquote>",
169+
},
170+
]);
171+
expect(result?.body).toBe("line one line two");
172+
});
173+
174+
it("skips non-string content values", () => {
175+
expect(
176+
extractMSTeamsQuoteInfo([{ contentType: "application/json", content: { foo: "bar" } }]),
177+
).toBeUndefined();
178+
});
179+
180+
it("finds quote in second attachment when first has no quote", () => {
181+
const result = extractMSTeamsQuoteInfo([
182+
{ contentType: "text/plain", content: "plain text" },
183+
replyAttachment(),
184+
]);
185+
expect(result).toEqual({ sender: "Alice", body: "Hello world" });
186+
});
187+
});
66188
});

extensions/msteams/src/inbound.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,63 @@
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(/&amp;/g, "&")
12+
.replace(/&lt;/g, "<")
13+
.replace(/&gt;/g, ">")
14+
.replace(/&quot;/g, '"')
15+
.replace(/&#39;/g, "'")
16+
.replace(/&#x27;/g, "'")
17+
.replace(/&nbsp;/g, " ");
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+
const content = typeof att.content === "string" ? att.content : "";
41+
if (!content) continue;
42+
43+
// Look for the Skype Reply schema blockquote.
44+
if (!content.includes("http://schema.skype.com/Reply")) continue;
45+
46+
// Extract sender from <strong itemprop="mri">.
47+
const senderMatch = /<strong[^>]*itemprop=["']mri["'][^>]*>(.*?)<\/strong>/i.exec(content);
48+
const sender = senderMatch?.[1] ? htmlToPlainText(senderMatch[1]) : undefined;
49+
50+
// Extract body from <p itemprop="copy">.
51+
const bodyMatch = /<p[^>]*itemprop=["']copy["'][^>]*>(.*?)<\/p>/is.exec(content);
52+
const body = bodyMatch?.[1] ? htmlToPlainText(bodyMatch[1]) : undefined;
53+
54+
if (body) {
55+
return { sender: sender ?? "unknown", body };
56+
}
57+
}
58+
return undefined;
59+
}
60+
161
export type MentionableActivity = {
262
recipient?: { id?: string } | null;
363
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)