diff --git a/examples/proactive-messaging/README.md b/examples/proactive-messaging/README.md new file mode 100644 index 000000000..54a07fb34 --- /dev/null +++ b/examples/proactive-messaging/README.md @@ -0,0 +1,77 @@ +# Proactive Messaging Example + +Send proactive messages to Teams users without running a server. + +## Key Concepts + +**Without a server:** +```typescript +await app.initialize(); +await app.send(conversationId, 'Hello!'); +``` + +**With a running server:** +```typescript +await app.start(); +// Later, anywhere in your code: +await app.send(conversationId, 'Hello!'); +``` + +> **Note**: Use `app.initialize()` only when you don't need a server. If using `app.start()`, just call `app.send()` directly. +> +> **Important**: Without a server (`app.initialize()`), you can only send messages. You cannot receive incoming messages from users. + +## Usage + +1. Set up `.env`: + ``` + BOT_ID= + BOT_PASSWORD= + ``` + +2. Run: + ```bash + npm run dev + ``` + +## Examples + +**Send text:** +```typescript +await app.send(conversationId, 'Your message'); +``` + +**Send card:** +```typescript +const card = new AdaptiveCard( + new TextBlock('Title', { size: 'Large' }) +); +await app.send(conversationId, card); +``` + +**Scheduled job (no server):** +```typescript +const app = new App(); +await app.initialize(); +await app.send(conversationId, 'Reminder!'); +``` + +**From running bot:** +```typescript +const app = new App(); +await app.start(); + +app.on('message', async ({ activity }) => { + await saveConversationId(activity.conversation.id); +}); + +// Send proactive messages anytime +await app.send(conversationId, 'Update!'); +``` + +## Notes + +- Without a server (`app.initialize()`), you can only send messages, not receive them +- Get conversation IDs from previous interactions, installation events, or Graph API +- Your bot must be installed in the conversation +- Be mindful of rate limits diff --git a/examples/proactive-messaging/eslint.config.js b/examples/proactive-messaging/eslint.config.js new file mode 100644 index 000000000..52f4934dc --- /dev/null +++ b/examples/proactive-messaging/eslint.config.js @@ -0,0 +1 @@ +module.exports = require('@microsoft/teams.config/eslint.config'); diff --git a/examples/proactive-messaging/package.json b/examples/proactive-messaging/package.json new file mode 100644 index 000000000..103ca232e --- /dev/null +++ b/examples/proactive-messaging/package.json @@ -0,0 +1,34 @@ +{ + "name": "@examples/proactive-messaging", + "version": "0.0.6", + "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 -r dotenv/config src/index.ts" + }, + "dependencies": { + "@microsoft/teams.api": "2.0.5", + "@microsoft/teams.apps": "2.0.5", + "@microsoft/teams.cards": "2.0.5", + "@microsoft/teams.common": "2.0.5" + }, + "devDependencies": { + "@microsoft/teams.config": "2.0.5", + "@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/proactive-messaging/src/index.ts b/examples/proactive-messaging/src/index.ts new file mode 100644 index 000000000..c96e15c1c --- /dev/null +++ b/examples/proactive-messaging/src/index.ts @@ -0,0 +1,72 @@ +/** + * Proactive Messaging Example + * + * Demonstrates sending messages without running a server using app.initialize(). + * Note: If using app.start(), you can call app.send() directly without app.initialize(). + */ + +import { App } from '@microsoft/teams.apps'; +import { ActionSet, AdaptiveCard, OpenUrlAction, TextBlock } from '@microsoft/teams.cards'; +import { ConsoleLogger } from '@microsoft/teams.common/logging'; + +async function sendProactiveMessage(app: App, conversationId: string, message: string) { + console.log(`Sending proactive message to conversation: ${conversationId}`); + console.log(`Message: ${message}`); + + const result = await app.send(conversationId, message); + + console.log(`✓ Message sent successfully! Activity ID: ${result.id}`); +} + +async function sendProactiveCard(app: App, conversationId: string) { + const card = new AdaptiveCard( + new TextBlock('Proactive Notification', { size: 'Large', weight: 'Bolder' }), + new TextBlock('This message was sent proactively without a server running!', { wrap: true }), + new TextBlock('Status: Active • Priority: High • Time: Now', { wrap: true, isSubtle: true }), + new ActionSet( + new OpenUrlAction('https://aka.ms/teams-sdk', { title: 'Learn More' }) + ) + ); + + console.log(`Sending proactive card to conversation: ${conversationId}`); + + const result = await app.send(conversationId, card); + + console.log(`✓ Card sent successfully! Activity ID: ${result.id}`); +} + +async function main() { + const conversationId = process.argv[2]; + + if (!conversationId) { + console.error('Error: Missing conversation ID argument'); + console.error('Usage: npm start '); + console.error(' npm run dev '); + process.exit(1); + } + + const app = new App({ + logger: new ConsoleLogger('@examples/proactive-messaging', { level: 'info' }) + }); + + // Initialize without starting HTTP server + // Note: If using app.start(), skip this and call app.send() directly + // Without a server, you can only send messages - you cannot receive incoming messages + console.log('Initializing app (without starting server)...'); + await app.initialize(); + console.log('✓ App initialized\n'); + + await sendProactiveMessage( + app, + conversationId, + 'Hello! This is a proactive message sent without a running server 🚀' + ); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + await sendProactiveCard(app, conversationId); + + console.log('\n✓ All proactive messages sent successfully!'); +} + +main().catch(console.error); diff --git a/examples/proactive-messaging/tsconfig.json b/examples/proactive-messaging/tsconfig.json new file mode 100644 index 000000000..9a42fe553 --- /dev/null +++ b/examples/proactive-messaging/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@microsoft/teams.config/tsconfig.node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/proactive-messaging/turbo.json b/examples/proactive-messaging/turbo.json new file mode 100644 index 000000000..1cf545ebe --- /dev/null +++ b/examples/proactive-messaging/turbo.json @@ -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" + ] + } + } +} diff --git a/packages/apps/src/activity-sender.ts b/packages/apps/src/activity-sender.ts new file mode 100644 index 000000000..7c96f377f --- /dev/null +++ b/packages/apps/src/activity-sender.ts @@ -0,0 +1,46 @@ +import { ActivityParams, Client, ConversationReference, SentActivity } from '@microsoft/teams.api'; +import * as $http from '@microsoft/teams.common/http'; +import { ILogger } from '@microsoft/teams.common/logging'; + +import { HttpStream } from './http-stream'; +import { IActivitySender, IStreamer } from './types'; + +/** + * Handles sending activities to the Bot Framework + * Separate from transport concerns (HTTP, WebSocket, etc.) + */ +export class ActivitySender implements IActivitySender { + constructor( + private client: $http.Client, + private logger: ILogger + ) { } + + async send(activity: ActivityParams, ref: ConversationReference): Promise { + // Create API client for this conversation's service URL + const api = new Client(ref.serviceUrl, this.client); + + // Merge activity with conversation reference + activity = { + ...activity, + from: ref.bot, + conversation: ref.conversation, + }; + + // Decide create vs update + if (activity.id) { + const res = await api.conversations + .activities(ref.conversation.id) + .update(activity.id, activity); + return { ...activity, ...res }; + } + + const res = await api.conversations.activities(ref.conversation.id).create(activity); + return { ...activity, ...res }; + } + + createStream(ref: ConversationReference): IStreamer { + // Create API client for this conversation's service URL + const api = new Client(ref.serviceUrl, this.client); + return new HttpStream(api, ref, this.logger); + } +} diff --git a/packages/apps/src/app.events.ts b/packages/apps/src/app.events.ts index 99cca0512..50e70f4fc 100644 --- a/packages/apps/src/app.events.ts +++ b/packages/apps/src/app.events.ts @@ -6,7 +6,7 @@ import { IActivitySentEvent, IErrorEvent, } from './events'; -import { AppEvents, IPlugin, ISender } from './types'; +import { AppEvents, IPlugin } from './types'; /** * subscribe to an event @@ -36,34 +36,26 @@ export async function onError( export async function onActivitySent( this: App, - sender: ISender, event: IActivitySentEvent ) { for (const plugin of this.plugins) { if (plugin.onActivitySent) { - await plugin.onActivitySent({ - ...event, - sender, - }); + await plugin.onActivitySent(event); } } - this.events.emit('activity.sent', { ...event, sender }); + this.events.emit('activity.sent', event); } export async function onActivityResponse( this: App, - sender: ISender, event: IActivityResponseEvent ) { for (const plugin of this.plugins) { if (plugin.onActivityResponse) { - await plugin.onActivityResponse({ - ...event, - sender, - }); + await plugin.onActivityResponse(event); } } - this.events.emit('activity.response', { ...event, sender }); + this.events.emit('activity.response', event); } diff --git a/packages/apps/src/app.plugin.spec.ts b/packages/apps/src/app.plugin.spec.ts index bc468725e..3b1a20fb1 100644 --- a/packages/apps/src/app.plugin.spec.ts +++ b/packages/apps/src/app.plugin.spec.ts @@ -219,12 +219,10 @@ describe('app.plugin', () => { await app.start(); // Trigger a message activity by directly calling onActivity (internal API for testing) - const httpPlugin = app.getPlugin('http') as TestHttpPlugin; const activity = new MessageActivity('test message'); - // @ts-expect-error - accessing internal method for testing - await app.onActivity(httpPlugin, { - activity: activity.toInterface(), + await app.onActivity({ + body: activity.toInterface(), token: { appId: 'test-app-id', serviceUrl: 'https://test.botframework.com', diff --git a/packages/apps/src/app.plugins.ts b/packages/apps/src/app.plugins.ts index 4722e0a87..0346b98f2 100644 --- a/packages/apps/src/app.plugins.ts +++ b/packages/apps/src/app.plugins.ts @@ -1,8 +1,8 @@ import { ILogger } from '@microsoft/teams.common'; import { App } from './app'; -import { allIEventKeys, IEvents } from './events'; -import { IPlugin, IPluginActivityEvent, IPluginErrorEvent, ISender, PluginName } from './types'; +import { allIEventKeys, IActivityEvent, IEvents } from './events'; +import { IPlugin, IPluginErrorEvent, PluginName } from './types'; import { DependencyMetadata, PLUGIN_DEPENDENCIES_METADATA_KEY, @@ -81,11 +81,11 @@ export function inject(this: App, plugin: IPlu if (name === 'error') { handler = (event: IPluginErrorEvent) => { - this.onError({ ...event, sender: plugin }); + this.onError(event); }; } else if (name === 'activity') { - handler = (event: IPluginActivityEvent) => { - return this.onActivity(plugin as ISender, event); + handler = (event: IActivityEvent) => { + return this.onActivity(event); }; } else if (name === 'custom') { handler = (name: string, event: unknown) => { diff --git a/packages/apps/src/app.process.spec.ts b/packages/apps/src/app.process.spec.ts index 215cc2ea5..04fbacca9 100644 --- a/packages/apps/src/app.process.spec.ts +++ b/packages/apps/src/app.process.spec.ts @@ -33,11 +33,10 @@ describe('App', () => { it('should return status 200 if no route matches', async () => { const event: IActivityEvent = { token: token, - activity: activity, - sender: senderPlugin, + body: activity, }; - const response = await app.process(senderPlugin, event); + const response = await app.process(event); expect(response.status).toBe(200); expect(response.body).toBeUndefined(); }); @@ -45,8 +44,7 @@ describe('App', () => { it('should return an invoke response', async () => { const event: IActivityEvent = { token: token, - activity: activity, - sender: senderPlugin, + body: activity, }; app.use(() => { @@ -58,7 +56,7 @@ describe('App', () => { return response; }); - const response = await app.process(senderPlugin, event); + const response = await app.process(event); expect(response.status).toBe(413); expect(response.body).toEqual({ result: 'success' }); }); @@ -72,8 +70,7 @@ describe('App', () => { const event: IActivityEvent = { token: token, - activity: taskFetchInvokeActivity, - sender: senderPlugin, + body: taskFetchInvokeActivity, }; const dialogOpenResponse: TaskModuleResponse = { @@ -88,7 +85,7 @@ describe('App', () => { return dialogOpenResponse; }); - const response = await app.process(senderPlugin, event); + const response = await app.process(event); expect(response.status).toBe(200); expect(response.body).toEqual(dialogOpenResponse); }); @@ -96,17 +93,108 @@ describe('App', () => { it('should return 500 status response if an error is thrown', async () => { const event: IActivityEvent = { token: token, - activity: activity, - sender: senderPlugin, + body: activity, }; app.use(() => { throw new Error('Test error'); }); - const response = await app.process(senderPlugin, event); + const response = await app.process(event); expect(response.status).toBe(500); expect(response.body).toBeUndefined(); }); + + it('should use incoming activity serviceUrl when sending replies', async () => { + const incomingServiceUrl = 'https://incoming-service.botframework.com'; + + // Create incoming activity with specific serviceUrl + const incomingActivity: IMessageActivity = new MessageActivity('hello') + .withFrom({ id: 'user-1', name: 'Test User', role: 'user' }) + .withRecipient({ id: 'bot-1', name: 'Test Bot', role: 'bot' }) + .withConversation({ id: 'conv-123', conversationType: 'personal' }) + .withChannelId('msteams') + .withServiceUrl(incomingServiceUrl) + .toInterface(); + + const incomingToken: IToken = { + appId: 'app-id', + serviceUrl: incomingServiceUrl, + from: 'bot', + fromId: 'bot-1', + toString: () => 'token', + isExpired: () => false, + }; + + const event: IActivityEvent = { + token: incomingToken, + body: incomingActivity, + }; + + // Track what serviceUrl is used when sending + let capturedServiceUrl: string | undefined; + const originalSend = app['activitySender'].send.bind(app['activitySender']); + jest.spyOn(app['activitySender'], 'send').mockImplementation((activity, ref) => { + capturedServiceUrl = ref.serviceUrl; + return originalSend(activity, ref); + }); + + // Set up handler that replies + app.on('message', async ({ reply }) => { + await reply('response'); + }); + + await app.process(event); + + // Verify the serviceUrl from incoming activity was used + expect(capturedServiceUrl).toBe(incomingServiceUrl); + }); + + it('should use different serviceUrls for different incoming activities', async () => { + const serviceUrl1 = 'https://service-1.botframework.com'; + const serviceUrl2 = 'https://service-2.botframework.com'; + + const capturedServiceUrls: string[] = []; + const originalSend = app['activitySender'].send.bind(app['activitySender']); + jest.spyOn(app['activitySender'], 'send').mockImplementation((activity, ref) => { + capturedServiceUrls.push(ref.serviceUrl); + return originalSend(activity, ref); + }); + + app.on('message', async ({ reply }) => { + await reply('response'); + }); + + // Process first activity with serviceUrl1 + const activity1: IMessageActivity = new MessageActivity('hello1') + .withFrom({ id: 'user-1', name: 'Test User', role: 'user' }) + .withRecipient({ id: 'bot-1', name: 'Test Bot', role: 'bot' }) + .withConversation({ id: 'conv-1', conversationType: 'personal' }) + .withChannelId('msteams') + .withServiceUrl(serviceUrl1) + .toInterface(); + + await app.process({ + token: { ...token, serviceUrl: serviceUrl1 }, + body: activity1, + }); + + // Process second activity with serviceUrl2 + const activity2: IMessageActivity = new MessageActivity('hello2') + .withFrom({ id: 'user-2', name: 'Test User 2', role: 'user' }) + .withRecipient({ id: 'bot-1', name: 'Test Bot', role: 'bot' }) + .withConversation({ id: 'conv-2', conversationType: 'personal' }) + .withChannelId('msteams') + .withServiceUrl(serviceUrl2) + .toInterface(); + + await app.process({ + token: { ...token, serviceUrl: serviceUrl2 }, + body: activity2, + }); + + // Verify both serviceUrls were used correctly + expect(capturedServiceUrls).toEqual([serviceUrl1, serviceUrl2]); + }); }); }); diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index cf3a7b2bb..65e370200 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -1,22 +1,28 @@ -import { ActivityLike, ConversationReference, InvokeResponse, isInvokeResponse } from '@microsoft/teams.api'; +import { Activity, ActivityLike, ConversationReference, InvokeResponse, isInvokeResponse } from '@microsoft/teams.api'; import { ApiClient, GraphClient } from './api'; import { App } from './app'; import { ActivityContext, IActivityContext } from './contexts'; import { IActivityEvent } from './events'; -import { IPlugin, ISender } from './types'; +import { IPlugin } from './types'; /** * activity handler called when an inbound activity is received - * @param sender the plugin to use for sending activities * @param event the received activity event */ export async function $process( this: App, - sender: ISender, event: IActivityEvent ): Promise { - const { token, activity } = event; + const { token, body } = event; + + if (!body) { + throw new Error('Activity body is required'); + } + + // TODO: We currently simply cast the models to Activity, + // but we should probably be validating this conversion + const activity = body as Activity; this.log.debug( `activity/${activity.type}${activity.type === 'invoke' ? `/${activity.name}` : ''}` @@ -65,7 +71,6 @@ export async function $process( if (plugin.onActivity) { const additionalPluginContext = await plugin.onActivity({ ...ref, - sender: sender, activity, token, }); @@ -104,8 +109,8 @@ export async function $process( return data; }; - const context = new ActivityContext(sender, { - ...event, + const context = new ActivityContext({ + activity, next, api: apiClient, userGraph, @@ -117,15 +122,16 @@ export async function $process( storage: this.storage, isSignedIn: !!userToken, connectionName: this.oauth.defaultConnectionName, + activitySender: this.activitySender, + ...pluginContexts }); const send = context.send.bind(context); context.send = async (activity: ActivityLike, conversationRef?: ConversationReference) => { - const res = await send(activity, conversationRef); + const res = await send(activity, conversationRef ?? ref); - this.onActivitySent(sender, { + this.onActivitySent({ ...(conversationRef ?? ref), - sender, activity: res, }); @@ -133,17 +139,15 @@ export async function $process( }; context.stream.events.on('chunk', (activity) => { - this.onActivitySent(sender, { + this.onActivitySent({ ...ref, - sender, activity, }); }); context.stream.events.once('close', (activity) => { - this.onActivitySent(sender, { + this.onActivitySent({ ...ref, - sender, activity, }); }); @@ -160,18 +164,16 @@ export async function $process( response = { status: 200, body: res }; } - this.onActivityResponse(sender, { + this.onActivityResponse({ ...ref, - sender, activity, response: res, }); } catch (error: any) { response = { status: 500 }; - this.onError({ error, activity, sender }); - this.onActivityResponse(sender, { + this.onError({ error, activity }); + this.onActivityResponse({ ...ref, - sender, activity, response: response, }); diff --git a/packages/apps/src/app.spec.ts b/packages/apps/src/app.spec.ts index 78cdc02a3..eb0db063e 100644 --- a/packages/apps/src/app.spec.ts +++ b/packages/apps/src/app.spec.ts @@ -18,6 +18,11 @@ class TestApp extends App { public async testSend(conversationId: string, activity: any) { return this.send(conversationId, activity); } + + // Expose activitySender for mocking (it's protected, so we expose it publicly) + public get testActivitySender() { + return this.activitySender; + } } describe('App', () => { @@ -120,9 +125,9 @@ describe('App', () => { await app.start(); - // Mock the http.send method + // Mock the activitySender.send method const mockSend = jest.fn().mockResolvedValue({ id: 'activity-id' }); - jest.spyOn(app.http, 'send').mockImplementation(mockSend); + jest.spyOn(app.testActivitySender, 'send').mockImplementation(mockSend); await app.testSend('conversation-id', { text: 'Hello' }); @@ -145,9 +150,9 @@ describe('App', () => { await app.start(); - // Mock the http.send method + // Mock the activitySender.send method const mockSend = jest.fn().mockResolvedValue({ id: 'activity-id' }); - jest.spyOn(app.http, 'send').mockImplementation(mockSend); + jest.spyOn(app.testActivitySender, 'send').mockImplementation(mockSend); await app.testSend('conversation-id', { text: 'Hello' }); diff --git a/packages/apps/src/app.ts b/packages/apps/src/app.ts index 622417806..13fde409e 100644 --- a/packages/apps/src/app.ts +++ b/packages/apps/src/app.ts @@ -17,6 +17,7 @@ import { IStorage, LocalStorage } from '@microsoft/teams.common/storage'; import pkg from '../package.json'; +import { ActivitySender } from './activity-sender'; import { ApiClient, GraphClient } from './api'; import { configTab, func, tab } from './app.embed'; @@ -41,7 +42,7 @@ import { DEFAULT_OAUTH_SETTINGS, OAuthSettings } from './oauth'; import { HttpPlugin } from './plugins'; import { Router } from './router'; import { TokenManager } from './token-manager'; -import { IPlugin, AppEvents, ISender } from './types'; +import { IPlugin, AppEvents } from './types'; import { PluginAdditionalContext } from './types/app-routing'; /** @@ -218,6 +219,7 @@ export class App { protected events = new EventEmitter>(); protected startedAt?: Date; protected port?: number | string; + protected activitySender: ActivitySender; private readonly _userAgent = `teams.ts[apps]/${pkg.version}`; @@ -272,6 +274,12 @@ export class App { managedIdentityClientId: this.options.managedIdentityClientId, }, this.log); + // initialize ActivitySender for sending activities + this.activitySender = new ActivitySender( + this.client.clone({ token: () => this.getBotToken() }), + this.log + ); + if (this.credentials?.clientId) { this.entraTokenValidator = middleware.createEntraTokenValidator( this.credentials.tenantId || 'common', @@ -349,22 +357,30 @@ export class App { } /** - * start the app + * initialize the app. + */ + async initialize() { + // initialize plugins + for (const plugin of this.plugins) { + // inject dependencies + this.inject(plugin); + + if (plugin.onInit) { + plugin.onInit(); + } + } + + } + + /** + * start the server after initialization * @param port port to listen on */ async start(port?: number | string) { this.port = port || process.env.PORT || 3978; try { - // initialize plugins - for (const plugin of this.plugins) { - // inject dependencies - this.inject(plugin); - - if (plugin.onInit) { - plugin.onInit(); - } - } + await this.initialize(); // start plugins for (const plugin of this.plugins) { @@ -372,7 +388,6 @@ export class App { await plugin.onStart({ port: this.port }); } } - this.events.emit('start', this.log); this.startedAt = new Date(); } catch (error: any) { @@ -419,7 +434,7 @@ export class App { }, }; - const res = await this.http.send(toActivityParams(activity), ref); + const res = await this.activitySender.send(toActivityParams(activity), ref); return res; } @@ -509,11 +524,10 @@ export class App { protected onActivityResponse = onActivityResponse; // eslint-disable-line @typescript-eslint/member-ordering async onActivity( - sender: ISender, event: IActivityEvent ): Promise { this.events.emit('activity', event); - return await this.process(sender, { ...event, sender }); + return await this.process(event); } /// diff --git a/packages/apps/src/contexts/activity.test.ts b/packages/apps/src/contexts/activity.test.ts index 17598fddc..d2553250a 100644 --- a/packages/apps/src/contexts/activity.test.ts +++ b/packages/apps/src/contexts/activity.test.ts @@ -12,12 +12,11 @@ import { ILogger } from '@microsoft/teams.common/logging'; import { IStorage } from '@microsoft/teams.common/storage'; import { ApiClient, GraphClient } from '../api'; -import { ISender } from '../types'; import { ActivityContext } from './activity'; describe('ActivityContext', () => { - let mockSender: ISender; + let mockSender: { send: jest.Mock; createStream: jest.Mock }; let mockApiClient: MockedObject; let mockLogger: ILogger; let mockStorage: MockedObject; @@ -100,7 +99,7 @@ describe('ActivityContext', () => { }; const buildActivityContext = (activity: Activity): ActivityContext => { - return new ActivityContext(mockSender, { + return new ActivityContext({ appId: 'test-app', activity, ref: mockRef, @@ -111,6 +110,7 @@ describe('ActivityContext', () => { storage: mockStorage, connectionName: 'test-connection', next: jest.fn(), + activitySender: mockSender, }); }; @@ -282,7 +282,7 @@ describe('ActivityContext', () => { }); it('creates new 1:1 conversation for group chat signin', async () => { - context = new ActivityContext(mockSender, { + context = new ActivityContext({ ...context, activity: { ...buildIncomingMessageActivity('Test message'), @@ -292,6 +292,7 @@ describe('ActivityContext', () => { conversationType: 'group', }, }, + activitySender: mockSender, }); mockApiClient.users.token.get.mockRejectedValueOnce( diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index da56494b6..1f7676139 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -19,9 +19,32 @@ import { ILogger } from '@microsoft/teams.common/logging'; import { IStorage } from '@microsoft/teams.common/storage'; import { ApiClient, GraphClient } from '../api'; -import { ISender, IStreamer } from '../types'; +import { IStreamer } from '../types'; +import { IActivitySender } from '../types/plugin/sender'; + +/** + * Constructor arguments for ActivityContext + * Internal implementation details not exposed in public interface + */ +export interface IActivityContextConstructorArgs { + /** + * activity sender for sending activities and creating streams + */ + activitySender: IActivitySender; + + /** + * call the next event/middleware handler + */ + next: ( + context?: IActivityContext + ) => (void | InvokeResponse) | Promise; +} -export interface IBaseActivityContextOptions = Record> { +/** + * Base activity context options + * These are the public properties exposed on the context + */ +export interface IBaseActivityContextOptions { /** * the app id of the bot */ @@ -79,21 +102,9 @@ export interface IBaseActivityContextOptions (void | InvokeResponse) | Promise; } -export type IActivityContextOptions = Record> = IBaseActivityContextOptions & TExtraCtx; +export type IActivityContextOptions = Record> = IBaseActivityContextOptions & TExtraCtx; type SignInOptions = { /** @@ -131,12 +142,19 @@ type SignInOptions = { }; export interface IBaseActivityContext = Record> - extends IBaseActivityContextOptions { + extends IBaseActivityContextOptions { /** * a stream that can emit activity chunks */ stream: IStreamer; + /** + * call the next event/middleware handler + */ + next: ( + context?: IActivityContext & TExtraCtx + ) => (void | InvokeResponse) | Promise; + /** * send an activity to the conversation * @param activity activity to send @@ -176,7 +194,7 @@ export class ActivityContext (void | InvokeResponse) | Promise; [key: string]: any; - protected _plugin: ISender; - protected _next?: ( - context?: IActivityContext - ) => (void | InvokeResponse) | Promise; + private activitySender: IActivitySender; - constructor(plugin: ISender, value: IBaseActivityContextOptions) { - Object.assign(this, value); - this._plugin = plugin; - this.stream = plugin.createStream(value.ref); + constructor(value: IBaseActivityContextOptions & IActivityContextConstructorArgs) { + // Extract activitySender and next before Object.assign to avoid overwriting methods + const { activitySender, next, ...rest } = value; + Object.assign(this, rest); + this.activitySender = activitySender; + this.next = next; + this.stream = activitySender.createStream(value.ref); this.connectionName = value.connectionName; if (value.activity.type === 'message') { @@ -213,7 +231,7 @@ export class ActivityContext { let client: any; diff --git a/packages/apps/src/plugins/http/stream.ts b/packages/apps/src/http-stream.ts similarity index 93% rename from packages/apps/src/plugins/http/stream.ts rename to packages/apps/src/http-stream.ts index dd6d507b8..2ae507c6c 100644 --- a/packages/apps/src/plugins/http/stream.ts +++ b/packages/apps/src/http-stream.ts @@ -13,8 +13,8 @@ import { } from '@microsoft/teams.api'; import { ConsoleLogger, EventEmitter, ILogger } from '@microsoft/teams.common'; -import { IStreamer, IStreamerEvents } from '../../types'; -import { promises } from '../../utils'; +import { IStreamer, IStreamerEvents } from './types'; +import { promises } from './utils'; /** * HTTP-based streaming implementation for Microsoft Teams activities. @@ -53,7 +53,7 @@ export class HttpStream implements IStreamer { constructor(client: Client, ref: ConversationReference, logger?: ILogger) { this.client = client; this.ref = ref; - this._logger = logger?.child('stream') || new ConsoleLogger('@teams/http/stream'); + this._logger = logger?.child('stream') || new ConsoleLogger('@teams/http-stream'); } /** @@ -228,19 +228,19 @@ export class HttpStream implements IStreamer { * @param activity TypingActivity to send. */ protected async pushStreamChunk(activity: TypingActivity) { - if (this.id) { - activity.id = this.id; - } - activity.addStreamUpdate(this.index + 1); - - const res = await promises.retry(() => this.send(activity as ActivityParams), { - logger: this._logger - }); - this.events.emit('chunk', res); - this.index++; - if (!this.id) { - this.id = res.id; - } + if (this.id) { + activity.id = this.id; + } + activity.addStreamUpdate(this.index + 1); + + const res = await promises.retry(() => this.send(activity as ActivityParams), { + logger: this._logger + }); + this.events.emit('chunk', res); + this.index++; + if (!this.id) { + this.id = res.id; + } } /** diff --git a/packages/apps/src/plugins/http/index.ts b/packages/apps/src/plugins/http/index.ts index 327f3e6c1..1110b6451 100644 --- a/packages/apps/src/plugins/http/index.ts +++ b/packages/apps/src/plugins/http/index.ts @@ -1,2 +1 @@ export * from './plugin'; -export * from './stream'; diff --git a/packages/apps/src/plugins/http/plugin.ts b/packages/apps/src/plugins/http/plugin.ts index 32b838eb8..4227d8217 100644 --- a/packages/apps/src/plugins/http/plugin.ts +++ b/packages/apps/src/plugins/http/plugin.ts @@ -4,17 +4,12 @@ import cors from 'cors'; import express from 'express'; import { - Activity, - ActivityParams, - Client, - ConversationReference, Credentials, InvokeResponse, IToken } from '@microsoft/teams.api'; import { ILogger } from '@microsoft/teams.common'; -import * as $http from '@microsoft/teams.common/http'; import pkg from '../../../package.json'; import { IActivityEvent, IErrorEvent } from '../../events'; @@ -24,39 +19,29 @@ import { Dependency, Event, IPluginStartEvent, - ISender, - IStreamer, Logger, Plugin, } from '../../types'; - -import { HttpStream } from './stream'; - /** - * Can send/receive activities via http + * Receives activities via HTTP + * Handles HTTP server setup, routing, and authentication */ @Plugin({ name: 'http', version: pkg.version, - description: 'the default plugin for sending/receiving activities', + description: 'the default plugin for receiving activities via HTTP', }) -export class HttpPlugin implements ISender { +export class HttpPlugin { @Logger() readonly logger!: ILogger; - @Dependency() - readonly client!: $http.Client; - @Dependency() readonly manifest!: Partial; @Dependency({ optional: true }) readonly credentials?: Credentials; - @Dependency({ optional: true }) - readonly botToken?: () => IToken; - @Event('error') readonly $onError!: (event: IErrorEvent) => void; @@ -148,45 +133,8 @@ export class HttpPlugin implements ISender { this._server.close(); } - async send(activity: ActivityParams, ref: ConversationReference) { - const api = new Client( - ref.serviceUrl, - this.client.clone({ - token: this.botToken, - }) - ); - - activity = { - ...activity, - from: ref.bot, - conversation: ref.conversation, - }; - - if (activity.id) { - const res = await api.conversations - .activities(ref.conversation.id) - .update(activity.id, activity); - return { ...activity, ...res }; - } - - const res = await api.conversations.activities(ref.conversation.id).create(activity); - return { ...activity, ...res }; - } - - createStream(ref: ConversationReference): IStreamer { - return new HttpStream( - new Client( - ref.serviceUrl, - this.client.clone({ - token: this.botToken, - }) - ), - ref, - this.logger - ); - } /** - * validates an incoming http request + * handles an incoming http request * @param req the incoming http request * @param res the http response */ @@ -195,26 +143,31 @@ export class HttpPlugin implements ISender { res: express.Response, _next: express.NextFunction ) { - const activity: Activity = req.body; - let token: IToken | undefined; - if (req.validatedToken) { - token = req.validatedToken; - } else { - token = { - appId: '', - from: 'azure', - fromId: '', - serviceUrl: activity.serviceUrl || '', - isExpired: () => false, - }; - } - - const response = await this.$onActivity({ - sender: this, - activity, - token, - }); + try { + let token: IToken | undefined; + if (req.validatedToken) { + token = req.validatedToken; + } else { + token = { + appId: '', + from: 'azure', + fromId: '', + serviceUrl: req.body.serviceUrl || '', + isExpired: () => false, + }; + } + + const response = await this.$onActivity({ + body: req.body, + token, + }); - res.status(response.status || 200).send(response.body); + res.status(response.status || 200).send(response.body); + } catch (err) { + this.logger.error('Error processing activity:', err); + if (!res.headersSent) { + res.status(500).send({ error: 'Internal server error' }); + } + } } } diff --git a/packages/apps/src/types/event.ts b/packages/apps/src/types/event.ts index aef92719f..9ca3887a0 100644 --- a/packages/apps/src/types/event.ts +++ b/packages/apps/src/types/event.ts @@ -1,12 +1,5 @@ -import { IPlugin } from './plugin'; - /** * some event emitted from * either the App or a Plugin */ -export interface IEvent { - /** - * the sender of the event - */ - sender?: IPlugin; -} +export interface IEvent {} diff --git a/packages/apps/src/types/plugin/plugin-activity-event.ts b/packages/apps/src/types/plugin/plugin-activity-event.ts index 142580ba7..20c816108 100644 --- a/packages/apps/src/types/plugin/plugin-activity-event.ts +++ b/packages/apps/src/types/plugin/plugin-activity-event.ts @@ -1,17 +1,10 @@ import { Activity, ConversationReference, IToken } from '@microsoft/teams.api'; -import { ISender } from './sender'; - /** * the event emitted by a plugin * when an activity is received */ export interface IPluginActivityEvent extends ConversationReference { - /** - * the sender - */ - readonly sender: ISender; - /** * inbound request token */ diff --git a/packages/apps/src/types/plugin/plugin-activity-response-event.ts b/packages/apps/src/types/plugin/plugin-activity-response-event.ts index 88c9a4ef8..05138a0a0 100644 --- a/packages/apps/src/types/plugin/plugin-activity-response-event.ts +++ b/packages/apps/src/types/plugin/plugin-activity-response-event.ts @@ -1,17 +1,10 @@ import { Activity, ConversationReference, InvokeResponse } from '@microsoft/teams.api'; -import { ISender } from './sender'; - /** * the event emitted by a plugin * before an activity response is sent */ export interface IPluginActivityResponseEvent extends ConversationReference { - /** - * the sender - */ - readonly sender: ISender; - /** * inbound request activity payload */ diff --git a/packages/apps/src/types/plugin/plugin-activity-sent-event.ts b/packages/apps/src/types/plugin/plugin-activity-sent-event.ts index f1c53f56f..c580e5744 100644 --- a/packages/apps/src/types/plugin/plugin-activity-sent-event.ts +++ b/packages/apps/src/types/plugin/plugin-activity-sent-event.ts @@ -1,17 +1,10 @@ import { ConversationReference, SentActivity } from '@microsoft/teams.api'; -import { ISender } from './sender'; - /** * the event emitted by a plugin * when an activity is sent */ export interface IPluginActivitySentEvent extends ConversationReference { - /** - * the sender of the activity - */ - readonly sender: ISender; - /** * the sent activity */ diff --git a/packages/apps/src/types/plugin/sender.ts b/packages/apps/src/types/plugin/sender.ts index ae3b226b9..5f3d47df3 100644 --- a/packages/apps/src/types/plugin/sender.ts +++ b/packages/apps/src/types/plugin/sender.ts @@ -2,21 +2,18 @@ import { ActivityParams, ConversationReference, SentActivity } from '@microsoft/ import { IStreamer } from '../streamer'; -import { IPlugin } from './plugin'; - /** - * a plugin that can send activities + * Interface for activity sending (NOT a plugin) + * Separates sending concerns from transport concerns */ -export interface ISender extends IPlugin { +export interface IActivitySender { /** - * called by the `App` - * to send an activity + * Send an activity */ send(activity: ActivityParams, ref: ConversationReference): Promise; /** - * called by the `App` - * to create a new activity stream + * Create a new activity stream */ createStream(ref: ConversationReference): IStreamer; -}; +} diff --git a/packages/botbuilder/src/plugin.ts b/packages/botbuilder/src/plugin.ts index b4454916f..52f0bece1 100644 --- a/packages/botbuilder/src/plugin.ts +++ b/packages/botbuilder/src/plugin.ts @@ -16,7 +16,6 @@ import { HttpPlugin, IActivityEvent, IErrorEvent, - ISender, Logger, Plugin, manifest, @@ -38,7 +37,7 @@ export type BotBuilderPluginOptions = { name: 'http', version: pkg.version, }) -export class BotBuilderPlugin extends HttpPlugin implements ISender { +export class BotBuilderPlugin extends HttpPlugin { @Logger() declare readonly logger: ILogger; @@ -72,8 +71,8 @@ export class BotBuilderPlugin extends HttpPlugin implements ISender { this.handler = options?.handler; } - onInit() { - super.onInit(); + async onInit() { + await super.onInit(); if (!this.adapter) { const clientId = this.credentials?.clientId; const clientSecret = @@ -133,9 +132,9 @@ export class BotBuilderPlugin extends HttpPlugin implements ISender { } const response = await this.$onActivity({ - sender: this, token, - activity: new $Activity(context.activity as any) as Activity, + body: + new $Activity(context.activity as any) as Activity, }); res.status(response.status || 200).send(response.body); diff --git a/packages/dev/src/plugin.ts b/packages/dev/src/plugin.ts index 435c1a48e..e1184b754 100644 --- a/packages/dev/src/plugin.ts +++ b/packages/dev/src/plugin.ts @@ -7,7 +7,7 @@ import * as uuid from 'uuid'; import { WebSocket, WebSocketServer } from 'ws'; -import { ActivityParams, ConversationReference, IToken } from '@microsoft/teams.api'; +import { InvokeResponse, IToken } from '@microsoft/teams.api'; import { HttpPlugin, Logger, @@ -15,8 +15,6 @@ import { IPluginActivityResponseEvent, IPluginActivitySentEvent, IPluginStartEvent, - ISender, - IStreamer, Plugin, Dependency, Event, @@ -47,7 +45,7 @@ export type DevtoolsPluginOptions = { '\n' ), }) -export class DevtoolsPlugin implements ISender { +export class DevtoolsPlugin { @Logger() readonly log!: ILogger; @@ -64,7 +62,7 @@ export class DevtoolsPlugin implements ISender { readonly $onError!: (event: IErrorEvent) => void; @Event('activity') - readonly $onActivity!: (event: IActivityEvent) => void; + readonly $onActivity!: (event: IActivityEvent) => Promise; protected http: http.Server; protected express: express.Application; @@ -119,9 +117,12 @@ export class DevtoolsPlugin implements ISender { return new Promise((resolve, reject) => { this.pending[activity.id] = { resolve, reject }; this.$onActivity({ - sender: this.httpPlugin, token, - activity, + body: activity, + }).catch((err) => { + this.log.error('Error processing activity:', err); + reject(err); + delete this.pending[activity.id]; }); }); }, @@ -170,14 +171,6 @@ export class DevtoolsPlugin implements ISender { delete this.pending[activity.id]; } - async send(activity: ActivityParams, ref: ConversationReference) { - return await this.httpPlugin.send(activity, ref); - } - - createStream(ref: ConversationReference): IStreamer { - return this.httpPlugin.createStream(ref); - } - protected onSocketConnection(socket: WebSocket) { const id = uuid.v4(); this.sockets.set(id, socket); diff --git a/packages/dev/src/routes/v3/conversations/activities/create.ts b/packages/dev/src/routes/v3/conversations/activities/create.ts index 5e4161160..a93642efb 100644 --- a/packages/dev/src/routes/v3/conversations/activities/create.ts +++ b/packages/dev/src/routes/v3/conversations/activities/create.ts @@ -24,6 +24,24 @@ export function create({ port, log, process }: RouteContext) { } try { + const activity = { + ...req.body, + type: req.body.type || 'message', + id: req.body.id || uuid.v4(), + channelId: 'msteams', + from: { + id: 'devtools', + name: 'devtools', + role: 'user', + }, + conversation: { + id: req.params.conversationId, + conversationType: 'personal', + isGroup: false, + name: 'default', + }, + }; + process( new JsonWebToken( jwt.sign( @@ -33,22 +51,7 @@ export function create({ port, log, process }: RouteContext) { 'secret' ) ), - { - ...req.body, - id: req.body.id || uuid.v4(), - channelId: 'msteams', - from: { - id: 'devtools', - name: 'devtools', - role: 'user', - }, - conversation: { - id: req.params.conversationId, - conversationType: 'personal', - isGroup: false, - name: 'default', - }, - } + activity as Activity ); res.status(201).send({ id });