From 9ed54813ff6f2ee27c071437cc55e97800465147 Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:43:43 -0800 Subject: [PATCH] Revert "Revert "Add support for Targeted Messages" (#429)" This reverts commit 9d8250fc58386ccc41ae30c8b45df0a726dae3f7. --- .../src/clients/conversation/activity.spec.ts | 72 ++++++++++++++++++ .../api/src/clients/conversation/activity.ts | 47 +++++++----- .../api/src/clients/conversation/index.ts | 14 ++-- packages/apps/src/app.spec.ts | 74 +++++++++++++++++++ packages/apps/src/app.ts | 13 +++- packages/apps/src/contexts/activity.test.ts | 21 ++++-- packages/apps/src/contexts/activity.ts | 8 +- packages/apps/src/plugins/http/plugin.ts | 8 +- packages/apps/src/plugins/http/stream.ts | 9 ++- packages/apps/src/types/plugin/sender.ts | 5 +- 10 files changed, 226 insertions(+), 45 deletions(-) diff --git a/packages/api/src/clients/conversation/activity.spec.ts b/packages/api/src/clients/conversation/activity.spec.ts index c6c259228..dd1fa4730 100644 --- a/packages/api/src/clients/conversation/activity.spec.ts +++ b/packages/api/src/clients/conversation/activity.spec.ts @@ -93,4 +93,76 @@ describe('ConversationActivityClient', () => { await client.getMembers('1', '2'); expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2/members'); }); + + describe('targeted messages', () => { + it('should create with query parameter when isTargeted is true', async () => { + const client = new ConversationActivityClient(''); + const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); + + await client.create('1', { + type: 'message', + text: 'hi', + }, true); + + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities?isTargetedActivity=true', { + type: 'message', + text: 'hi', + }); + }); + + it('should update with query parameter when isTargeted is true', async () => { + const client = new ConversationActivityClient(''); + const spy = jest.spyOn(client.http, 'put').mockResolvedValueOnce({}); + + await client.update('1', '2', { + type: 'message', + text: 'hi', + }, true); + + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2?isTargetedActivity=true', { + type: 'message', + text: 'hi', + }); + }); + + it('should reply with query parameter when isTargeted is true', async () => { + const client = new ConversationActivityClient(''); + const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); + + await client.reply('1', '2', { + type: 'message', + text: 'hi', + }, true); + + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2?isTargetedActivity=true', { + type: 'message', + text: 'hi', + replyToId: '2', + }); + }); + + it('should delete with query parameter when isTargeted is true', async () => { + const client = new ConversationActivityClient(''); + const spy = jest.spyOn(client.http, 'delete').mockResolvedValueOnce({}); + + await client.delete('1', '2', true); + + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2?isTargetedActivity=true'); + }); + + it('should not add query parameter when isTargeted is false', async () => { + const client = new ConversationActivityClient(''); + const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); + + await client.create('1', { + type: 'message', + text: 'hi', + }, false); + + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities', { + type: 'message', + text: 'hi', + }); + }); + }); }); diff --git a/packages/api/src/clients/conversation/activity.ts b/packages/api/src/clients/conversation/activity.ts index ae3140093..f2dd25be6 100644 --- a/packages/api/src/clients/conversation/activity.ts +++ b/packages/api/src/clients/conversation/activity.ts @@ -32,35 +32,44 @@ export class ConversationActivityClient { this._apiClientSettings = mergeApiClientSettings(apiClientSettings); } - async create(conversationId: string, params: ActivityParams) { - const res = await this.http.post( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities`, - params - ); + async create(conversationId: string, params: ActivityParams, isTargeted: boolean = false) { + let url = `${this.serviceUrl}/v3/conversations/${conversationId}/activities`; + if (isTargeted) { + url += '?isTargetedActivity=true'; + } + + const res = await this.http.post(url, params); return res.data; } - async update(conversationId: string, id: string, params: ActivityParams) { - const res = await this.http.put( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}`, - params - ); + async update(conversationId: string, id: string, params: ActivityParams, isTargeted: boolean = false) { + let url = `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}`; + if (isTargeted) { + url += '?isTargetedActivity=true'; + } + + const res = await this.http.put(url, params); return res.data; } - async reply(conversationId: string, id: string, params: ActivityParams) { + async reply(conversationId: string, id: string, params: ActivityParams, isTargeted: boolean = false) { params.replyToId = id; - const res = await this.http.post( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}`, - params - ); + let url = `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}`; + if (isTargeted) { + url += '?isTargetedActivity=true'; + } + + const res = await this.http.post(url, params); return res.data; } - async delete(conversationId: string, id: string) { - const res = await this.http.delete( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}` - ); + async delete(conversationId: string, id: string, isTargeted: boolean = false) { + let url = `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}`; + if (isTargeted) { + url += '?isTargetedActivity=true'; + } + + const res = await this.http.delete(url); return res.data; } diff --git a/packages/api/src/clients/conversation/index.ts b/packages/api/src/clients/conversation/index.ts index 9742283d0..fc41ad812 100644 --- a/packages/api/src/clients/conversation/index.ts +++ b/packages/api/src/clients/conversation/index.ts @@ -67,12 +67,14 @@ export class ConversationClient { activities(conversationId: string) { return { - create: (params: ActivityParams) => this._activities.create(conversationId, params), - update: (id: string, params: ActivityParams) => - this._activities.update(conversationId, id, params), - reply: (id: string, params: ActivityParams) => - this._activities.reply(conversationId, id, params), - delete: (id: string) => this._activities.delete(conversationId, id), + create: (params: ActivityParams, isTargeted?: boolean) => + this._activities.create(conversationId, params, isTargeted), + update: (id: string, params: ActivityParams, isTargeted?: boolean) => + this._activities.update(conversationId, id, params, isTargeted), + reply: (id: string, params: ActivityParams, isTargeted?: boolean) => + this._activities.reply(conversationId, id, params, isTargeted), + delete: (id: string, isTargeted?: boolean) => + this._activities.delete(conversationId, id, isTargeted), members: (activityId: string) => this._activities.getMembers(conversationId, activityId), }; } diff --git a/packages/apps/src/app.spec.ts b/packages/apps/src/app.spec.ts index 78cdc02a3..4725a1806 100644 --- a/packages/apps/src/app.spec.ts +++ b/packages/apps/src/app.spec.ts @@ -169,4 +169,78 @@ describe('App', () => { ).rejects.toThrow('app not started'); }); }); + + describe('targeted messages', () => { + let app: TestApp; + + beforeEach(() => { + app = new TestApp({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + tenantId: 'test-tenant-id', + plugins: [new TestHttpPlugin()], + }); + }); + + it('should set ref.user when isTargeted is true', async () => { + await app.start(); + + const mockSend = jest.fn().mockResolvedValue({ id: 'activity-id' }); + jest.spyOn(app.http, 'send').mockImplementation(mockSend); + + await app.send('conversation-id', { + type: 'message', + text: 'Hello', + recipient: { id: 'user-123', name: 'Test User', role: 'user' }, + }, true); + + expect(mockSend).toHaveBeenCalled(); + const [, ref, isTargeted] = mockSend.mock.calls[0]; + expect(ref.user).toEqual({ id: 'user-123', name: 'Test User', role: 'user' }); + expect(isTargeted).toBe(true); + }); + + it('should not set ref.user when isTargeted is false', async () => { + await app.start(); + + const mockSend = jest.fn().mockResolvedValue({ id: 'activity-id' }); + jest.spyOn(app.http, 'send').mockImplementation(mockSend); + + await app.send('conversation-id', { + type: 'message', + text: 'Hello', + recipient: { id: 'user-123', name: 'Test User', role: 'user' }, + }, false); + + expect(mockSend).toHaveBeenCalled(); + const [, ref, isTargeted] = mockSend.mock.calls[0]; + expect(ref.user).toBeUndefined(); + expect(isTargeted).toBe(false); + }); + + it('should throw error when isTargeted is true but no recipient', async () => { + await app.start(); + + await expect( + app.send('conversation-id', { + type: 'message', + text: 'Hello', + }, true) + ).rejects.toThrow('activity.recipient is required for targeted messages'); + }); + + it('should allow isTargeted false without recipient', async () => { + await app.start(); + + const mockSend = jest.fn().mockResolvedValue({ id: 'activity-id' }); + jest.spyOn(app.http, 'send').mockImplementation(mockSend); + + await app.send('conversation-id', { + type: 'message', + text: 'Hello', + }, false); + + expect(mockSend).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/apps/src/app.ts b/packages/apps/src/app.ts index 622417806..fca219ffd 100644 --- a/packages/apps/src/app.ts +++ b/packages/apps/src/app.ts @@ -399,12 +399,20 @@ export class App { * send an activity proactively * @param conversationId the conversation to send to * @param activity the activity to send + * @param isTargeted when true, sends the message privately to the recipient specified in activity.recipient */ - async send(conversationId: string, activity: ActivityLike) { + async send(conversationId: string, activity: ActivityLike, isTargeted: boolean = false) { if (!this.id) { throw new Error('app not started'); } + const activityParams = toActivityParams(activity); + + // Validate that recipient is provided for targeted messages + if (isTargeted && !activityParams.recipient) { + throw new Error('activity.recipient is required for targeted messages'); + } + const ref: ConversationReference = { channelId: 'msteams', serviceUrl: this.api.serviceUrl, @@ -417,9 +425,10 @@ export class App { id: conversationId, conversationType: 'personal', }, + user: isTargeted ? activityParams.recipient : undefined, }; - const res = await this.http.send(toActivityParams(activity), ref); + const res = await this.http.send(activityParams, ref, isTargeted); return res; } diff --git a/packages/apps/src/contexts/activity.test.ts b/packages/apps/src/contexts/activity.test.ts index 17598fddc..5da53a029 100644 --- a/packages/apps/src/contexts/activity.test.ts +++ b/packages/apps/src/contexts/activity.test.ts @@ -131,7 +131,8 @@ describe('ActivityContext', () => { \r\nWhat is up?`, type: 'message', }), - mockRef + mockRef, + undefined ); }); @@ -152,7 +153,8 @@ describe('ActivityContext', () => { \r\nWhat is up?`, type: 'message', }), - mockRef + mockRef, + undefined ); }); @@ -169,7 +171,8 @@ describe('ActivityContext', () => { text: 'What is up?', type: 'message', }), - mockRef + mockRef, + undefined ); }); @@ -186,7 +189,8 @@ describe('ActivityContext', () => { text: '', type: 'message', }), - mockRef + mockRef, + undefined ); }); }); @@ -203,7 +207,8 @@ describe('ActivityContext', () => { text: 'What is up?', type: 'message', }), - mockRef + mockRef, + undefined ); }); }); @@ -277,7 +282,8 @@ describe('ActivityContext', () => { inputHint: 'acceptingInput', recipient: { id: 'test-user', name: 'Test User', role: 'user' }, }), - mockRef + mockRef, + undefined ); }); @@ -314,7 +320,8 @@ describe('ActivityContext', () => { expect(mockApiClient.conversations.create).toHaveBeenCalled(); expect(mockSender.send).toHaveBeenCalledWith( expect.objectContaining({ type: 'message', text: 'Please Sign In...' }), - expect.any(Object) + expect.any(Object), + undefined ); }); diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index da56494b6..587b7846a 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -212,11 +212,11 @@ export class ActivityContext) { diff --git a/packages/apps/src/plugins/http/plugin.ts b/packages/apps/src/plugins/http/plugin.ts index 32b838eb8..0b63e4d05 100644 --- a/packages/apps/src/plugins/http/plugin.ts +++ b/packages/apps/src/plugins/http/plugin.ts @@ -148,7 +148,7 @@ export class HttpPlugin implements ISender { this._server.close(); } - async send(activity: ActivityParams, ref: ConversationReference) { + async send(activity: ActivityParams, ref: ConversationReference, isTargeted: boolean = false) { const api = new Client( ref.serviceUrl, this.client.clone({ @@ -160,16 +160,18 @@ export class HttpPlugin implements ISender { ...activity, from: ref.bot, conversation: ref.conversation, + // Set recipient from ref.user if provided (for targeted messages) + ...(ref.user && { recipient: ref.user }), }; if (activity.id) { const res = await api.conversations .activities(ref.conversation.id) - .update(activity.id, activity); + .update(activity.id, activity, isTargeted); return { ...activity, ...res }; } - const res = await api.conversations.activities(ref.conversation.id).create(activity); + const res = await api.conversations.activities(ref.conversation.id).create(activity, isTargeted); return { ...activity, ...res }; } diff --git a/packages/apps/src/plugins/http/stream.ts b/packages/apps/src/plugins/http/stream.ts index dd6d507b8..cb10f1798 100644 --- a/packages/apps/src/plugins/http/stream.ts +++ b/packages/apps/src/plugins/http/stream.ts @@ -246,25 +246,28 @@ export class HttpStream implements IStreamer { /** * Send or update a streaming activity * @param activity ActivityParams to send. + * @param isTargeted when true, sends the message privately to the specified recipient */ - protected async send(activity: ActivityParams) { + protected async send(activity: ActivityParams, isTargeted: boolean = false) { activity = { ...activity, from: this.ref.bot, conversation: this.ref.conversation, + // Set recipient from ref.user if provided (for targeted messages) + ...(this.ref.user && { recipient: this.ref.user }), }; if (activity.id && !(activity.entities?.some((e) => e.type === 'streaminfo') || false)) { const res = await this.client.conversations .activities(this.ref.conversation.id) - .update(activity.id, activity); + .update(activity.id, activity, isTargeted); return { ...activity, ...res }; } const res = await this.client.conversations .activities(this.ref.conversation.id) - .create(activity); + .create(activity, isTargeted); return { ...activity, ...res }; } diff --git a/packages/apps/src/types/plugin/sender.ts b/packages/apps/src/types/plugin/sender.ts index ae3b226b9..f345fc275 100644 --- a/packages/apps/src/types/plugin/sender.ts +++ b/packages/apps/src/types/plugin/sender.ts @@ -11,8 +11,11 @@ export interface ISender extends IPlugin; + send(activity: ActivityParams, ref: ConversationReference, isTargeted?: boolean): Promise; /** * called by the `App`