Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions examples/agent365/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# agent365

Demonstrates passing `AgenticIdentity` directly to Teams API surfaces.

## Reactive Echo

`src/main.ts` mimics the echo example. Incoming messages are handled normally; the inbound service URL and agentic identity are carried by the context/API layer.

```bash
export CLIENT_ID=<agent-identity-blueprint-app-id>
export CLIENT_SECRET=<agent-identity-blueprint-secret>
export TENANT_ID=<tenant-id>

npm run dev --workspace @examples/agent365
```

## Proactive API Send

`src/proactive.ts` shows both `app.send(..., { agenticIdentity })` and the lower-level conversation activity API. In both cases the API layer asks the auth provider for the right Agent ID token and uses it in the request header.

```bash
export CLIENT_ID=<agent-identity-blueprint-app-id>
export CLIENT_SECRET=<agent-identity-blueprint-secret>
export TENANT_ID=<tenant-id>

npm run dev:proactive --workspace @examples/agent365 -- \
<conversation-id> \
<agentic-app-id> \
<agentic-user-id>
```
1 change: 1 addition & 0 deletions examples/agent365/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@microsoft/teams.config/eslint.config').default;
33 changes: 33 additions & 0 deletions examples/agent365/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@examples/agent365",
"version": "0.0.1",
"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/main.ts",
"dev:proactive": "tsx -r dotenv/config src/proactive.ts"
},
"dependencies": {
"@microsoft/teams.apps": "*"
},
"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",
"env-cmd": "latest"
}
}
44 changes: 44 additions & 0 deletions examples/agent365/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*
* Reactive echo agent demonstrating Agent 365 agentic identity.
*
* Incoming messages are handled normally; the inbound service URL and
* agentic identity are carried by the context/API layer automatically.
*/

import { App } from '@microsoft/teams.apps';
import { ConsoleLogger } from '@microsoft/teams.common';

const app = new App({
logger: new ConsoleLogger('@examples/agent365', { level: 'debug' }),
});

app.on('message', async ({ send, reply, activity, api, log }) => {
log.info(`[Agent365 reactive] Message received: ${activity.text}`);
log.info(`[Agent365 reactive] From: ${activity.from?.id}`);
log.info(`[Agent365 reactive] Recipient: ${activity.recipient?.id}`);

await reply({ type: 'typing' });

const text = activity.text?.toLowerCase() ?? '';

if (text.includes('react')) {
await api.reactions.add(
activity.conversation.id,
activity.id,
'like'
);
await reply('Added a like reaction to your message.');
return;
}

if (text.includes('reply')) {
await reply('Hello! How can I assist you today?');
} else {
await send(`You said "${activity.text}"`);
}
});

app.start().catch(console.error);
53 changes: 53 additions & 0 deletions examples/agent365/src/proactive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*
* Proactive messaging with Agent 365 agentic identity.
*
* Demonstrates both `app.send(..., { agenticIdentity })` and the
* lower-level conversation activity API. In both cases the API layer
* asks the auth provider for the right Agent ID token.
*
* Usage:
* npx tsx -r dotenv/config src/proactive.ts <conversation-id> <agentic-app-id> <agentic-user-id>
*/

import { MessageActivity } from '@microsoft/teams.api';
import { App } from '@microsoft/teams.apps';
import { ConsoleLogger } from '@microsoft/teams.common';

async function main() {
const [conversationId, agenticAppId, agenticUserId] = process.argv.slice(2);

if (!conversationId || !agenticAppId || !agenticUserId) {
console.error(
'Usage: npx tsx -r dotenv/config src/proactive.ts <conversation-id> <agentic-app-id> <agentic-user-id>'
);
process.exit(1);
}

const app = new App({
logger: new ConsoleLogger('@examples/agent365', { level: 'debug' }),
});

await app.initialize();

const agenticIdentity = app.getAgenticIdentity(agenticAppId, agenticUserId);

// 1. High-level app.send with agentic identity
const sent = await app.send(
conversationId,
new MessageActivity('Hello from app.send with an AgenticIdentity.'),
{ agenticIdentity },
);
console.log(`Sent activity through app.send. Activity ID: ${sent.id}`);

// 2. Lower-level conversation activity API with agentic identity
const apiSent = await app.api.conversations.activities(conversationId).create(
{ type: 'message', text: 'Hello from the conversation activity API with an AgenticIdentity.' },
{ agenticIdentity },
);
console.log(`Sent activity through app.api. Activity ID: ${apiSent.id}`);
}

main().catch(console.error);
8 changes: 8 additions & 0 deletions examples/agent365/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "@microsoft/teams.config/tsconfig.node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}
18 changes: 18 additions & 0 deletions examples/agent365/turbo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["//"],
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"],
"cache": false,
"dependsOn": [
"@microsoft/teams.api#build",
"@microsoft/teams.apps#build",
"@microsoft/teams.cards#build",
"@microsoft/teams.common#build",
"@microsoft/teams.dev#build",
"@microsoft/teams.graph#build"
]
}
}
}
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 42 additions & 46 deletions packages/apps/src/activity-sender.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
import { ActivityParams, ConversationReference } from '@microsoft/teams.api';
import {Client as HttpClient } from '@microsoft/teams.common';
import { ActivityParams, Client, ConversationReference } from '@microsoft/teams.api';

import { ActivitySender } from './activity-sender';

describe('ActivitySender', () => {
let sender: ActivitySender;
let mockHttpClient: HttpClient;
let mockCreate: jest.Mock;
let mockUpdate: jest.Mock;
let mockCreateTargeted: jest.Mock;
let mockUpdateTargeted: jest.Mock;
let mockApi: Client;
let ref: ConversationReference;

beforeEach(() => {
mockHttpClient = {
post: jest.fn().mockResolvedValue({ data: { id: 'activity-1' } }),
put: jest.fn().mockResolvedValue({ data: { id: 'activity-1' } }),
request: jest.fn(),
mockCreate = jest.fn().mockResolvedValue({ id: 'activity-1' });
mockUpdate = jest.fn().mockResolvedValue({ id: 'activity-1' });
mockCreateTargeted = jest.fn().mockResolvedValue({ id: 'activity-1' });
mockUpdateTargeted = jest.fn().mockResolvedValue({ id: 'activity-1' });

mockApi = {
conversations: {
activities: () => ({
create: mockCreate,
update: mockUpdate,
createTargeted: mockCreateTargeted,
updateTargeted: mockUpdateTargeted,
}),
},
} as any;

ref = {
Expand All @@ -22,29 +35,28 @@ describe('ActivitySender', () => {
conversation: { id: 'conv-123', conversationType: 'personal' },
};

sender = new ActivitySender(mockHttpClient, undefined as any);
sender = new ActivitySender(mockApi, undefined as any);
});

describe('send', () => {
it('should POST to create a new activity', async () => {
it('should call create for a new activity', async () => {
const activity: ActivityParams = { type: 'message', text: 'hello' };

const result = await sender.send(activity, ref);

expect(mockHttpClient.post).toHaveBeenCalledWith(
'https://smba.trafficmanager.net/teams/v3/conversations/conv-123/activities',
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
type: 'message',
text: 'hello',
from: ref.bot,
conversation: ref.conversation,
}),
{}
{ serviceUrl: ref.serviceUrl },
);
expect(result).toEqual(expect.objectContaining({ id: 'activity-1' }));
});

it('should PUT to update an existing activity', async () => {
it('should call update for an existing activity', async () => {
const activity: ActivityParams = {
type: 'message',
text: 'updated',
Expand All @@ -53,15 +65,15 @@ describe('ActivitySender', () => {

await sender.send(activity, ref);

expect(mockHttpClient.put).toHaveBeenCalledWith(
'https://smba.trafficmanager.net/teams/v3/conversations/conv-123/activities/existing-id',
expect(mockUpdate).toHaveBeenCalledWith(
'existing-id',
expect.objectContaining({ type: 'message', text: 'updated' }),
{}
{ serviceUrl: ref.serviceUrl },
);
expect(mockHttpClient.post).not.toHaveBeenCalled();
expect(mockCreate).not.toHaveBeenCalled();
});

it('should POST with isTargetedActivity query param for targeted messages', async () => {
it('should call createTargeted for targeted messages', async () => {
const groupRef = {
...ref,
conversation: { id: 'conv-123', conversationType: 'groupChat' },
Expand All @@ -74,13 +86,13 @@ describe('ActivitySender', () => {

await sender.send(activity, groupRef);

expect(mockHttpClient.post).toHaveBeenCalledWith(
'https://smba.trafficmanager.net/teams/v3/conversations/conv-123/activities?isTargetedActivity=true',
expect.objectContaining({ type: 'message', text: 'targeted' })
expect(mockCreateTargeted).toHaveBeenCalledWith(
expect.objectContaining({ type: 'message', text: 'targeted' }),
{ serviceUrl: groupRef.serviceUrl },
);
});

it('should PUT with isTargetedActivity query param for targeted updates', async () => {
it('should call updateTargeted for targeted updates', async () => {
const groupRef = {
...ref,
conversation: { id: 'conv-123', conversationType: 'groupChat' },
Expand All @@ -94,44 +106,28 @@ describe('ActivitySender', () => {

await sender.send(activity, groupRef);

expect(mockHttpClient.put).toHaveBeenCalledWith(
'https://smba.trafficmanager.net/teams/v3/conversations/conv-123/activities/existing-id?isTargetedActivity=true',
expect(mockUpdateTargeted).toHaveBeenCalledWith(
'existing-id',
expect.objectContaining({
recipient: expect.objectContaining({ isTargeted: true }),
})
}),
{ serviceUrl: groupRef.serviceUrl },
);
expect(mockHttpClient.put).toHaveBeenCalledTimes(1);
expect(mockHttpClient.post).not.toHaveBeenCalled();
expect(mockUpdateTargeted).toHaveBeenCalledTimes(1);
expect(mockCreate).not.toHaveBeenCalled();
});

it('should merge bot and conversation from ref into activity', async () => {
const activity: ActivityParams = { type: 'message', text: 'hello' };

await sender.send(activity, ref);

expect(mockHttpClient.post).toHaveBeenCalledWith(
expect.any(String),
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
from: { id: 'bot-id', name: 'Bot', role: 'bot' },
conversation: { id: 'conv-123', conversationType: 'personal' },
}),
{}
);
});

it('should use the serviceUrl from the conversation reference in the endpoint', async () => {
const customRef = {
...ref,
serviceUrl: 'https://custom-service.botframework.com',
conversation: { id: 'conv-456', conversationType: 'personal' },
};

await sender.send({ type: 'message', text: 'hi' }, customRef);

expect(mockHttpClient.post).toHaveBeenCalledWith(
'https://custom-service.botframework.com/v3/conversations/conv-456/activities',
expect.any(Object),
{}
{ serviceUrl: ref.serviceUrl },
);
});

Expand Down
Loading
Loading