Skip to content
Merged
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
5 changes: 3 additions & 2 deletions examples/socket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ Socket Mode bots should not be submitted to Marketplace for publishing.

- **Enabling Socket Mode** — `new App({ socketMode: true })` for WebSocket inbound delivery with no HTTP endpoint.
- **Multi-geo by default** — a single bot opens one connection per geo
(`amer`, `emea`, `apac`) so it has inbound coverage across regions. Override
with `geos: [...]` or point at a custom ring with `negotiateBaseUrl`.
(`amer`, `emea`, `apac`) so it has inbound coverage across regions. Supply
unique, non-empty hostname prefixes with `geos: [...]`; an empty list keeps
the defaults.
- **Lifecycle events** — subscribing to `app.socketMode.events` for `ready`,
`disconnected`, and `reconnected`. Each event carries the `geo` it relates to,
since connections are per geo. Reconnects are automatic; the events are purely
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,13 @@ describe('Socket Mode E2E: protocol and observability', () => {
await app.stop();
});

it('exposes geo-tagged lifecycle events', async () => {
it.each([
['amer', 'https://amer.botapi.skype.com/v3/websockets/connect'],
['emea', 'https://emea.botapi.skype.com/v3/websockets/connect'],
['apac', 'https://apac.botapi.skype.com/v3/websockets/connect'],
])('exposes %s negotiate and geo-tagged lifecycle observability', async (geo, url) => {
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [0] },
socketMode: { geos: [geo], reconnectDelaysMs: [0] },
});
const ready = jest.fn();
const disconnected = jest.fn();
Expand All @@ -334,16 +338,18 @@ describe('Socket Mode E2E: protocol and observability', () => {
app.socketMode!.events.on('reconnected', reconnected);
await app.start();

expect(connectionState.contexts[0].negotiateUrl).toBe(url);

const error = new Error('network drop');
connectionState.connections[0].drop(error);
await new Promise((resolve) => setTimeout(resolve, 0));

expect(ready).toHaveBeenCalledWith({
geo: '',
geo,
frame: expect.objectContaining({ botKey: 'bot1' }),
});
expect(disconnected).toHaveBeenCalledWith({ geo: '', error, terminal: false });
expect(reconnected).toHaveBeenCalledWith({ geo: '' });
expect(disconnected).toHaveBeenCalledWith({ geo, error, terminal: false });
expect(reconnected).toHaveBeenCalledWith({ geo });
await app.stop();
});
});
7 changes: 3 additions & 4 deletions packages/apps/src/socket-mode/app.e2e.test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,13 @@ export function createSocketTestApp(
socketMode?: boolean | (SocketModeOptions & SocketModeTestSettings);
} = {}
): App {
const { socketMode = { geos: [''] }, ...appOptions } = options;
const { geos, negotiateBaseUrl, ...settings } =
typeof socketMode === 'object' ? socketMode : {};
const { socketMode = { geos: ['amer'] }, ...appOptions } = options;
const { geos, ...settings } = typeof socketMode === 'object' ? socketMode : {};
const app = createTestApp({
clientId: 'bot1',
logger: quietLogger(),
...appOptions,
socketMode: typeof socketMode === 'object' ? { geos, negotiateBaseUrl } : socketMode,
socketMode: typeof socketMode === 'object' ? { geos } : socketMode,
});
if (app.socketMode) configureSocketModeForTest(app.socketMode, settings);
return app;
Expand Down
35 changes: 17 additions & 18 deletions packages/apps/src/socket-mode/app.e2e.transport-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'

it('renegotiates a fresh connection after an unexpected drop', async () => {
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [0] },
socketMode: { geos: ['amer'], reconnectDelaysMs: [0] },
});
const disconnected = jest.fn();
const reconnected = jest.fn();
Expand All @@ -105,8 +105,8 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
await ticks();

expect(connectionState.connections).toHaveLength(2);
expect(disconnected).toHaveBeenCalledWith({ geo: '', error: dropped, terminal: false });
expect(reconnected).toHaveBeenCalledWith({ geo: '' });
expect(disconnected).toHaveBeenCalledWith({ geo: 'amer', error: dropped, terminal: false });
expect(reconnected).toHaveBeenCalledWith({ geo: 'amer' });
expect(app.socketMode!.status).toBe('ready');
await app.stop();
});
Expand Down Expand Up @@ -152,7 +152,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
connectionState.autoReadyQueue = [true, false];
const handler = jest.fn();
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [0] },
socketMode: { geos: ['amer'], reconnectDelaysMs: [0] },
});
app.on('message', handler);
await app.start();
Expand Down Expand Up @@ -197,7 +197,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [0] },
socketMode: { geos: ['amer'], reconnectDelaysMs: [0] },
});
app.on('message', () => gate);
await app.start();
Expand Down Expand Up @@ -232,7 +232,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
const firstGate = new Promise<void>((resolve) => { finishFirst = resolve; });
const secondGate = new Promise<void>((resolve) => { finishSecond = resolve; });
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [0] },
socketMode: { geos: ['amer'], reconnectDelaysMs: [0] },
});
app.on('message', ({ activity }) => activity.id === 'first' ? firstGate : secondGate);
const disconnected = jest.fn();
Expand Down Expand Up @@ -297,7 +297,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
connectionState.expiresInSeconds = 120;
const releases = new Map<string, () => void>();
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [0] },
socketMode: { geos: ['amer'], reconnectDelaysMs: [0] },
});
app.on('message', ({ activity }) =>
new Promise<void>((resolve) => { releases.set(activity.id, resolve); })
Expand Down Expand Up @@ -352,7 +352,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [0] },
socketMode: { geos: ['amer'], reconnectDelaysMs: [0] },
});
app.on('message', () => gate);
const disconnected = jest.fn();
Expand Down Expand Up @@ -393,7 +393,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
connectionState.expiresInSeconds = 120;
const handler = jest.fn();
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [100] },
socketMode: { geos: ['amer'], reconnectDelaysMs: [100] },
});
app.on('message', handler);
await app.start();
Expand Down Expand Up @@ -439,7 +439,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
connectionState.expiresInSeconds = 120;
connectionState.autoReadyQueue = [true, false];
const app = createSocketTestApp({
socketMode: { geos: [''], reconnectDelaysMs: [0] },
socketMode: { geos: ['amer'], reconnectDelaysMs: [0] },
});
const disconnected = jest.fn();
const reconnected = jest.fn();
Expand All @@ -455,13 +455,13 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
oldConnection.drop(error);

expect(app.socketMode!.status).toBe('disconnected');
expect(disconnected).toHaveBeenCalledWith({ geo: '', error, terminal: false });
expect(disconnected).toHaveBeenCalledWith({ geo: 'amer', error, terminal: false });

replacement.fireReady();
await jest.advanceTimersByTimeAsync(0);

expect(app.socketMode!.status).toBe('ready');
expect(reconnected).toHaveBeenCalledWith({ geo: '' });
expect(reconnected).toHaveBeenCalledWith({ geo: 'amer' });
await app.stop();
} finally {
jest.useRealTimers();
Expand All @@ -487,14 +487,13 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
await defaultApp.start();

expect(connectionState.contexts[0]).toMatchObject({
negotiateUrl: 'https://botapi.skype.com/v3/websockets/connect',
negotiateUrl: 'https://amer.botapi.skype.com/v3/websockets/connect',
readinessTimeoutMs: 30_000,
});
await defaultApp.stop();

const unsupportedOptions = {
geos: [''],
negotiateBaseUrl: 'https://apx.example/ring/',
geos: ['amer'],
readinessTimeoutMs: 1234,
keepAliveIntervalMs: 7000,
serverTimeoutMs: 9000,
Expand All @@ -506,7 +505,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
await customApp.start();

expect(connectionState.contexts[1]).toMatchObject({
negotiateUrl: 'https://apx.example/ring/v3/websockets/connect',
negotiateUrl: 'https://amer.botapi.skype.com/v3/websockets/connect',
getConnectionIdToReplace: undefined,
readinessTimeoutMs: 30_000,
});
Expand All @@ -524,7 +523,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'
jest.useFakeTimers();
const random = jest.spyOn(Math, 'random').mockReturnValue(0.5);
const unsupportedOptions = {
geos: [''],
geos: ['amer'],
startupTimeoutMs: 0,
reconnectDelaysMs: [0],
};
Expand Down Expand Up @@ -554,7 +553,7 @@ describe('Socket Mode E2E: exclusive transport, failure, ordering, and recovery'

it('does not let a runtime override extend the 30-second startup budget', async () => {
jest.useFakeTimers();
const unsupportedOptions = { geos: [''], startupTimeoutMs: 60_000 };
const unsupportedOptions = { geos: ['amer'], startupTimeoutMs: 60_000 };
const app = createTestApp({ clientId: 'bot1', socketMode: unsupportedOptions });
try {
connectionState.startErrorQueue.push(
Expand Down
25 changes: 25 additions & 0 deletions packages/apps/src/socket-mode/negotiate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,31 @@ describe('socket-mode negotiate', () => {
expect(diagnostics).toContain('[REDACTED]');
});

it('sanitizes the negotiate URL in diagnostics without changing the request URL', async () => {
const originalUrl =
'https://diagnostic-user:diagnostic-pass@apx.example/v3/websockets/connect?sig=query-secret#fragment-secret';
const log = {
debug: jest.fn(),
};
post.mockResolvedValue(
httpResponse({ url: 'wss://sr/hub', accessToken: 'sr-token', expiresIn: 3600 })
);

await negotiate({
...deps(),
negotiateUrl: originalUrl,
log: log as any,
});

const diagnostics = log.debug.mock.calls.flat().join(' ');
expect(diagnostics).not.toContain('diagnostic-user');
expect(diagnostics).not.toContain('diagnostic-pass');
expect(diagnostics).not.toContain('query-secret');
expect(diagnostics).not.toContain('fragment-secret');
expect(diagnostics).toContain('https://apx.example/v3/websockets/connect');
expect(post.mock.calls[0][0]).toBe(originalUrl);
});

it('parses Retry-After (delta-seconds) into NegotiateError.retryAfterMs on a 429', async () => {
post.mockResolvedValue(
httpResponse('slow down', 429, {
Expand Down
10 changes: 5 additions & 5 deletions packages/apps/src/socket-mode/negotiate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Client as HttpClient, ILogger } from '@microsoft/teams.common';

import { redactSocketModeSecrets } from './redact';
import { sanitizeSocketModeUrl, redactSocketModeSecrets } from './redact';
import { NegotiateResult } from './types';

/**
Expand All @@ -20,9 +20,9 @@ export type NegotiateDeps = {
*/
readonly getConnectionIdToReplace?: () => string | undefined;
/**
* Abort the negotiate request after this many milliseconds so a bad
* `negotiateBaseUrl` (or a hung endpoint) can't leave the caller pending
* forever. Defaults to {@link DEFAULT_NEGOTIATE_TIMEOUT_MS}.
* Abort the negotiate request after this many milliseconds so a hung endpoint
* can't leave the caller pending forever. Defaults to
* {@link DEFAULT_NEGOTIATE_TIMEOUT_MS}.
*/
readonly timeoutMs?: number;
readonly log?: ILogger;
Expand Down Expand Up @@ -143,7 +143,7 @@ export async function negotiate(deps: NegotiateDeps): Promise<NegotiateResult> {

assertSecureNegotiateUrl(deps.negotiateUrl);

deps.log?.debug(`socket-mode: negotiate POST ${deps.negotiateUrl}`);
deps.log?.debug(`socket-mode: negotiate POST ${sanitizeSocketModeUrl(deps.negotiateUrl)}`);

const replacesConnectionId = deps.getConnectionIdToReplace?.();
const res = await deps.client.post<Partial<NegotiateResult>>(
Expand Down
10 changes: 10 additions & 0 deletions packages/apps/src/socket-mode/redact.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
const REDACTED = '[REDACTED]';

/** Removes credentials, query parameters, and fragments from a URL used in diagnostics. */
export function sanitizeSocketModeUrl(value: string): string {
const url = new URL(value);
url.username = '';
url.password = '';
url.search = '';
url.hash = '';
return url.toString();
}

/**
* Removes Socket Mode credentials from diagnostic text before it reaches logs
* or caller-visible errors.
Expand Down
19 changes: 11 additions & 8 deletions packages/apps/src/socket-mode/socket-connection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const client = { post } as unknown as HttpClient;

function makeContext(overrides: Partial<SocketConnectionContext> = {}): SocketConnectionContext {
return {
negotiateUrl: 'https://apx.example/v3/websockets/connect',
negotiateUrl: 'https://amer.botapi.skype.com/v3/websockets/connect',
client,
getBotToken: async () => 'bot-jwt',
readinessTimeoutMs: 50,
Expand All @@ -119,10 +119,7 @@ function makeHandlers(overrides: Partial<SocketConnectionHandlers> = {}): Socket
}

function makeAdapter(getBotToken = async () => 'bot-jwt', reconnectDelayMs = 0): SocketModeAdapter {
const adapter = new SocketModeAdapter({
negotiateBaseUrl: 'https://apx.example',
geos: ['amer'],
}, {
const adapter = new SocketModeAdapter({ geos: ['amer'] }, {
client,
tokenProvider: {
getAppToken: async () => {
Expand Down Expand Up @@ -186,7 +183,9 @@ describe('SignalRSocketConnection', () => {

it('keeps server IDs independent across overlapping sockets', async () => {
const first = new SignalRSocketConnection(
makeContext({ negotiateUrl: 'https://apx.example/amer/v3/websockets/connect' }),
makeContext({
negotiateUrl: 'https://amer.botapi.skype.com/v3/websockets/connect',
}),
makeHandlers()
);
await first.start();
Expand All @@ -198,7 +197,9 @@ describe('SignalRSocketConnection', () => {
headers: {},
});
const next = new SignalRSocketConnection(
makeContext({ negotiateUrl: 'https://apx.example/emea/v3/websockets/connect' }),
makeContext({
negotiateUrl: 'https://emea.botapi.skype.com/v3/websockets/connect',
}),
makeHandlers()
);
await next.start();
Expand All @@ -224,7 +225,9 @@ describe('SignalRSocketConnection', () => {
state.readyFrame = { connectionId: '' };
await jest.advanceTimersByTimeAsync(240_000);
expect(post).toHaveBeenCalledTimes(2);
expect(post.mock.calls[1][0]).toBe('https://apx.example/amer/v3/websockets/connect');
expect(post.mock.calls[1][0]).toBe(
'https://amer.botapi.skype.com/v3/websockets/connect'
);
expect(post.mock.calls[1][2].headers[header]).toBe(' c1/+== ');
expect(state.stopped).toBe(0);

Expand Down
Loading
Loading