@komaa/standin-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/ambient.d.ts +80 -0
  4. package/dist/ambient.d.ts.map +1 -0
  5. package/dist/ambient.js +281 -0
  6. package/dist/ambient.js.map +1 -0
  7. package/dist/attachments.d.ts +149 -0
  8. package/dist/attachments.d.ts.map +1 -0
  9. package/dist/attachments.js +451 -0
  10. package/dist/attachments.js.map +1 -0
  11. package/dist/audio.d.ts +80 -0
  12. package/dist/audio.d.ts.map +1 -0
  13. package/dist/audio.js +170 -0
  14. package/dist/audio.js.map +1 -0
  15. package/dist/avatar.d.ts +111 -0
  16. package/dist/avatar.d.ts.map +1 -0
  17. package/dist/avatar.js +201 -0
  18. package/dist/avatar.js.map +1 -0
  19. package/dist/callServer.d.ts +151 -0
  20. package/dist/callServer.d.ts.map +1 -0
  21. package/dist/callServer.js +1097 -0
  22. package/dist/callServer.js.map +1 -0
  23. package/dist/callTools.d.ts +154 -0
  24. package/dist/callTools.d.ts.map +1 -0
  25. package/dist/callTools.js +255 -0
  26. package/dist/callTools.js.map +1 -0
  27. package/dist/chat.d.ts +226 -0
  28. package/dist/chat.d.ts.map +1 -0
  29. package/dist/chat.js +574 -0
  30. package/dist/chat.js.map +1 -0
  31. package/dist/config.d.ts +32 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +86 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/consult.d.ts +147 -0
  36. package/dist/consult.d.ts.map +1 -0
  37. package/dist/consult.js +388 -0
  38. package/dist/consult.js.map +1 -0
  39. package/dist/delivery.d.ts +81 -0
  40. package/dist/delivery.d.ts.map +1 -0
  41. package/dist/delivery.js +187 -0
  42. package/dist/delivery.js.map +1 -0
  43. package/dist/echoGuard.d.ts +56 -0
  44. package/dist/echoGuard.d.ts.map +1 -0
  45. package/dist/echoGuard.js +57 -0
  46. package/dist/echoGuard.js.map +1 -0
  47. package/dist/errors.d.ts +5 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +10 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/fetch.d.ts +40 -0
  52. package/dist/fetch.d.ts.map +1 -0
  53. package/dist/fetch.js +240 -0
  54. package/dist/fetch.js.map +1 -0
  55. package/dist/gate.d.ts +80 -0
  56. package/dist/gate.d.ts.map +1 -0
  57. package/dist/gate.js +249 -0
  58. package/dist/gate.js.map +1 -0
  59. package/dist/handler.d.ts +283 -0
  60. package/dist/handler.d.ts.map +1 -0
  61. package/dist/handler.js +4 -0
  62. package/dist/handler.js.map +1 -0
  63. package/dist/hmac.d.ts +32 -0
  64. package/dist/hmac.d.ts.map +1 -0
  65. package/dist/hmac.js +108 -0
  66. package/dist/hmac.js.map +1 -0
  67. package/dist/index.d.ts +59 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +60 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/lane.d.ts +92 -0
  72. package/dist/lane.d.ts.map +1 -0
  73. package/dist/lane.js +269 -0
  74. package/dist/lane.js.map +1 -0
  75. package/dist/lipsync.d.ts +136 -0
  76. package/dist/lipsync.d.ts.map +1 -0
  77. package/dist/lipsync.js +310 -0
  78. package/dist/lipsync.js.map +1 -0
  79. package/dist/log.d.ts +18 -0
  80. package/dist/log.d.ts.map +1 -0
  81. package/dist/log.js +20 -0
  82. package/dist/log.js.map +1 -0
  83. package/dist/media.d.ts +43 -0
  84. package/dist/media.d.ts.map +1 -0
  85. package/dist/media.js +190 -0
  86. package/dist/media.js.map +1 -0
  87. package/dist/minutes.d.ts +398 -0
  88. package/dist/minutes.d.ts.map +1 -0
  89. package/dist/minutes.js +805 -0
  90. package/dist/minutes.js.map +1 -0
  91. package/dist/outbound.d.ts +388 -0
  92. package/dist/outbound.d.ts.map +1 -0
  93. package/dist/outbound.js +933 -0
  94. package/dist/outbound.js.map +1 -0
  95. package/dist/plugins/cartesia/agent.d.ts +58 -0
  96. package/dist/plugins/cartesia/agent.d.ts.map +1 -0
  97. package/dist/plugins/cartesia/agent.js +254 -0
  98. package/dist/plugins/cartesia/agent.js.map +1 -0
  99. package/dist/plugins/cartesia/cli.d.ts +3 -0
  100. package/dist/plugins/cartesia/cli.d.ts.map +1 -0
  101. package/dist/plugins/cartesia/cli.js +10 -0
  102. package/dist/plugins/cartesia/cli.js.map +1 -0
  103. package/dist/plugins/cartesia/config.d.ts +30 -0
  104. package/dist/plugins/cartesia/config.d.ts.map +1 -0
  105. package/dist/plugins/cartesia/config.js +29 -0
  106. package/dist/plugins/cartesia/config.js.map +1 -0
  107. package/dist/plugins/cartesia/handler.d.ts +40 -0
  108. package/dist/plugins/cartesia/handler.d.ts.map +1 -0
  109. package/dist/plugins/cartesia/handler.js +142 -0
  110. package/dist/plugins/cartesia/handler.js.map +1 -0
  111. package/dist/plugins/cartesia/index.d.ts +6 -0
  112. package/dist/plugins/cartesia/index.d.ts.map +1 -0
  113. package/dist/plugins/cartesia/index.js +54 -0
  114. package/dist/plugins/cartesia/index.js.map +1 -0
  115. package/dist/plugins/deepgram/agent.d.ts +60 -0
  116. package/dist/plugins/deepgram/agent.d.ts.map +1 -0
  117. package/dist/plugins/deepgram/agent.js +302 -0
  118. package/dist/plugins/deepgram/agent.js.map +1 -0
  119. package/dist/plugins/deepgram/cli.d.ts +3 -0
  120. package/dist/plugins/deepgram/cli.d.ts.map +1 -0
  121. package/dist/plugins/deepgram/cli.js +10 -0
  122. package/dist/plugins/deepgram/cli.js.map +1 -0
  123. package/dist/plugins/deepgram/config.d.ts +39 -0
  124. package/dist/plugins/deepgram/config.d.ts.map +1 -0
  125. package/dist/plugins/deepgram/config.js +66 -0
  126. package/dist/plugins/deepgram/config.js.map +1 -0
  127. package/dist/plugins/deepgram/handler.d.ts +87 -0
  128. package/dist/plugins/deepgram/handler.d.ts.map +1 -0
  129. package/dist/plugins/deepgram/handler.js +268 -0
  130. package/dist/plugins/deepgram/handler.js.map +1 -0
  131. package/dist/plugins/deepgram/index.d.ts +11 -0
  132. package/dist/plugins/deepgram/index.d.ts.map +1 -0
  133. package/dist/plugins/deepgram/index.js +76 -0
  134. package/dist/plugins/deepgram/index.js.map +1 -0
  135. package/dist/plugins/echo/cli.d.ts +3 -0
  136. package/dist/plugins/echo/cli.d.ts.map +1 -0
  137. package/dist/plugins/echo/cli.js +10 -0
  138. package/dist/plugins/echo/cli.js.map +1 -0
  139. package/dist/plugins/echo/index.d.ts +37 -0
  140. package/dist/plugins/echo/index.d.ts.map +1 -0
  141. package/dist/plugins/echo/index.js +61 -0
  142. package/dist/plugins/echo/index.js.map +1 -0
  143. package/dist/plugins/elevenlabs/agent.d.ts +72 -0
  144. package/dist/plugins/elevenlabs/agent.d.ts.map +1 -0
  145. package/dist/plugins/elevenlabs/agent.js +305 -0
  146. package/dist/plugins/elevenlabs/agent.js.map +1 -0
  147. package/dist/plugins/elevenlabs/cli.d.ts +3 -0
  148. package/dist/plugins/elevenlabs/cli.d.ts.map +1 -0
  149. package/dist/plugins/elevenlabs/cli.js +10 -0
  150. package/dist/plugins/elevenlabs/cli.js.map +1 -0
  151. package/dist/plugins/elevenlabs/config.d.ts +27 -0
  152. package/dist/plugins/elevenlabs/config.d.ts.map +1 -0
  153. package/dist/plugins/elevenlabs/config.js +42 -0
  154. package/dist/plugins/elevenlabs/config.js.map +1 -0
  155. package/dist/plugins/elevenlabs/handler.d.ts +67 -0
  156. package/dist/plugins/elevenlabs/handler.d.ts.map +1 -0
  157. package/dist/plugins/elevenlabs/handler.js +419 -0
  158. package/dist/plugins/elevenlabs/handler.js.map +1 -0
  159. package/dist/plugins/elevenlabs/index.d.ts +11 -0
  160. package/dist/plugins/elevenlabs/index.d.ts.map +1 -0
  161. package/dist/plugins/elevenlabs/index.js +72 -0
  162. package/dist/plugins/elevenlabs/index.js.map +1 -0
  163. package/dist/plugins/livekit/cli.d.ts +3 -0
  164. package/dist/plugins/livekit/cli.d.ts.map +1 -0
  165. package/dist/plugins/livekit/cli.js +10 -0
  166. package/dist/plugins/livekit/cli.js.map +1 -0
  167. package/dist/plugins/livekit/config.d.ts +47 -0
  168. package/dist/plugins/livekit/config.d.ts.map +1 -0
  169. package/dist/plugins/livekit/config.js +46 -0
  170. package/dist/plugins/livekit/config.js.map +1 -0
  171. package/dist/plugins/livekit/handler.d.ts +59 -0
  172. package/dist/plugins/livekit/handler.d.ts.map +1 -0
  173. package/dist/plugins/livekit/handler.js +211 -0
  174. package/dist/plugins/livekit/handler.js.map +1 -0
  175. package/dist/plugins/livekit/index.d.ts +6 -0
  176. package/dist/plugins/livekit/index.d.ts.map +1 -0
  177. package/dist/plugins/livekit/index.js +68 -0
  178. package/dist/plugins/livekit/index.js.map +1 -0
  179. package/dist/plugins/livekit/room.d.ts +117 -0
  180. package/dist/plugins/livekit/room.d.ts.map +1 -0
  181. package/dist/plugins/livekit/room.js +449 -0
  182. package/dist/plugins/livekit/room.js.map +1 -0
  183. package/dist/plugins/openai/agent.d.ts +74 -0
  184. package/dist/plugins/openai/agent.d.ts.map +1 -0
  185. package/dist/plugins/openai/agent.js +264 -0
  186. package/dist/plugins/openai/agent.js.map +1 -0
  187. package/dist/plugins/openai/cli.d.ts +3 -0
  188. package/dist/plugins/openai/cli.d.ts.map +1 -0
  189. package/dist/plugins/openai/cli.js +10 -0
  190. package/dist/plugins/openai/cli.js.map +1 -0
  191. package/dist/plugins/openai/config.d.ts +29 -0
  192. package/dist/plugins/openai/config.d.ts.map +1 -0
  193. package/dist/plugins/openai/config.js +37 -0
  194. package/dist/plugins/openai/config.js.map +1 -0
  195. package/dist/plugins/openai/handler.d.ts +65 -0
  196. package/dist/plugins/openai/handler.d.ts.map +1 -0
  197. package/dist/plugins/openai/handler.js +297 -0
  198. package/dist/plugins/openai/handler.js.map +1 -0
  199. package/dist/plugins/openai/index.d.ts +6 -0
  200. package/dist/plugins/openai/index.d.ts.map +1 -0
  201. package/dist/plugins/openai/index.js +72 -0
  202. package/dist/plugins/openai/index.js.map +1 -0
  203. package/dist/plugins/openclaw/allowlist.d.ts +9 -0
  204. package/dist/plugins/openclaw/allowlist.d.ts.map +1 -0
  205. package/dist/plugins/openclaw/allowlist.js +11 -0
  206. package/dist/plugins/openclaw/allowlist.js.map +1 -0
  207. package/dist/plugins/openclaw/config.d.ts +50 -0
  208. package/dist/plugins/openclaw/config.d.ts.map +1 -0
  209. package/dist/plugins/openclaw/config.js +53 -0
  210. package/dist/plugins/openclaw/config.js.map +1 -0
  211. package/dist/plugins/openclaw/handler.d.ts +90 -0
  212. package/dist/plugins/openclaw/handler.d.ts.map +1 -0
  213. package/dist/plugins/openclaw/handler.js +146 -0
  214. package/dist/plugins/openclaw/handler.js.map +1 -0
  215. package/dist/plugins/openclaw/index.d.ts +55 -0
  216. package/dist/plugins/openclaw/index.d.ts.map +1 -0
  217. package/dist/plugins/openclaw/index.js +89 -0
  218. package/dist/plugins/openclaw/index.js.map +1 -0
  219. package/dist/plugins/openclaw/openclaw.plugin.json +74 -0
  220. package/dist/plugins/openclaw/package.json +14 -0
  221. package/dist/plugins/openclaw/realtime.d.ts +82 -0
  222. package/dist/plugins/openclaw/realtime.d.ts.map +1 -0
  223. package/dist/plugins/openclaw/realtime.js +238 -0
  224. package/dist/plugins/openclaw/realtime.js.map +1 -0
  225. package/dist/plugins/openclaw/runtime.d.ts +19 -0
  226. package/dist/plugins/openclaw/runtime.d.ts.map +1 -0
  227. package/dist/plugins/openclaw/runtime.js +160 -0
  228. package/dist/plugins/openclaw/runtime.js.map +1 -0
  229. package/dist/policy.d.ts +38 -0
  230. package/dist/policy.d.ts.map +1 -0
  231. package/dist/policy.js +95 -0
  232. package/dist/policy.js.map +1 -0
  233. package/dist/protocol.d.ts +44 -0
  234. package/dist/protocol.d.ts.map +1 -0
  235. package/dist/protocol.js +77 -0
  236. package/dist/protocol.js.map +1 -0
  237. package/dist/protocolRuntime.d.ts +19 -0
  238. package/dist/protocolRuntime.d.ts.map +1 -0
  239. package/dist/protocolRuntime.js +78 -0
  240. package/dist/protocolRuntime.js.map +1 -0
  241. package/dist/smoke.d.ts +65 -0
  242. package/dist/smoke.d.ts.map +1 -0
  243. package/dist/smoke.js +293 -0
  244. package/dist/smoke.js.map +1 -0
  245. package/dist/startup.d.ts +74 -0
  246. package/dist/startup.d.ts.map +1 -0
  247. package/dist/startup.js +112 -0
  248. package/dist/startup.js.map +1 -0
  249. package/dist/tile.d.ts +101 -0
  250. package/dist/tile.d.ts.map +1 -0
  251. package/dist/tile.js +195 -0
  252. package/dist/tile.js.map +1 -0
  253. package/dist/version.d.ts +2 -0
  254. package/dist/version.d.ts.map +1 -0
  255. package/dist/version.js +4 -0
  256. package/dist/version.js.map +1 -0
  257. package/dist/vision.d.ts +160 -0
  258. package/dist/vision.d.ts.map +1 -0
  259. package/dist/vision.js +301 -0
  260. package/dist/vision.js.map +1 -0
  261. package/dist/visionTools.d.ts +237 -0
  262. package/dist/visionTools.d.ts.map +1 -0
  263. package/dist/visionTools.js +578 -0
  264. package/dist/visionTools.js.map +1 -0
  265. package/dist/voice.d.ts +143 -0
  266. package/dist/voice.d.ts.map +1 -0
  267. package/dist/voice.js +403 -0
  268. package/dist/voice.js.map +1 -0
  269. package/package.json +129 -0
@@ -0,0 +1,1097 @@
1
+ // Copyright (c) 2026 Komaa DigiTech
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The call listener every StandIn plugin shares.
5
+ *
6
+ * StandIn dials `wss://<your-host>/msteams/calling/{callId}` once
7
+ * per call. This server answers that dial, authenticates it, speaks the call
8
+ * wire protocol, and hands each call to a {@link CallHandler} supplied by a
9
+ * plugin. Everything here is the same whichever agent framework is on the other
10
+ * side, which is exactly why it lives in the SDK and not in the plugins:
11
+ *
12
+ * - the HMAC handshake, its freshness window and its single-use replay guard
13
+ * - capacity, draining, and the one-live-session-per-callId rule
14
+ * - the frame loop
15
+ * - outbound sequence numbers and the audio timeline
16
+ * - the pre-start watchdog and the caller-audio idle watchdog
17
+ * - idempotent teardown that always frees the slot
18
+ *
19
+ * The Python SDK's `standin/sdk/call_server.py` is the same server, method for
20
+ * method. Behavioural parity is asserted by the shared conformance vectors.
21
+ */
22
+ import { createServer, } from "node:http";
23
+ import { WebSocket, WebSocketServer } from "ws";
24
+ import { expression as buildExpression, speechMarks as buildSpeechMarks, } from "./avatar.js";
25
+ import { StandInError } from "./errors.js";
26
+ import { logger } from "./log.js";
27
+ import { REPLAY_WINDOW_MS, SIGNATURE_HEADER, SIGNATURE_V2_HEADER, TIMESTAMP_HEADER, nowMs, signRequest, verifyHandshake, } from "./hmac.js";
28
+ import { SAMPLE_RATE_HZ, assistantCancel, audioFrame, contextSentences, decodePcm, parseMessage, parseSessionStart, pong, sessionEnd, } from "./protocol.js";
29
+ import { displayFrame as buildDisplayFrame, displayImage as buildDisplayImage, parseVideoFrame, } from "./vision.js";
30
+ /** How often the single-use handshake cache is swept for expired entries. */
31
+ const PRUNE_INTERVAL_MS = 1_000;
32
+ /**
33
+ * Teardown must never be held hostage by a peer that stopped reading. Until the
34
+ * close settles the callId is still occupied - so every retry for that call
35
+ * 409s, and at maxConnections the whole listener stops accepting. The advisory
36
+ * `session.end` is written before we wait, so a peer that is still listening has
37
+ * what it needs either way.
38
+ */
39
+ const CLOSE_TIMEOUT_MS = 2_000;
40
+ /** 2 MB bounds a single inbound message, matching the Python SDK. */
41
+ const MAX_PAYLOAD_BYTES = 2 * 1024 * 1024;
42
+ /**
43
+ * An outcome report is a handful of JSON fields. The cap matters because the
44
+ * body must be read before the signature over its hash can be checked, so this
45
+ * is the bound on what an unauthenticated peer can make the worker read.
46
+ */
47
+ const MAX_OUTCOME_BYTES = 8 * 1024;
48
+ /** Constant-time compare of two lowercase hex digests of the same length. */
49
+ function timingSafeEqualHex(a, b) {
50
+ if (a.length !== b.length)
51
+ return false;
52
+ let diff = 0;
53
+ for (let i = 0; i < a.length; i += 1)
54
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
55
+ return diff === 0;
56
+ }
57
+ /**
58
+ * Log-safe rendering of an attacker-influenceable id: control characters (CR/LF
59
+ * forge log lines) replaced, length bounded.
60
+ */
61
+ function safe(value) {
62
+ // eslint-disable-next-line no-control-regex
63
+ return value.replace(/[^\x20-\x7e]/g, "?").slice(0, 80);
64
+ }
65
+ /**
66
+ * Call one optional handler method. A {@link CallHandler} is an interface with
67
+ * every member optional, so a plugin implements only what it cares about and a
68
+ * missing method is a no-op. Sync implementations are accepted too.
69
+ */
70
+ async function dispatch(handler, method, ...args) {
71
+ const fn = handler[method];
72
+ if (typeof fn !== "function")
73
+ return;
74
+ await fn.apply(handler, args);
75
+ }
76
+ /**
77
+ * Outbound bytes past which agent audio is dropped rather than queued.
78
+ *
79
+ * A slow or wedged peer turns "send everything" into an unbounded queue, and
80
+ * that queue is what stalls the provider receive loop feeding it. Shedding keeps
81
+ * the loop moving: the caller hears a gap rather than the call wedging.
82
+ *
83
+ * Audio only. Control frames are what END a call, and a call that cannot be
84
+ * ended is the failure this exists to prevent.
85
+ */
86
+ export const MAX_AUDIO_BUFFER_BYTES = 1024 * 1024;
87
+ /**
88
+ * How long a call may go without an agent ever answering it.
89
+ *
90
+ * The one gap none of the other watchdogs cover. `preStartTimeoutMs` watches for
91
+ * session.start, which arrived. `onStartTimeoutMs` bounds onStart, which
92
+ * succeeded. `audioIdleTimeoutMs` is satisfied because the CALLER is still
93
+ * talking. So StandIn is on the call, the caller hears nothing, and nothing ends
94
+ * it.
95
+ */
96
+ export const STALE_CALL_REAPER_MS = 120_000;
97
+ /**
98
+ * How often to look. Coarse on purpose: this is a grace period, not something
99
+ * anybody measures to the second.
100
+ */
101
+ export const REAPER_CHECK_INTERVAL_MS = 15_000;
102
+ export const REAPER_MIN_INTERVAL_MS = 50;
103
+ /** Milliseconds from a clock that cannot step backwards. */
104
+ function monotonicMs() {
105
+ return Number(process.hrtime.bigint() / 1000000n);
106
+ }
107
+ /**
108
+ * Whether this call has run past its grace with nothing answering it.
109
+ *
110
+ * Pure, with the clock passed in, so the boundary is testable without waiting.
111
+ * Strictly greater, so a tick landing exactly on the grace does not reap a call
112
+ * one instant early.
113
+ */
114
+ export function isUnanswered(call, staleMs, now) {
115
+ return (call.answeredAtMs === undefined &&
116
+ staleMs > 0 &&
117
+ now - call.startedAtMs > staleMs);
118
+ }
119
+ /** One live call: the StandIn socket on one side, a plugin's handler on the other. */
120
+ class Call {
121
+ #server;
122
+ #callId;
123
+ #ws;
124
+ #handler;
125
+ #start;
126
+ #seq = 0;
127
+ #sentMs = 0;
128
+ // The tile stream has its own sequence: it is a separate stream from the
129
+ // audio, and a receiver drops out-of-order frames per stream.
130
+ #tileSeq = 0;
131
+ #lastAudio;
132
+ // One recording flag, kept current here so no plugin has to re-derive it
133
+ // from the context sentence it happened to see.
134
+ #recording = false;
135
+ // Whether a recording.status frame has actually said so. session.start OMITS
136
+ // the field when the state was unknown at answer time, and an omitted field is
137
+ // not "not recording": a recording.status that lands first would otherwise be
138
+ // overwritten by the absent snapshot, and every recording-gated capability
139
+ // would stay shut for the rest of the call with nothing said.
140
+ #recordingReported = false;
141
+ // The active speaker, when StandIn sends unmixed audio. Undefined on the
142
+ // mixed path, which is most calls.
143
+ #speaker;
144
+ #participants = 0;
145
+ #audioDropped = 0;
146
+ #lastDropLog = 0;
147
+ // Monotonic, not wall clock: a clock step forward larger than the grace
148
+ // period would otherwise reap every live unanswered call at once.
149
+ startedAtMs = monotonicMs();
150
+ answeredAtMs;
151
+ // Latest frame per source, and only the latest: frames arrive sparsely and a
152
+ // held history would be an unbounded buffer of the caller's screen. Never
153
+ // written to disk.
154
+ #frames = new Map();
155
+ #closed = false;
156
+ /** Set only AFTER onStart resolves; the pre-start watchdog keys on this. */
157
+ #started = false;
158
+ #inStart = false;
159
+ #queue = Promise.resolve();
160
+ #closingReason = "call-ended";
161
+ #closePromise;
162
+ #idleTimer;
163
+ #durationTimer;
164
+ #preStartTimer;
165
+ constructor(server, callId, ws) {
166
+ this.#server = server;
167
+ this.#callId = callId;
168
+ this.#ws = ws;
169
+ }
170
+ // ---- the CallSession surface handed to the plugin ----
171
+ get callId() {
172
+ return this.#callId;
173
+ }
174
+ get start() {
175
+ if (this.#start === undefined)
176
+ throw new StandInError("the call has not started yet");
177
+ return this.#start;
178
+ }
179
+ /**
180
+ * Send agent audio to the caller. The server owns `seq` and the timeline, so a
181
+ * handler that swaps or re-publishes its audio source cannot make
182
+ * `timestampMs` jump backwards while `seq` keeps climbing.
183
+ */
184
+ /** Whether this call is being recorded, right now. */
185
+ get recordingActive() {
186
+ return this.#recording;
187
+ }
188
+ /** Whether anything has actually taken this call yet. */
189
+ get answered() {
190
+ return this.answeredAtMs !== undefined;
191
+ }
192
+ /**
193
+ * Say that an agent has taken the call. Stamped once, never re-stamped.
194
+ *
195
+ * A plugin that joins a room calls this when the agent's own audio track
196
+ * appears, not when a participant connects: monitors, recorders and avatar
197
+ * workers all connect, and none of them is an agent answering.
198
+ *
199
+ * A plugin that never calls it is still covered, because sending audio counts.
200
+ * The explicit call exists for an agent that joins and listens before it says
201
+ * anything.
202
+ */
203
+ markAnswered() {
204
+ this.answeredAtMs ??= monotonicMs();
205
+ }
206
+ /** Who is speaking, when StandIn sends unmixed audio. */
207
+ get speaker() {
208
+ return this.#speaker;
209
+ }
210
+ /** How many people are on the call. Zero until StandIn says. */
211
+ get participantCount() {
212
+ return this.#participants;
213
+ }
214
+ /** Outbound bytes the socket has not flushed yet, straight off the socket. */
215
+ get bufferedBytes() {
216
+ return this.#ws.bufferedAmount;
217
+ }
218
+ /** The outbound audio timeline this call stamps its audio with. */
219
+ get mediaTimeMs() {
220
+ return this.#sentMs;
221
+ }
222
+ async sendAudio(pcm) {
223
+ // Gated on the SOCKET, not on #closed: aclose sets #closed BEFORE teardown
224
+ // dispatches the handler's aclose, so gating on the flag would silently
225
+ // break the guarantee that a handler can still speak on the way out.
226
+ if (this.#ws.readyState !== WebSocket.OPEN || pcm.length === 0)
227
+ return;
228
+ // Sending audio IS answering, so every plugin is covered by the reaper
229
+ // without doing anything.
230
+ this.markAnswered();
231
+ const timestampMs = this.#sentMs;
232
+ // The timeline advances whether or not this frame goes out. It is the
233
+ // CALLER's clock: a dropped frame is a gap in what they hear, not a rewind,
234
+ // and stalling the clock would make every later frame claim a time that has
235
+ // already passed.
236
+ this.#seq += 1;
237
+ // Integer division at every step, matching Python's
238
+ // `(len(pcm) // 2) * 1000 // SAMPLE_RATE_HZ` exactly. A float here would
239
+ // drift the two SDKs' timelines apart over a long call.
240
+ this.#sentMs += Math.floor((Math.floor(pcm.length / 2) * 1000) / SAMPLE_RATE_HZ);
241
+ if (this.#overAudioBudget())
242
+ return;
243
+ this.#send(audioFrame(this.#seq, timestampMs, pcm));
244
+ }
245
+ /** Whether the socket is too far behind to take another audio frame. */
246
+ #overAudioBudget() {
247
+ if (this.bufferedBytes <= MAX_AUDIO_BUFFER_BYTES)
248
+ return false;
249
+ this.#audioDropped += 1;
250
+ const now = Date.now();
251
+ if (now - this.#lastDropLog >= 5_000) {
252
+ logger.warn(`standin: call ${safe(this.#callId)} is shedding agent audio to keep the loop ` +
253
+ `moving (${this.#audioDropped} frames so far); the peer is not reading`);
254
+ this.#lastDropLog = now;
255
+ }
256
+ return true;
257
+ }
258
+ /**
259
+ * Drop whatever agent audio StandIn still has buffered.
260
+ *
261
+ * The only lever that un-sends audio already handed to the service: it
262
+ * flushes the platform player, so the caller stops hearing the turn they just
263
+ * interrupted. Without it a barge-in stops the MODEL but the bot keeps talking
264
+ * for the length of the buffered PCM.
265
+ *
266
+ * Call it the moment your provider reports the caller started speaking, before
267
+ * you cancel the response upstream.
268
+ */
269
+ async cancelPlayback() {
270
+ this.#send(assistantCancel(this.#seq));
271
+ }
272
+ /**
273
+ * Ask for the call to end.
274
+ *
275
+ * Awaiting teardown from INSIDE onStart would deadlock: teardown waits for
276
+ * onStart to return before dispatching the handler's aclose, and onStart
277
+ * would be waiting for teardown. Refusing a call is a normal thing to do from
278
+ * onStart - an allowlist rejection, a busy runtime, a provider that will not
279
+ * connect - so it is made safe here: ask for the close, return immediately,
280
+ * and let it run once onStart unwinds.
281
+ */
282
+ latestVideoFrame(source) {
283
+ if (source !== undefined)
284
+ return this.#frames.get(source);
285
+ return this.#frames.get("screenshare") ?? this.#frames.get("camera");
286
+ }
287
+ /**
288
+ * Draw an image on the bot's video tile.
289
+ *
290
+ * Gated on the socket for the same reason sendAudio is: a handler is allowed
291
+ * to show something on its way out.
292
+ */
293
+ /**
294
+ * Send one frame of continuous avatar video.
295
+ *
296
+ * Gated on the socket, like sendAudio: a handler may still be showing
297
+ * something on its way out.
298
+ */
299
+ async sendTileFrame(jpeg, width, height) {
300
+ if (this.#ws.readyState !== this.#ws.OPEN || jpeg.length === 0)
301
+ return;
302
+ this.#tileSeq += 1;
303
+ this.#send(buildDisplayFrame(this.#tileSeq, this.#sentMs, jpeg, {
304
+ mime: "image/jpeg",
305
+ width,
306
+ height,
307
+ }));
308
+ }
309
+ async displayImage(image, options = {}) {
310
+ if (this.#ws.readyState !== this.#ws.OPEN)
311
+ return;
312
+ this.#send(buildDisplayImage(image, options));
313
+ }
314
+ /** Hint the avatar's emotion. Video only, and never fatal. */
315
+ async express(emotion) {
316
+ if (this.#ws.readyState !== this.#ws.OPEN)
317
+ return;
318
+ this.#send(buildExpression(emotion));
319
+ }
320
+ /** Send one utterance's viseme timeline for avatar lip-sync. */
321
+ async sendSpeechMarks(marks) {
322
+ if (this.#ws.readyState !== this.#ws.OPEN)
323
+ return;
324
+ this.#send(buildSpeechMarks(marks));
325
+ }
326
+ async end(reason) {
327
+ if (this.#inStart) {
328
+ this.#closingReason =
329
+ this.#closePromise === undefined ? reason : this.#closingReason;
330
+ if (this.#closePromise === undefined) {
331
+ this.#closed = true;
332
+ this.#closePromise = this.#teardown();
333
+ }
334
+ return;
335
+ }
336
+ await this.aclose(reason);
337
+ }
338
+ // ---- lifecycle ----
339
+ /** Wire the socket up. Resolves when the call has fully ended. */
340
+ run() {
341
+ this.#preStartTimer = setTimeout(() => {
342
+ if (!this.#started && !this.#closed) {
343
+ logger.warn(`standin: call ${safe(this.#callId)} never sent session.start`);
344
+ void this.aclose("pre-start-timeout");
345
+ }
346
+ }, this.#server.preStartTimeoutMs);
347
+ // Never keep the process alive for a watchdog.
348
+ this.#preStartTimer.unref?.();
349
+ // SERIALIZED, matching Python's `async for msg in self._ws`. Unawaited,
350
+ // any audio.frame packed into the same TCP read as session.start is
351
+ // dispatched while #onSessionStart is still awaiting onStart - so the
352
+ // handler sees audio before it is built, which handler.ts documents as
353
+ // impossible. Python honours it by awaiting serially; without this queue the
354
+ // two SDKs disagree on the one ordering guarantee the seam makes.
355
+ this.#ws.on("message", (data, isBinary) => {
356
+ if (isBinary)
357
+ return;
358
+ this.#queue = this.#queue
359
+ .then(() => this.#onFrame(data))
360
+ .catch(() => undefined);
361
+ });
362
+ this.#ws.on("error", (err) => {
363
+ logger.error(`standin: call ${safe(this.#callId)} socket error: ${String(err)}`);
364
+ void this.aclose("transport-failure");
365
+ });
366
+ this.#ws.on("close", () => {
367
+ void this.aclose();
368
+ });
369
+ return new Promise((resolve) => {
370
+ this.#onEnded = resolve;
371
+ });
372
+ }
373
+ #onEnded;
374
+ async #onFrame(data) {
375
+ const frame = parseMessage(data);
376
+ if (frame === undefined)
377
+ return;
378
+ const kind = frame.type;
379
+ try {
380
+ if (kind === "session.start") {
381
+ if (this.#start !== undefined)
382
+ return; // a second start is a sender bug
383
+ await this.#onSessionStart(parseSessionStart(frame));
384
+ }
385
+ else if (kind === "audio.frame") {
386
+ this.#lastAudio = Date.now();
387
+ await this.#onCallerAudio(frame);
388
+ }
389
+ else if (kind === "video.frame") {
390
+ await this.#onVideoFrame(frame);
391
+ }
392
+ else if (kind === "ping") {
393
+ this.#send(pong(frame.ts));
394
+ }
395
+ else if (kind === "participants") {
396
+ // The same sentences the Python SDK publishes, so agents written against
397
+ // either read identical context.
398
+ if (typeof frame.count === "number") {
399
+ this.#participants = Math.max(0, Math.trunc(frame.count));
400
+ await this.#onContext(contextSentences.participants(frame.count));
401
+ }
402
+ }
403
+ else if (kind === "dtmf") {
404
+ if (typeof frame.digit === "string" && frame.digit !== "") {
405
+ await this.#onContext(contextSentences.dtmf(frame.digit));
406
+ }
407
+ }
408
+ else if (kind === "recording.status") {
409
+ if (typeof frame.status === "string") {
410
+ this.#recording = frame.status === "active";
411
+ this.#recordingReported = true;
412
+ await this.#onContext(contextSentences.recording(frame.status));
413
+ }
414
+ }
415
+ else if (kind === "assistant.say") {
416
+ const text = frame.text;
417
+ if (typeof text === "string" && text.trim() !== "") {
418
+ // Flush the worker's queued agent playback FIRST: without the cancel,
419
+ // the goodbye publishes behind seconds of already-buffered audio and
420
+ // the call is torn down before it plays.
421
+ this.#send(assistantCancel(this.#seq));
422
+ await this.#guard("onGoodbye", text);
423
+ }
424
+ }
425
+ else if (kind === "session.end") {
426
+ const reason = typeof frame.reason === "string" && frame.reason
427
+ ? frame.reason
428
+ : "call-ended";
429
+ await this.aclose(reason);
430
+ }
431
+ // Anything else (the avatar surface included) is ignored by contract, so an
432
+ // older plugin and a newer StandIn interoperate.
433
+ }
434
+ catch (err) {
435
+ logger.error(`standin: call ${safe(this.#callId)} failed: ${String(err)}`);
436
+ await this.aclose("transport-failure");
437
+ }
438
+ }
439
+ async #onSessionStart(start) {
440
+ if (start.callId !== this.#callId) {
441
+ // The URL path is what the HMAC signed. A body that disagrees is either a
442
+ // bug or an attempt to ride one call's signature into another's session.
443
+ throw new StandInError(`session.start callId ${JSON.stringify(start.callId)} does not match the authenticated path`);
444
+ }
445
+ clearTimeout(this.#preStartTimer);
446
+ this.#start = start;
447
+ // Only when nothing has reported the real state yet. recording.status can
448
+ // land before session.start, and the snapshot is omitted when the state was
449
+ // unknown at answer time, so seeding unconditionally turns a live ACTIVE
450
+ // into false for the whole call.
451
+ if (!this.#recordingReported)
452
+ this.#recording = start.recordingStatus === "active";
453
+ this.#handler = this.#server.buildHandler();
454
+ this.#lastAudio = Date.now();
455
+ // Armed BEFORE onStart, not after: onStart does real network work, and while
456
+ // it is awaited the frame loop is queued behind it, so session.end is never
457
+ // read. Armed after, a hung onStart has no watchdog at all and the callId
458
+ // 409s forever - one leaked slot per inbound call, up to maxConnections.
459
+ this.#armIdleWatchdog();
460
+ this.#armDurationCeiling();
461
+ this.#inStart = true;
462
+ try {
463
+ await this.#guardStart();
464
+ }
465
+ finally {
466
+ this.#inStart = false;
467
+ }
468
+ this.#started = true;
469
+ logger.info(`standin: call ${safe(this.#callId)} started (${start.direction}, caller ` +
470
+ `${safe(start.caller.displayName ?? "unknown")})`);
471
+ }
472
+ /** onStart with its own timeout and its own close reason. */
473
+ async #guardStart() {
474
+ const handler = this.#handler;
475
+ if (handler === undefined)
476
+ return;
477
+ const timeout = this.#server.onStartTimeoutMs;
478
+ try {
479
+ if (timeout > 0) {
480
+ let timer;
481
+ const expiry = new Promise((_, reject) => {
482
+ timer = setTimeout(() => reject(new Error("on-start-timeout")), timeout);
483
+ timer.unref?.();
484
+ });
485
+ try {
486
+ await Promise.race([dispatch(handler, "onStart", this), expiry]);
487
+ }
488
+ finally {
489
+ clearTimeout(timer);
490
+ }
491
+ }
492
+ else {
493
+ await dispatch(handler, "onStart", this);
494
+ }
495
+ }
496
+ catch (err) {
497
+ const timedOut = String(err).includes("on-start-timeout");
498
+ logger.error(`standin: handler.onStart ${timedOut ? "timed out" : "failed"} on call ` +
499
+ `${safe(this.#callId)}: ${String(err)}`);
500
+ // A third-party outage inside a plugin must not be reported to StandIn as
501
+ // StandIn's own socket failing - that sends both sides debugging the wrong
502
+ // system.
503
+ await this.aclose(timedOut ? "handler-start-timeout" : "handler-start-failure");
504
+ }
505
+ }
506
+ async #onCallerAudio(frame) {
507
+ if (this.#handler === undefined || this.#closed)
508
+ return;
509
+ let pcm;
510
+ try {
511
+ pcm = decodePcm(frame.payloadBase64);
512
+ }
513
+ catch (err) {
514
+ logger.warn(`standin: dropping caller frame: ${String(err)}`);
515
+ return;
516
+ }
517
+ await this.#noteSpeaker(frame.speakerName);
518
+ await this.#guard("onCallerAudio", pcm);
519
+ }
520
+ /**
521
+ * Remember who is talking, and say so once when it changes.
522
+ *
523
+ * Absent on the mixed path, which is most calls, so this is additive: a
524
+ * handler that never looks at it behaves exactly as before. The callback
525
+ * fires on CHANGE only. It rides every audio frame, and a model told forty
526
+ * times a second who is speaking would hear nothing else.
527
+ */
528
+ async #noteSpeaker(name) {
529
+ if (typeof name !== "string")
530
+ return;
531
+ const speaker = name.trim();
532
+ if (speaker === "" || speaker === this.#speaker)
533
+ return;
534
+ this.#speaker = speaker;
535
+ // #guard already no-ops on a handler that does not implement it.
536
+ await this.#guard("onSpeakerChange", speaker);
537
+ }
538
+ /**
539
+ * Store the latest frame per source, then offer it to the handler.
540
+ *
541
+ * Stored even when the handler implements no callback, because
542
+ * latestVideoFrame is the way most plugins use this lane: the model asks
543
+ * to look long after the frame arrived.
544
+ */
545
+ async #onVideoFrame(frame) {
546
+ if (this.#handler === undefined || this.#closed)
547
+ return;
548
+ const parsed = parseVideoFrame(frame);
549
+ // Sparse and best-effort by contract. One unusable frame is not worth a log
550
+ // line per frame, let alone ending the call.
551
+ if (parsed === undefined)
552
+ return;
553
+ this.#frames.set(parsed.source, parsed);
554
+ await this.#guard("onVideoFrame", parsed);
555
+ }
556
+ async #onContext(text) {
557
+ // Context can arrive before session.start on a fast dial. Dropping it is
558
+ // correct: there is no handler to receive it, and the server does not queue
559
+ // on a plugin's behalf.
560
+ if (this.#handler === undefined)
561
+ return;
562
+ await this.#guard("onContext", text);
563
+ }
564
+ /**
565
+ * Run one handler callback. A plugin throwing must end its own call, not the
566
+ * worker, and not the frame loop mid-utterance.
567
+ */
568
+ async #guard(method, ...args) {
569
+ const handler = this.#handler;
570
+ if (handler === undefined)
571
+ return;
572
+ try {
573
+ await dispatch(handler, method, ...args);
574
+ }
575
+ catch (err) {
576
+ logger.error(`standin: handler.${String(method)} failed on call ${safe(this.#callId)}: ${String(err)}`);
577
+ await this.aclose("handler-failure");
578
+ }
579
+ }
580
+ /**
581
+ * End the call when the caller's audio stops arriving.
582
+ *
583
+ * A live Microsoft Teams call delivers PCM continuously - silence is still frames - so
584
+ * audio going quiet for this long means the call is gone on the far side and
585
+ * nobody told us. That happens: the peer keeps the socket open (and even keeps
586
+ * pinging) while its own teardown is wedged, and without this backstop the
587
+ * handler's session burns until someone notices.
588
+ */
589
+ /**
590
+ * End a call that has run past its ceiling, still going.
591
+ *
592
+ * The idle watchdog ends a call that went QUIET. This one ends a call that
593
+ * has not: a caller who will not hang up, a model looping at itself, an
594
+ * automated system that dialled and never stopped talking. Each of those bills
595
+ * a provider by the minute for as long as the socket lives, and none of them
596
+ * trips a silence check.
597
+ *
598
+ * The goodbye goes through the handler's `onGoodbye`, the same callback
599
+ * StandIn's own closing line uses, so no plugin needs new code for this.
600
+ * Playback is flushed FIRST, or the line queues behind however many seconds of
601
+ * agent audio the service still holds and the call ends before anyone hears it.
602
+ */
603
+ #armDurationCeiling() {
604
+ const limit = this.#server.maxCallMs;
605
+ if (limit <= 0)
606
+ return;
607
+ this.#durationTimer = setTimeout(() => void this.#endOnCeiling(limit), limit);
608
+ this.#durationTimer.unref?.();
609
+ }
610
+ async #endOnCeiling(limit) {
611
+ if (this.#closed || this.#ws.readyState !== WebSocket.OPEN)
612
+ return;
613
+ logger.info(`standin: call ${safe(this.#callId)} reached its ${limit}ms limit; saying goodbye`);
614
+ try {
615
+ await this.cancelPlayback();
616
+ }
617
+ catch {
618
+ // A dying socket is teardown's business, not the ceiling's.
619
+ }
620
+ const text = this.#server.goodbyeText.trim();
621
+ if (text !== "") {
622
+ await this.#guard("onGoodbye", text);
623
+ // Bounded whatever the handler does with it. A plugin that hangs in
624
+ // onGoodbye must not turn a time-limited call into an endless one, which
625
+ // is the exact failure this exists to prevent.
626
+ await new Promise((resolve) => {
627
+ const timer = setTimeout(resolve, Math.max(0, this.#server.goodbyeGraceMs));
628
+ timer.unref?.();
629
+ });
630
+ }
631
+ await this.aclose("call-duration-limit");
632
+ }
633
+ #armIdleWatchdog() {
634
+ const idle = this.#server.audioIdleTimeoutMs;
635
+ if (idle <= 0)
636
+ return;
637
+ const tick = Math.min(idle / 4, 10_000);
638
+ this.#idleTimer = setInterval(() => {
639
+ if (this.#closed)
640
+ return;
641
+ const last = this.#lastAudio;
642
+ if (last !== undefined && Date.now() - last > idle) {
643
+ logger.warn(`standin: call ${safe(this.#callId)} got no caller audio for ${idle}ms; ending it`);
644
+ void this.aclose("caller-idle-timeout");
645
+ }
646
+ }, tick);
647
+ this.#idleTimer.unref?.();
648
+ }
649
+ /**
650
+ * Idempotent teardown, and the FIRST reason wins - a cascade of close causes
651
+ * must not overwrite the one that actually ended the call. Every caller awaits
652
+ * the same promise.
653
+ */
654
+ async aclose(reason) {
655
+ if (this.#closePromise === undefined) {
656
+ this.#closingReason = reason ?? this.#closingReason;
657
+ this.#closed = true;
658
+ this.#closePromise = this.#teardown();
659
+ }
660
+ await this.#closePromise;
661
+ }
662
+ async #teardown() {
663
+ try {
664
+ clearTimeout(this.#preStartTimer);
665
+ clearInterval(this.#idleTimer);
666
+ clearTimeout(this.#durationTimer);
667
+ // A handler may refuse the call from inside onStart by awaiting
668
+ // session.end(...). Dispatching aclose while onStart is still on the stack
669
+ // tears down half-built state, and then onStart RESUMES and finishes
670
+ // building a provider session nothing will ever close - one leaked socket
671
+ // per refusal. Wait for it, bounded.
672
+ for (let i = 0; this.#inStart && i < 200; i++) {
673
+ await new Promise((r) => setTimeout(r, 10));
674
+ }
675
+ // The plugin releases its side BEFORE the socket closes, so a handler that
676
+ // wants to say something on the way out still can.
677
+ const handler = this.#handler;
678
+ this.#handler = undefined;
679
+ if (handler !== undefined) {
680
+ try {
681
+ await dispatch(handler, "aclose", this.#closingReason);
682
+ }
683
+ catch (err) {
684
+ logger.error(`standin: handler.aclose failed: ${String(err)}`);
685
+ }
686
+ }
687
+ if (this.#ws.readyState === WebSocket.OPEN) {
688
+ this.#send(sessionEnd(this.#closingReason));
689
+ await this.#closeSocket();
690
+ }
691
+ }
692
+ finally {
693
+ // Unconditional: whatever failed above, the slot is released and the
694
+ // callId becomes usable again.
695
+ this.#server.release(this.#callId);
696
+ logger.info(`standin: call ${safe(this.#callId)} ended (${this.#closingReason})`);
697
+ this.#onEnded?.();
698
+ }
699
+ }
700
+ /** Bounded: see CLOSE_TIMEOUT_MS. Releasing the slot matters more than a clean
701
+ * close handshake with an absent peer. */
702
+ #closeSocket() {
703
+ return new Promise((resolve) => {
704
+ const done = () => {
705
+ clearTimeout(timer);
706
+ resolve();
707
+ };
708
+ const timer = setTimeout(() => {
709
+ this.#ws.terminate();
710
+ resolve();
711
+ }, CLOSE_TIMEOUT_MS);
712
+ timer.unref?.();
713
+ this.#ws.once("close", done);
714
+ try {
715
+ this.#ws.close();
716
+ }
717
+ catch {
718
+ done();
719
+ }
720
+ });
721
+ }
722
+ #send(text) {
723
+ if (this.#ws.readyState !== WebSocket.OPEN)
724
+ return;
725
+ try {
726
+ this.#ws.send(text);
727
+ }
728
+ catch {
729
+ // A send failing on a dying socket is not an error worth surfacing; the
730
+ // close handler is already on its way.
731
+ }
732
+ }
733
+ }
734
+ /**
735
+ * Answers the socket StandIn dials, and hands each call to a plugin.
736
+ *
737
+ * Defaults match the StandIn plugin layout: port 9442, path `/msteams/calling`.
738
+ * `0.0.0.0` because the worker usually runs in a container behind an ingress;
739
+ * bind `127.0.0.1` when only a local tunnel should reach the listener (the
740
+ * upgrade is HMAC-authenticated either way).
741
+ */
742
+ export class CallServer {
743
+ #handlerFactory;
744
+ #onCallOutcome;
745
+ #secret;
746
+ #host;
747
+ #port;
748
+ #wsPath;
749
+ #maxConnections;
750
+ preStartTimeoutMs;
751
+ audioIdleTimeoutMs;
752
+ onStartTimeoutMs;
753
+ maxCallMs;
754
+ goodbyeText;
755
+ goodbyeGraceMs;
756
+ staleCallReaperMs;
757
+ #reaper;
758
+ #calls = new Map();
759
+ /**
760
+ * fingerprint -> signing timestamp (ms). Pruned by AGE, never wholesale:
761
+ * clearing the map would reopen the replay window for every handshake still
762
+ * inside it.
763
+ */
764
+ #usedSignatures = new Map();
765
+ #lastPrune = nowMs();
766
+ #http;
767
+ #wss;
768
+ /** Stop accepting new calls; live ones continue. */
769
+ draining = false;
770
+ constructor(options) {
771
+ if (typeof options.handlerFactory !== "function") {
772
+ throw new StandInError("handlerFactory must be a function that builds one handler per call");
773
+ }
774
+ this.#handlerFactory = options.handlerFactory;
775
+ this.#onCallOutcome = options.onCallOutcome;
776
+ this.#secret = options.secret ?? process.env.STANDIN_SECRET ?? "";
777
+ if (!this.#secret) {
778
+ throw new StandInError("a StandIn connection secret is required: pass secret or set STANDIN_SECRET");
779
+ }
780
+ this.#host = options.host ?? process.env.STANDIN_HOST ?? "0.0.0.0";
781
+ this.#port = options.port ?? Number(process.env.STANDIN_PORT ?? 9442);
782
+ const path = options.wsPath ?? process.env.STANDIN_WS_PATH ?? "/msteams/calling";
783
+ this.#wsPath = "/" + path.trim().replace(/^\/+|\/+$/g, "");
784
+ if (this.#wsPath === "/") {
785
+ throw new StandInError("wsPath must be a real path such as /msteams/calling");
786
+ }
787
+ this.#maxConnections = options.maxConnections ?? 64;
788
+ this.preStartTimeoutMs = options.preStartTimeoutMs ?? 10_000;
789
+ this.audioIdleTimeoutMs = options.audioIdleTimeoutMs ?? 45_000;
790
+ this.onStartTimeoutMs = options.onStartTimeoutMs ?? 15_000;
791
+ this.maxCallMs = options.maxCallMs ?? 0;
792
+ this.goodbyeText =
793
+ options.goodbyeText ??
794
+ "We are out of time on this call, so I have to stop here. Goodbye.";
795
+ this.goodbyeGraceMs = options.goodbyeGraceMs ?? 6_000;
796
+ this.staleCallReaperMs = options.staleCallReaperMs ?? STALE_CALL_REAPER_MS;
797
+ }
798
+ get wsPath() {
799
+ return this.#wsPath;
800
+ }
801
+ /** The interface the listener is bound to, or will be. */
802
+ get host() {
803
+ return this.#host;
804
+ }
805
+ get port() {
806
+ return this.#port;
807
+ }
808
+ get activeCalls() {
809
+ return this.#calls.size;
810
+ }
811
+ /**
812
+ * Whether the listener is actually bound.
813
+ *
814
+ * A host that calls connect twice today binds a second listener and leaks the
815
+ * first, and has no way to ask whether the bind succeeded, so it reports a
816
+ * dead platform as connected.
817
+ */
818
+ get running() {
819
+ return this.#http !== undefined;
820
+ }
821
+ /** @internal */
822
+ buildHandler() {
823
+ return this.#handlerFactory();
824
+ }
825
+ /** @internal */
826
+ release(callId) {
827
+ this.#calls.delete(callId);
828
+ }
829
+ /**
830
+ * How an outbound call ended, reported by StandIn.
831
+ *
832
+ * Only reached when a plugin asked for it. The route carries the only signal
833
+ * that nobody answered, and without it an unanswered call waits out the ring
834
+ * timeout before anything can be said about it.
835
+ *
836
+ * The body is read and capped BEFORE the signature is checked, because v2
837
+ * signs a hash of the body: there is nothing to verify until the bytes are in
838
+ * hand. The cap is what stops that being a way to make the worker read an
839
+ * unbounded request from an unauthenticated peer.
840
+ */
841
+ async #onOutcome(req, res, path, callId) {
842
+ const declared = Number(req.headers["content-length"] ?? NaN);
843
+ if (Number.isFinite(declared) && declared > MAX_OUTCOME_BYTES) {
844
+ res.writeHead(413).end();
845
+ req.destroy();
846
+ return;
847
+ }
848
+ const chunks = [];
849
+ let total = 0;
850
+ let tooBig = false;
851
+ for await (const chunk of req) {
852
+ total += chunk.length;
853
+ if (total > MAX_OUTCOME_BYTES) {
854
+ tooBig = true;
855
+ break;
856
+ }
857
+ chunks.push(chunk);
858
+ }
859
+ if (tooBig) {
860
+ res.writeHead(413).end();
861
+ req.destroy();
862
+ return;
863
+ }
864
+ const raw = Buffer.concat(chunks);
865
+ const timestamp = String(req.headers[TIMESTAMP_HEADER] ?? "");
866
+ const signature = String(req.headers[SIGNATURE_V2_HEADER] ?? "");
867
+ const expected = signRequest(this.#secret, timestamp, "POST", path, raw);
868
+ if (!signature ||
869
+ !timingSafeEqualHex(signature.trim().toLowerCase(), expected)) {
870
+ logger.warn(`standin: refused an unsigned call outcome for ${safe(callId)}`);
871
+ res.writeHead(401).end();
872
+ return;
873
+ }
874
+ const sent = Number(timestamp);
875
+ if (!Number.isFinite(sent) || Math.abs(nowMs() - sent) > REPLAY_WINDOW_MS) {
876
+ res.writeHead(401).end();
877
+ return;
878
+ }
879
+ let outcome = "";
880
+ try {
881
+ const parsed = raw.length
882
+ ? JSON.parse(raw.toString("utf8"))
883
+ : {};
884
+ if (typeof parsed === "object" && parsed !== null) {
885
+ const body = parsed;
886
+ outcome = String(body.outcome ?? body.reason ?? "");
887
+ }
888
+ }
889
+ catch {
890
+ outcome = "";
891
+ }
892
+ try {
893
+ await this.#onCallOutcome?.(callId, outcome);
894
+ }
895
+ catch (err) {
896
+ // A plugin failing to handle an outcome must not make StandIn retry
897
+ // forever. Log it and acknowledge.
898
+ logger.error(`standin: handling the outcome for ${safe(callId)} failed: ${String(err)}`);
899
+ }
900
+ res.writeHead(204).end();
901
+ }
902
+ /**
903
+ * Bind the listener. Transactional: either it is listening when this resolves,
904
+ * or nothing of it survives.
905
+ */
906
+ async start() {
907
+ if (this.#http !== undefined)
908
+ throw new StandInError("this listener is already running");
909
+ const outcomePrefix = `${this.#wsPath}/outcome/`;
910
+ const http = createServer((req, res) => {
911
+ if (req.url === "/healthz") {
912
+ res.writeHead(200, { "content-type": "application/json" });
913
+ res.end(JSON.stringify({ ok: true, calls: this.#calls.size }));
914
+ return;
915
+ }
916
+ const path = (req.url ?? "").split("?")[0] ?? "";
917
+ if (this.#onCallOutcome !== undefined &&
918
+ req.method === "POST" &&
919
+ path.startsWith(outcomePrefix)) {
920
+ void this.#onOutcome(req, res, path, path.slice(outcomePrefix.length));
921
+ return;
922
+ }
923
+ res.writeHead(404).end();
924
+ });
925
+ const wss = new WebSocketServer({
926
+ noServer: true,
927
+ maxPayload: MAX_PAYLOAD_BYTES,
928
+ });
929
+ http.on("upgrade", (req, socket, head) => {
930
+ this.#onUpgrade(req, socket, head, wss);
931
+ });
932
+ try {
933
+ await new Promise((resolve, reject) => {
934
+ http.once("error", reject);
935
+ http.listen(this.#port, this.#host, () => {
936
+ http.removeListener("error", reject);
937
+ resolve();
938
+ });
939
+ });
940
+ }
941
+ catch (err) {
942
+ http.close();
943
+ throw err;
944
+ }
945
+ const address = http.address();
946
+ if (address !== null && typeof address === "object")
947
+ this.#port = address.port;
948
+ this.#http = http;
949
+ this.#wss = wss;
950
+ this.#armReaper();
951
+ logger.info(`standin: answering Microsoft Teams calls on ${this.#host}:${this.#port}${this.#wsPath}`);
952
+ }
953
+ /**
954
+ * End calls that nothing ever answered.
955
+ *
956
+ * An agent dispatch that never lands is the commonest misconfiguration there
957
+ * is, and it is invisible to every other watchdog: session.start arrived,
958
+ * onStart succeeded, and the caller keeps sending audio the whole time.
959
+ * Without this the caller sits on a live call hearing nothing and the worker
960
+ * holds the slot until somebody notices.
961
+ */
962
+ #armReaper() {
963
+ const stale = this.staleCallReaperMs;
964
+ if (stale <= 0)
965
+ return;
966
+ const interval = Math.max(REAPER_MIN_INTERVAL_MS, Math.min(REAPER_CHECK_INTERVAL_MS, stale));
967
+ const reaped = new Set();
968
+ this.#reaper = setInterval(() => {
969
+ const now = monotonicMs();
970
+ // A SNAPSHOT: ending a call removes it from the registry being iterated.
971
+ const live = [...this.#calls.entries()];
972
+ // Bounded by what is actually running, so a long-lived worker does not
973
+ // accumulate ids for ever.
974
+ for (const id of [...reaped]) {
975
+ if (!this.#calls.has(id))
976
+ reaped.delete(id);
977
+ }
978
+ for (const [callId, call] of live) {
979
+ if (reaped.has(callId) || !isUnanswered(call, stale, now))
980
+ continue;
981
+ reaped.add(callId);
982
+ logger.warn(`standin: nothing answered call ${safe(callId)} within ${stale}ms; ending it`);
983
+ void call.aclose("no-agent-answered");
984
+ }
985
+ }, interval);
986
+ this.#reaper.unref?.();
987
+ }
988
+ /**
989
+ * Drain live calls, then stop listening. Awaits the calls' REAL teardown - a
990
+ * close that early-returns on an in-flight closer would let the process exit
991
+ * with teardown still pending, leaking sessions.
992
+ */
993
+ async aclose() {
994
+ clearInterval(this.#reaper);
995
+ this.#reaper = undefined;
996
+ const calls = [...this.#calls.values()];
997
+ await Promise.allSettled(calls.map((c) => c.aclose("server-shutdown")));
998
+ this.#calls.clear();
999
+ const wss = this.#wss;
1000
+ this.#wss = undefined;
1001
+ if (wss !== undefined)
1002
+ await new Promise((resolve) => wss.close(() => resolve()));
1003
+ const http = this.#http;
1004
+ this.#http = undefined;
1005
+ if (http !== undefined)
1006
+ await new Promise((resolve) => http.close(() => resolve()));
1007
+ }
1008
+ #reject(socket, status, text) {
1009
+ const body = Buffer.from(text, "utf8");
1010
+ socket.write(`HTTP/1.1 ${status} ${text}\r\n` +
1011
+ "connection: close\r\n" +
1012
+ "content-type: text/plain; charset=utf-8\r\n" +
1013
+ `content-length: ${body.length}\r\n\r\n`);
1014
+ socket.end(body);
1015
+ socket.destroy();
1016
+ }
1017
+ #onUpgrade(req, socket, head, wss) {
1018
+ const prefix = `${this.#wsPath}/`;
1019
+ let callId;
1020
+ try {
1021
+ const url = new URL(req.url ?? "/", "http://localhost");
1022
+ if (!url.pathname.startsWith(prefix)) {
1023
+ this.#reject(socket, 404, "not found");
1024
+ return;
1025
+ }
1026
+ callId = decodeURIComponent(url.pathname.slice(prefix.length));
1027
+ }
1028
+ catch {
1029
+ // This event listener runs before authentication. A malformed URL or
1030
+ // percent escape must reject one request, never escape and kill Node.
1031
+ this.#reject(socket, 400, "malformed request path");
1032
+ return;
1033
+ }
1034
+ if (!callId || callId.includes("/")) {
1035
+ this.#reject(socket, 400, "missing callId");
1036
+ return;
1037
+ }
1038
+ // Draining: live calls continue, new ones are refused so a worker that is
1039
+ // winding down does not accept calls it will never serve.
1040
+ if (this.draining) {
1041
+ this.#reject(socket, 503, "draining");
1042
+ return;
1043
+ }
1044
+ // Capacity is checked BEFORE any crypto, so a flood cannot make us spend CPU
1045
+ // on signatures for calls we were never going to accept.
1046
+ if (this.#calls.size >= this.#maxConnections) {
1047
+ logger.warn(`standin: refusing ${safe(callId)}, at capacity`);
1048
+ this.#reject(socket, 503, "at capacity");
1049
+ return;
1050
+ }
1051
+ const timestamp = req.headers[TIMESTAMP_HEADER];
1052
+ const signature = req.headers[SIGNATURE_HEADER];
1053
+ if (!verifyHandshake(this.#secret, timestamp, callId, signature)) {
1054
+ this.#reject(socket, 401, "unauthorized");
1055
+ return;
1056
+ }
1057
+ // Single-use handshake: a correctly signed upgrade replayed inside the
1058
+ // freshness window must not open a second socket. The fingerprint uses the
1059
+ // NORMALIZED signature - verify accepts case/whitespace variants, so keying
1060
+ // on the raw header would let the same capture replay once per casing.
1061
+ const sigNorm = (signature ?? "").trim().toLowerCase();
1062
+ const fingerprint = `${timestamp}.${sigNorm}`;
1063
+ const now = nowMs();
1064
+ if (this.#usedSignatures.has(fingerprint)) {
1065
+ this.#reject(socket, 401, "handshake already used");
1066
+ return;
1067
+ }
1068
+ // Key on the SIGNING timestamp, never the arrival time: verification accepts
1069
+ // a timestamp up to REPLAY_WINDOW_MS in the FUTURE, so an entry aged from
1070
+ // arrival can be pruned while its signature is still valid - reopening the
1071
+ // exact replay this guard exists to close.
1072
+ this.#usedSignatures.set(fingerprint, timestamp ? Number(timestamp) : now);
1073
+ // Prune on a time throttle, not a size threshold: rebuilding once the map
1074
+ // passes a watermark makes every later request O(n). Only correctly signed,
1075
+ // not-yet-seen handshakes reach this line, so the map tracks StandIn's real
1076
+ // call rate rather than attacker traffic.
1077
+ if (now - this.#lastPrune >= PRUNE_INTERVAL_MS) {
1078
+ this.#lastPrune = now;
1079
+ const cutoff = now - REPLAY_WINDOW_MS;
1080
+ for (const [fp, ts] of this.#usedSignatures) {
1081
+ if (ts < cutoff)
1082
+ this.#usedSignatures.delete(fp);
1083
+ }
1084
+ }
1085
+ if (this.#calls.has(callId)) {
1086
+ this.#reject(socket, 409, "call already has a live session");
1087
+ return;
1088
+ }
1089
+ wss.handleUpgrade(req, socket, head, (ws) => {
1090
+ const call = new Call(this, callId, ws);
1091
+ this.#calls.set(callId, call);
1092
+ logger.info(`standin: call ${safe(callId)} connected`);
1093
+ void call.run();
1094
+ });
1095
+ }
1096
+ }
1097
+ //# sourceMappingURL=callServer.js.map