Skip to content

Update quoted replies#482

Open
corinagum wants to merge 8 commits intomainfrom
cg/quoted-replies
Open

Update quoted replies#482
corinagum wants to merge 8 commits intomainfrom
cg/quoted-replies

Conversation

@corinagum
Copy link
Collaborator

  • Remove withReplyToId(): replyToId body property has no effect on threading (APX only uses it for encryption)
  • Add QuotedReplyEntity type with nested quotedReply data object (messageId required, senderId/senderName/preview/time/isReplyDeleted/validatedMessageReference optional)
  • Add getQuotedMessages() on MessageActivity to read inbound quoted reply entities
  • Add addQuotedReply(messageId, response?) builder on MessageActivity for constructing outbound quotes (build order = reading order)
  • Update reply() to stamp quotedReply entity + <quoted messageId="..."/> placeholder instead of blockquote HTML; remove replyToId assignment
  • Add quoteReply(messageId, activity) on ActivityContext for quoting arbitrary messages
  • Mark all quoted reply types and methods as @experimental
  • Fix bug in ActivityContext constructor where Object.assign(this, rest) ran before MessageActivity.from().toInterface(), causing interface methods (getQuotedMessages, stripMentionsText, isRecipientMentioned, getAccountMention) to be missing on context.activity. Added regression test.
  • Add quoted-replies example exercising all APIs

@corinagum corinagum marked this pull request as ready for review March 24, 2026 18:19
@corinagum corinagum requested a review from Copilot March 24, 2026 21:43
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the SDK’s reply/quote behavior to use Teams “quoted replies” (entity + <quoted .../> placeholder) instead of replyToId/blockquote HTML, adds first-class quoted-reply types/APIs, fixes an ActivityContext constructor enrichment bug, and introduces an example app to exercise the new functionality.

Changes:

  • Add QuotedReplyEntity/QuotedReplyData, plumb into Entity union, and add MessageActivity.getQuotedMessages() + addQuotedReply().
  • Update ActivityContext.reply() to stamp quoted-reply metadata + placeholder, and add ActivityContext.quoteReply(messageId, ...).
  • Fix ActivityContext constructor ordering bug (enrichment before Object.assign) and add regression tests + a new quoted-replies example.

Reviewed changes

Copilot reviewed 16 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
packages/apps/src/contexts/activity.ts Adds quoteReply() API, changes reply() quoting behavior, and fixes constructor activity enrichment ordering.
packages/apps/src/contexts/activity.test.ts Updates tests to assert quoted-reply entity + placeholder behavior for reply() and quoteReply().
packages/apps/src/app.process.spec.ts Regression test ensuring message activities are enriched with interface methods (e.g., getQuotedMessages).
packages/api/src/models/entity/quoted-reply-entity.ts Introduces QuotedReplyEntity and nested quoted reply data model.
packages/api/src/models/entity/index.ts Exposes QuotedReplyEntity via the Entity union and exports.
packages/api/src/activities/message/message.ts Adds getQuotedMessages() and addQuotedReply() builder API.
packages/api/src/activities/message/message.spec.ts Adds unit tests for quoted-reply parsing and builder behavior.
packages/api/src/activities/activity.ts Removes withReplyToId() builder method.
packages/api/src/activities/activity.spec.ts Updates tests to reflect removal of withReplyToId().
package-lock.json Adds the new @examples/quoted-replies workspace entry.
examples/quoted-replies/** New runnable example demonstrating reading/sending quoted replies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +261 to +269
if (this.activity.id) {
const placeholder = `<quoted messageId="${this.activity.id}"/>`;
if (!activity.entities) {
activity.entities = [];
}
activity.entities.push({
type: 'quotedReply',
quotedReply: { messageId: this.activity.id },
});
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reply() currently stamps a quotedReply entity whenever this.activity.id exists, even when the outbound activity is not a message (e.g., reply({ type: 'typing' })). That can lead to sending a typing activity with a quotedReply entity that Teams/Bot Framework may not understand. Consider only adding the quoted-reply entity (and placeholder) when the outbound activity is a message (or otherwise explicitly supported).

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design — the entity is metadata that is harmless on non-message types, and the text placeholder is only added when activity.type === 'message' (line 271). This is consistent across all three SDKs.

Comment on lines +262 to +273
const placeholder = `<quoted messageId="${this.activity.id}"/>`;
if (!activity.entities) {
activity.entities = [];
}
activity.entities.push({
type: 'quotedReply',
quotedReply: { messageId: this.activity.id },
});

if (blockQuote) {
activity.text = `${blockQuote}\r\n${activity.text}`;
if (activity.type === 'message') {
const text = activity.text?.trim() ?? '';
activity.text = text ? `${placeholder} ${text}` : placeholder;
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The <quoted messageId="..."/> placeholder is built via string interpolation without escaping/validating this.activity.id. If an ID ever contains ", <, or >, this can break the tag and allow markup injection into the outbound message text. Please ensure messageId is validated to an expected safe format (or XML-escaped) before embedding it in the placeholder string.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.activity.id is a service-generated ID from the Teams/Bot Framework backend — always a safe alphanumeric string. Escaping would add complexity for a scenario that cannot occur with real Teams activity IDs.

Comment on lines +288 to +303
async quoteReply(messageId: string, activity: ActivityLike) {
activity = toActivityParams(activity);
const placeholder = `<quoted messageId="${messageId}"/>`;

if (!activity.entities) {
activity.entities = [];
}
activity.entities.push({
type: 'quotedReply',
quotedReply: { messageId },
});

if (activity.type === 'message') {
const text = activity.text?.trim() ?? '';
activity.text = text ? `${placeholder} ${text}` : placeholder;
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quoteReply() always appends a quotedReply entity, even when the outbound activity is not a message. If callers pass { type: 'typing' } (or other non-message activities), this will attach a quotedReply entity without adding the required <quoted .../> text placeholder, which is likely invalid/ineffective. Consider restricting quoteReply() to message activities (throw or no-op for other types) so the entity + placeholder stay consistent.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the reply() comment — entity is metadata, text placeholder only added for messages (line 300 check). Consistent behavior.

Comment on lines +289 to +303
activity = toActivityParams(activity);
const placeholder = `<quoted messageId="${messageId}"/>`;

if (!activity.entities) {
activity.entities = [];
}
activity.entities.push({
type: 'quotedReply',
quotedReply: { messageId },
});

if (activity.type === 'message') {
const text = activity.text?.trim() ?? '';
activity.text = text ? `${placeholder} ${text}` : placeholder;
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The messageId parameter is interpolated directly into <quoted messageId="${messageId}"/> without escaping/validation. Because this method is public and messageId can be arbitrary input, this creates a markup-injection risk and can also generate invalid placeholders. Validate the ID (e.g., non-empty and matches expected character set) or XML-escape it before constructing the placeholder.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The messageId parameter comes from a prior send() result or from the service. Same reasoning — these are service-generated IDs.

type: 'quotedReply',
quotedReply: { messageId },
});
this.addText(`<quoted messageId="${messageId}"/>`);
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addQuotedReply() interpolates messageId directly into the <quoted messageId="..."/> placeholder without escaping/validation. Since messageId is a public input, this can break the placeholder markup (and potentially inject additional tags) if it contains special characters like " or <. Please validate/escape messageId before embedding it in the text.

Suggested change
this.addText(`<quoted messageId="${messageId}"/>`);
const escapedMessageId = messageId
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
this.addText(`<quoted messageId="${escapedMessageId}"/>`);

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning — message IDs are service-generated. Also fixed the text stripping issue: now using a trimmed copy only for the empty-check decision, preserving the original text.

@@ -257,11 +257,6 @@ export class Activity<T extends string = string> implements IActivity<T> {
return this;
}

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing withReplyToId() from the public Activity builder is a breaking API change for any consumers that used it. If this is intended, it likely needs a deprecation period (keep the method but mark it @deprecated, or provide an alternative) and a documented versioning note; otherwise existing user code will stop compiling on upgrade.

Suggested change
/**
* @deprecated Use {@link Activity.clone} with `replyToId` in options
* or set the `replyToId` property directly.
*/
withReplyToId(value: string) {
this.replyToId = value;
return this;
}

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — withReplyToId() is being replaced by the quoted-reply entity approach across all three SDKs (Python, .NET, TypeScript) as part of the design spec. The replyToId property itself still exists on the activity interface for raw access; the builder was the only thing removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants