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..57ce94748 --- /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` | `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` | `addQuote()` + `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 000000000..f27ccf203 Binary files /dev/null and b/examples/quoted-replies/appPackage/color.png differ diff --git a/examples/quoted-replies/appPackage/manifest.json b/examples/quoted-replies/appPackage/manifest.json new file mode 100644 index 000000000..ba143334d --- /dev/null +++ b/examples/quoted-replies/appPackage/manifest.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.20/MicrosoftTeams.schema.json", + "version": "1.0.0", + "manifestVersion": "1.20", + "id": "${{TEAMS_APP_ID}}", + "name": { + "short": "quoted-replies", + "full": "Quoted Replies Example" + }, + "developer": { + "name": "Microsoft", + "mpnId": "", + "websiteUrl": "https://microsoft.com", + "privacyUrl": "https://privacy.microsoft.com/privacystatement", + "termsOfUseUrl": "https://www.microsoft.com/legal/terms-of-use" + }, + "description": { + "short": "Bot demonstrating quoted replies", + "full": "A sample bot that demonstrates how to send and receive quoted replies in Microsoft Teams." + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "staticTabs": [ + { + "entityId": "conversations", + "scopes": ["personal"] + }, + { + "entityId": "about", + "scopes": ["personal"] + } + ], + "bots": [ + { + "botId": "${{BOT_ID}}", + "scopes": ["personal", "team", "groupChat"], + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "supportsFiles": false + } + ], + "validDomains": ["${{BOT_DOMAIN}}", "*.botframework.com"], + "webApplicationInfo": { + "id": "${{BOT_ID}}", + "resource": "api://botid-${{BOT_ID}}" + } +} diff --git a/examples/quoted-replies/appPackage/outline.png b/examples/quoted-replies/appPackage/outline.png new file mode 100644 index 000000000..e8cb4b6ba Binary files /dev/null and b/examples/quoted-replies/appPackage/outline.png differ 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..28121841f --- /dev/null +++ b/examples/quoted-replies/src/index.ts @@ -0,0 +1,115 @@ +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, quote, 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('Thanks for your message! This reply auto-quotes it using reply().'); + return; + } + + // ============================================ + // 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 quote(sent.id, 'Just to confirm — does the new time work for everyone?'); + return; + } + + // ============================================ + // 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() + .addQuote(sent.id, 'Done! Left my comments on the PR.'); + await send(msg); + return; + } + + // ============================================ + // Multi-quote with mixed responses + // ============================================ + if (text.includes('test multi')) { + 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() + .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; + } + + // ============================================ + // addQuote() + addText() — manual control + // ============================================ + if (text.includes('test manual')) { + const sent = await send('Deployment to staging is complete.'); + const msg = new MessageActivity() + .addQuote(sent.id) + .addText(' Verified — all smoke tests passing.'); + 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` - 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` - addQuote() + 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"] +} 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 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/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; diff --git a/packages/api/src/activities/message/message.spec.ts b/packages/api/src/activities/message/message.spec.ts index 40e111f8d..0dc5af44c 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('addQuote', () => { + it('should add entity and append placeholder', () => { + 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' } }) + ); + expect(activity.text).toEqual(''); + }); + + it('should append response text after placeholder', () => { + 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() + .addQuote('msg-1', 'response to first') + .addQuote('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() + .addQuote('msg-1') + .addQuote('msg-2', 'response to both'); + expect(activity.text).toEqual( + ' response to both' + ); + }); + + it('should be chainable', () => { + const activity = new MessageActivity() + .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 3e38beb55..3f49ef67b 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,14 @@ 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 + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsQuotedReplies + */ + getQuotedMessages(): QuotedReplyEntity[]; } export class MessageActivity extends Activity<'message'> implements IMessageActivity { @@ -188,6 +197,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 +371,18 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi .find((e) => e.mentioned.id === accountId); } + /** + * 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( + (e): e is QuotedReplyEntity => e.type === 'quotedReply' + ); + } + /** * Add stream info, making * this a final stream message @@ -394,6 +416,54 @@ export class MessageActivity extends Activity<'message'> implements IMessageActi super.withRecipient(account, isTargeted); return this; } + + /** + * 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 + */ + addQuote(messageId: string, text?: string): this { + if (!this.entities) { + this.entities = []; + } + this.entities.push({ + type: 'quotedReply', + quotedReply: { messageId }, + }); + this.addText(``); + if (text) { + this.addText(` ${text}`); + } + return this; + } + + /** + * Prepend a quotedReply entity and `` placeholder + * 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 + */ + prependQuote(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/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..3033ff1b7 --- /dev/null +++ b/packages/api/src/models/entity/quoted-reply-entity.ts @@ -0,0 +1,63 @@ +/** + * 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 + */ + 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 (IC3 epoch value, e.g. "1772050244572"). + * Populated on inbound; ignored on outbound. + */ + time?: string | null; + + /** + * whether the quoted message has been deleted + */ + isReplyDeleted?: boolean; + + /** + * whether the message reference has been validated + */ + 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'; + + /** + * the quoted reply data + */ + quotedReply: QuotedReplyData; + + /** + * other properties + */ + [key: string]: any; +}; 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.test.ts b/packages/apps/src/contexts/activity.test.ts index ba0606ec1..b01cefff8 100644 --- a/packages/apps/src/contexts/activity.test.ts +++ b/packages/apps/src/contexts/activity.test.ts @@ -115,80 +115,105 @@ 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.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('does not add blockquotes for empty quoted messages', async () => { - const activity = buildIncomingMessageActivity(''); + 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('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'); + }); + describe('quote', () => { + it('stamps quotedReply entity with given messageId', async () => { + const activity = buildIncomingMessageActivity('Hello world'); context = buildActivityContext(activity); - await context.reply(''); + await context.quote('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.quote('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.quote('msg-42', { type: 'message' }); + + const sentActivity = (mockSender.send as jest.Mock).mock.calls[0][0]; + expect(sentActivity.text).toEqual(''); + }); + }); describe('send', () => { diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index 763041ec8..3751dc95e 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, @@ -163,11 +164,21 @@ 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 + * + * @experimental This API is in preview and may change in the future. + * Diagnostic: ExperimentalTeamsQuotedReplies + */ + quote: (messageId: string, activity: ActivityLike) => Promise; + /** * trigger user signin flow for the activity sender * @param options options for the signin flow @@ -207,27 +218,28 @@ 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; - } }