From 47582738e7e2c964897bea502ecd6441acb63f19 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Tue, 17 Mar 2026 17:07:17 -0700 Subject: [PATCH 01/12] Remove withReplyToId --- packages/api/src/activities/activity.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/api/src/activities/activity.ts b/packages/api/src/activities/activity.ts index 6aa799ff6..1394b4839 100644 --- a/packages/api/src/activities/activity.ts +++ b/packages/api/src/activities/activity.ts @@ -257,11 +257,6 @@ export class Activity implements IActivity { return this; } - withReplyToId(value: string) { - this.replyToId = value; - return this; - } - withChannelId(value: ChannelID) { this.channelId = value; return this; From 67ca7d32b9ac373f26d036758013a4d9cc32a736 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 18 Mar 2026 11:16:45 -0700 Subject: [PATCH 02/12] Quotes replies feature --- packages/api/src/activities/activity.spec.ts | 2 - .../src/activities/message/message.spec.ts | 106 ++++++++++++++ .../api/src/activities/message/message.ts | 39 +++++ packages/api/src/models/entity/index.ts | 5 +- .../src/models/entity/quoted-reply-entity.ts | 50 +++++++ packages/apps/src/contexts/activity.test.ts | 134 ++++++++++++------ packages/apps/src/contexts/activity.ts | 64 ++++++--- 7 files changed, 329 insertions(+), 71 deletions(-) create mode 100644 packages/api/src/models/entity/quoted-reply-entity.ts diff --git a/packages/api/src/activities/activity.spec.ts b/packages/api/src/activities/activity.spec.ts index 8daf143ba..51d9e4582 100644 --- a/packages/api/src/activities/activity.spec.ts +++ b/packages/api/src/activities/activity.spec.ts @@ -33,7 +33,6 @@ describe('Activity', () => { conversation: chat, }) .withRecipient(bot) - .withReplyToId('3') .withServiceUrl('http://localhost') .withTimestamp(new Date()) .withLocalTimestamp(new Date()); @@ -51,7 +50,6 @@ describe('Activity', () => { }); expect(activity.recipient).toEqual(bot); - expect(activity.replyToId).toEqual('3'); expect(activity.serviceUrl).toEqual('http://localhost'); expect(activity.timestamp).toBeDefined(); expect(activity.localTimestamp).toBeDefined(); diff --git a/packages/api/src/activities/message/message.spec.ts b/packages/api/src/activities/message/message.spec.ts index 40e111f8d..2670019ef 100644 --- a/packages/api/src/activities/message/message.spec.ts +++ b/packages/api/src/activities/message/message.spec.ts @@ -356,4 +356,110 @@ describe('MessageActivity', () => { expect(msg.recipient.role).toBe('user'); }); }); + + describe('getQuotedMessages', () => { + it('should return quoted reply entities', () => { + const activity = new MessageActivity('hello'); + activity.addEntity({ + type: 'quotedReply', + quotedReply: { messageId: 'msg-1' }, + }); + + expect(activity.getQuotedMessages()).toHaveLength(1); + expect(activity.getQuotedMessages()[0].quotedReply.messageId).toEqual('msg-1'); + }); + + it('should return empty array when no quoted replies', () => { + const activity = new MessageActivity('hello'); + expect(activity.getQuotedMessages()).toHaveLength(0); + }); + + it('should return empty array when no entities', () => { + const activity = new MessageActivity('hello'); + activity.entities = undefined; + expect(activity.getQuotedMessages()).toHaveLength(0); + }); + + it('should filter out non-quoted-reply entities', () => { + const activity = new MessageActivity('hello') + .addMention({ id: '1', name: 'user', role: 'user' }); + activity.addEntity({ + type: 'quotedReply', + quotedReply: { messageId: 'msg-1' }, + }); + + expect(activity.getQuotedMessages()).toHaveLength(1); + expect(activity.entities).toHaveLength(2); + }); + + it('should return multiple quoted replies', () => { + const activity = new MessageActivity('hello'); + activity.addEntity({ + type: 'quotedReply', + quotedReply: { messageId: 'msg-1' }, + }); + activity.addEntity({ + type: 'quotedReply', + quotedReply: { messageId: 'msg-2' }, + }); + + expect(activity.getQuotedMessages()).toHaveLength(2); + expect(activity.getQuotedMessages()[0].quotedReply.messageId).toEqual('msg-1'); + expect(activity.getQuotedMessages()[1].quotedReply.messageId).toEqual('msg-2'); + }); + + it('should be accessible via toInterface', () => { + const activity = new MessageActivity('hello'); + activity.addEntity({ + type: 'quotedReply', + quotedReply: { messageId: 'msg-1' }, + }); + + const iface = activity.toInterface(); + expect(iface.getQuotedMessages()).toHaveLength(1); + expect(iface.getQuotedMessages()[0].quotedReply.messageId).toEqual('msg-1'); + }); + }); + + describe('addQuotedReply', () => { + it('should add entity and append placeholder', () => { + const activity = new MessageActivity().addQuotedReply('msg-1'); + expect(activity.entities).toHaveLength(1); + expect(activity.entities![0]).toEqual( + expect.objectContaining({ type: 'quotedReply', quotedReply: { messageId: 'msg-1' } }) + ); + expect(activity.text).toEqual(''); + }); + + it('should append response text after placeholder', () => { + const activity = new MessageActivity().addQuotedReply('msg-1', 'my response'); + expect(activity.text).toEqual(' my response'); + }); + + it('should support multi-quote with interleaved responses', () => { + const activity = new MessageActivity() + .addQuotedReply('msg-1', 'response to first') + .addQuotedReply('msg-2', 'response to second'); + expect(activity.text).toEqual( + ' response to first response to second' + ); + expect(activity.entities).toHaveLength(2); + }); + + it('should support grouped quotes', () => { + const activity = new MessageActivity() + .addQuotedReply('msg-1') + .addQuotedReply('msg-2', 'response to both'); + expect(activity.text).toEqual( + ' response to both' + ); + }); + + it('should be chainable', () => { + const activity = new MessageActivity() + .addQuotedReply('msg-1') + .addText(' manual text'); + expect(activity.text).toEqual(' manual text'); + }); + }); }); diff --git a/packages/api/src/activities/message/message.ts b/packages/api/src/activities/message/message.ts index 3e38beb55..69ace7c45 100644 --- a/packages/api/src/activities/message/message.ts +++ b/packages/api/src/activities/message/message.ts @@ -9,6 +9,7 @@ import { Importance, InputHint, MentionEntity, + QuotedReplyEntity, SuggestedActions, TextFormat, } from '../../models'; @@ -95,6 +96,11 @@ export interface IMessageActivity extends IActivity<'message'> { * get a mention by the account id if exists */ getAccountMention(accountId: string): MentionEntity | undefined; + + /** + * get all quoted reply entities from this message + */ + getQuotedMessages(): QuotedReplyEntity[]; } export class MessageActivity extends Activity<'message'> implements IMessageActivity { @@ -188,6 +194,7 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi stripMentionsText: this.stripMentionsText.bind(this), isRecipientMentioned: this.isRecipientMentioned.bind(this), getAccountMention: this.getAccountMention.bind(this), + getQuotedMessages: this.getQuotedMessages.bind(this), }, this ); @@ -361,6 +368,15 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi .find((e) => e.mentioned.id === accountId); } + /** + * get all quoted reply entities from this message + */ + getQuotedMessages(): QuotedReplyEntity[] { + return (this.entities ?? []).filter( + (e): e is QuotedReplyEntity => e.type === 'quotedReply' + ); + } + /** * Add stream info, making * this a final stream message @@ -394,6 +410,29 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi super.withRecipient(account, isTargeted); return this; } + + /** + * Add a quotedReply entity for the given message ID and append a + * `` placeholder to text. + * If response is provided, it is appended after the placeholder. + * @param messageId - The IC3 message ID of the message to quote + * @param response - Optional response text to append after the placeholder + * @returns this instance for chaining + */ + addQuotedReply(messageId: string, response?: string): this { + if (!this.entities) { + this.entities = []; + } + this.entities.push({ + type: 'quotedReply', + quotedReply: { messageId }, + }); + this.addText(``); + if (response) { + this.addText(` ${response}`); + } + return this; + } } /** diff --git a/packages/api/src/models/entity/index.ts b/packages/api/src/models/entity/index.ts index 8e3f50095..8a5659361 100644 --- a/packages/api/src/models/entity/index.ts +++ b/packages/api/src/models/entity/index.ts @@ -4,6 +4,7 @@ import { ClientInfoEntity } from './client-info-entity'; import { MentionEntity } from './mention-entity'; import { MessageEntity } from './message-entity'; import { ProductInfoEntity } from './product-info-entity'; +import { QuotedReplyEntity } from './quoted-reply-entity'; import { SensitiveUsageEntity } from './sensitive-usage-entity'; import { StreamInfoEntity } from './stream-info-entity'; @@ -15,7 +16,8 @@ export type Entity = | StreamInfoEntity | CitationEntity | SensitiveUsageEntity - | ProductInfoEntity; + | ProductInfoEntity + | QuotedReplyEntity; export * from './client-info-entity'; export * from './mention-entity'; @@ -25,3 +27,4 @@ export * from './stream-info-entity'; export * from './citation-entity'; export * from './sensitive-usage-entity'; export * from './product-info-entity'; +export * from './quoted-reply-entity'; diff --git a/packages/api/src/models/entity/quoted-reply-entity.ts b/packages/api/src/models/entity/quoted-reply-entity.ts new file mode 100644 index 000000000..2e20143f1 --- /dev/null +++ b/packages/api/src/models/entity/quoted-reply-entity.ts @@ -0,0 +1,50 @@ +export type QuotedReplyData = { + /** + * ID of the message being quoted + */ + messageId: string; + + /** + * ID of the sender of the quoted message + */ + senderId?: string | null; + + /** + * display name of the sender of the quoted message + */ + senderName?: string | null; + + /** + * preview text of the quoted message + */ + preview?: string | null; + + /** + * timestamp of the quoted message + */ + time?: string | null; + + /** + * whether the quoted message has been deleted + */ + isReplyDeleted?: boolean; + + /** + * whether the message reference has been validated + */ + validatedMessageReference?: boolean; +}; + +export type QuotedReplyEntity = { + readonly type: 'quotedReply'; + + /** + * the quoted reply data + */ + quotedReply: QuotedReplyData; + + /** + * other properties + */ + [key: string]: any; +}; diff --git a/packages/apps/src/contexts/activity.test.ts b/packages/apps/src/contexts/activity.test.ts index ba0606ec1..903c151b7 100644 --- a/packages/apps/src/contexts/activity.test.ts +++ b/packages/apps/src/contexts/activity.test.ts @@ -115,80 +115,124 @@ describe('ActivityContext', () => { }; describe('reply', () => { - it('generates blockquote for message activity with short text', async () => { + it('stamps quotedReply entity with activity id', async () => { const activity = buildIncomingMessageActivity('Hello world'); - context = buildActivityContext(activity); await context.reply('What is up?'); expect(mockSender.send).toHaveBeenCalledTimes(1); - expect(mockSender.send).toHaveBeenCalledWith( - expect.objectContaining({ - text: `
-Test User -

Hello world

-
\r\nWhat is up?`, - type: 'message', - }), - mockRef + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.replyToId).toEqual('test-activity-id'); + expect(sentActivity.entities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'quotedReply', + quotedReply: { messageId: 'test-activity-id' }, + }), + ]) ); }); - it('truncates long messages over 120 characters in blockquote', async () => { - const longText = 'A'.repeat(150); - const activity = buildIncomingMessageActivity(longText); - + it('prepends placeholder to text', async () => { + const activity = buildIncomingMessageActivity('Hello world'); context = buildActivityContext(activity); await context.reply('What is up?'); - expect(mockSender.send).toHaveBeenCalledTimes(1); - expect(mockSender.send).toHaveBeenCalledWith( - expect.objectContaining({ - text: `
-Test User -

${'A'.repeat(120)}...

-
\r\nWhat is up?`, - type: 'message', - }), - mockRef - ); + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.text).toEqual(' What is up?'); + }); + + it('sets placeholder as text when reply text is empty', async () => { + const activity = buildIncomingMessageActivity('Hello world'); + context = buildActivityContext(activity); + + await context.reply(''); + + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.text).toEqual(''); }); - it('does not add blockquotes for empty quoted messages', async () => { - const activity = buildIncomingMessageActivity(''); + it('sets placeholder as text when reply has no text', async () => { + const activity = buildIncomingMessageActivity('Hello world'); + context = buildActivityContext(activity); + + await context.reply({ type: 'message' }); + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.text).toEqual(''); + }); + + it('does not stamp entity when activity has no id', async () => { + const activity = buildIncomingMessageActivity('Hello world', ''); context = buildActivityContext(activity); await context.reply('What is up?'); - expect(mockSender.send).toHaveBeenCalledTimes(1); - expect(mockSender.send).toHaveBeenCalledWith( - expect.objectContaining({ - text: 'What is up?', - type: 'message', - }), - mockRef - ); + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.entities).toBeUndefined(); }); - it('does not add blockquotes for empty messages', async () => { - const activity = buildIncomingMessageActivity('Original Message'); + it('preserves replyToId', async () => { + const activity = buildIncomingMessageActivity('Hello world'); + context = buildActivityContext(activity); + + await context.reply('What is up?'); + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.replyToId).toEqual('test-activity-id'); + }); + }); + + describe('quoteReply', () => { + it('stamps quotedReply entity with given messageId', async () => { + const activity = buildIncomingMessageActivity('Hello world'); context = buildActivityContext(activity); - await context.reply(''); + await context.quoteReply('arbitrary-msg-id', 'some text'); expect(mockSender.send).toHaveBeenCalledTimes(1); - expect(mockSender.send).toHaveBeenCalledWith( - expect.objectContaining({ - text: '', - type: 'message', - }), - mockRef + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.entities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'quotedReply', + quotedReply: { messageId: 'arbitrary-msg-id' }, + }), + ]) ); }); + + it('prepends placeholder to text', async () => { + const activity = buildIncomingMessageActivity('Hello world'); + context = buildActivityContext(activity); + + await context.quoteReply('msg-42', 'reply text'); + + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.text).toEqual(' reply text'); + }); + + it('sets placeholder as text when no text provided', async () => { + const activity = buildIncomingMessageActivity('Hello world'); + context = buildActivityContext(activity); + + await context.quoteReply('msg-42', { type: 'message' }); + + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.text).toEqual(''); + }); + + it('does not set replyToId', async () => { + const activity = buildIncomingMessageActivity('Hello world'); + context = buildActivityContext(activity); + + await context.quoteReply('msg-42', 'some text'); + + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.replyToId).toBeUndefined(); + }); }); describe('send', () => { diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index 763041ec8..6462c957b 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -163,11 +163,18 @@ export interface IBaseActivityContext Promise; /** - * reply to the inbound activity + * reply to the inbound activity, automatically quoting the inbound message * @param activity activity to send */ reply: (activity: ActivityLike) => Promise; + /** + * send a reply quoting a specific message by ID + * @param messageId the ID of the message to quote + * @param activity activity to send + */ + quoteReply: (messageId: string, activity: ActivityLike) => Promise; + /** * trigger user signin flow for the activity sender * @param options options for the signin flow @@ -248,17 +255,45 @@ export class ActivityContext`; + 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; } } return this.send(activity); } + async quoteReply(messageId: string, activity: ActivityLike) { + activity = toActivityParams(activity); + const placeholder = ``; + + 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; + } + + return this.send(activity); + } + async signin(options?: Partial) { const { oauthCardText, @@ -365,28 +400,11 @@ export class ActivityContext maxLength - ? `${this.activity.text.substring(0, maxLength)}...` - : this.activity.text; - - return `
-${this.activity.from.name} -

${truncatedText}

-
`; - } else { - this.log.debug('Skipping building blockquote for activity type:', this.activity.type); - } - - return null; - } } From eb33aa52f437bbaf18c79bf45fa25664a6191abb Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 18 Mar 2026 11:17:05 -0700 Subject: [PATCH 03/12] Quoted replies sample --- examples/quoted-replies/.gitignore | 7 ++ examples/quoted-replies/README.md | 30 +++++ examples/quoted-replies/appPackage/color.png | Bin 0 -> 1066 bytes .../quoted-replies/appPackage/manifest.json | 51 ++++++++ .../quoted-replies/appPackage/outline.png | Bin 0 -> 249 bytes examples/quoted-replies/eslint.config.js | 1 + examples/quoted-replies/package.json | 35 ++++++ examples/quoted-replies/src/index.ts | 113 ++++++++++++++++++ examples/quoted-replies/tsconfig.json | 8 ++ 9 files changed, 245 insertions(+) create mode 100644 examples/quoted-replies/.gitignore create mode 100644 examples/quoted-replies/README.md create mode 100644 examples/quoted-replies/appPackage/color.png create mode 100644 examples/quoted-replies/appPackage/manifest.json create mode 100644 examples/quoted-replies/appPackage/outline.png create mode 100644 examples/quoted-replies/eslint.config.js create mode 100644 examples/quoted-replies/package.json create mode 100644 examples/quoted-replies/src/index.ts create mode 100644 examples/quoted-replies/tsconfig.json diff --git a/examples/quoted-replies/.gitignore b/examples/quoted-replies/.gitignore new file mode 100644 index 000000000..2a10fd5e7 --- /dev/null +++ b/examples/quoted-replies/.gitignore @@ -0,0 +1,7 @@ +teams*.yml +env/ +infra/ +node_modules/ +.vscode/ +dist/ +.env diff --git a/examples/quoted-replies/README.md b/examples/quoted-replies/README.md new file mode 100644 index 000000000..fe8326d0d --- /dev/null +++ b/examples/quoted-replies/README.md @@ -0,0 +1,30 @@ +# Example: Quoted Replies + +A bot that demonstrates quoted reply features in Microsoft Teams — referencing previous messages when sending responses. + +## Commands + +| Command | Behavior | +|---------|----------| +| `test reply` | `reply()` — auto-quotes the inbound message | +| `test quote` | `quoteReply()` — sends a message, then quotes it by ID | +| `test add` | `addQuotedReply()` — sends a message, then quotes it with builder + response | +| `test multi` | Sends two messages, then quotes both with interleaved responses | +| `test manual` | `addQuotedReply()` + `addText()` — manual control | +| `help` | Shows available commands | +| *(quote a message)* | Bot reads and displays the quoted reply metadata | + +## Run + +```bash +npm run dev +``` + +## Environment Variables + +Create a `.env` file: + +``` +CLIENT_ID= +CLIENT_SECRET= +``` diff --git a/examples/quoted-replies/appPackage/color.png b/examples/quoted-replies/appPackage/color.png new file mode 100644 index 0000000000000000000000000000000000000000..f27ccf2036bf2264dc0d11edf2af2bda62e4efdf GIT binary patch literal 1066 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zcaloCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di49xpIT^vIy7~kGC%#(I!aJU%yVD^#PnvNe5 zY1um`Oj+#WC3y9e;Us63+9EE_EXjNxu|}7}jyc6;|Ee3L%wi^d-gxKYxi>fe9)4Wc zxa_YvcME5O3F8DchD$6Cvlu*t88Vp^d>NL|Lr`DL?D4N}c{_jp%(HAg{rPUu*Szg# zj!7mc`&s@7H-CTs|F4$!|Ce$kF#Fm5;7{vuU}%<3{UCovtdW7u?A8PO8JbLtJXu!` z)*E=Uq<`n{|J}Oq&G+9=pRT^{A7^iE9sTdm-|J7arQcTAE#7O4&s)AbmEKG-&pNxS zC@bvpTt>gz>5tZEFHbWKWmwE(Czx~Ggt5o$h06xsU>1W{3Bm_|n8_b_(d@&LeEW~i zg^dTlUYFmmyZpo3^85CcxxEL@T$u4k9$$#sDCao5UUz!>`!fG+?(yZBb?@R92k)%- zop#eIy}>bd?)!h82_c(x{)!wp;MSY4tn@sS#GMSmGuvLoGe{eFu^7LrP;BV6C}r84 z_dQ80!}%J=HG4bxbez$5Ys+@0HQnxRMXMf1ieE3d1B&%|CHbgX^da?eHs6`1kLARhjOac zf3;y1Iq~M{LLS3g_M2bU{+PBvomV=FH7$YTy5I%1<5B$=?>3fqI5P%5iajq7)W9SX p;gpazd1JnvZNlx8HB0WjVJ`J~Q+P@%pA+aZ22WQ%mvv4FO#n^cR9FB2 literal 0 HcmV?d00001 diff --git a/examples/quoted-replies/eslint.config.js b/examples/quoted-replies/eslint.config.js new file mode 100644 index 000000000..5ccf8112f --- /dev/null +++ b/examples/quoted-replies/eslint.config.js @@ -0,0 +1 @@ +module.exports = require('@microsoft/teams.config/eslint.config').default; diff --git a/examples/quoted-replies/package.json b/examples/quoted-replies/package.json new file mode 100644 index 000000000..2f85b0517 --- /dev/null +++ b/examples/quoted-replies/package.json @@ -0,0 +1,35 @@ +{ + "name": "@examples/quoted-replies", + "version": "0.0.0", + "private": true, + "license": "MIT", + "main": "dist/index", + "types": "dist/index", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "clean": "npx rimraf ./dist", + "lint": "npx eslint", + "lint:fix": "npx eslint --fix", + "build": "npx tsc", + "start": "node .", + "dev": "tsx watch -r dotenv/config src/index.ts" + }, + "dependencies": { + "@microsoft/teams.api": "*", + "@microsoft/teams.apps": "*", + "@microsoft/teams.cards": "*", + "@microsoft/teams.common": "*", + "@microsoft/teams.dev": "*" + }, + "devDependencies": { + "@microsoft/teams.config": "*", + "@types/node": "^22.5.4", + "dotenv": "^16.4.5", + "rimraf": "^6.0.1", + "tsx": "^4.20.6", + "typescript": "^5.4.5" + } +} diff --git a/examples/quoted-replies/src/index.ts b/examples/quoted-replies/src/index.ts new file mode 100644 index 000000000..f4e1b4577 --- /dev/null +++ b/examples/quoted-replies/src/index.ts @@ -0,0 +1,113 @@ +import { MessageActivity } from '@microsoft/teams.api'; +import { App } from '@microsoft/teams.apps'; +import { ConsoleLogger } from '@microsoft/teams.common/logging'; +import { DevtoolsPlugin } from '@microsoft/teams.dev'; + +const app = new App({ + logger: new ConsoleLogger('@examples/quoted-replies', { level: 'debug' }), + plugins: [new DevtoolsPlugin()], +}); + +app.on('message', async ({ send, reply, quoteReply, activity }) => { + await reply({ type: 'typing' }); + + const text = activity.text?.toLowerCase() || ''; + + // ============================================ + // Read inbound quoted replies + // ============================================ + const quotes = activity.getQuotedMessages(); + if (quotes.length > 0) { + const quote = quotes[0].quotedReply; + const info = [ + `Quoted message ID: ${quote.messageId}`, + quote.senderName ? `From: ${quote.senderName}` : null, + quote.preview ? `Preview: "${quote.preview}"` : null, + quote.isReplyDeleted ? '(deleted)' : null, + quote.validatedMessageReference ? '(validated)' : null, + ].filter(Boolean).join('\n'); + + await send(`You sent a message with a quoted reply:\n\n${info}`); + } + + // ============================================ + // reply() — auto-quotes the inbound message + // ============================================ + if (text.includes('test reply')) { + await reply('This reply auto-quotes your message using reply()'); + return; + } + + // ============================================ + // quoteReply() — quote a previously sent message by ID + // ============================================ + if (text.includes('test quote')) { + const sent = await send('This message will be quoted next...'); + await quoteReply(sent.id, 'This quotes the message above using quoteReply()'); + return; + } + + // ============================================ + // addQuotedReply() — builder with response + // ============================================ + if (text.includes('test add')) { + const sent = await send('This message will be quoted next...'); + const msg = new MessageActivity() + .addQuotedReply(sent.id, 'This uses addQuotedReply() with a response'); + await send(msg); + return; + } + + // ============================================ + // Multi-quote interleaved + // ============================================ + if (text.includes('test multi')) { + const sentA = await send('Message A — will be quoted'); + const sentB = await send('Message B — will be quoted'); + const msg = new MessageActivity() + .addQuotedReply(sentA.id, 'Response to A') + .addQuotedReply(sentB.id, 'Response to B'); + await send(msg); + return; + } + + // ============================================ + // addQuotedReply() + addText() — manual control + // ============================================ + if (text.includes('test manual')) { + const sent = await send('This message will be quoted next...'); + const msg = new MessageActivity() + .addQuotedReply(sent.id) + .addText(' Custom text after the quote placeholder'); + await send(msg); + return; + } + + // ============================================ + // Help / Default + // ============================================ + if (text.includes('help')) { + await reply( + '**Quoted Replies Test Bot**\n\n' + + '**Commands:**\n' + + '- `test reply` - reply() auto-quotes your message\n' + + '- `test quote` - quoteReply() quotes a previously sent message\n' + + '- `test add` - addQuotedReply() builder with response\n' + + '- `test multi` - Multi-quote interleaved (quotes two separate messages)\n' + + '- `test manual` - addQuotedReply() + addText() manual control\n\n' + + 'Quote any message to me to see the parsed metadata!' + ); + return; + } + + await reply('Say "help" for available commands.'); +}); + +app.on('install.add', async ({ send }) => { + await send( + 'Hi! I demonstrate quoted replies.\n\n' + + 'Say **help** to see available commands.' + ); +}); + +app.start().catch(console.error); diff --git a/examples/quoted-replies/tsconfig.json b/examples/quoted-replies/tsconfig.json new file mode 100644 index 000000000..9a42fe553 --- /dev/null +++ b/examples/quoted-replies/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@microsoft/teams.config/tsconfig.node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} From e635d31eeadc888425ac993180fff1e86b7ead73 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 18 Mar 2026 15:07:37 -0700 Subject: [PATCH 04/12] Mark as experimental in docs and remove replyToId changes in reply --- .../api/src/activities/message/message.ts | 9 +++++++++ .../src/models/entity/quoted-reply-entity.ts | 12 ++++++++++++ packages/apps/src/contexts/activity.test.ts | 19 ------------------- packages/apps/src/contexts/activity.ts | 12 +++++++++++- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/packages/api/src/activities/message/message.ts b/packages/api/src/activities/message/message.ts index 69ace7c45..bd82dfb2b 100644 --- a/packages/api/src/activities/message/message.ts +++ b/packages/api/src/activities/message/message.ts @@ -99,6 +99,9 @@ export interface IMessageActivity extends IActivity<'message'> { /** * get all quoted reply entities from this message + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsQuotedReplies */ getQuotedMessages(): QuotedReplyEntity[]; } @@ -370,6 +373,9 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi /** * get all quoted reply entities from this message + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsQuotedReplies */ getQuotedMessages(): QuotedReplyEntity[] { return (this.entities ?? []).filter( @@ -418,6 +424,9 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi * @param messageId - The IC3 message ID of the message to quote * @param response - Optional response text to append after the placeholder * @returns this instance for chaining + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsQuotedReplies */ addQuotedReply(messageId: string, response?: string): this { if (!this.entities) { diff --git a/packages/api/src/models/entity/quoted-reply-entity.ts b/packages/api/src/models/entity/quoted-reply-entity.ts index 2e20143f1..5f09ecdd1 100644 --- a/packages/api/src/models/entity/quoted-reply-entity.ts +++ b/packages/api/src/models/entity/quoted-reply-entity.ts @@ -1,3 +1,9 @@ +/** + * Data for a quoted reply entity. + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsQuotedReplies + */ export type QuotedReplyData = { /** * ID of the message being quoted @@ -35,6 +41,12 @@ export type QuotedReplyData = { validatedMessageReference?: boolean; }; +/** + * Entity containing quoted reply information. + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsQuotedReplies + */ export type QuotedReplyEntity = { readonly type: 'quotedReply'; diff --git a/packages/apps/src/contexts/activity.test.ts b/packages/apps/src/contexts/activity.test.ts index 903c151b7..2083fba18 100644 --- a/packages/apps/src/contexts/activity.test.ts +++ b/packages/apps/src/contexts/activity.test.ts @@ -123,7 +123,6 @@ describe('ActivityContext', () => { expect(mockSender.send).toHaveBeenCalledTimes(1); const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; - expect(sentActivity.replyToId).toEqual('test-activity-id'); expect(sentActivity.entities).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -174,15 +173,6 @@ describe('ActivityContext', () => { expect(sentActivity.entities).toBeUndefined(); }); - it('preserves replyToId', async () => { - const activity = buildIncomingMessageActivity('Hello world'); - context = buildActivityContext(activity); - - await context.reply('What is up?'); - - const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; - expect(sentActivity.replyToId).toEqual('test-activity-id'); - }); }); describe('quoteReply', () => { @@ -224,15 +214,6 @@ describe('ActivityContext', () => { expect(sentActivity.text).toEqual(''); }); - it('does not set replyToId', async () => { - const activity = buildIncomingMessageActivity('Hello world'); - context = buildActivityContext(activity); - - await context.quoteReply('msg-42', 'some text'); - - const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; - expect(sentActivity.replyToId).toBeUndefined(); - }); }); describe('send', () => { diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index 6462c957b..d40f4f90d 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -172,6 +172,9 @@ export interface IBaseActivityContext Promise; @@ -253,7 +256,6 @@ export class ActivityContext`; @@ -274,6 +276,14 @@ export class ActivityContext`; From 17d2d0c17c514f50c301da6e31c993cb2dbd1388 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Mon, 23 Mar 2026 15:40:16 -0700 Subject: [PATCH 05/12] Fix binding to rest.activity --- packages/apps/src/app.process.spec.ts | 31 ++++++++++++++++++++++++++ packages/apps/src/contexts/activity.ts | 27 +++++++++++----------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/apps/src/app.process.spec.ts b/packages/apps/src/app.process.spec.ts index 48725e844..512e9d1e5 100644 --- a/packages/apps/src/app.process.spec.ts +++ b/packages/apps/src/app.process.spec.ts @@ -215,5 +215,36 @@ describe('App', () => { // Verify both serviceUrls were used correctly expect(capturedServiceUrls).toEqual([serviceUrl1, serviceUrl2]); }); + + it('should expose interface methods like getQuotedMessages on message activities', async () => { + // Use a plain object (as would arrive from JSON deserialization over HTTP) + // rather than a MessageActivity instance, to verify the context constructor + // enriches it with bound interface methods. + const incomingActivity = { + type: 'message', + text: 'hello', + from: { id: 'user-1', name: 'Test User', role: 'user' }, + recipient: { id: 'bot-1', name: 'Test Bot', role: 'bot' }, + conversation: { id: 'conv-1', conversationType: 'personal' }, + channelId: 'msteams', + serviceUrl: 'https://service.url', + } as unknown as IMessageActivity; + + const event: IActivityEvent = { + token: token, + body: incomingActivity, + }; + + let capturedActivity: IMessageActivity | undefined; + app.on('message', async ({ activity }) => { + capturedActivity = activity; + }); + + await app.process(event); + + expect(capturedActivity).toBeDefined(); + expect(typeof capturedActivity!.getQuotedMessages).toBe('function'); + expect(capturedActivity!.getQuotedMessages()).toEqual([]); + }); }); }); diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index d40f4f90d..194239d6a 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -217,27 +217,28 @@ export class ActivityContext Date: Mon, 23 Mar 2026 16:03:47 -0700 Subject: [PATCH 06/12] Update examples --- examples/quoted-replies/src/index.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/examples/quoted-replies/src/index.ts b/examples/quoted-replies/src/index.ts index f4e1b4577..1e9747f16 100644 --- a/examples/quoted-replies/src/index.ts +++ b/examples/quoted-replies/src/index.ts @@ -34,7 +34,7 @@ app.on('message', async ({ send, reply, quoteReply, activity }) => { // reply() — auto-quotes the inbound message // ============================================ if (text.includes('test reply')) { - await reply('This reply auto-quotes your message using reply()'); + await reply('Thanks for your message! This reply auto-quotes it using reply().'); return; } @@ -42,8 +42,8 @@ app.on('message', async ({ send, reply, quoteReply, activity }) => { // quoteReply() — quote a previously sent message by ID // ============================================ if (text.includes('test quote')) { - const sent = await send('This message will be quoted next...'); - await quoteReply(sent.id, 'This quotes the message above using quoteReply()'); + const sent = await send('The meeting has been moved to 3 PM tomorrow.'); + await quoteReply(sent.id, 'Just to confirm — does the new time work for everyone?'); return; } @@ -51,22 +51,24 @@ app.on('message', async ({ send, reply, quoteReply, activity }) => { // addQuotedReply() — builder with response // ============================================ if (text.includes('test add')) { - const sent = await send('This message will be quoted next...'); + const sent = await send('Please review the latest PR before end of day.'); const msg = new MessageActivity() - .addQuotedReply(sent.id, 'This uses addQuotedReply() with a response'); + .addQuotedReply(sent.id, 'Done! Left my comments on the PR.'); await send(msg); return; } // ============================================ - // Multi-quote interleaved + // Multi-quote with mixed responses // ============================================ if (text.includes('test multi')) { - const sentA = await send('Message A — will be quoted'); - const sentB = await send('Message B — will be quoted'); + const sentA = await send('We need to update the API docs before launch.'); + const sentB = await send('The design mockups are ready for review.'); + const sentC = await send('CI pipeline is green on main.'); const msg = new MessageActivity() - .addQuotedReply(sentA.id, 'Response to A') - .addQuotedReply(sentB.id, 'Response to B'); + .addQuotedReply(sentA.id, 'I can take the docs — will have a draft by Thursday.') + .addQuotedReply(sentB.id, 'Looks great, approved!') + .addQuotedReply(sentC.id); await send(msg); return; } @@ -75,10 +77,10 @@ app.on('message', async ({ send, reply, quoteReply, activity }) => { // addQuotedReply() + addText() — manual control // ============================================ if (text.includes('test manual')) { - const sent = await send('This message will be quoted next...'); + const sent = await send('Deployment to staging is complete.'); const msg = new MessageActivity() .addQuotedReply(sent.id) - .addText(' Custom text after the quote placeholder'); + .addText(' Verified — all smoke tests passing.'); await send(msg); return; } @@ -93,7 +95,7 @@ app.on('message', async ({ send, reply, quoteReply, activity }) => { '- `test reply` - reply() auto-quotes your message\n' + '- `test quote` - quoteReply() quotes a previously sent message\n' + '- `test add` - addQuotedReply() builder with response\n' + - '- `test multi` - Multi-quote interleaved (quotes two separate messages)\n' + + '- `test multi` - Multi-quote with mixed responses (one bare quote with no response)\n' + '- `test manual` - addQuotedReply() + addText() manual control\n\n' + 'Quote any message to me to see the parsed metadata!' ); From e73214c23dc9bdb487b3aa9961205f0ad748971a Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Mon, 23 Mar 2026 16:04:11 -0700 Subject: [PATCH 07/12] Update package-lock --- package-lock.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/package-lock.json b/package-lock.json index f9e173df4..c595fd469 100644 --- a/package-lock.json +++ b/package-lock.json @@ -555,6 +555,26 @@ "typescript": "^5.4.5" } }, + "examples/quoted-replies": { + "name": "@examples/quoted-replies", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@microsoft/teams.api": "*", + "@microsoft/teams.apps": "*", + "@microsoft/teams.cards": "*", + "@microsoft/teams.common": "*", + "@microsoft/teams.dev": "*" + }, + "devDependencies": { + "@microsoft/teams.config": "*", + "@types/node": "^22.5.4", + "dotenv": "^16.4.5", + "rimraf": "^6.0.1", + "tsx": "^4.20.6", + "typescript": "^5.4.5" + } + }, "examples/reactions": { "name": "@examples/reactions", "version": "0.0.1", @@ -2206,6 +2226,10 @@ "resolved": "examples/proactive-messaging", "link": true }, + "node_modules/@examples/quoted-replies": { + "resolved": "examples/quoted-replies", + "link": true + }, "node_modules/@examples/reactions": { "resolved": "examples/reactions", "link": true From 1036764867a84512387f8dc95e989ceeda11b6d8 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Tue, 24 Mar 2026 15:57:45 -0700 Subject: [PATCH 08/12] Apply Copilot feedback? --- packages/apps/src/contexts/activity.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index 194239d6a..2c89789c1 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -269,8 +269,8 @@ export class ActivityContext Date: Wed, 25 Mar 2026 14:23:09 -0700 Subject: [PATCH 09/12] Add JSDoc for time field format (IC3 epoch) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/api/src/models/entity/quoted-reply-entity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api/src/models/entity/quoted-reply-entity.ts b/packages/api/src/models/entity/quoted-reply-entity.ts index 5f09ecdd1..3033ff1b7 100644 --- a/packages/api/src/models/entity/quoted-reply-entity.ts +++ b/packages/api/src/models/entity/quoted-reply-entity.ts @@ -26,7 +26,8 @@ export type QuotedReplyData = { preview?: string | null; /** - * timestamp of the quoted message + * timestamp of the quoted message (IC3 epoch value, e.g. "1772050244572"). + * Populated on inbound; ignored on outbound. */ time?: string | null; From b80fb34194884a7b64f95156869cdccbff69421c Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 25 Mar 2026 17:29:27 -0700 Subject: [PATCH 10/12] reply defer to quoteReply --- packages/apps/src/contexts/activity.ts | 34 +++++++------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index 2c89789c1..5a87a6ca4 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -256,24 +256,9 @@ export class ActivityContext`; - if (!activity.entities) { - activity.entities = []; - } - activity.entities.push({ - type: 'quotedReply', - quotedReply: { messageId: this.activity.id }, - }); - - if (activity.type === 'message') { - const hasText = !!activity.text?.trim(); - activity.text = hasText ? `${placeholder} ${activity.text}` : placeholder; - } + return this.quoteReply(this.activity.id, activity); } - return this.send(activity); } @@ -287,17 +272,16 @@ export class ActivityContext`; - - if (!activity.entities) { - activity.entities = []; - } - activity.entities.push({ - type: 'quotedReply', - quotedReply: { messageId }, - }); if (activity.type === 'message') { + const placeholder = ``; + if (!activity.entities) { + activity.entities = []; + } + activity.entities.push({ + type: 'quotedReply', + quotedReply: { messageId }, + }); const hasText = !!activity.text?.trim(); activity.text = hasText ? `${placeholder} ${activity.text}` : placeholder; } From ec398309da58cf22e9c3d760520c5d409856c5e7 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 25 Mar 2026 17:54:30 -0700 Subject: [PATCH 11/12] Move stamping to model layer --- .../api/src/activities/message/message.ts | 22 +++++++++++++++++++ packages/apps/src/contexts/activity.ts | 14 ++++-------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/api/src/activities/message/message.ts b/packages/api/src/activities/message/message.ts index bd82dfb2b..6faa2abae 100644 --- a/packages/api/src/activities/message/message.ts +++ b/packages/api/src/activities/message/message.ts @@ -442,6 +442,28 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi } return this; } + + /** + * Prepend a quotedReply entity and `` placeholder + * before existing text. Used by reply()/quoteReply() for quote-above-response. + * @param messageId - The IC3 message ID of the message to quote + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsQuotedReplies + */ + prependQuotedReply(messageId: string): this { + if (!this.entities) { + this.entities = []; + } + this.entities.push({ + type: 'quotedReply', + quotedReply: { messageId }, + }); + const placeholder = ``; + const hasText = !!this.text?.trim(); + this.text = hasText ? `${placeholder} ${this.text}` : placeholder; + return this; + } } /** diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index 5a87a6ca4..e2ad152bd 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -5,6 +5,7 @@ import { ConversationAccount, ConversationReference, InvokeResponse, + IMessageActivity, MessageActivity, MessageDeleteActivity, MessageUpdateActivity, @@ -274,16 +275,9 @@ export class ActivityContext`; - if (!activity.entities) { - activity.entities = []; - } - activity.entities.push({ - type: 'quotedReply', - quotedReply: { messageId }, - }); - const hasText = !!activity.text?.trim(); - activity.text = hasText ? `${placeholder} ${activity.text}` : placeholder; + const message = MessageActivity.from(activity as IMessageActivity); + message.prependQuotedReply(messageId); + return this.send(message); } return this.send(activity); From 62a7a480f9630f1b7ff24bf3e6b9322ab2c4f169 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:09:38 -0700 Subject: [PATCH 12/12] update to quote and verbiage improvements --- examples/quoted-replies/README.md | 6 ++--- examples/quoted-replies/src/index.ts | 26 +++++++++---------- .../src/activities/message/message.spec.ts | 16 ++++++------ .../api/src/activities/message/message.ts | 20 +++++++------- packages/apps/src/contexts/activity.test.ts | 8 +++--- packages/apps/src/contexts/activity.ts | 15 ++++++----- 6 files changed, 46 insertions(+), 45 deletions(-) diff --git a/examples/quoted-replies/README.md b/examples/quoted-replies/README.md index fe8326d0d..57ce94748 100644 --- a/examples/quoted-replies/README.md +++ b/examples/quoted-replies/README.md @@ -7,10 +7,10 @@ A bot that demonstrates quoted reply features in Microsoft Teams — referencing | Command | Behavior | |---------|----------| | `test reply` | `reply()` — auto-quotes the inbound message | -| `test quote` | `quoteReply()` — sends a message, then quotes it by ID | -| `test add` | `addQuotedReply()` — sends a message, then quotes it with builder + response | +| `test quote` | `quote()` — sends a message, then quotes it by ID | +| `test add` | `addQuote()` — sends a message, then quotes it with builder + response | | `test multi` | Sends two messages, then quotes both with interleaved responses | -| `test manual` | `addQuotedReply()` + `addText()` — manual control | +| `test manual` | `addQuote()` + `addText()` — manual control | | `help` | Shows available commands | | *(quote a message)* | Bot reads and displays the quoted reply metadata | diff --git a/examples/quoted-replies/src/index.ts b/examples/quoted-replies/src/index.ts index 1e9747f16..28121841f 100644 --- a/examples/quoted-replies/src/index.ts +++ b/examples/quoted-replies/src/index.ts @@ -8,7 +8,7 @@ const app = new App({ plugins: [new DevtoolsPlugin()], }); -app.on('message', async ({ send, reply, quoteReply, activity }) => { +app.on('message', async ({ send, reply, quote, activity }) => { await reply({ type: 'typing' }); const text = activity.text?.toLowerCase() || ''; @@ -39,21 +39,21 @@ app.on('message', async ({ send, reply, quoteReply, activity }) => { } // ============================================ - // quoteReply() — quote a previously sent message by ID + // quote() — quote a previously sent message by ID // ============================================ if (text.includes('test quote')) { const sent = await send('The meeting has been moved to 3 PM tomorrow.'); - await quoteReply(sent.id, 'Just to confirm — does the new time work for everyone?'); + await quote(sent.id, 'Just to confirm — does the new time work for everyone?'); return; } // ============================================ - // addQuotedReply() — builder with response + // addQuote() — builder with response // ============================================ if (text.includes('test add')) { const sent = await send('Please review the latest PR before end of day.'); const msg = new MessageActivity() - .addQuotedReply(sent.id, 'Done! Left my comments on the PR.'); + .addQuote(sent.id, 'Done! Left my comments on the PR.'); await send(msg); return; } @@ -66,20 +66,20 @@ app.on('message', async ({ send, reply, quoteReply, activity }) => { const sentB = await send('The design mockups are ready for review.'); const sentC = await send('CI pipeline is green on main.'); const msg = new MessageActivity() - .addQuotedReply(sentA.id, 'I can take the docs — will have a draft by Thursday.') - .addQuotedReply(sentB.id, 'Looks great, approved!') - .addQuotedReply(sentC.id); + .addQuote(sentA.id, 'I can take the docs — will have a draft by Thursday.') + .addQuote(sentB.id, 'Looks great, approved!') + .addQuote(sentC.id); await send(msg); return; } // ============================================ - // addQuotedReply() + addText() — manual control + // addQuote() + addText() — manual control // ============================================ if (text.includes('test manual')) { const sent = await send('Deployment to staging is complete.'); const msg = new MessageActivity() - .addQuotedReply(sent.id) + .addQuote(sent.id) .addText(' Verified — all smoke tests passing.'); await send(msg); return; @@ -93,10 +93,10 @@ app.on('message', async ({ send, reply, quoteReply, activity }) => { '**Quoted Replies Test Bot**\n\n' + '**Commands:**\n' + '- `test reply` - reply() auto-quotes your message\n' + - '- `test quote` - quoteReply() quotes a previously sent message\n' + - '- `test add` - addQuotedReply() builder with response\n' + + '- `test quote` - quote() quotes a previously sent message\n' + + '- `test add` - addQuote() builder with response\n' + '- `test multi` - Multi-quote with mixed responses (one bare quote with no response)\n' + - '- `test manual` - addQuotedReply() + addText() manual control\n\n' + + '- `test manual` - addQuote() + addText() manual control\n\n' + 'Quote any message to me to see the parsed metadata!' ); return; diff --git a/packages/api/src/activities/message/message.spec.ts b/packages/api/src/activities/message/message.spec.ts index 2670019ef..0dc5af44c 100644 --- a/packages/api/src/activities/message/message.spec.ts +++ b/packages/api/src/activities/message/message.spec.ts @@ -421,9 +421,9 @@ describe('MessageActivity', () => { }); }); - describe('addQuotedReply', () => { + describe('addQuote', () => { it('should add entity and append placeholder', () => { - const activity = new MessageActivity().addQuotedReply('msg-1'); + const activity = new MessageActivity().addQuote('msg-1'); expect(activity.entities).toHaveLength(1); expect(activity.entities![0]).toEqual( expect.objectContaining({ type: 'quotedReply', quotedReply: { messageId: 'msg-1' } }) @@ -432,14 +432,14 @@ describe('MessageActivity', () => { }); it('should append response text after placeholder', () => { - const activity = new MessageActivity().addQuotedReply('msg-1', 'my response'); + const activity = new MessageActivity().addQuote('msg-1', 'my response'); expect(activity.text).toEqual(' my response'); }); it('should support multi-quote with interleaved responses', () => { const activity = new MessageActivity() - .addQuotedReply('msg-1', 'response to first') - .addQuotedReply('msg-2', 'response to second'); + .addQuote('msg-1', 'response to first') + .addQuote('msg-2', 'response to second'); expect(activity.text).toEqual( ' response to first response to second' ); @@ -448,8 +448,8 @@ describe('MessageActivity', () => { it('should support grouped quotes', () => { const activity = new MessageActivity() - .addQuotedReply('msg-1') - .addQuotedReply('msg-2', 'response to both'); + .addQuote('msg-1') + .addQuote('msg-2', 'response to both'); expect(activity.text).toEqual( ' response to both' ); @@ -457,7 +457,7 @@ describe('MessageActivity', () => { it('should be chainable', () => { const activity = new MessageActivity() - .addQuotedReply('msg-1') + .addQuote('msg-1') .addText(' manual text'); expect(activity.text).toEqual(' manual text'); }); diff --git a/packages/api/src/activities/message/message.ts b/packages/api/src/activities/message/message.ts index 6faa2abae..3f49ef67b 100644 --- a/packages/api/src/activities/message/message.ts +++ b/packages/api/src/activities/message/message.ts @@ -418,17 +418,17 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi } /** - * Add a quotedReply entity for the given message ID and append a - * `` placeholder to text. - * If response is provided, it is appended after the placeholder. - * @param messageId - The IC3 message ID of the message to quote - * @param response - Optional response text to append after the placeholder + * Add a quoted message reference and append a `` placeholder to text. + * Teams renders the quoted message as a preview bubble above the response text. + * If text is provided, it is appended to the quoted message placeholder. + * @param messageId - The ID of the message to quote + * @param text - Optional text, appended to the quoted message placeholder * @returns this instance for chaining * * @experimental This API is in preview and may change in the future. * Diagnostic: ExperimentalTeamsQuotedReplies */ - addQuotedReply(messageId: string, response?: string): this { + addQuote(messageId: string, text?: string): this { if (!this.entities) { this.entities = []; } @@ -437,21 +437,21 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi quotedReply: { messageId }, }); this.addText(``); - if (response) { - this.addText(` ${response}`); + if (text) { + this.addText(` ${text}`); } return this; } /** * Prepend a quotedReply entity and `` placeholder - * before existing text. Used by reply()/quoteReply() for quote-above-response. + * before existing text. Used by reply()/quote() for quote-above-response. * @param messageId - The IC3 message ID of the message to quote * * @experimental This API is in preview and may change in the future. * Diagnostic: ExperimentalTeamsQuotedReplies */ - prependQuotedReply(messageId: string): this { + prependQuote(messageId: string): this { if (!this.entities) { this.entities = []; } diff --git a/packages/apps/src/contexts/activity.test.ts b/packages/apps/src/contexts/activity.test.ts index 2083fba18..b01cefff8 100644 --- a/packages/apps/src/contexts/activity.test.ts +++ b/packages/apps/src/contexts/activity.test.ts @@ -175,12 +175,12 @@ describe('ActivityContext', () => { }); - describe('quoteReply', () => { + describe('quote', () => { it('stamps quotedReply entity with given messageId', async () => { const activity = buildIncomingMessageActivity('Hello world'); context = buildActivityContext(activity); - await context.quoteReply('arbitrary-msg-id', 'some text'); + await context.quote('arbitrary-msg-id', 'some text'); expect(mockSender.send).toHaveBeenCalledTimes(1); const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; @@ -198,7 +198,7 @@ describe('ActivityContext', () => { const activity = buildIncomingMessageActivity('Hello world'); context = buildActivityContext(activity); - await context.quoteReply('msg-42', 'reply text'); + await context.quote('msg-42', 'reply text'); const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; expect(sentActivity.text).toEqual(' reply text'); @@ -208,7 +208,7 @@ describe('ActivityContext', () => { const activity = buildIncomingMessageActivity('Hello world'); context = buildActivityContext(activity); - await context.quoteReply('msg-42', { type: 'message' }); + await context.quote('msg-42', { type: 'message' }); const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; expect(sentActivity.text).toEqual(''); diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index e2ad152bd..3751dc95e 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -177,7 +177,7 @@ export interface IBaseActivityContext Promise; + quote: (messageId: string, activity: ActivityLike) => Promise; /** * trigger user signin flow for the activity sender @@ -258,25 +258,26 @@ export class ActivityContext