diff --git a/packages/api/src/clients/conversation/activity.spec.ts b/packages/api/src/clients/conversation/activity.spec.ts index 57f65c3c9..9466f3c30 100644 --- a/packages/api/src/clients/conversation/activity.spec.ts +++ b/packages/api/src/clients/conversation/activity.spec.ts @@ -1,5 +1,7 @@ import { Client } from '@microsoft/teams.common'; +import { AGENTIC_IDENTITY_EXTENSION } from '../auth-provider-interceptor'; + import { ConversationActivityClient } from './activity'; describe('ConversationActivityClient', () => { @@ -16,7 +18,7 @@ describe('ConversationActivityClient', () => { expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities', { type: 'message', text: 'hi', - }); + }, {}); }); it('should use client options', async () => { @@ -31,7 +33,7 @@ describe('ConversationActivityClient', () => { expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities', { type: 'message', text: 'hi', - }); + }, {}); }); it('should create', async () => { @@ -46,7 +48,24 @@ describe('ConversationActivityClient', () => { expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities', { type: 'message', text: 'hi', + }, {}); + }); + + it('should pass serviceUrl and agentic identity options', async () => { + const client = new ConversationActivityClient('https://default.service'); + const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); + const agenticIdentity = { agenticAppId: 'agent-app', agenticUserId: 'agent-user' }; + + await client.create('1', { type: 'message', text: 'hi' }, { + serviceUrl: 'https://override.service/', + agenticIdentity, }); + + expect(spy).toHaveBeenCalledWith( + 'https://override.service/v3/conversations/1/activities', + { type: 'message', text: 'hi' }, + { extensions: { [AGENTIC_IDENTITY_EXTENSION]: agenticIdentity } } + ); }); it('should update', async () => { @@ -61,7 +80,7 @@ describe('ConversationActivityClient', () => { expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2', { type: 'message', text: 'hi', - }); + }, {}); }); it('should reply', async () => { @@ -77,21 +96,21 @@ describe('ConversationActivityClient', () => { type: 'message', text: 'hi', replyToId: '2', - }); + }, {}); }); it('should delete', async () => { const client = new ConversationActivityClient(''); const spy = jest.spyOn(client.http, 'delete').mockResolvedValueOnce({}); await client.delete('1', '2'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2', {}); }); it('should get members', async () => { const client = new ConversationActivityClient(''); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({ data: [] }); await client.getMembers('1', '2'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2/members'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/activities/2/members', {}); }); it('should resolve objectId to aadObjectId in getMembers', async () => { diff --git a/packages/api/src/clients/conversation/activity.ts b/packages/api/src/clients/conversation/activity.ts index ec9add26b..387cedcf4 100644 --- a/packages/api/src/clients/conversation/activity.ts +++ b/packages/api/src/clients/conversation/activity.ts @@ -6,6 +6,7 @@ import { import { Activity } from '../../activities'; import { resolveAadObjectId, Resource, TeamsChannelAccount } from '../../models'; import { ApiClientSettings, mergeApiClientSettings } from '../api-client-settings'; +import { agenticIdentityExtension, RequestOptions, resolveServiceUrl } from '../request-options'; export type ActivityParams = Pick & Partial; @@ -35,65 +36,52 @@ 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, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities`; + const res = await this.http.post(url, params, agenticIdentityExtension(options)); 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, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities/${id}`; + const res = await this.http.put(url, params, agenticIdentityExtension(options)); return res.data; } - async reply(conversationId: string, id: string, params: ActivityParams) { + async reply(conversationId: string, id: string, params: ActivityParams, options?: RequestOptions) { params.replyToId = id; - const res = await this.http.post( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}`, - params - ); + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities/${id}`; + const res = await this.http.post(url, params, agenticIdentityExtension(options)); 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, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities/${id}`; + const res = await this.http.delete(url, agenticIdentityExtension(options)); return res.data; } - async getMembers(conversationId: string, id: string): Promise { - const res = await this.http.get( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}/members` - ); + async getMembers(conversationId: string, id: string, options?: RequestOptions): Promise { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities/${id}/members`; + const res = await this.http.get(url, agenticIdentityExtension(options)); return (res.data ?? []).map(resolveAadObjectId); } - async createTargeted(conversationId: string, params: ActivityParams) { - const res = await this.http.post( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities?isTargetedActivity=true`, - params - ); + async createTargeted(conversationId: string, params: ActivityParams, options?: RequestOptions<'serviceUrl'>) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities?isTargetedActivity=true`; + const res = await this.http.post(url, params); return res.data; } - async updateTargeted(conversationId: string, id: string, params: ActivityParams) { - const res = await this.http.put( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}?isTargetedActivity=true`, - params - ); + async updateTargeted(conversationId: string, id: string, params: ActivityParams, options?: RequestOptions<'serviceUrl'>) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities/${id}?isTargetedActivity=true`; + const res = await this.http.put(url, params); return res.data; } - async deleteTargeted(conversationId: string, id: string) { - const res = await this.http.delete( - `${this.serviceUrl}/v3/conversations/${conversationId}/activities/${id}?isTargetedActivity=true` - ); + async deleteTargeted(conversationId: string, id: string, options?: RequestOptions<'serviceUrl'>) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities/${id}?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 dbc9a1973..2ceb99c97 100644 --- a/packages/api/src/clients/conversation/index.ts +++ b/packages/api/src/clients/conversation/index.ts @@ -8,6 +8,7 @@ import { import { Account, Conversation, ConversationResource } from '../../models'; import { ApiClientSettings, mergeApiClientSettings } from '../api-client-settings'; +import { agenticIdentityExtension, RequestOptions, resolveServiceUrl } from '../request-options'; import { ActivityParams, ConversationActivityClient } from './activity'; import { ConversationMemberClient } from './member'; @@ -85,30 +86,35 @@ 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), - members: (activityId: string) => this._activities.getMembers(conversationId, activityId), - createTargeted: (params: ActivityParams) => this._activities.createTargeted(conversationId, params), - updateTargeted: (id: string, params: ActivityParams) => - this._activities.updateTargeted(conversationId, id, params), - deleteTargeted: (id: string) => this._activities.deleteTargeted(conversationId, id), + create: (params: ActivityParams, options?: RequestOptions) => + this._activities.create(conversationId, params, options), + update: (id: string, params: ActivityParams, options?: RequestOptions) => + this._activities.update(conversationId, id, params, options), + reply: (id: string, params: ActivityParams, options?: RequestOptions) => + this._activities.reply(conversationId, id, params, options), + delete: (id: string, options?: RequestOptions) => + this._activities.delete(conversationId, id, options), + members: (activityId: string, options?: RequestOptions) => + this._activities.getMembers(conversationId, activityId, options), + createTargeted: (params: ActivityParams, options?: RequestOptions<'serviceUrl'>) => + this._activities.createTargeted(conversationId, params, options), + updateTargeted: (id: string, params: ActivityParams, options?: RequestOptions<'serviceUrl'>) => + this._activities.updateTargeted(conversationId, id, params, options), + deleteTargeted: (id: string, options?: RequestOptions<'serviceUrl'>) => + this._activities.deleteTargeted(conversationId, id, options), }; } members(conversationId: string) { return { - get: () => this._members.get(conversationId), - getById: (id: string) => this._members.getById(conversationId, id), - getPaged: (pageSize?: number, continuationToken?: string) => - this._members.getPaged(conversationId, pageSize, continuationToken), + get: (options?: RequestOptions) => this._members.get(conversationId, options), + getById: (id: string, options?: RequestOptions) => this._members.getById(conversationId, id, options), + getPaged: (pageSize?: number, continuationToken?: string, options?: RequestOptions) => + this._members.getPaged(conversationId, pageSize, continuationToken, options), /** * @deprecated This will be removed by end of summer 2026. */ - delete: (id: string) => this._members.delete(conversationId, id), + delete: (id: string, options?: RequestOptions) => this._members.delete(conversationId, id, options), }; } @@ -123,11 +129,9 @@ export class ConversationClient { return res.data; } - async create(params: CreateConversationParams) { - const res = await this.http.post( - `${this.serviceUrl}/v3/conversations`, - params - ); + async create(params: CreateConversationParams, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations`; + const res = await this.http.post(url, params, agenticIdentityExtension(options)); return res.data; } } diff --git a/packages/api/src/clients/conversation/member.ts b/packages/api/src/clients/conversation/member.ts index e51505caf..a27866f74 100644 --- a/packages/api/src/clients/conversation/member.ts +++ b/packages/api/src/clients/conversation/member.ts @@ -5,6 +5,7 @@ import { import { PagedMembersResult, resolveAadObjectId, TeamsChannelAccount } from '../../models'; import { ApiClientSettings, mergeApiClientSettings } from '../api-client-settings'; +import { agenticIdentityExtension, RequestOptions, resolveServiceUrl } from '../request-options'; export class ConversationMemberClient { readonly serviceUrl: string; @@ -31,17 +32,15 @@ export class ConversationMemberClient { this._apiClientSettings = mergeApiClientSettings(apiClientSettings); } - async get(conversationId: string): Promise { - const res = await this.http.get( - `${this.serviceUrl}/v3/conversations/${conversationId}/members` - ); + async get(conversationId: string, options?: RequestOptions): Promise { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/members`; + const res = await this.http.get(url, agenticIdentityExtension(options)); return res.data.map(resolveAadObjectId); } - async getById(conversationId: string, id: string): Promise { - const res = await this.http.get( - `${this.serviceUrl}/v3/conversations/${conversationId}/members/${id}` - ); + async getById(conversationId: string, id: string, options?: RequestOptions): Promise { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/members/${id}`; + const res = await this.http.get(url, agenticIdentityExtension(options)); return resolveAadObjectId(res.data); } @@ -52,25 +51,22 @@ export class ConversationMemberClient { * @param continuationToken - Optional token from a previous call to fetch the next page. * @returns PagedMembersResult containing members and an optional continuation token. */ - async getPaged(conversationId: string, pageSize?: number, continuationToken?: string): Promise { + async getPaged(conversationId: string, pageSize?: number, continuationToken?: string, options?: RequestOptions): Promise { const params: Record = {}; if (pageSize !== undefined) params['pageSize'] = pageSize; if (continuationToken !== undefined) params['continuationToken'] = continuationToken; - const res = await this.http.get( - `${this.serviceUrl}/v3/conversations/${conversationId}/pagedMembers`, - { params } - ); + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/pagedMembers`; + const res = await this.http.get(url, { params, ...agenticIdentityExtension(options) }); return { ...res.data, members: res.data.members.map(resolveAadObjectId) }; } /** * @deprecated This will be removed by end of summer 2026. */ - async delete(conversationId: string, id: string) { - const res = await this.http.delete( - `${this.serviceUrl}/v3/conversations/${conversationId}/members/${id}` - ); + async delete(conversationId: string, id: string, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/members/${id}`; + const res = await this.http.delete(url, agenticIdentityExtension(options)); return res.data; } } diff --git a/packages/api/src/clients/conversation/members.spec.ts b/packages/api/src/clients/conversation/members.spec.ts index 74e0cf6ef..9292d7c19 100644 --- a/packages/api/src/clients/conversation/members.spec.ts +++ b/packages/api/src/clients/conversation/members.spec.ts @@ -8,28 +8,28 @@ describe('ConversationMemberClient', () => { const client = new ConversationMemberClient('', http); const spy = jest.spyOn(http, 'get').mockResolvedValueOnce({ data: [] }); await client.get('1'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members', {}); }); it('should use client options', async () => { const client = new ConversationMemberClient('', {}); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({ data: [] }); await client.get('1'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members', {}); }); it('should get', async () => { const client = new ConversationMemberClient(''); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({ data: [] }); await client.get('1'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members', {}); }); it('should get by id', async () => { const client = new ConversationMemberClient(''); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({ data: {} }); await client.getById('1', '2'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members/2'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members/2', {}); }); it('should get paged', async () => { @@ -52,6 +52,6 @@ describe('ConversationMemberClient', () => { const client = new ConversationMemberClient(''); const spy = jest.spyOn(client.http, 'delete').mockResolvedValueOnce({}); await client.delete('1', '2'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members/2'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/1/members/2', {}); }); }); diff --git a/packages/api/src/clients/index.ts b/packages/api/src/clients/index.ts index 088809417..882b86998 100644 --- a/packages/api/src/clients/index.ts +++ b/packages/api/src/clients/index.ts @@ -102,3 +102,4 @@ export * from './team'; export * from './api-client-settings'; export * from './auth'; export * from './auth-provider-interceptor'; +export * from './request-options'; diff --git a/packages/api/src/clients/meeting.spec.ts b/packages/api/src/clients/meeting.spec.ts index e4891b35d..bf847c7c8 100644 --- a/packages/api/src/clients/meeting.spec.ts +++ b/packages/api/src/clients/meeting.spec.ts @@ -1,5 +1,6 @@ import { Client } from '@microsoft/teams.common'; +import { AGENTIC_IDENTITY_EXTENSION } from './auth-provider-interceptor'; import { MeetingClient } from './meeting'; describe('MeetingClient', () => { @@ -8,14 +9,14 @@ describe('MeetingClient', () => { const client = new MeetingClient('', http); const spy = jest.spyOn(http, 'get').mockResolvedValueOnce({}); await client.getById('1'); - expect(spy).toHaveBeenCalledWith('/v1/meetings/1'); + expect(spy).toHaveBeenCalledWith('/v1/meetings/1', {}); }); it('should use client options', async () => { const client = new MeetingClient('', {}); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({}); await client.getById('1'); - expect(spy).toHaveBeenCalledWith('/v1/meetings/1'); + expect(spy).toHaveBeenCalledWith('/v1/meetings/1', {}); }); it('should use replaced http client for subsequent calls', async () => { @@ -25,7 +26,7 @@ describe('MeetingClient', () => { const newSpy = jest.spyOn(http, 'get').mockResolvedValueOnce({}); client.http = http; await client.getById('123'); - expect(newSpy).toHaveBeenCalledWith('/v1/meetings/123'); + expect(newSpy).toHaveBeenCalledWith('/v1/meetings/123', {}); expect(oldSpy).not.toHaveBeenCalled(); }); @@ -33,29 +34,26 @@ describe('MeetingClient', () => { const client = new MeetingClient(''); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({}); await client.getById('1'); - expect(spy).toHaveBeenCalledWith('/v1/meetings/1'); + expect(spy).toHaveBeenCalledWith('/v1/meetings/1', {}); }); it('should get participant', async () => { const client = new MeetingClient(''); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({}); await client.getParticipant('1', '2', '3'); - expect(spy).toHaveBeenCalledWith('/v1/meetings/1/participants/2?tenantId=3'); + expect(spy).toHaveBeenCalledWith('/v1/meetings/1/participants/2?tenantId=3', {}); }); - it('should URL-encode meeting id in getById', async () => { - const client = new MeetingClient(''); + it('should pass serviceUrl and agentic identity options', async () => { + const client = new MeetingClient('https://default.service'); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({}); - await client.getById('abc+def/ghi='); - expect(spy).toHaveBeenCalledWith('/v1/meetings/abc%2Bdef%2Fghi%3D'); - }); + const agenticIdentity = { agenticAppId: 'agent-app', agenticUserId: 'agent-user' }; + + await client.getById('1', { serviceUrl: 'https://override.service/', agenticIdentity }); - it('should URL-encode participant parameters', async () => { - const client = new MeetingClient(''); - const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({}); - await client.getParticipant('abc+def/ghi=', 'user=1', 'tenant/1'); expect(spy).toHaveBeenCalledWith( - '/v1/meetings/abc%2Bdef%2Fghi%3D/participants/user%3D1?tenantId=tenant%2F1' + 'https://override.service/v1/meetings/1', + { extensions: { [AGENTIC_IDENTITY_EXTENSION]: agenticIdentity } } ); }); @@ -68,18 +66,7 @@ describe('MeetingClient', () => { expect(spy).toHaveBeenCalledWith('/v1/meetings/1/notification', { type: 'targetedMeetingNotification', value: { recipients: ['user1'], surfaces: [{ surface: 'meetingTabIcon' }] }, - }); + }, {}); }); - it('should URL-encode meeting id in sendNotification', async () => { - const client = new MeetingClient(''); - const spy = jest.spyOn(client.http, 'post').mockResolvedValueOnce({}); - await client.sendNotification('abc+def/ghi=', { - value: { recipients: ['user1'], surfaces: [{ surface: 'meetingTabIcon' }] }, - }); - expect(spy).toHaveBeenCalledWith( - '/v1/meetings/abc%2Bdef%2Fghi%3D/notification', - expect.any(Object) - ); - }); }); diff --git a/packages/api/src/clients/meeting.ts b/packages/api/src/clients/meeting.ts index f70340b4e..0854adce3 100644 --- a/packages/api/src/clients/meeting.ts +++ b/packages/api/src/clients/meeting.ts @@ -11,6 +11,7 @@ import { } from '../models'; import { ApiClientSettings, mergeApiClientSettings } from './api-client-settings'; +import { agenticIdentityExtension, RequestOptions, resolveServiceUrl } from './request-options'; export class MeetingClient { readonly serviceUrl: string; @@ -42,8 +43,9 @@ export class MeetingClient { * Retrieves meeting information including details, organizer, and conversation. * @param id - The meeting ID. */ - async getById(id: string) { - const res = await this.http.get(`${this.serviceUrl}/v1/meetings/${encodeURIComponent(id)}`); + async getById(id: string, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v1/meetings/${id}`; + const res = await this.http.get(url, agenticIdentityExtension(options)); return res.data; } @@ -54,10 +56,9 @@ export class MeetingClient { * @param tenantId - The tenant ID of the meeting and user. * @returns {MeetingParticipant} The meeting participant information. */ - async getParticipant(meetingId: string, id: string, tenantId: string) { - const res = await this.http.get( - `${this.serviceUrl}/v1/meetings/${encodeURIComponent(meetingId)}/participants/${encodeURIComponent(id)}?tenantId=${encodeURIComponent(tenantId)}` - ); + async getParticipant(meetingId: string, id: string, tenantId: string, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v1/meetings/${meetingId}/participants/${id}?tenantId=${tenantId}`; + const res = await this.http.get(url, agenticIdentityExtension(options)); return res.data; } @@ -75,16 +76,15 @@ export class MeetingClient { */ async sendNotification( meetingId: string, - params: MeetingNotificationParams + params: MeetingNotificationParams, + options?: RequestOptions ): Promise { const body = { type: params.type ?? 'targetedMeetingNotification', value: params.value, }; - const res = await this.http.post( - `${this.serviceUrl}/v1/meetings/${encodeURIComponent(meetingId)}/notification`, - body - ); + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v1/meetings/${meetingId}/notification`; + const res = await this.http.post(url, body, agenticIdentityExtension(options)); return res.data || undefined; } } diff --git a/packages/api/src/clients/reaction/reaction.spec.ts b/packages/api/src/clients/reaction/reaction.spec.ts index 65af798b9..390b47125 100644 --- a/packages/api/src/clients/reaction/reaction.spec.ts +++ b/packages/api/src/clients/reaction/reaction.spec.ts @@ -1,5 +1,7 @@ import { Client } from '@microsoft/teams.common'; +import { AGENTIC_IDENTITY_EXTENSION } from '../auth-provider-interceptor'; + import { ReactionClient } from './reaction'; describe('ReactionClient', () => { @@ -8,14 +10,14 @@ describe('ReactionClient', () => { const client = new ReactionClient('', http); const spy = jest.spyOn(http, 'put').mockResolvedValueOnce({}); await client.add('conv1', 'act1', 'like'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like', undefined, {}); }); it('should use client options', async () => { const client = new ReactionClient('', {}); const spy = jest.spyOn(client.http, 'put').mockResolvedValueOnce({}); await client.add('conv1', 'act1', 'like'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like', undefined, {}); }); it('should use replaced http client for subsequent calls', async () => { @@ -25,7 +27,7 @@ describe('ReactionClient', () => { const newSpy = jest.spyOn(http, 'put').mockResolvedValueOnce({}); client.http = http; await client.add('conv1', 'act1', 'like'); - expect(newSpy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like'); + expect(newSpy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like', undefined, {}); expect(oldSpy).not.toHaveBeenCalled(); }); @@ -33,41 +35,31 @@ describe('ReactionClient', () => { const client = new ReactionClient(''); const spy = jest.spyOn(client.http, 'put').mockResolvedValueOnce({}); await client.add('conv1', 'act1', 'like'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like', undefined, {}); }); - it('should delete reaction', async () => { - const client = new ReactionClient(''); - const spy = jest.spyOn(client.http, 'delete').mockResolvedValueOnce({}); - await client.delete('conv1', 'act1', 'like'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like'); - }); - - it('should URL-encode conversation id in add', async () => { - const client = new ReactionClient(''); + it('should pass serviceUrl and agentic identity options', async () => { + const client = new ReactionClient('https://default.service'); const spy = jest.spyOn(client.http, 'put').mockResolvedValueOnce({}); - await client.add('conv+1/test=', 'act1', 'like'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/conv%2B1%2Ftest%3D/activities/act1/reactions/like'); - }); + const agenticIdentity = { agenticAppId: 'agent-app', agenticUserId: 'agent-user' }; - it('should URL-encode activity id in add', async () => { - const client = new ReactionClient(''); - const spy = jest.spyOn(client.http, 'put').mockResolvedValueOnce({}); - await client.add('conv1', 'act+1/test=', 'like'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act%2B1%2Ftest%3D/reactions/like'); - }); + await client.add('conv1', 'act1', 'like', { + serviceUrl: 'https://override.service/', + agenticIdentity, + }); - it('should URL-encode reaction type in add', async () => { - const client = new ReactionClient(''); - const spy = jest.spyOn(client.http, 'put').mockResolvedValueOnce({}); - await client.add('conv1', 'act1', 'like'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like'); + expect(spy).toHaveBeenCalledWith( + 'https://override.service/v3/conversations/conv1/activities/act1/reactions/like', + undefined, + { extensions: { [AGENTIC_IDENTITY_EXTENSION]: agenticIdentity } } + ); }); - it('should URL-encode parameters in delete', async () => { + it('should delete reaction', async () => { const client = new ReactionClient(''); const spy = jest.spyOn(client.http, 'delete').mockResolvedValueOnce({}); - await client.delete('conv+1/test=', 'act+1/test=', 'heart'); - expect(spy).toHaveBeenCalledWith('/v3/conversations/conv%2B1%2Ftest%3D/activities/act%2B1%2Ftest%3D/reactions/heart'); + await client.delete('conv1', 'act1', 'like'); + expect(spy).toHaveBeenCalledWith('/v3/conversations/conv1/activities/act1/reactions/like', {}); }); + }); diff --git a/packages/api/src/clients/reaction/reaction.ts b/packages/api/src/clients/reaction/reaction.ts index c43f6291b..ab7a48269 100644 --- a/packages/api/src/clients/reaction/reaction.ts +++ b/packages/api/src/clients/reaction/reaction.ts @@ -6,6 +6,7 @@ import { import { MessageReactionType } from '../../models/message/message-reaction'; import { ApiClientSettings, mergeApiClientSettings } from '../api-client-settings'; +import { agenticIdentityExtension, RequestOptions, resolveServiceUrl } from '../request-options'; /** * Client for adding and removing emoji reactions on messages in a conversation. @@ -39,20 +40,18 @@ export class ReactionClient { /** * Add a reaction to a message. */ - async add(conversationId: string, activityId: string, reactionType: MessageReactionType) { - const res = await this.http.put( - `${this.serviceUrl}/v3/conversations/${encodeURIComponent(conversationId)}/activities/${encodeURIComponent(activityId)}/reactions/${encodeURIComponent(reactionType)}` - ); + async add(conversationId: string, activityId: string, reactionType: MessageReactionType, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities/${activityId}/reactions/${reactionType}`; + const res = await this.http.put(url, undefined, agenticIdentityExtension(options)); return res.data; } /** * Delete a reaction from a message. */ - async delete(conversationId: string, activityId: string, reactionType: MessageReactionType) { - const res = await this.http.delete( - `${this.serviceUrl}/v3/conversations/${encodeURIComponent(conversationId)}/activities/${encodeURIComponent(activityId)}/reactions/${encodeURIComponent(reactionType)}` - ); + async delete(conversationId: string, activityId: string, reactionType: MessageReactionType, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/conversations/${conversationId}/activities/${activityId}/reactions/${reactionType}`; + const res = await this.http.delete(url, agenticIdentityExtension(options)); return res.data; } } diff --git a/packages/api/src/clients/request-options.ts b/packages/api/src/clients/request-options.ts new file mode 100644 index 000000000..2bb9d6166 --- /dev/null +++ b/packages/api/src/clients/request-options.ts @@ -0,0 +1,19 @@ +import type { AgenticIdentity } from '../models'; + +import { AGENTIC_IDENTITY_EXTENSION } from './auth-provider-interceptor'; + +type AllRequestOptions = { + readonly agenticIdentity?: AgenticIdentity; + readonly serviceUrl?: string; +}; + +export type RequestOptions = Pick; + +export function agenticIdentityExtension(options?: RequestOptions): Record { + if (!options?.agenticIdentity) return {}; + return { extensions: { [AGENTIC_IDENTITY_EXTENSION]: options.agenticIdentity } }; +} + +export function resolveServiceUrl(defaultServiceUrl: string, options?: { serviceUrl?: string }): string { + return (options?.serviceUrl ?? defaultServiceUrl).replace(/\/+$/, ''); +} diff --git a/packages/api/src/clients/team.spec.ts b/packages/api/src/clients/team.spec.ts index 29806b4fc..c7abcedc7 100644 --- a/packages/api/src/clients/team.spec.ts +++ b/packages/api/src/clients/team.spec.ts @@ -1,5 +1,6 @@ import { Client } from '@microsoft/teams.common'; +import { AGENTIC_IDENTITY_EXTENSION } from './auth-provider-interceptor'; import { TeamClient } from './team'; describe('TeamClient', () => { @@ -8,21 +9,21 @@ describe('TeamClient', () => { const client = new TeamClient('', http); const spy = jest.spyOn(http, 'get').mockResolvedValueOnce({}); await client.getById('1'); - expect(spy).toHaveBeenCalledWith('/v3/teams/1'); + expect(spy).toHaveBeenCalledWith('/v3/teams/1', {}); }); it('should use client options', async () => { const client = new TeamClient('', {}); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({}); await client.getById('1'); - expect(spy).toHaveBeenCalledWith('/v3/teams/1'); + expect(spy).toHaveBeenCalledWith('/v3/teams/1', {}); }); it('should get by id', async () => { const client = new TeamClient(''); const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({}); await client.getById('1'); - expect(spy).toHaveBeenCalledWith('/v3/teams/1'); + expect(spy).toHaveBeenCalledWith('/v3/teams/1', {}); }); it('should get conversations', async () => { @@ -31,6 +32,19 @@ describe('TeamClient', () => { .spyOn(client.http, 'get') .mockResolvedValueOnce({ data: { conversations: [] } }); await client.getConversations('1'); - expect(spy).toHaveBeenCalledWith('/v3/teams/1/conversations'); + expect(spy).toHaveBeenCalledWith('/v3/teams/1/conversations', {}); + }); + + it('should pass serviceUrl and agentic identity options', async () => { + const client = new TeamClient('https://default.service'); + const spy = jest.spyOn(client.http, 'get').mockResolvedValueOnce({}); + const agenticIdentity = { agenticAppId: 'agent-app', agenticUserId: 'agent-user' }; + + await client.getById('1', { serviceUrl: 'https://override.service/', agenticIdentity }); + + expect(spy).toHaveBeenCalledWith( + 'https://override.service/v3/teams/1', + { extensions: { [AGENTIC_IDENTITY_EXTENSION]: agenticIdentity } } + ); }); }); diff --git a/packages/api/src/clients/team.ts b/packages/api/src/clients/team.ts index ebb66ebcb..964ec5e08 100644 --- a/packages/api/src/clients/team.ts +++ b/packages/api/src/clients/team.ts @@ -6,6 +6,7 @@ import { import { ChannelInfo, TeamDetails } from '../models'; import { ApiClientSettings, mergeApiClientSettings } from './api-client-settings'; +import { agenticIdentityExtension, RequestOptions, resolveServiceUrl } from './request-options'; export class TeamClient { readonly serviceUrl: string; @@ -33,15 +34,15 @@ export class TeamClient { this._apiClientSettings = mergeApiClientSettings(apiClientSettings); } - async getById(id: string) { - const res = await this.http.get(`${this.serviceUrl}/v3/teams/${id}`); + async getById(id: string, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/teams/${id}`; + const res = await this.http.get(url, agenticIdentityExtension(options)); return res.data; } - async getConversations(id: string) { - const res = await this.http.get<{ conversations: ChannelInfo[] }>( - `${this.serviceUrl}/v3/teams/${id}/conversations` - ); + async getConversations(id: string, options?: RequestOptions) { + const url = `${resolveServiceUrl(this.serviceUrl, options)}/v3/teams/${id}/conversations`; + const res = await this.http.get<{ conversations: ChannelInfo[] }>(url, agenticIdentityExtension(options)); return res.data.conversations; } } diff --git a/packages/apps/src/activity-sender.spec.ts b/packages/apps/src/activity-sender.spec.ts index a298ae541..4a49dee6b 100644 --- a/packages/apps/src/activity-sender.spec.ts +++ b/packages/apps/src/activity-sender.spec.ts @@ -38,7 +38,8 @@ describe('ActivitySender', () => { text: 'hello', from: ref.bot, conversation: ref.conversation, - }) + }), + {} ); expect(result).toEqual(expect.objectContaining({ id: 'activity-1' })); }); @@ -54,7 +55,8 @@ describe('ActivitySender', () => { expect(mockHttpClient.put).toHaveBeenCalledWith( 'https://smba.trafficmanager.net/teams/v3/conversations/conv-123/activities/existing-id', - expect.objectContaining({ type: 'message', text: 'updated' }) + expect.objectContaining({ type: 'message', text: 'updated' }), + {} ); expect(mockHttpClient.post).not.toHaveBeenCalled(); }); @@ -112,7 +114,8 @@ describe('ActivitySender', () => { expect.objectContaining({ from: { id: 'bot-id', name: 'Bot', role: 'bot' }, conversation: { id: 'conv-123', conversationType: 'personal' }, - }) + }), + {} ); }); @@ -127,7 +130,8 @@ describe('ActivitySender', () => { expect(mockHttpClient.post).toHaveBeenCalledWith( 'https://custom-service.botframework.com/v3/conversations/conv-456/activities', - expect.any(Object) + expect.any(Object), + {} ); });