Minimal Azure SignalR socket-mode test client for a local APX (bot API service) instance. No Teams app and no inbound HTTP endpoint — it only connects out to Azure SignalR and exercises the full APX bot socket-mode flow, driven by directives in the activity payload.
It exercises both legs of socket mode:
- Invoke round-trip — APX dispatches an
invoke; the bot replies with a real Bot FrameworkinvokeResponseover the socket (APX routes it back through its Redis backplane). - One-way activity + Ack — the socket equivalent of a classic bot's
onMessageActivity: APX delivers amessage/typingactivity and the bot returns a deliveryAck.
POST /v3/websockets/connecton the local APX BotNotifications role ->{ url, accessToken, sessionId, expiresIn }.- Opens a SignalR WebSocket to the negotiated Azure SignalR
urland joins the bot's groupbot_<botKey>. - Receives APX->bot frames on the
activityclient method (aSocketActivityEnvelope). - For invokes (
type:"invoke"), computes the real Bot Framework response for the invoke name (src/invokeResponses.ts) and replies withconnection.send("invokeResponse", frame)— correlation key isenvelopeId. - For ack-required one-way activities, runs its
onMessageActivity-equivalent handler and sendsconnection.send("Ack", …). - The connection is resilient (re-negotiates through APX on restart / token expiry) and single-instance (a PID lockfile kills any prior copy on start, so orphans can't pile up).
- Node 18+ (uses global
fetch). - A local APX built in DEBUG with the BotNotifications role running (serves negotiate, the upstream webhook, and the DEBUG test endpoints).
- An Azure SignalR resource in Serverless mode, with its Upstream endpoint pointed at your APX (via a tunnel). See the full E2E setup guide linked at the bottom.
- (Optional) a local Redis to exercise APX's reply backplane —
./start-local-redis.ps1.
Open the folder in VS Code and run "Run socket bot (local)" (F5). The .vscode task chain:
- Validate prerequisites — Node + Microsoft 365 sign-in.
- Provision (
teamsapp provision --env local) —aadApp/create: whenBOT_IDinenv/.env.localis empty, mints a new Microsoft Entra app (its client id becomes the socketbotKey); when set, reuses it. WritesBOT_ID/secret/tenant back to the env. - Deploy (
teamsapp deploy --env local) — writes the runtime env to.localConfigs. - Start —
npm run dev:teamsfxlaunches the bot under nodemon; it negotiates with APX, connects to Azure SignalR, and is ready on the socket.
There is no manifest, app package, or messaging endpoint — only the bot identity is provisioned.
npm install
cp .env.example .env # set BOT_KEY (= bot MSA AppId) and APX_BASE_URL
# local APX uses a dev cert. PowerShell: $env:NODE_TLS_REJECT_UNAUTHORIZED = "0"
npm run dev # nodemon + ts-node (auto-restart on save)
# or one-shot: npm run build && npm startExpected output:
prem-socket-test-bot starting. apx=https://localhost:444 botKey=<appId> connections=1 ...
[conn1] connected. sessionId=... expiresIn=3600s
and in the APX log: WebSocket session allocated. botKey=<appId> sessionId=....
.env (manual path) / env/.env.local (F5 path) / .localConfigs (generated by F5):
| var | meaning |
|---|---|
APX_BASE_URL |
local APX BotNotifications role, e.g. https://localhost:444 (negotiate + invoke + upstream all live there) |
BOT_ID / BOT_KEY |
the bot's MSA AppId = APX's socket key (group bot_<key>), echoed on replies. BOT_ID (F5) takes precedence over BOT_KEY. |
CONNECTIONS |
parallel sockets for one bot (>1 exercises group fan-out duplicate delivery + dedup) |
DEFAULT_DIRECTIVE |
behavior when an invoke carries no value.directive |
RENEGOTIATE_FRACTION |
re-negotiate at this fraction of the token lifetime (default 0.8) |
NODE_TLS_REJECT_UNAUTHORIZED=0 |
accept the local APX dev cert |
Secrets are never committed.
.env,.localConfigs,env/.env.local, andenv/.env.local.user(the provisioned bot id + password) are gitignored. Only the.env.example/env/.env.local.sampletemplates are tracked.
APX routes invoke replies through Redis when ForceRedisReplyPath is on. To run a local Redis
so that path is genuinely exercised:
./start-local-redis.ps1 # downloads a portable Windows Redis on first run; starts it on 6379
# stop later: Get-Process redis-server | Stop-ProcessAPX picks it up automatically via its LocalRedisConnectionString (defaults to localhost:6379).
Per-activity behavior comes from payload.value.directive. The easiest way to fire scenarios is
APX's DEBUG-only test endpoints (no auth, dispatch straight to the bot's group). With
BOTKEY=<your bot MSA AppId> and BASE=https://localhost:444:
# Invoke round-trip (name = real Bot Framework invoke type; directive controls transport)
curl -sk -X POST "$BASE/v3/websockets/testinvoke?botKey=$BOTKEY&directive=ok&name=composeExtension/query&timeoutSeconds=25" --data ""
curl -sk -X POST "$BASE/v3/websockets/testinvoke?botKey=$BOTKEY&directive=dupe&name=task/fetch&timeoutSeconds=25" --data ""
curl -sk -X POST "$BASE/v3/websockets/testinvoke?botKey=$BOTKEY&directive=drop&name=task/fetch&timeoutSeconds=6" --data ""
# One-way activity + Ack (onMessageActivity equivalent)
curl -sk -X POST "$BASE/v3/websockets/testactivity?botKey=$BOTKEY&type=message&directive=ok&text=hi&timeoutSeconds=10" --data ""
curl -sk -X POST "$BASE/v3/websockets/testactivity?botKey=$BOTKEY&type=message&directive=error&timeoutSeconds=6" --data ""Invoke directives:
| directive | bot behavior | exercises |
|---|---|---|
ok |
reply 200 + the real BF response for name |
happy-path round-trip |
error |
reply 500 + error body | bot-error status passthrough |
dupe |
reply twice | APX first-reply-wins dedup |
delay (delayMs, default 27000) |
sleep then reply | APX deadline -> HTTP fallback |
drop |
no reply | timeout -> HTTP fallback |
Activity (onMessageActivity) directives:
| directive | bot behavior | result |
|---|---|---|
ok |
handler succeeds -> send Ack |
delivery confirmed (Success) |
error / drop |
handler "throws" / dropped -> no Ack |
APX Timeout (received, not acknowledged) |
delay |
Ack after delayMs |
Success if within the deadline |
Recognized invoke names (real Bot Framework shapes in src/invokeResponses.ts):
composeExtension/query, composeExtension/submitAction, task/fetch, task/submit,
adaptiveCard/action, signin/verifyState, signin/tokenExchange, message/submitAction.
The bot logs the full flow so you can trace it against the APX console:
[recv] <-- APX envelopeId=... type=invoke ...
[recv] full activity payload={"name":"composeExtension/query","type":"invoke",...}
[invoke] HANDLE name=... directive=ok -> computed status=200 body=...
[reply] --> APX invokeResponse envelopeId=... status=200 body=...
[msg] onMessageActivity (socket) type=message directive=ok ... # one-way activities
[ack] --> APX Ack envelopeId=... (sending delivery confirmation)
- Single instance: each start SIGTERMs any prior bot process (PID lockfile in
%TEMP%), so orphanednodeprocesses can't accumulate and double-ack. The--inspect=9239 address already in usewarning at startup is harmless (the inspector just doesn't attach). - Resilient: the socket survives APX restarts — it re-negotiates and rejoins automatically.
- Wire parity: APX serializes the activity so the bot receives it byte-identical to the HTTP
send-to-bot form (camelCase, nulls stripped); the bot's
full activity payloadlog line should match APX'sActivity PARITY wire payloadline exactly. - Negotiate uses the DEBUG
?botKey=override; for an authenticated run, add a Bot Framework JWTAuthorization: Bearerheader in the negotiate call.
For the complete local end-to-end walkthrough (Azure SignalR + Upstream + tunnel + APX config + Redis verification), see the Bot Socket Mode - Local E2E Setup guide.