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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
463 changes: 463 additions & 0 deletions docs/proposals/socket-mode.md

Large diffs are not rendered by default.

133 changes: 132 additions & 1 deletion package-lock.json

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

1 change: 1 addition & 0 deletions packages/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"dependencies": {
"@azure/msal-node": "^3.8.1",
"@microsoft/signalr": "^8.0.7",
"@microsoft/teams.api": "*",
"@microsoft/teams.common": "*",
"@microsoft/teams.graph": "*",
Expand Down
3 changes: 3 additions & 0 deletions packages/apps/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ export * as manifest from './manifest';
// HTTP infrastructure - public API
export * from './http';

// Socket Mode (APX → bot delivery over Azure SignalR)
export * from './socket-mode';

// Threading utilities
export { toThreadedConversationId } from './utils/thread';
45 changes: 45 additions & 0 deletions packages/apps/src/socket-mode/backoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type BackoffOptions = {
readonly minMs?: number;
readonly maxMs?: number;
readonly factor?: number;
readonly jitter?: boolean;
};

const DEFAULT_MIN_MS = 2000;
const DEFAULT_MAX_MS = 30000;
const DEFAULT_FACTOR = 2;

/**
* Jittered exponential backoff. Defaults match the dev-guide recommendation
* (≥ 2s, max 30s, factor 2, with jitter to avoid thundering herd on a fleet of pods).
*/
export class Backoff {
private attempt = 0;
private readonly minMs: number;
private readonly maxMs: number;
private readonly factor: number;
private readonly jitter: boolean;

constructor(opts: BackoffOptions = {}) {
this.minMs = opts.minMs ?? DEFAULT_MIN_MS;
this.maxMs = opts.maxMs ?? DEFAULT_MAX_MS;
this.factor = opts.factor ?? DEFAULT_FACTOR;
this.jitter = opts.jitter ?? true;
}

reset(): void {
this.attempt = 0;
}

/** Returns the next delay in ms and advances the attempt counter. */
next(): number {
const base = Math.min(this.maxMs, this.minMs * Math.pow(this.factor, this.attempt));
this.attempt++;
if (!this.jitter) return base;
return Math.floor(base * (0.5 + Math.random() * 0.5));
}
}

export function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
24 changes: 24 additions & 0 deletions packages/apps/src/socket-mode/envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Wire envelope sent from APX over the Azure SignalR socket.
* Method name on the hub is `"activity"` — see SocketModeDispatcher.cs on the platform side.
*/
export interface ISocketActivityEnvelope {
/** Frame discriminator. v1 only emits `"activity"`. Future: `ping`, `ack`, `disconnect`. */
readonly type: string;
/** Unique per frame; the bot should log this and use it for dedup. */
readonly envelopeId: string;
/** APX correlation vector — log on every frame for cross-service traceability. */
readonly cv?: string;
/** The Bot Framework Activity JSON. */
readonly payload: unknown;
}

export function isActivityEnvelope(v: unknown): v is ISocketActivityEnvelope {
if (v == null || typeof v !== 'object') return false;
const e = v as Record<string, unknown>;
return (
typeof e.type === 'string' &&
typeof e.envelopeId === 'string' &&
e.payload != null
);
}
11 changes: 11 additions & 0 deletions packages/apps/src/socket-mode/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { SocketModeApp, SocketModeOptions, SocketModeEvents } from './socket-mode-app';
export { ISocketModeClient, SocketModeClient, ConnectionState } from './socket-mode-client';
export { ISocketActivityEnvelope, isActivityEnvelope } from './envelope';
export {
NegotiateRoute,
NegotiateResult,
NegotiateUnavailableError,
negotiate,
} from './negotiate';
export { synthesizeToken } from './synthesize-token';
export { Backoff, BackoffOptions } from './backoff';
83 changes: 83 additions & 0 deletions packages/apps/src/socket-mode/negotiate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Client as HttpClient } from '@microsoft/teams.common';

/**
* Route variant for POST /v3/websockets/connect.
*
* - `global`: `POST /v3/websockets/connect`
* - `regional`: `POST /{cloud}/v3/websockets/connect`
* - `regional-tenant`: `POST /{cloud}/{tenantId}/v3/websockets/connect`
*/
export type NegotiateRoute =
| { kind: 'global' }
| { kind: 'regional'; cloud: string }
| { kind: 'regional-tenant'; cloud: string; tenantId: string };

export type NegotiateResult = {
/** Opaque WSS URL returned by Azure SignalR via APX. */
readonly url: string;
/** Client access token for the WSS connect. */
readonly accessToken: string;
/** APX-generated session id — include in logs for support correlation. */
readonly sessionId: string;
/** Token lifetime in seconds — re-negotiate before this expires. */
readonly expiresIn: number;
};

/** Returned when APX explicitly signals socket mode is unavailable (503). */
export class NegotiateUnavailableError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'NegotiateUnavailableError';
this.status = status;
}
}

export type NegotiateOptions = {
readonly client: HttpClient;
readonly serviceUrl: string;
readonly route: NegotiateRoute;
/** Bot Framework JWT as a raw string. */
readonly bearerToken: string;
};

function routePath(route: NegotiateRoute): string {
switch (route.kind) {
case 'global':
return '/v3/websockets/connect';
case 'regional':
return `/${encodeURIComponent(route.cloud)}/v3/websockets/connect`;
case 'regional-tenant':
return `/${encodeURIComponent(route.cloud)}/${encodeURIComponent(route.tenantId)}/v3/websockets/connect`;
}
}

function trimTrailingSlash(s: string): string {
return s.replace(/\/+$/, '');
}

/**
* Calls APX's negotiate endpoint and returns the SignalR connect coordinates.
*
* Throws `NegotiateUnavailableError` when APX returns 503 — the caller can decide
* whether to fall back to HTTP delivery or rethrow.
*/
export async function negotiate(opts: NegotiateOptions): Promise<NegotiateResult> {
const { client, serviceUrl, route, bearerToken } = opts;
const url = `${trimTrailingSlash(serviceUrl)}${routePath(route)}`;

try {
const res = await client.post<NegotiateResult>(url, undefined, {
token: bearerToken,
});
return res.data;
} catch (err: unknown) {
const e = err as { response?: { status?: number; data?: { error?: string } }; message?: string };
const status = e.response?.status;
const message = e.response?.data?.error ?? e.message ?? 'Negotiate failed';
if (status === 503) {
throw new NegotiateUnavailableError(503, message);
}
throw err;
}
}
Loading
Loading