From bdd93319de0cecbc55d950db5644d86f28fcec16 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 15 Dec 2025 16:00:13 -0800 Subject: [PATCH 01/17] Separate activity sending from HTTP transport layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous architecture tightly coupled HTTP transport concerns with activity sending logic: **Previous Architecture:** ``` HttpPlugin (transport) → implements ISender (sending) → has send() method (creates new Client per call) → has createStream() method → knows about Activity protocol details ActivityContext → depends on ISender plugin → cannot work without transport plugin → conflates transport and sending concerns ``` Key Issues: - HttpPlugin created NEW Client instances on every send() call - Transport plugins (HttpPlugin) were forced to implement send/createStream - Users couldn't "bring their own server" without implementing ISender - ActivityContext was tightly coupled to plugin architecture - HttpPlugin knew about Bot Framework Activity protocol details ``` HttpPlugin (transport) → only handles HTTP server/routing/auth → emits ICoreActivity (minimal protocol knowledge) → just passes body payload to app ActivitySender (NEW) → dedicated class for sending activities → receives injected, reusable Client → handles all send/stream logic → private to App class ActivityContext → uses send callback (abstraction) → receives pre-created stream → no direct dependency on ActivitySender ``` - Centralized all activity sending logic - Receives reusable Client in constructor (no per-send instantiation) - Private to App class - internal implementation detail - Provides send() and createStream() methods - Minimal fields transport layer needs: serviceUrl, id, type - Extensible via [key: string]: any for protocol-specific fields - Transport plugins work with this instead of full Activity type - Parsing to Activity happens in app.process.ts - No longer needed - plugins don't send activities - Plugins only handle transport (receiving requests) - Breaking change, but simplifies plugin architecture - Constructor accepts send callback function - Receives pre-created stream (not factory function) - No knowledge of ActivitySender implementation - Proper abstraction via dependency injection - Initially renamed to ActivityStream - Reverted because it's still HTTP-specific (uses Bot Framework HTTP Client API) - Moved from src/plugins/http/stream.ts to src/http-stream.ts - Still transport-specific, just not plugin-owned 1. **ISender removed** - Custom plugins should implement IPlugin only 2. **IActivityEvent changed** - Now has body: ICoreActivity instead of activity: Activity 3. **Plugin.onActivity** - Still receives parsed activity: Activity (unchanged) 4. **App.process signature** - Internal change, not exposed to plugin API Before: ```typescript class MyPlugin implements ISender { send(activity, ref) { ... } // Required createStream(ref) { ... } // Required async onRequest(req, res) { const activity: Activity = req.body; // Need Activity type await this.$onActivity({ activity, token }); } } ``` After: ```typescript class MyPlugin implements IPlugin { // No send() or createStream() needed! async onRequest(req, res) { // Just pass body - don't need Activity type knowledge await this.$onActivity({ body: req.body, // ICoreActivity (minimal fields) token }); } } ``` 1. **Client Reuse** - ActivitySender reuses same Client, no per-send instantiation 2. **Separation of Concerns** - Transport vs sending clearly separated 3. **Bring Your Own Server** - Easy to implement custom transports (Socket.io, gRPC, etc.) 4. **Less Protocol Knowledge** - Transport layer only needs ICoreActivity, not full Activity 5. **Cleaner Architecture** - Each class has single responsibility 6. **Better Abstraction** - ActivityContext uses callbacks, not direct dependencies - src/activity-sender.ts - Dedicated activity sending class - src/http-stream.ts - Moved from src/plugins/http/stream.ts - src/app.ts - Added activitySender, updated send() - src/app.process.ts - Removed sender param, uses activitySender - src/plugins/http/plugin.ts - Removed send/createStream, works with ICoreActivity - src/contexts/activity.ts - Uses callbacks instead of ISender plugin - src/events/activity.ts - Added ICoreActivity, changed to body field - src/types/plugin/sender.ts - Removed ISender, kept IActivitySender - src/plugins/http/stream.ts - Moved to src/http-stream.ts - ISender interface - Completely removed --- packages/apps/src/activity-sender.ts | 46 +++++++++++++ packages/apps/src/app.events.ts | 18 ++--- packages/apps/src/app.plugins.ts | 8 +-- packages/apps/src/app.process.ts | 36 +++++----- packages/apps/src/app.ts | 44 ++++++++---- packages/apps/src/contexts/activity.test.ts | 11 +-- packages/apps/src/contexts/activity.ts | 24 +++++-- packages/apps/src/events/activity-sent.ts | 7 +- packages/apps/src/events/activity.ts | 39 ++++++++--- .../http/stream.ts => http-stream.ts} | 11 ++- packages/apps/src/plugins/http/index.ts | 1 - packages/apps/src/plugins/http/plugin.ts | 68 ++----------------- .../src/types/plugin/plugin-activity-event.ts | 7 -- .../plugin/plugin-activity-response-event.ts | 7 -- .../plugin/plugin-activity-sent-event.ts | 7 -- packages/apps/src/types/plugin/sender.ts | 15 ++-- packages/botbuilder/src/plugin.ts | 3 +- packages/dev/src/plugin.ts | 17 +---- 18 files changed, 181 insertions(+), 188 deletions(-) create mode 100644 packages/apps/src/activity-sender.ts rename packages/apps/src/{plugins/http/stream.ts => http-stream.ts} (95%) diff --git a/packages/apps/src/activity-sender.ts b/packages/apps/src/activity-sender.ts new file mode 100644 index 000000000..23e339501 --- /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 { IStreamer } from './types'; + +/** + * Handles sending activities to the Bot Framework + * Separate from transport concerns (HTTP, WebSocket, etc.) + */ +export class ActivitySender { + 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.plugins.ts b/packages/apps/src/app.plugins.ts index 4722e0a87..b3123fa5d 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, @@ -84,8 +84,8 @@ export function inject(this: App, plugin: IPlu this.onError({ ...event, sender: plugin }); }; } 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.ts b/packages/apps/src/app.process.ts index cf3a7b2bb..c04de23dc 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -1,22 +1,21 @@ -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; + const activity = body as Activity; this.log.debug( `activity/${activity.type}${activity.type === 'invoke' ? `/${activity.name}` : ''}` @@ -65,7 +64,6 @@ export async function $process( if (plugin.onActivity) { const additionalPluginContext = await plugin.onActivity({ ...ref, - sender: sender, activity, token, }); @@ -104,8 +102,10 @@ export async function $process( return data; }; - const context = new ActivityContext(sender, { - ...event, + // Create stream once for this conversation + + const context = new ActivityContext({ + activity, next, api: apiClient, userGraph, @@ -117,15 +117,17 @@ export async function $process( storage: this.storage, isSignedIn: !!userToken, connectionName: this.oauth.defaultConnectionName, + send: (activity, ref) => this.activitySender.send(activity, ref), + stream: stream, + ...pluginContexts }); const send = context.send.bind(context); context.send = async (activity: ActivityLike, conversationRef?: ConversationReference) => { const res = await send(activity, conversationRef); - this.onActivitySent(sender, { + this.onActivitySent({ ...(conversationRef ?? ref), - sender, activity: res, }); @@ -133,17 +135,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 +160,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.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..e732f3163 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,8 @@ describe('ActivityContext', () => { storage: mockStorage, connectionName: 'test-connection', next: jest.fn(), + send: mockSender.send.bind(mockSender), + stream: mockSender.createStream(mockRef), }); }; @@ -282,7 +283,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 +293,8 @@ describe('ActivityContext', () => { conversationType: 'group', }, }, + send: mockSender.send.bind(mockSender), + stream: mockSender.createStream(mockRef), }); mockApiClient.users.token.get.mockRejectedValueOnce( diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index da56494b6..8e2232b83 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -1,6 +1,7 @@ import { Activity, ActivityLike, + ActivityParams, cardAttachment, ConversationAccount, ConversationReference, @@ -19,7 +20,7 @@ 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'; export interface IBaseActivityContextOptions = Record> { /** @@ -80,6 +81,16 @@ export interface IBaseActivityContextOptions Promise; + + /** + * stream for this conversation + */ + stream: IStreamer; + /** * extra data */ @@ -176,7 +187,7 @@ export class ActivityContext (void | InvokeResponse) | Promise; [key: string]: any; - protected _plugin: ISender; + protected _send: (activity: ActivityParams, ref: ConversationReference) => Promise; protected _next?: ( context?: IActivityContext ) => (void | InvokeResponse) | Promise; - constructor(plugin: ISender, value: IBaseActivityContextOptions) { + constructor(value: IBaseActivityContextOptions) { Object.assign(this, value); - this._plugin = plugin; - this.stream = plugin.createStream(value.ref); + this._send = value.send; this.connectionName = value.connectionName; if (value.activity.type === 'message') { @@ -213,7 +223,7 @@ export class ActivityContext>>>>>> 3d505721 (Separate activity sending from HTTP transport layer):packages/apps/src/http-stream.ts */ export class HttpStream implements IStreamer { readonly events = new EventEmitter(); @@ -53,7 +58,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'); } /** 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..b3bec19dc 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,7 +143,6 @@ 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; @@ -204,14 +151,13 @@ export class HttpPlugin implements ISender { appId: '', from: 'azure', fromId: '', - serviceUrl: activity.serviceUrl || '', + serviceUrl: req.body.serviceUrl || '', isExpired: () => false, }; } const response = await this.$onActivity({ - sender: this, - activity, + body: req.body, token, }); 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..d5dd8e8db 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; diff --git a/packages/dev/src/plugin.ts b/packages/dev/src/plugin.ts index 435c1a48e..043a0f304 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 { 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; @@ -119,9 +117,8 @@ 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, }); }); }, @@ -170,14 +167,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); From a8f0453a3f9a1a426bdb1e10219927ceafb28e6f Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 14 Jan 2026 21:47:47 -0800 Subject: [PATCH 02/17] Add comment --- packages/apps/src/app.process.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index c04de23dc..1766190c2 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -15,6 +15,8 @@ export async function $process( event: IActivityEvent ): Promise { const { token, body } = event; + // 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( From 0c305c7fdce30d3c4d5237e4a61f5b4ecbd57f2f Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 14 Jan 2026 21:55:44 -0800 Subject: [PATCH 03/17] Update --- packages/apps/src/http-stream.ts | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/apps/src/http-stream.ts b/packages/apps/src/http-stream.ts index 6434f28ce..2ae507c6c 100644 --- a/packages/apps/src/http-stream.ts +++ b/packages/apps/src/http-stream.ts @@ -17,7 +17,6 @@ import { IStreamer, IStreamerEvents } from './types'; import { promises } from './utils'; /** -<<<<<<< HEAD:packages/apps/src/plugins/http/stream.ts * HTTP-based streaming implementation for Microsoft Teams activities. * * Allows sending typing indicators and messages in chunks to Teams. @@ -31,10 +30,6 @@ import { promises } from './utils'; * 4. Message text is combined and sent as a typing activity. * 5. `_flush()` schedules another flush if more items remain in queue. * 6. `close()` waits for the queue to empty and sends the final message activity. -======= - * HTTP streaming implementation - * Handles batching, retry logic, and streaming state ->>>>>>> 3d505721 (Separate activity sending from HTTP transport layer):packages/apps/src/http-stream.ts */ export class HttpStream implements IStreamer { readonly events = new EventEmitter(); @@ -233,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; + } } /** From b673e3ee04cada8000a002f94b7708f442d3502e Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 15 Jan 2026 22:05:42 -0800 Subject: [PATCH 04/17] Fix send --- packages/apps/src/app.process.ts | 2 +- packages/apps/src/contexts/activity.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index 1766190c2..d811e17cc 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -126,7 +126,7 @@ export async function $process( 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({ ...(conversationRef ?? ref), diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index 8e2232b83..f729a7252 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -201,8 +201,10 @@ export class ActivityContext (void | InvokeResponse) | Promise; constructor(value: IBaseActivityContextOptions) { - Object.assign(this, value); - this._send = value.send; + // Extract send before Object.assign to avoid overwriting the send() method + const { send, ...rest } = value; + Object.assign(this, rest); + this._send = send; this.connectionName = value.connectionName; if (value.activity.type === 'message') { From a23d8ab083e0762dd354c81d7fe4ebfc25b6b82f Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 15 Jan 2026 23:03:15 -0800 Subject: [PATCH 05/17] fx --- packages/botbuilder/src/plugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/botbuilder/src/plugin.ts b/packages/botbuilder/src/plugin.ts index d5dd8e8db..b5dbc8c56 100644 --- a/packages/botbuilder/src/plugin.ts +++ b/packages/botbuilder/src/plugin.ts @@ -134,7 +134,8 @@ export class BotBuilderPlugin extends HttpPlugin { 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); From d57abc4836fa3ef5b35012ab144f2c07cefa75e6 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Fri, 16 Jan 2026 10:08:45 -0800 Subject: [PATCH 06/17] fix --- packages/apps/src/app.process.spec.ts | 20 ++++++++----------- packages/apps/src/app.spec.ts | 13 ++++++++---- .../stream.spec.ts => http-stream.spec.ts} | 2 +- packages/botbuilder/src/plugin.ts | 4 ++-- 4 files changed, 20 insertions(+), 19 deletions(-) rename packages/apps/src/{plugins/http/stream.spec.ts => http-stream.spec.ts} (99%) diff --git a/packages/apps/src/app.process.spec.ts b/packages/apps/src/app.process.spec.ts index 215cc2ea5..fef73cf87 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,15 +93,14 @@ 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(); }); 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/plugins/http/stream.spec.ts b/packages/apps/src/http-stream.spec.ts similarity index 99% rename from packages/apps/src/plugins/http/stream.spec.ts rename to packages/apps/src/http-stream.spec.ts index 4ead48637..f93e313c5 100644 --- a/packages/apps/src/plugins/http/stream.spec.ts +++ b/packages/apps/src/http-stream.spec.ts @@ -1,4 +1,4 @@ -import { HttpStream } from './stream'; +import { HttpStream } from './http-stream'; describe('HttpStream', () => { let client: any; diff --git a/packages/botbuilder/src/plugin.ts b/packages/botbuilder/src/plugin.ts index b5dbc8c56..ff4846923 100644 --- a/packages/botbuilder/src/plugin.ts +++ b/packages/botbuilder/src/plugin.ts @@ -71,8 +71,8 @@ export class BotBuilderPlugin extends HttpPlugin { this.handler = options?.handler; } - onInit() { - super.onInit(); + async onInit() { + await super.onInit(); if (!this.adapter) { const clientId = this.credentials?.clientId; const clientSecret = From 4590e50ed8afa8c3fce315f8e4a55879cf3435fe Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Fri, 16 Jan 2026 23:11:28 -0800 Subject: [PATCH 07/17] Fix error handling in HTTP and dev plugins - Add validation for missing activity body in app.process.ts - Add try-catch in HttpPlugin.onRequest to prevent server hanging on errors - Fix DevtoolsPlugin to properly handle promise rejections - Add default type fallback in devtools activity creation route Fixes issue where server would become unresponsive after activity processing errors. Co-Authored-By: Claude Sonnet 4.5 --- packages/apps/src/app.process.ts | 5 +++ packages/apps/src/plugins/http/plugin.ts | 43 +++++++++++-------- packages/dev/src/plugin.ts | 8 +++- .../v3/conversations/activities/create.ts | 35 ++++++++------- 4 files changed, 55 insertions(+), 36 deletions(-) diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index d811e17cc..62e0b9a07 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -15,6 +15,11 @@ export async function $process( event: IActivityEvent ): Promise { 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; diff --git a/packages/apps/src/plugins/http/plugin.ts b/packages/apps/src/plugins/http/plugin.ts index b3bec19dc..4227d8217 100644 --- a/packages/apps/src/plugins/http/plugin.ts +++ b/packages/apps/src/plugins/http/plugin.ts @@ -143,24 +143,31 @@ export class HttpPlugin { res: express.Response, _next: express.NextFunction ) { - 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, - }); + 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/dev/src/plugin.ts b/packages/dev/src/plugin.ts index 043a0f304..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 { IToken } from '@microsoft/teams.api'; +import { InvokeResponse, IToken } from '@microsoft/teams.api'; import { HttpPlugin, Logger, @@ -62,7 +62,7 @@ export class DevtoolsPlugin { 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,6 +119,10 @@ export class DevtoolsPlugin { this.$onActivity({ token, body: activity, + }).catch((err) => { + this.log.error('Error processing activity:', err); + reject(err); + delete this.pending[activity.id]; }); }); }, 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 }); From 2f765e3ed214b106aab53d3cdf16cc41d6025765 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Fri, 23 Jan 2026 22:28:54 -0800 Subject: [PATCH 08/17] Fix --- packages/apps/src/app.process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index 62e0b9a07..43b347879 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -110,7 +110,7 @@ export async function $process( }; // Create stream once for this conversation - + const stream = this.activitySender.createStream(ref); const context = new ActivityContext({ activity, next, From 3a6190c896ff562cb66b19ce5c930185428f4f92 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Fri, 23 Jan 2026 23:52:52 -0800 Subject: [PATCH 09/17] Rm comment --- packages/apps/src/app.process.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index 43b347879..9cfe1d5ef 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -109,7 +109,6 @@ export async function $process( return data; }; - // Create stream once for this conversation const stream = this.activitySender.createStream(ref); const context = new ActivityContext({ activity, From b0b9c1d2f7c4e01aeeae36c7e70276c62a6a3bb0 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 24 Jan 2026 00:14:04 -0800 Subject: [PATCH 10/17] Remove sender from IEvent --- packages/apps/src/types/event.ts | 9 +-------- packages/botbuilder/src/plugin.ts | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) 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/botbuilder/src/plugin.ts b/packages/botbuilder/src/plugin.ts index ff4846923..52f0bece1 100644 --- a/packages/botbuilder/src/plugin.ts +++ b/packages/botbuilder/src/plugin.ts @@ -132,7 +132,6 @@ export class BotBuilderPlugin extends HttpPlugin { } const response = await this.$onActivity({ - sender: this, token, body: new $Activity(context.activity as any) as Activity, From f7542c02c52ff5459da7debd2f2759bc3713cbfe Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 24 Jan 2026 00:20:57 -0800 Subject: [PATCH 11/17] fix tests --- packages/apps/src/app.plugin.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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', From 8571979cb45c7c4d15d7176aacdb1c561afac3ec Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 24 Jan 2026 01:20:26 -0800 Subject: [PATCH 12/17] fix tests --- packages/apps/src/app.plugins.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/src/app.plugins.ts b/packages/apps/src/app.plugins.ts index b3123fa5d..0346b98f2 100644 --- a/packages/apps/src/app.plugins.ts +++ b/packages/apps/src/app.plugins.ts @@ -81,7 +81,7 @@ 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: IActivityEvent) => { From 904f9172219a0ba9ab69cee25cbd59e01c997c72 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 26 Jan 2026 07:16:44 -0800 Subject: [PATCH 13/17] simlify app.process --- packages/apps/src/app.process.ts | 4 +- packages/apps/src/contexts/activity.test.ts | 6 +- packages/apps/src/contexts/activity.ts | 76 +++++++++++---------- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/packages/apps/src/app.process.ts b/packages/apps/src/app.process.ts index 9cfe1d5ef..65e370200 100644 --- a/packages/apps/src/app.process.ts +++ b/packages/apps/src/app.process.ts @@ -109,7 +109,6 @@ export async function $process( return data; }; - const stream = this.activitySender.createStream(ref); const context = new ActivityContext({ activity, next, @@ -123,8 +122,7 @@ export async function $process( storage: this.storage, isSignedIn: !!userToken, connectionName: this.oauth.defaultConnectionName, - send: (activity, ref) => this.activitySender.send(activity, ref), - stream: stream, + activitySender: this.activitySender, ...pluginContexts }); diff --git a/packages/apps/src/contexts/activity.test.ts b/packages/apps/src/contexts/activity.test.ts index e732f3163..d2553250a 100644 --- a/packages/apps/src/contexts/activity.test.ts +++ b/packages/apps/src/contexts/activity.test.ts @@ -110,8 +110,7 @@ describe('ActivityContext', () => { storage: mockStorage, connectionName: 'test-connection', next: jest.fn(), - send: mockSender.send.bind(mockSender), - stream: mockSender.createStream(mockRef), + activitySender: mockSender, }); }; @@ -293,8 +292,7 @@ describe('ActivityContext', () => { conversationType: 'group', }, }, - send: mockSender.send.bind(mockSender), - stream: mockSender.createStream(mockRef), + activitySender: mockSender, }); mockApiClient.users.token.get.mockRejectedValueOnce( diff --git a/packages/apps/src/contexts/activity.ts b/packages/apps/src/contexts/activity.ts index f729a7252..1f7676139 100644 --- a/packages/apps/src/contexts/activity.ts +++ b/packages/apps/src/contexts/activity.ts @@ -1,7 +1,6 @@ import { Activity, ActivityLike, - ActivityParams, cardAttachment, ConversationAccount, ConversationReference, @@ -21,8 +20,31 @@ import { IStorage } from '@microsoft/teams.common/storage'; import { ApiClient, GraphClient } from '../api'; import { IStreamer } from '../types'; +import { IActivitySender } from '../types/plugin/sender'; -export interface IBaseActivityContextOptions = Record> { +/** + * 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; +} + +/** + * Base activity context options + * These are the public properties exposed on the context + */ +export interface IBaseActivityContextOptions { /** * the app id of the bot */ @@ -80,31 +102,9 @@ export interface IBaseActivityContextOptions Promise; - - /** - * stream for this conversation - */ - stream: IStreamer; - - /** - * extra data - */ - [key: string]: any; - - /** - * call the next event/middleware handler - */ - next: ( - context?: IActivityContext & TExtraCtx - ) => (void | InvokeResponse) | Promise; } -export type IActivityContextOptions = Record> = IBaseActivityContextOptions & TExtraCtx; +export type IActivityContextOptions = Record> = IBaseActivityContextOptions & TExtraCtx; type SignInOptions = { /** @@ -142,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 @@ -195,16 +202,15 @@ export class ActivityContext (void | InvokeResponse) | Promise; [key: string]: any; - protected _send: (activity: ActivityParams, ref: ConversationReference) => Promise; - protected _next?: ( - context?: IActivityContext - ) => (void | InvokeResponse) | Promise; + private activitySender: IActivitySender; - constructor(value: IBaseActivityContextOptions) { - // Extract send before Object.assign to avoid overwriting the send() method - const { send, ...rest } = value; + 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._send = send; + this.activitySender = activitySender; + this.next = next; + this.stream = activitySender.createStream(value.ref); this.connectionName = value.connectionName; if (value.activity.type === 'message') { @@ -225,7 +231,7 @@ export class ActivityContext Date: Mon, 26 Jan 2026 08:30:38 -0800 Subject: [PATCH 14/17] Activity Sender --- packages/apps/src/activity-sender.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/apps/src/activity-sender.ts b/packages/apps/src/activity-sender.ts index 23e339501..7c96f377f 100644 --- a/packages/apps/src/activity-sender.ts +++ b/packages/apps/src/activity-sender.ts @@ -3,17 +3,17 @@ import * as $http from '@microsoft/teams.common/http'; import { ILogger } from '@microsoft/teams.common/logging'; import { HttpStream } from './http-stream'; -import { IStreamer } from './types'; +import { IActivitySender, IStreamer } from './types'; /** * Handles sending activities to the Bot Framework * Separate from transport concerns (HTTP, WebSocket, etc.) */ -export class ActivitySender { +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 From 3fc27f1fabb3513c38da597141ed215483116acf Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 26 Jan 2026 09:00:21 -0800 Subject: [PATCH 15/17] Add proactive messaging q --- examples/proactive-messaging/CHANGELOG.md | 241 ++++++++++++++++++ examples/proactive-messaging/README.md | 140 ++++++++++ examples/proactive-messaging/eslint.config.js | 1 + examples/proactive-messaging/package.json | 34 +++ examples/proactive-messaging/src/index.ts | 112 ++++++++ examples/proactive-messaging/tsconfig.json | 8 + examples/proactive-messaging/turbo.json | 18 ++ packages/apps/src/app.process.spec.ts | 92 +++++++ 8 files changed, 646 insertions(+) create mode 100644 examples/proactive-messaging/CHANGELOG.md create mode 100644 examples/proactive-messaging/README.md create mode 100644 examples/proactive-messaging/eslint.config.js create mode 100644 examples/proactive-messaging/package.json create mode 100644 examples/proactive-messaging/src/index.ts create mode 100644 examples/proactive-messaging/tsconfig.json create mode 100644 examples/proactive-messaging/turbo.json diff --git a/examples/proactive-messaging/CHANGELOG.md b/examples/proactive-messaging/CHANGELOG.md new file mode 100644 index 000000000..de54c12f4 --- /dev/null +++ b/examples/proactive-messaging/CHANGELOG.md @@ -0,0 +1,241 @@ +# @examples/echo + +## 0.0.6 + +### Patch Changes + +- Updated 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 + - @microsoft/teams.dev@2.0.5 + - @microsoft/teams.graph@2.0.5 + +## 0.0.5 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.4 + - @microsoft/teams.apps@2.0.4 + - @microsoft/teams.cards@2.0.4 + - @microsoft/teams.common@2.0.4 + - @microsoft/teams.dev@2.0.4 + - @microsoft/teams.graph@2.0.4 + +## 0.0.4 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.3 + - @microsoft/teams.apps@2.0.3 + - @microsoft/teams.cards@2.0.3 + - @microsoft/teams.common@2.0.3 + - @microsoft/teams.dev@2.0.3 + - @microsoft/teams.graph@2.0.3 + +## 0.0.3 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.2 + - @microsoft/teams.apps@2.0.2 + - @microsoft/teams.cards@2.0.2 + - @microsoft/teams.common@2.0.2 + - @microsoft/teams.dev@2.0.2 + - @microsoft/teams.graph@2.0.2 + +## 0.0.2 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.1 + - @microsoft/teams.apps@2.0.1 + - @microsoft/teams.cards@2.0.1 + - @microsoft/teams.common@2.0.1 + - @microsoft/teams.dev@2.0.1 + - @microsoft/teams.graph@2.0.1 + +## 0.0.1 + +### Patch Changes + +- Updated dependencies [a231813] +- Updated dependencies [05085e8] +- Updated dependencies [7a0e5f6] +- Updated dependencies [1d5f350] +- Updated dependencies [9bc2cee] +- Updated dependencies [9b08518] +- Updated dependencies [00d3edb] +- Updated dependencies [ee61ca0] +- Updated dependencies [70cb729] +- Updated dependencies [2337a4f] +- Updated dependencies [e6f9b56] +- Updated dependencies [9e2414b] +- Updated dependencies [753af04] + - @microsoft/teams.api@2.0.0 + - @microsoft/teams.apps@2.0.0 + - @microsoft/teams.cards@2.0.0 + - @microsoft/teams.common@2.0.0 + - @microsoft/teams.dev@2.0.0 + - @microsoft/teams.graph@2.0.0 + +## 0.0.1-preview.12 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.12 + - @microsoft/teams.apps@2.0.0-preview.12 + - @microsoft/teams.cards@2.0.0-preview.12 + - @microsoft/teams.common@2.0.0-preview.12 + - @microsoft/teams.dev@2.0.0-preview.12 + - @microsoft/teams.graph@2.0.0-preview.12 + +## 0.0.1-preview.11 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.11 + - @microsoft/teams.apps@2.0.0-preview.11 + - @microsoft/teams.cards@2.0.0-preview.11 + - @microsoft/teams.common@2.0.0-preview.11 + - @microsoft/teams.dev@2.0.0-preview.11 + - @microsoft/teams.graph@2.0.0-preview.11 + +## 0.0.1-preview.10 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.10 + - @microsoft/teams.apps@2.0.0-preview.10 + - @microsoft/teams.cards@2.0.0-preview.10 + - @microsoft/teams.common@2.0.0-preview.10 + - @microsoft/teams.dev@2.0.0-preview.10 + - @microsoft/teams.graph@2.0.0-preview.10 + +## 0.0.1-preview.9 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.9 + - @microsoft/teams.apps@2.0.0-preview.9 + - @microsoft/teams.cards@2.0.0-preview.9 + - @microsoft/teams.common@2.0.0-preview.9 + - @microsoft/teams.dev@2.0.0-preview.9 + - @microsoft/teams.graph@2.0.0-preview.9 + +## 0.0.1-preview.8 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.8 + - @microsoft/teams.apps@2.0.0-preview.8 + - @microsoft/teams.cards@2.0.0-preview.8 + - @microsoft/teams.common@2.0.0-preview.8 + - @microsoft/teams.dev@2.0.0-preview.8 + - @microsoft/teams.graph@2.0.0-preview.8 + +## 0.0.1-preview.7 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.7 + - @microsoft/teams.apps@2.0.0-preview.7 + - @microsoft/teams.cards@2.0.0-preview.7 + - @microsoft/teams.common@2.0.0-preview.7 + - @microsoft/teams.dev@2.0.0-preview.7 + - @microsoft/teams.graph@2.0.0-preview.7 + +## 0.0.1-preview.6 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.6 + - @microsoft/teams.apps@2.0.0-preview.6 + - @microsoft/teams.cards@2.0.0-preview.6 + - @microsoft/teams.common@2.0.0-preview.6 + - @microsoft/teams.dev@2.0.0-preview.6 + - @microsoft/teams.graph@2.0.0-preview.6 + +## 0.0.1-preview.5 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.apps@2.0.0-preview.5 + - @microsoft/teams.api@2.0.0-preview.5 + - @microsoft/teams.cards@2.0.0-preview.5 + - @microsoft/teams.common@2.0.0-preview.5 + - @microsoft/teams.dev@2.0.0-preview.5 + - @microsoft/teams.graph@2.0.0-preview.5 + +## 0.0.1-preview.4 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.4 + - @microsoft/teams.apps@2.0.0-preview.4 + - @microsoft/teams.cards@2.0.0-preview.4 + - @microsoft/teams.common@2.0.0-preview.4 + - @microsoft/teams.dev@2.0.0-preview.4 + - @microsoft/teams.graph@2.0.0-preview.4 + +## 0.0.1-preview.3 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.3 + - @microsoft/teams.apps@2.0.0-preview.3 + - @microsoft/teams.cards@2.0.0-preview.3 + - @microsoft/teams.common@2.0.0-preview.3 + - @microsoft/teams.dev@2.0.0-preview.3 + - @microsoft/teams.graph@2.0.0-preview.3 + +## 0.0.1-preview.2 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.2 + - @microsoft/teams.apps@2.0.0-preview.2 + - @microsoft/teams.cards@2.0.0-preview.2 + - @microsoft/teams.common@2.0.0-preview.2 + - @microsoft/teams.dev@2.0.0-preview.2 + - @microsoft/teams.graph@2.0.0-preview.2 + +## 0.0.1-preview.1 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.api@2.0.0-preview.1 + - @microsoft/teams.apps@2.0.0-preview.1 + - @microsoft/teams.cards@2.0.0-preview.1 + - @microsoft/teams.common@2.0.0-preview.1 + - @microsoft/teams.dev@2.0.0-preview.1 + - @microsoft/teams.graph@2.0.0-preview.1 + +## 0.0.1-preview.0 + +### Patch Changes + +- Updated dependencies + - @microsoft/teams.common@2.0.0-preview.0 + - @microsoft/teams.cards@2.0.0-preview.0 + - @microsoft/teams.graph@2.0.0-preview.0 + - @microsoft/teams.apps@2.0.0-preview.0 + - @microsoft/teams.api@2.0.0-preview.0 + - @microsoft/teams.dev@2.0.0-preview.0 diff --git a/examples/proactive-messaging/README.md b/examples/proactive-messaging/README.md new file mode 100644 index 000000000..3d4c823f7 --- /dev/null +++ b/examples/proactive-messaging/README.md @@ -0,0 +1,140 @@ +# Proactive Messaging Example + +This example demonstrates how to send proactive messages to Teams users **without running a server**. This is useful for: + +- Scheduled notifications +- Alert systems +- Background jobs that need to notify users +- Webhook handlers that send messages + +## Key Concepts + +### No Server Required + +Unlike typical Teams bots that listen for incoming messages, this example shows how to send messages proactively: + +```typescript +// Initialize without starting an HTTP server +await app.initialize(); + +// Send messages directly +await app.send(conversationId, 'Hello!'); +``` + +### Getting Conversation IDs + +To send proactive messages, you need the conversation ID. You can get this from: + +1. **Previous bot interactions** - Store the `activity.conversation.id` when users first message your bot +2. **Teams API** - Use the Graph API to get conversation IDs +3. **Installation events** - Save the conversation ID from `install.add` events + +## Usage + +### Prerequisites + +1. Set up your `.env` file with bot credentials: + ``` + BOT_ID= + BOT_PASSWORD= + ``` + +2. Get a conversation ID (from previous bot interactions or the Teams API) + +### Run the Example + +```bash +# Using npm start (after building) +npm run build +npm start + +# Using dev mode +npm run dev +``` + +### Example Output + +``` +Initializing app (without starting server)... +✓ App initialized + +Sending proactive message to conversation: 19:abc123... +Message: Hello! This is a proactive message sent without a running server 🚀 +✓ Message sent successfully! Activity ID: 1234567890 + +Sending proactive card to conversation: 19:abc123... +✓ Card sent successfully! Activity ID: 0987654321 + +✓ All proactive messages sent successfully! +``` + +## Code Structure + +The example demonstrates two types of proactive messages: + +### 1. Text Messages + +```typescript +await app.send(conversationId, 'Your notification text here'); +``` + +### 2. Adaptive Cards + +```typescript +const card = new AdaptiveCard() + .addItem(new TextBlock('Title').size('Large')) + .addItem(new TextBlock('Description').wrap(true)); + +await app.send(conversationId, card); +``` + +## Real-World Use Cases + +### Scheduled Reminders + +```typescript +// Run this script on a schedule (e.g., via cron) +const app = new App(); +await app.initialize(); + +for (const user of usersWithReminders) { + await app.send(user.conversationId, `Reminder: ${user.reminderText}`); +} +``` + +### Alert System + +```typescript +// Trigger from monitoring system +if (systemAlert) { + const app = new App(); + await app.initialize(); + await app.send(adminConversationId, `🚨 Alert: ${alert.message}`); +} +``` + +### Webhook Handler + +```typescript +// Express webhook endpoint +app.post('/webhook', async (req, res) => { + const teamsApp = new App(); + await teamsApp.initialize(); + + await teamsApp.send(req.body.conversationId, req.body.message); + + res.sendStatus(200); +}); +``` + +## Important Notes + +- **Conversation IDs persist** - Save them to storage for later use +- **No server overhead** - This approach doesn't require a running HTTP server +- **Rate limits apply** - Be mindful of Teams API rate limits when sending bulk messages +- **Permissions required** - Your bot must be installed in the conversation to send messages + +## Learn More + +- [Teams Bot Framework Documentation](https://aka.ms/teams-sdk) +- [Proactive Messaging Guide](https://learn.microsoft.com/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) 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..a3624cbf2 --- /dev/null +++ b/examples/proactive-messaging/src/index.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + * + * Proactive Messaging Example + * =========================== + * This example demonstrates how to send proactive messages to Teams users + * without running a server. This is useful for: + * - Scheduled notifications + * - Alert systems + * - Background jobs that need to notify users + * - Webhook handlers that send messages + * + * Key points: + * - Uses app.initialize() instead of app.start() (no HTTP server) + * - Directly sends messages using app.send() + * - Requires a conversation ID (from previous interactions or from the Teams API) + */ + +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) { + /** + * Send a proactive message to a Teams conversation. + * + * Args: + * app: The initialized App instance + * conversationId: The Teams conversation ID to send the message to + * message: The message text to send + */ + console.log(`Sending proactive message to conversation: ${conversationId}`); + console.log(`Message: ${message}`); + + // Send the message + const result = await app.send(conversationId, message); + + console.log(`✓ Message sent successfully! Activity ID: ${result.id}`); +} + +async function sendProactiveCard(app: App, conversationId: string) { + /** + * Send a proactive Adaptive Card to a Teams conversation. + * + * Args: + * app: The initialized App instance + * conversationId: The Teams conversation ID to send the card to + */ + // Create an Adaptive Card + 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() { + /** + * Main function demonstrating proactive messaging. + * + * In a real application, you would: + * 1. Store conversation IDs when users first interact with your bot + * 2. Use those IDs later to send proactive messages + * 3. Get conversation IDs from the Teams API or from previous interactions + */ + 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); + } + + // Create app (no plugins needed for sending only) + const app = new App({ + logger: new ConsoleLogger('@examples/proactive-messaging', { level: 'info' }) + }); + + // Initialize the app without starting the HTTP server + // This sets up credentials, token manager, and activity sender + console.log('Initializing app (without starting server)...'); + await app.initialize(); + console.log('✓ App initialized\n'); + + // Example 1: Send a simple text message + await sendProactiveMessage( + app, + conversationId, + 'Hello! This is a proactive message sent without a running server 🚀' + ); + + // Wait a bit between messages + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Example 2: Send an Adaptive Card + 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/app.process.spec.ts b/packages/apps/src/app.process.spec.ts index fef73cf87..04fbacca9 100644 --- a/packages/apps/src/app.process.spec.ts +++ b/packages/apps/src/app.process.spec.ts @@ -104,5 +104,97 @@ describe('App', () => { 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]); + }); }); }); From aa31eb3392b8acdce9856be0f6190d348ab739fa Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 26 Jan 2026 09:04:23 -0800 Subject: [PATCH 16/17] add better readmes --- examples/proactive-messaging/README.md | 139 ++++++---------------- examples/proactive-messaging/src/index.ts | 50 +------- 2 files changed, 43 insertions(+), 146 deletions(-) diff --git a/examples/proactive-messaging/README.md b/examples/proactive-messaging/README.md index 3d4c823f7..54a07fb34 100644 --- a/examples/proactive-messaging/README.md +++ b/examples/proactive-messaging/README.md @@ -1,140 +1,77 @@ # Proactive Messaging Example -This example demonstrates how to send proactive messages to Teams users **without running a server**. This is useful for: - -- Scheduled notifications -- Alert systems -- Background jobs that need to notify users -- Webhook handlers that send messages +Send proactive messages to Teams users without running a server. ## Key Concepts -### No Server Required - -Unlike typical Teams bots that listen for incoming messages, this example shows how to send messages proactively: - +**Without a server:** ```typescript -// Initialize without starting an HTTP server await app.initialize(); - -// Send messages directly await app.send(conversationId, 'Hello!'); ``` -### Getting Conversation IDs - -To send proactive messages, you need the conversation ID. You can get this from: +**With a running server:** +```typescript +await app.start(); +// Later, anywhere in your code: +await app.send(conversationId, 'Hello!'); +``` -1. **Previous bot interactions** - Store the `activity.conversation.id` when users first message your bot -2. **Teams API** - Use the Graph API to get conversation IDs -3. **Installation events** - Save the conversation ID from `install.add` events +> **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 -### Prerequisites - -1. Set up your `.env` file with bot credentials: +1. Set up `.env`: ``` BOT_ID= BOT_PASSWORD= ``` -2. Get a conversation ID (from previous bot interactions or the Teams API) - -### Run the Example - -```bash -# Using npm start (after building) -npm run build -npm start - -# Using dev mode -npm run dev -``` - -### Example Output - -``` -Initializing app (without starting server)... -✓ App initialized - -Sending proactive message to conversation: 19:abc123... -Message: Hello! This is a proactive message sent without a running server 🚀 -✓ Message sent successfully! Activity ID: 1234567890 - -Sending proactive card to conversation: 19:abc123... -✓ Card sent successfully! Activity ID: 0987654321 - -✓ All proactive messages sent successfully! -``` - -## Code Structure - -The example demonstrates two types of proactive messages: +2. Run: + ```bash + npm run dev + ``` -### 1. Text Messages +## Examples +**Send text:** ```typescript -await app.send(conversationId, 'Your notification text here'); +await app.send(conversationId, 'Your message'); ``` -### 2. Adaptive Cards - +**Send card:** ```typescript -const card = new AdaptiveCard() - .addItem(new TextBlock('Title').size('Large')) - .addItem(new TextBlock('Description').wrap(true)); - +const card = new AdaptiveCard( + new TextBlock('Title', { size: 'Large' }) +); await app.send(conversationId, card); ``` -## Real-World Use Cases - -### Scheduled Reminders - +**Scheduled job (no server):** ```typescript -// Run this script on a schedule (e.g., via cron) const app = new App(); await app.initialize(); - -for (const user of usersWithReminders) { - await app.send(user.conversationId, `Reminder: ${user.reminderText}`); -} +await app.send(conversationId, 'Reminder!'); ``` -### Alert System - +**From running bot:** ```typescript -// Trigger from monitoring system -if (systemAlert) { - const app = new App(); - await app.initialize(); - await app.send(adminConversationId, `🚨 Alert: ${alert.message}`); -} -``` - -### Webhook Handler - -```typescript -// Express webhook endpoint -app.post('/webhook', async (req, res) => { - const teamsApp = new App(); - await teamsApp.initialize(); - - await teamsApp.send(req.body.conversationId, req.body.message); +const app = new App(); +await app.start(); - res.sendStatus(200); +app.on('message', async ({ activity }) => { + await saveConversationId(activity.conversation.id); }); -``` -## Important Notes - -- **Conversation IDs persist** - Save them to storage for later use -- **No server overhead** - This approach doesn't require a running HTTP server -- **Rate limits apply** - Be mindful of Teams API rate limits when sending bulk messages -- **Permissions required** - Your bot must be installed in the conversation to send messages +// Send proactive messages anytime +await app.send(conversationId, 'Update!'); +``` -## Learn More +## Notes -- [Teams Bot Framework Documentation](https://aka.ms/teams-sdk) -- [Proactive Messaging Guide](https://learn.microsoft.com/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +- 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/src/index.ts b/examples/proactive-messaging/src/index.ts index a3624cbf2..c96e15c1c 100644 --- a/examples/proactive-messaging/src/index.ts +++ b/examples/proactive-messaging/src/index.ts @@ -1,20 +1,8 @@ /** - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. - * * Proactive Messaging Example - * =========================== - * This example demonstrates how to send proactive messages to Teams users - * without running a server. This is useful for: - * - Scheduled notifications - * - Alert systems - * - Background jobs that need to notify users - * - Webhook handlers that send messages * - * Key points: - * - Uses app.initialize() instead of app.start() (no HTTP server) - * - Directly sends messages using app.send() - * - Requires a conversation ID (from previous interactions or from the Teams API) + * 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'; @@ -22,32 +10,15 @@ import { ActionSet, AdaptiveCard, OpenUrlAction, TextBlock } from '@microsoft/te import { ConsoleLogger } from '@microsoft/teams.common/logging'; async function sendProactiveMessage(app: App, conversationId: string, message: string) { - /** - * Send a proactive message to a Teams conversation. - * - * Args: - * app: The initialized App instance - * conversationId: The Teams conversation ID to send the message to - * message: The message text to send - */ console.log(`Sending proactive message to conversation: ${conversationId}`); console.log(`Message: ${message}`); - // Send the message const result = await app.send(conversationId, message); console.log(`✓ Message sent successfully! Activity ID: ${result.id}`); } async function sendProactiveCard(app: App, conversationId: string) { - /** - * Send a proactive Adaptive Card to a Teams conversation. - * - * Args: - * app: The initialized App instance - * conversationId: The Teams conversation ID to send the card to - */ - // Create an Adaptive Card 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 }), @@ -65,14 +36,6 @@ async function sendProactiveCard(app: App, conversationId: string) { } async function main() { - /** - * Main function demonstrating proactive messaging. - * - * In a real application, you would: - * 1. Store conversation IDs when users first interact with your bot - * 2. Use those IDs later to send proactive messages - * 3. Get conversation IDs from the Teams API or from previous interactions - */ const conversationId = process.argv[2]; if (!conversationId) { @@ -82,28 +45,25 @@ async function main() { process.exit(1); } - // Create app (no plugins needed for sending only) const app = new App({ logger: new ConsoleLogger('@examples/proactive-messaging', { level: 'info' }) }); - // Initialize the app without starting the HTTP server - // This sets up credentials, token manager, and activity sender + // 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'); - // Example 1: Send a simple text message await sendProactiveMessage( app, conversationId, 'Hello! This is a proactive message sent without a running server 🚀' ); - // Wait a bit between messages await new Promise(resolve => setTimeout(resolve, 2000)); - // Example 2: Send an Adaptive Card await sendProactiveCard(app, conversationId); console.log('\n✓ All proactive messages sent successfully!'); From 7237a1d111105edd10d85d7d0019b1f844aa03c9 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 26 Jan 2026 11:25:16 -0800 Subject: [PATCH 17/17] Rm changelog --- examples/proactive-messaging/CHANGELOG.md | 241 ---------------------- 1 file changed, 241 deletions(-) delete mode 100644 examples/proactive-messaging/CHANGELOG.md diff --git a/examples/proactive-messaging/CHANGELOG.md b/examples/proactive-messaging/CHANGELOG.md deleted file mode 100644 index de54c12f4..000000000 --- a/examples/proactive-messaging/CHANGELOG.md +++ /dev/null @@ -1,241 +0,0 @@ -# @examples/echo - -## 0.0.6 - -### Patch Changes - -- Updated 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 - - @microsoft/teams.dev@2.0.5 - - @microsoft/teams.graph@2.0.5 - -## 0.0.5 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.4 - - @microsoft/teams.apps@2.0.4 - - @microsoft/teams.cards@2.0.4 - - @microsoft/teams.common@2.0.4 - - @microsoft/teams.dev@2.0.4 - - @microsoft/teams.graph@2.0.4 - -## 0.0.4 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.3 - - @microsoft/teams.apps@2.0.3 - - @microsoft/teams.cards@2.0.3 - - @microsoft/teams.common@2.0.3 - - @microsoft/teams.dev@2.0.3 - - @microsoft/teams.graph@2.0.3 - -## 0.0.3 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.2 - - @microsoft/teams.apps@2.0.2 - - @microsoft/teams.cards@2.0.2 - - @microsoft/teams.common@2.0.2 - - @microsoft/teams.dev@2.0.2 - - @microsoft/teams.graph@2.0.2 - -## 0.0.2 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.1 - - @microsoft/teams.apps@2.0.1 - - @microsoft/teams.cards@2.0.1 - - @microsoft/teams.common@2.0.1 - - @microsoft/teams.dev@2.0.1 - - @microsoft/teams.graph@2.0.1 - -## 0.0.1 - -### Patch Changes - -- Updated dependencies [a231813] -- Updated dependencies [05085e8] -- Updated dependencies [7a0e5f6] -- Updated dependencies [1d5f350] -- Updated dependencies [9bc2cee] -- Updated dependencies [9b08518] -- Updated dependencies [00d3edb] -- Updated dependencies [ee61ca0] -- Updated dependencies [70cb729] -- Updated dependencies [2337a4f] -- Updated dependencies [e6f9b56] -- Updated dependencies [9e2414b] -- Updated dependencies [753af04] - - @microsoft/teams.api@2.0.0 - - @microsoft/teams.apps@2.0.0 - - @microsoft/teams.cards@2.0.0 - - @microsoft/teams.common@2.0.0 - - @microsoft/teams.dev@2.0.0 - - @microsoft/teams.graph@2.0.0 - -## 0.0.1-preview.12 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.12 - - @microsoft/teams.apps@2.0.0-preview.12 - - @microsoft/teams.cards@2.0.0-preview.12 - - @microsoft/teams.common@2.0.0-preview.12 - - @microsoft/teams.dev@2.0.0-preview.12 - - @microsoft/teams.graph@2.0.0-preview.12 - -## 0.0.1-preview.11 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.11 - - @microsoft/teams.apps@2.0.0-preview.11 - - @microsoft/teams.cards@2.0.0-preview.11 - - @microsoft/teams.common@2.0.0-preview.11 - - @microsoft/teams.dev@2.0.0-preview.11 - - @microsoft/teams.graph@2.0.0-preview.11 - -## 0.0.1-preview.10 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.10 - - @microsoft/teams.apps@2.0.0-preview.10 - - @microsoft/teams.cards@2.0.0-preview.10 - - @microsoft/teams.common@2.0.0-preview.10 - - @microsoft/teams.dev@2.0.0-preview.10 - - @microsoft/teams.graph@2.0.0-preview.10 - -## 0.0.1-preview.9 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.9 - - @microsoft/teams.apps@2.0.0-preview.9 - - @microsoft/teams.cards@2.0.0-preview.9 - - @microsoft/teams.common@2.0.0-preview.9 - - @microsoft/teams.dev@2.0.0-preview.9 - - @microsoft/teams.graph@2.0.0-preview.9 - -## 0.0.1-preview.8 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.8 - - @microsoft/teams.apps@2.0.0-preview.8 - - @microsoft/teams.cards@2.0.0-preview.8 - - @microsoft/teams.common@2.0.0-preview.8 - - @microsoft/teams.dev@2.0.0-preview.8 - - @microsoft/teams.graph@2.0.0-preview.8 - -## 0.0.1-preview.7 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.7 - - @microsoft/teams.apps@2.0.0-preview.7 - - @microsoft/teams.cards@2.0.0-preview.7 - - @microsoft/teams.common@2.0.0-preview.7 - - @microsoft/teams.dev@2.0.0-preview.7 - - @microsoft/teams.graph@2.0.0-preview.7 - -## 0.0.1-preview.6 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.6 - - @microsoft/teams.apps@2.0.0-preview.6 - - @microsoft/teams.cards@2.0.0-preview.6 - - @microsoft/teams.common@2.0.0-preview.6 - - @microsoft/teams.dev@2.0.0-preview.6 - - @microsoft/teams.graph@2.0.0-preview.6 - -## 0.0.1-preview.5 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.apps@2.0.0-preview.5 - - @microsoft/teams.api@2.0.0-preview.5 - - @microsoft/teams.cards@2.0.0-preview.5 - - @microsoft/teams.common@2.0.0-preview.5 - - @microsoft/teams.dev@2.0.0-preview.5 - - @microsoft/teams.graph@2.0.0-preview.5 - -## 0.0.1-preview.4 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.4 - - @microsoft/teams.apps@2.0.0-preview.4 - - @microsoft/teams.cards@2.0.0-preview.4 - - @microsoft/teams.common@2.0.0-preview.4 - - @microsoft/teams.dev@2.0.0-preview.4 - - @microsoft/teams.graph@2.0.0-preview.4 - -## 0.0.1-preview.3 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.3 - - @microsoft/teams.apps@2.0.0-preview.3 - - @microsoft/teams.cards@2.0.0-preview.3 - - @microsoft/teams.common@2.0.0-preview.3 - - @microsoft/teams.dev@2.0.0-preview.3 - - @microsoft/teams.graph@2.0.0-preview.3 - -## 0.0.1-preview.2 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.2 - - @microsoft/teams.apps@2.0.0-preview.2 - - @microsoft/teams.cards@2.0.0-preview.2 - - @microsoft/teams.common@2.0.0-preview.2 - - @microsoft/teams.dev@2.0.0-preview.2 - - @microsoft/teams.graph@2.0.0-preview.2 - -## 0.0.1-preview.1 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.api@2.0.0-preview.1 - - @microsoft/teams.apps@2.0.0-preview.1 - - @microsoft/teams.cards@2.0.0-preview.1 - - @microsoft/teams.common@2.0.0-preview.1 - - @microsoft/teams.dev@2.0.0-preview.1 - - @microsoft/teams.graph@2.0.0-preview.1 - -## 0.0.1-preview.0 - -### Patch Changes - -- Updated dependencies - - @microsoft/teams.common@2.0.0-preview.0 - - @microsoft/teams.cards@2.0.0-preview.0 - - @microsoft/teams.graph@2.0.0-preview.0 - - @microsoft/teams.apps@2.0.0-preview.0 - - @microsoft/teams.api@2.0.0-preview.0 - - @microsoft/teams.dev@2.0.0-preview.0