Adding socket mode support in Teams SDK#582
Draft
RajuRoopani wants to merge 1 commit into
Draft
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds Socket Mode support to the Teams SDK by introducing a SocketModeApp that negotiates Azure SignalR connections, routes envelopes into App.onActivity, and provides lifecycle/diagnostic events.
Changes:
- Introduces
SocketModeApp, SignalR client wrapper, negotiate helper, envelope guard, synthesized token, and backoff utilities. - Exposes the Socket Mode surface via
packages/apps/src/socket-mode/index.tsand re-exports it frompackages/apps/src/index.ts. - Adds Jest unit tests and a draft proposal document; adds
@microsoft/signalrdependency.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/apps/src/socket-mode/synthesize-token.ts | Adds helper to synthesize an IToken for socket-delivered activities. |
| packages/apps/src/socket-mode/socket-mode-client.ts | Adds a thin wrapper around SignalR HubConnection for production + test seams. |
| packages/apps/src/socket-mode/socket-mode-app.ts | Adds SocketModeApp lifecycle, negotiate/connect, envelope routing, dedupe, and events. |
| packages/apps/src/socket-mode/socket-mode-app.spec.ts | Adds unit tests for socket mode lifecycle, routing, dedupe, and multi-connection behavior. |
| packages/apps/src/socket-mode/negotiate.ts | Implements negotiate call and a typed 503-unavailable error. |
| packages/apps/src/socket-mode/index.ts | Re-exports socket-mode public API. |
| packages/apps/src/socket-mode/envelope.ts | Defines envelope shape + type guard. |
| packages/apps/src/socket-mode/backoff.ts | Adds jittered exponential backoff + sleep helper. |
| packages/apps/src/index.ts | Re-exports socket-mode module from package root. |
| packages/apps/package.json | Adds @microsoft/signalr dependency. |
| docs/proposals/socket-mode.md | Adds draft design proposal for Socket Mode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (ctx.previousRetryCount === 0) return 0; | ||
| if (ctx.previousRetryCount === 1) return 2_000; | ||
| if (ctx.previousRetryCount === 2) return 10_000; | ||
| return 30_000; |
Comment on lines
+63
to
+84
| async connect(url: string, accessToken: string): Promise<void> { | ||
| const conn = new HubConnectionBuilder() | ||
| .withUrl(url, { accessTokenFactory: () => accessToken }) | ||
| .withAutomaticReconnect(DEFAULT_RETRY_POLICY) | ||
| .build(); | ||
|
|
||
| if (this.activityHandler) { | ||
| conn.on('activity', this.activityHandler); | ||
| } | ||
| if (this.closeHandler) { | ||
| conn.onclose(this.closeHandler); | ||
| } | ||
| if (this.reconnectingHandler) { | ||
| conn.onreconnecting(this.reconnectingHandler); | ||
| } | ||
| if (this.reconnectedHandler) { | ||
| conn.onreconnected(this.reconnectedHandler); | ||
| } | ||
|
|
||
| await conn.start(); | ||
| this.connection = conn; | ||
| } |
Comment on lines
+187
to
+222
| // Boot the App: plugin onInit, plugin onStart, HTTP server bind. | ||
| // The HTTP server keeps handling invokes / OAuth callbacks / tabs / remote functions. | ||
| await this.app.start(port); | ||
|
|
||
| // Create slots up front so handlers can reference them. | ||
| this.slots = []; | ||
| this.sessionIds = new Array<string | undefined>(this.opts.connections); | ||
| for (let i = 0; i < this.opts.connections; i++) { | ||
| const slot: Slot = { | ||
| index: i, | ||
| client: this.clientFactory(i), | ||
| expiry: 0, | ||
| backoff: new Backoff(this.opts.backoff), | ||
| }; | ||
| this.wireClient(slot); | ||
| this.slots.push(slot); | ||
| } | ||
|
|
||
| // Open all slots in parallel. We don't rethrow per-slot failures here — each slot's | ||
| // own reconnect/backoff loop handles transient errors. If `fallbackOn503` is false | ||
| // and ALL slots get a 503, surface that to the caller. | ||
| const results = await Promise.allSettled( | ||
| this.slots.map((s) => this.negotiateAndConnect(s)), | ||
| ); | ||
|
|
||
| if (!this.opts.fallbackOn503) { | ||
| const allUnavailable = | ||
| results.length > 0 && | ||
| results.every( | ||
| (r) => | ||
| r.status === 'rejected' && r.reason instanceof NegotiateUnavailableError, | ||
| ); | ||
| if (allUnavailable) { | ||
| const firstReason = (results[0] as PromiseRejectedResult).reason as NegotiateUnavailableError; | ||
| throw firstReason; | ||
| } |
Comment on lines
+324
to
+356
| private async renegotiate(slot: Slot): Promise<void> { | ||
| if (this.stopping) return; | ||
| try { | ||
| const result = await this.negotiateOnce(); | ||
| // Make-before-break: bring up the new connection, then tear down the old one. | ||
| const oldClient = slot.client; | ||
| const newClient = this.clientFactory(slot.index); | ||
| slot.client = newClient; | ||
| this.wireClient(slot); | ||
| await newClient.connect(result.url, result.accessToken); | ||
| slot.sessionId = result.sessionId; | ||
| slot.expiry = Date.now() + result.expiresIn * 1000; | ||
| this.sessionIds[slot.index] = result.sessionId; | ||
| if (slot.index === 0) this.sessionId = result.sessionId; | ||
| this.emit('renegotiated', { | ||
| sessionId: result.sessionId, | ||
| expiresIn: result.expiresIn, | ||
| slot: slot.index, | ||
| }); | ||
| this.scheduleRenegotiate(slot, result.expiresIn); | ||
| try { | ||
| await oldClient.disconnect(); | ||
| } catch (err) { | ||
| this.log.debug(`old socket disconnect failed (slot ${slot.index})`, err); | ||
| } | ||
| } catch (err: unknown) { | ||
| this.log.warn( | ||
| `renegotiate failed (slot ${slot.index}); will retry via close handler`, | ||
| (err as Error)?.message ?? err, | ||
| ); | ||
| // The existing connection will drop on token expiry; handleClose triggers full reconnect. | ||
| } | ||
| } |
Comment on lines
+141
to
+150
| this.opts = { | ||
| route: options.route ?? { kind: 'global' }, | ||
| connections, | ||
| renegotiateAt: options.renegotiateAt ?? DEFAULT_RENEGOTIATE_AT, | ||
| backoff: options.backoff, | ||
| // Auto-enable dedup when running multiple sockets — APX fan-out delivers each | ||
| // envelope to every session, so duplicates are guaranteed. | ||
| dedupe: options.dedupe ?? (connections > 1), | ||
| fallbackOn503: options.fallbackOn503 ?? true, | ||
| }; |
Comment on lines
+38
to
+42
| | Negotiate endpoint | `POST /v3/websockets/connect` (also `{cloud}/...`, `{cloud}/{tenantId}/...`) | [`WebSocketConnectController.cs`](file:///c:/Work6/Git/async_messaging_botapiservice/BotFrontEnd.Library/Controllers/WebSocketConnectController.cs) | | ||
| | Auth | `Authorization: Bearer {BF JWT}` — same MSA token used for other `/v3/...` calls | [`WebSocketConnectService.cs`](file:///c:/Work6/Git/async_messaging_botapiservice/Library/Services/WebSocketConnectService.cs) | | ||
| | Response | `{ url, accessToken, sessionId, expiresIn }` (`expiresIn` in seconds) | `WebSocketConnectController.cs` | | ||
| | Transport | Azure SignalR Service Default protocol. Reference JS client: `@microsoft/signalr` | | ||
| | Hub method | `"activity"` (server → client) | [`SocketModeDispatcher.cs:40`](file:///c:/Work6/Git/async_messaging_botapiservice/Library/Services/SocketModeDispatcher.cs#L40) | |
Comment on lines
+98
to
+114
| function envelopeOfType(type: string, name?: string, id = `env-${Math.random()}`): ISocketActivityEnvelope { | ||
| return { | ||
| type: 'activity', | ||
| envelopeId: id, | ||
| cv: 'cv.x', | ||
| payload: { | ||
| type, | ||
| ...(name ? { name } : {}), | ||
| id: `act-${id}`, | ||
| serviceUrl: 'https://smba.trafficmanager.net/teams', | ||
| channelId: 'msteams', | ||
| from: { id: 'user-1' }, | ||
| recipient: { id: 'bot-1' }, | ||
| conversation: { id: 'conv-1', tenantId: 't1' }, | ||
| }, | ||
| }; | ||
| } |
Comment on lines
+311
to
+322
| private scheduleRenegotiate(slot: Slot, expiresInSec: number): void { | ||
| if (slot.renegotiateTimer) clearTimeout(slot.renegotiateTimer); | ||
| const delayMs = Math.max( | ||
| 1000, | ||
| Math.floor(expiresInSec * 1000 * this.opts.renegotiateAt), | ||
| ); | ||
| slot.renegotiateTimer = setTimeout(() => { | ||
| void this.renegotiate(slot); | ||
| }, delayMs); | ||
| // Don't hold the event loop open just for the timer. | ||
| (slot.renegotiateTimer as unknown as { unref?: () => void }).unref?.(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.