@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.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/ambient.d.ts +80 -0
- package/dist/ambient.d.ts.map +1 -0
- package/dist/ambient.js +281 -0
- package/dist/ambient.js.map +1 -0
- package/dist/attachments.d.ts +149 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +451 -0
- package/dist/attachments.js.map +1 -0
- package/dist/audio.d.ts +80 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +170 -0
- package/dist/audio.js.map +1 -0
- package/dist/avatar.d.ts +111 -0
- package/dist/avatar.d.ts.map +1 -0
- package/dist/avatar.js +201 -0
- package/dist/avatar.js.map +1 -0
- package/dist/callServer.d.ts +151 -0
- package/dist/callServer.d.ts.map +1 -0
- package/dist/callServer.js +1097 -0
- package/dist/callServer.js.map +1 -0
- package/dist/callTools.d.ts +154 -0
- package/dist/callTools.d.ts.map +1 -0
- package/dist/callTools.js +255 -0
- package/dist/callTools.js.map +1 -0
- package/dist/chat.d.ts +226 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +574 -0
- package/dist/chat.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +86 -0
- package/dist/config.js.map +1 -0
- package/dist/consult.d.ts +147 -0
- package/dist/consult.d.ts.map +1 -0
- package/dist/consult.js +388 -0
- package/dist/consult.js.map +1 -0
- package/dist/delivery.d.ts +81 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +187 -0
- package/dist/delivery.js.map +1 -0
- package/dist/echoGuard.d.ts +56 -0
- package/dist/echoGuard.d.ts.map +1 -0
- package/dist/echoGuard.js +57 -0
- package/dist/echoGuard.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +240 -0
- package/dist/fetch.js.map +1 -0
- package/dist/gate.d.ts +80 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +249 -0
- package/dist/gate.js.map +1 -0
- package/dist/handler.d.ts +283 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +4 -0
- package/dist/handler.js.map +1 -0
- package/dist/hmac.d.ts +32 -0
- package/dist/hmac.d.ts.map +1 -0
- package/dist/hmac.js +108 -0
- package/dist/hmac.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/lane.d.ts +92 -0
- package/dist/lane.d.ts.map +1 -0
- package/dist/lane.js +269 -0
- package/dist/lane.js.map +1 -0
- package/dist/lipsync.d.ts +136 -0
- package/dist/lipsync.d.ts.map +1 -0
- package/dist/lipsync.js +310 -0
- package/dist/lipsync.js.map +1 -0
- package/dist/log.d.ts +18 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +20 -0
- package/dist/log.js.map +1 -0
- package/dist/media.d.ts +43 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +190 -0
- package/dist/media.js.map +1 -0
- package/dist/minutes.d.ts +398 -0
- package/dist/minutes.d.ts.map +1 -0
- package/dist/minutes.js +805 -0
- package/dist/minutes.js.map +1 -0
- package/dist/outbound.d.ts +388 -0
- package/dist/outbound.d.ts.map +1 -0
- package/dist/outbound.js +933 -0
- package/dist/outbound.js.map +1 -0
- package/dist/plugins/cartesia/agent.d.ts +58 -0
- package/dist/plugins/cartesia/agent.d.ts.map +1 -0
- package/dist/plugins/cartesia/agent.js +254 -0
- package/dist/plugins/cartesia/agent.js.map +1 -0
- package/dist/plugins/cartesia/cli.d.ts +3 -0
- package/dist/plugins/cartesia/cli.d.ts.map +1 -0
- package/dist/plugins/cartesia/cli.js +10 -0
- package/dist/plugins/cartesia/cli.js.map +1 -0
- package/dist/plugins/cartesia/config.d.ts +30 -0
- package/dist/plugins/cartesia/config.d.ts.map +1 -0
- package/dist/plugins/cartesia/config.js +29 -0
- package/dist/plugins/cartesia/config.js.map +1 -0
- package/dist/plugins/cartesia/handler.d.ts +40 -0
- package/dist/plugins/cartesia/handler.d.ts.map +1 -0
- package/dist/plugins/cartesia/handler.js +142 -0
- package/dist/plugins/cartesia/handler.js.map +1 -0
- package/dist/plugins/cartesia/index.d.ts +6 -0
- package/dist/plugins/cartesia/index.d.ts.map +1 -0
- package/dist/plugins/cartesia/index.js +54 -0
- package/dist/plugins/cartesia/index.js.map +1 -0
- package/dist/plugins/deepgram/agent.d.ts +60 -0
- package/dist/plugins/deepgram/agent.d.ts.map +1 -0
- package/dist/plugins/deepgram/agent.js +302 -0
- package/dist/plugins/deepgram/agent.js.map +1 -0
- package/dist/plugins/deepgram/cli.d.ts +3 -0
- package/dist/plugins/deepgram/cli.d.ts.map +1 -0
- package/dist/plugins/deepgram/cli.js +10 -0
- package/dist/plugins/deepgram/cli.js.map +1 -0
- package/dist/plugins/deepgram/config.d.ts +39 -0
- package/dist/plugins/deepgram/config.d.ts.map +1 -0
- package/dist/plugins/deepgram/config.js +66 -0
- package/dist/plugins/deepgram/config.js.map +1 -0
- package/dist/plugins/deepgram/handler.d.ts +87 -0
- package/dist/plugins/deepgram/handler.d.ts.map +1 -0
- package/dist/plugins/deepgram/handler.js +268 -0
- package/dist/plugins/deepgram/handler.js.map +1 -0
- package/dist/plugins/deepgram/index.d.ts +11 -0
- package/dist/plugins/deepgram/index.d.ts.map +1 -0
- package/dist/plugins/deepgram/index.js +76 -0
- package/dist/plugins/deepgram/index.js.map +1 -0
- package/dist/plugins/echo/cli.d.ts +3 -0
- package/dist/plugins/echo/cli.d.ts.map +1 -0
- package/dist/plugins/echo/cli.js +10 -0
- package/dist/plugins/echo/cli.js.map +1 -0
- package/dist/plugins/echo/index.d.ts +37 -0
- package/dist/plugins/echo/index.d.ts.map +1 -0
- package/dist/plugins/echo/index.js +61 -0
- package/dist/plugins/echo/index.js.map +1 -0
- package/dist/plugins/elevenlabs/agent.d.ts +72 -0
- package/dist/plugins/elevenlabs/agent.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/agent.js +305 -0
- package/dist/plugins/elevenlabs/agent.js.map +1 -0
- package/dist/plugins/elevenlabs/cli.d.ts +3 -0
- package/dist/plugins/elevenlabs/cli.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/cli.js +10 -0
- package/dist/plugins/elevenlabs/cli.js.map +1 -0
- package/dist/plugins/elevenlabs/config.d.ts +27 -0
- package/dist/plugins/elevenlabs/config.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/config.js +42 -0
- package/dist/plugins/elevenlabs/config.js.map +1 -0
- package/dist/plugins/elevenlabs/handler.d.ts +67 -0
- package/dist/plugins/elevenlabs/handler.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/handler.js +419 -0
- package/dist/plugins/elevenlabs/handler.js.map +1 -0
- package/dist/plugins/elevenlabs/index.d.ts +11 -0
- package/dist/plugins/elevenlabs/index.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/index.js +72 -0
- package/dist/plugins/elevenlabs/index.js.map +1 -0
- package/dist/plugins/livekit/cli.d.ts +3 -0
- package/dist/plugins/livekit/cli.d.ts.map +1 -0
- package/dist/plugins/livekit/cli.js +10 -0
- package/dist/plugins/livekit/cli.js.map +1 -0
- package/dist/plugins/livekit/config.d.ts +47 -0
- package/dist/plugins/livekit/config.d.ts.map +1 -0
- package/dist/plugins/livekit/config.js +46 -0
- package/dist/plugins/livekit/config.js.map +1 -0
- package/dist/plugins/livekit/handler.d.ts +59 -0
- package/dist/plugins/livekit/handler.d.ts.map +1 -0
- package/dist/plugins/livekit/handler.js +211 -0
- package/dist/plugins/livekit/handler.js.map +1 -0
- package/dist/plugins/livekit/index.d.ts +6 -0
- package/dist/plugins/livekit/index.d.ts.map +1 -0
- package/dist/plugins/livekit/index.js +68 -0
- package/dist/plugins/livekit/index.js.map +1 -0
- package/dist/plugins/livekit/room.d.ts +117 -0
- package/dist/plugins/livekit/room.d.ts.map +1 -0
- package/dist/plugins/livekit/room.js +449 -0
- package/dist/plugins/livekit/room.js.map +1 -0
- package/dist/plugins/openai/agent.d.ts +74 -0
- package/dist/plugins/openai/agent.d.ts.map +1 -0
- package/dist/plugins/openai/agent.js +264 -0
- package/dist/plugins/openai/agent.js.map +1 -0
- package/dist/plugins/openai/cli.d.ts +3 -0
- package/dist/plugins/openai/cli.d.ts.map +1 -0
- package/dist/plugins/openai/cli.js +10 -0
- package/dist/plugins/openai/cli.js.map +1 -0
- package/dist/plugins/openai/config.d.ts +29 -0
- package/dist/plugins/openai/config.d.ts.map +1 -0
- package/dist/plugins/openai/config.js +37 -0
- package/dist/plugins/openai/config.js.map +1 -0
- package/dist/plugins/openai/handler.d.ts +65 -0
- package/dist/plugins/openai/handler.d.ts.map +1 -0
- package/dist/plugins/openai/handler.js +297 -0
- package/dist/plugins/openai/handler.js.map +1 -0
- package/dist/plugins/openai/index.d.ts +6 -0
- package/dist/plugins/openai/index.d.ts.map +1 -0
- package/dist/plugins/openai/index.js +72 -0
- package/dist/plugins/openai/index.js.map +1 -0
- package/dist/plugins/openclaw/allowlist.d.ts +9 -0
- package/dist/plugins/openclaw/allowlist.d.ts.map +1 -0
- package/dist/plugins/openclaw/allowlist.js +11 -0
- package/dist/plugins/openclaw/allowlist.js.map +1 -0
- package/dist/plugins/openclaw/config.d.ts +50 -0
- package/dist/plugins/openclaw/config.d.ts.map +1 -0
- package/dist/plugins/openclaw/config.js +53 -0
- package/dist/plugins/openclaw/config.js.map +1 -0
- package/dist/plugins/openclaw/handler.d.ts +90 -0
- package/dist/plugins/openclaw/handler.d.ts.map +1 -0
- package/dist/plugins/openclaw/handler.js +146 -0
- package/dist/plugins/openclaw/handler.js.map +1 -0
- package/dist/plugins/openclaw/index.d.ts +55 -0
- package/dist/plugins/openclaw/index.d.ts.map +1 -0
- package/dist/plugins/openclaw/index.js +89 -0
- package/dist/plugins/openclaw/index.js.map +1 -0
- package/dist/plugins/openclaw/openclaw.plugin.json +74 -0
- package/dist/plugins/openclaw/package.json +14 -0
- package/dist/plugins/openclaw/realtime.d.ts +82 -0
- package/dist/plugins/openclaw/realtime.d.ts.map +1 -0
- package/dist/plugins/openclaw/realtime.js +238 -0
- package/dist/plugins/openclaw/realtime.js.map +1 -0
- package/dist/plugins/openclaw/runtime.d.ts +19 -0
- package/dist/plugins/openclaw/runtime.d.ts.map +1 -0
- package/dist/plugins/openclaw/runtime.js +160 -0
- package/dist/plugins/openclaw/runtime.js.map +1 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +95 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +44 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +77 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocolRuntime.d.ts +19 -0
- package/dist/protocolRuntime.d.ts.map +1 -0
- package/dist/protocolRuntime.js +78 -0
- package/dist/protocolRuntime.js.map +1 -0
- package/dist/smoke.d.ts +65 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +293 -0
- package/dist/smoke.js.map +1 -0
- package/dist/startup.d.ts +74 -0
- package/dist/startup.d.ts.map +1 -0
- package/dist/startup.js +112 -0
- package/dist/startup.js.map +1 -0
- package/dist/tile.d.ts +101 -0
- package/dist/tile.d.ts.map +1 -0
- package/dist/tile.js +195 -0
- package/dist/tile.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/vision.d.ts +160 -0
- package/dist/vision.d.ts.map +1 -0
- package/dist/vision.js +301 -0
- package/dist/vision.js.map +1 -0
- package/dist/visionTools.d.ts +237 -0
- package/dist/visionTools.d.ts.map +1 -0
- package/dist/visionTools.js +578 -0
- package/dist/visionTools.js.map +1 -0
- package/dist/voice.d.ts +143 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +403 -0
- package/dist/voice.js.map +1 -0
- package/package.json +129 -0
package/dist/outbound.js
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
// Copyright (c) 2026 Komaa DigiTech
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Calling somebody, instead of waiting for them to call you.
|
|
5
|
+
*
|
|
6
|
+
* Every other lane in this SDK starts with a caller dialling your agent. This
|
|
7
|
+
* one runs the other way: your agent asks StandIn to ring a Microsoft Teams
|
|
8
|
+
* user, and speaks when they answer.
|
|
9
|
+
*
|
|
10
|
+
* That inversion is what makes it worth its own module, because the leg that
|
|
11
|
+
* answers is **a different call**. You ask for the call in one place, and
|
|
12
|
+
* minutes later StandIn dials your worker with `direction: "outbound"` and a
|
|
13
|
+
* fresh `callId`. The thing you wanted said has to survive the gap, so
|
|
14
|
+
* {@link PendingMessages} parks it on disk and the handler pops it when the leg
|
|
15
|
+
* arrives. Park it in memory and a restart between the two loses it silently,
|
|
16
|
+
* with the caller's phone still ringing.
|
|
17
|
+
*
|
|
18
|
+
* **Read the policy before you skip it.** Inbound, the caller chose to dial you.
|
|
19
|
+
* Outbound, a model decided to ring somebody, and that model is steered by
|
|
20
|
+
* whoever is talking to it. An agent with an outbound tool and no allowlist is
|
|
21
|
+
* an agent that can be talked into cold-calling your directory, which is why the
|
|
22
|
+
* allowlist here is separate from and stricter than any inbound one.
|
|
23
|
+
*
|
|
24
|
+
* Identical in shape to the Python SDK's `standin.outbound`.
|
|
25
|
+
*/
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { StandInError } from "./errors.js";
|
|
31
|
+
import { SIGNATURE_V2_HEADER, TIMESTAMP_HEADER, nowMs, signRequest, } from "./hmac.js";
|
|
32
|
+
import { logger } from "./log.js";
|
|
33
|
+
/**
|
|
34
|
+
* The control route StandIn exposes for placing a call. v2 signs the path, so
|
|
35
|
+
* this string is part of the signature: a route that is merely plausible
|
|
36
|
+
* produces a valid-looking request that is refused.
|
|
37
|
+
*/
|
|
38
|
+
const PLACE_PATH = "/api/calls";
|
|
39
|
+
/** Where the worker listens for control requests, when nothing says otherwise. */
|
|
40
|
+
const DEFAULT_WORKER_URL = "http://127.0.0.1:9440";
|
|
41
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
42
|
+
/**
|
|
43
|
+
* Placing or cancelling an outbound call failed.
|
|
44
|
+
*
|
|
45
|
+
* Carries the reason in its message, because the thing that usually wants it is
|
|
46
|
+
* a tool result being read back to whoever asked for the call.
|
|
47
|
+
*/
|
|
48
|
+
export class OutboundError extends StandInError {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "OutboundError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Where durable outbound state lives.
|
|
56
|
+
*
|
|
57
|
+
* `STANDIN_STATE_DIR` when set, otherwise `~/.standin/state`. Deliberately NOT a
|
|
58
|
+
* temp directory: a temp directory passes every test and loses every parked
|
|
59
|
+
* message on the next reboot, which is invisible until a caller answers a call
|
|
60
|
+
* that then says nothing.
|
|
61
|
+
*/
|
|
62
|
+
export function stateDir() {
|
|
63
|
+
const configured = (process.env.STANDIN_STATE_DIR ?? "").trim();
|
|
64
|
+
const path = configured || join(homedir(), ".standin", "state");
|
|
65
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Asks StandIn to ring a Microsoft Teams user.
|
|
70
|
+
*
|
|
71
|
+
* One instance per worker is enough; it holds no per-call state.
|
|
72
|
+
*
|
|
73
|
+
* Signed with v2 only, and that is deliberate. v2 binds the method, the path and
|
|
74
|
+
* a hash of the body, which is what puts `tenantId` under the signature. v1
|
|
75
|
+
* signs a single value, so a v1-signed request leaves the organisation being
|
|
76
|
+
* rung unsigned, and sending both would let a downgrade pick the weaker one.
|
|
77
|
+
*/
|
|
78
|
+
export class OutboundCaller {
|
|
79
|
+
#secret;
|
|
80
|
+
#workerUrl;
|
|
81
|
+
#timeoutMs;
|
|
82
|
+
constructor(options = {}) {
|
|
83
|
+
this.#secret = options.secret ?? process.env.STANDIN_SECRET ?? "";
|
|
84
|
+
if (!this.#secret) {
|
|
85
|
+
throw new OutboundError("STANDIN_SECRET is required to place an outbound call");
|
|
86
|
+
}
|
|
87
|
+
const raw = options.workerUrl || process.env.STANDIN_WORKER_URL || DEFAULT_WORKER_URL;
|
|
88
|
+
this.#workerUrl = checkWorkerUrl(raw);
|
|
89
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
90
|
+
}
|
|
91
|
+
/** The control endpoint this caller talks to. */
|
|
92
|
+
get workerUrl() {
|
|
93
|
+
return this.#workerUrl;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Ring a Microsoft Teams user. Returns the id the answering leg carries.
|
|
97
|
+
*
|
|
98
|
+
* Throws {@link OutboundError} for anything that is not an accepted request,
|
|
99
|
+
* with the reason in the message, so a tool can read it back to whoever asked
|
|
100
|
+
* for the call.
|
|
101
|
+
*/
|
|
102
|
+
async placeCall(opts) {
|
|
103
|
+
const target = opts.userObjectId.trim();
|
|
104
|
+
if (!target)
|
|
105
|
+
throw new OutboundError("an outbound call needs the person's directory id");
|
|
106
|
+
const body = JSON.stringify({
|
|
107
|
+
userObjectId: target,
|
|
108
|
+
tenantId: opts.tenantId.trim(),
|
|
109
|
+
});
|
|
110
|
+
const payload = await this.#send("POST", PLACE_PATH, body);
|
|
111
|
+
const callId = payload.callId ?? payload.call_id;
|
|
112
|
+
if (typeof callId !== "string" || !callId) {
|
|
113
|
+
throw new OutboundError("StandIn accepted the call but returned no callId");
|
|
114
|
+
}
|
|
115
|
+
const scenario = payload.scenarioId ?? payload.scenario_id ?? "";
|
|
116
|
+
return { callId, scenarioId: String(scenario) };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Stop a call that is still ringing. Never throws.
|
|
120
|
+
*
|
|
121
|
+
* Best-effort on purpose: this runs on the no-answer path, where the caller
|
|
122
|
+
* has already stopped waiting and an exception would only turn a tidy-up into
|
|
123
|
+
* a failure. A call that has already gone counts as cancelled.
|
|
124
|
+
*/
|
|
125
|
+
async cancelCall(callId) {
|
|
126
|
+
if (!callId)
|
|
127
|
+
return false;
|
|
128
|
+
try {
|
|
129
|
+
await this.#send("DELETE", `${PLACE_PATH}/${callId}`, "");
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
logger.info(`standin: could not cancel outbound call ${callId}: ${String(err)}`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async #send(method, path, body) {
|
|
138
|
+
const timestamp = String(nowMs());
|
|
139
|
+
const headers = {
|
|
140
|
+
[TIMESTAMP_HEADER]: timestamp,
|
|
141
|
+
[SIGNATURE_V2_HEADER]: signRequest(this.#secret, timestamp, method, path, body),
|
|
142
|
+
"content-type": "application/json",
|
|
143
|
+
};
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const deadline = setTimeout(() => controller.abort(), this.#timeoutMs);
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetch(`${this.#workerUrl}${path}`, {
|
|
148
|
+
method,
|
|
149
|
+
headers,
|
|
150
|
+
body: body || undefined,
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
});
|
|
153
|
+
const text = await response.text();
|
|
154
|
+
if (response.status === 401) {
|
|
155
|
+
// The one failure worth naming precisely: v2 signs the path, so a wrong
|
|
156
|
+
// path reads exactly like a wrong secret and has cost people hours.
|
|
157
|
+
throw new OutboundError(`StandIn rejected the signature on ${method} ${path}. ` +
|
|
158
|
+
"Check STANDIN_SECRET, and that the clock is not skewed.");
|
|
159
|
+
}
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new OutboundError(`${method} ${path} returned HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
162
|
+
}
|
|
163
|
+
if (!text.trim())
|
|
164
|
+
return {};
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(text);
|
|
167
|
+
return typeof parsed === "object" && parsed !== null
|
|
168
|
+
? parsed
|
|
169
|
+
: {};
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
if (err instanceof OutboundError)
|
|
177
|
+
throw err;
|
|
178
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
179
|
+
throw new OutboundError(`${method} ${path} timed out after ${this.#timeoutMs}ms`);
|
|
180
|
+
}
|
|
181
|
+
throw new OutboundError(`could not reach the StandIn worker at ${this.#workerUrl}: ${String(err)}`);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
clearTimeout(deadline);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function checkWorkerUrl(raw) {
|
|
189
|
+
let url;
|
|
190
|
+
try {
|
|
191
|
+
url = new URL(raw);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
throw new OutboundError(`STANDIN_WORKER_URL is not a valid URL: ${raw}`);
|
|
195
|
+
}
|
|
196
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
197
|
+
throw new OutboundError(`STANDIN_WORKER_URL must be http or https, got ${raw}`);
|
|
198
|
+
}
|
|
199
|
+
if (!url.hostname)
|
|
200
|
+
throw new OutboundError(`STANDIN_WORKER_URL has no host: ${raw}`);
|
|
201
|
+
if (url.username || url.password) {
|
|
202
|
+
throw new OutboundError("STANDIN_WORKER_URL must not carry credentials");
|
|
203
|
+
}
|
|
204
|
+
return raw.replace(/\/+$/, "");
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* What the agent wanted said, parked until the call is answered.
|
|
208
|
+
*
|
|
209
|
+
* On disk, because the answering leg is a different call and may be a different
|
|
210
|
+
* process. A restart between asking for a call and it being answered is
|
|
211
|
+
* ordinary, and an in-memory store loses the message silently: the callee picks
|
|
212
|
+
* up and hears nothing.
|
|
213
|
+
*
|
|
214
|
+
* Popping is atomic. Two workers racing the same answered call is a normal
|
|
215
|
+
* consequence of running more than one, and only one of them may speak.
|
|
216
|
+
*/
|
|
217
|
+
export class PendingMessages {
|
|
218
|
+
#dir;
|
|
219
|
+
constructor(directory) {
|
|
220
|
+
this.#dir = directory ?? join(stateDir(), "outbound");
|
|
221
|
+
mkdirSync(this.#dir, { recursive: true, mode: 0o700 });
|
|
222
|
+
}
|
|
223
|
+
#path(callId) {
|
|
224
|
+
return join(this.#dir, `${safeName(callId)}.json`);
|
|
225
|
+
}
|
|
226
|
+
/** Remember what to say on this call. Overwrites an earlier one. */
|
|
227
|
+
park(message) {
|
|
228
|
+
const record = { ...message, createdMs: message.createdMs || nowMs() };
|
|
229
|
+
const target = this.#path(message.callId);
|
|
230
|
+
// Written beside and renamed, so a reader never sees half a record.
|
|
231
|
+
const temp = `${target}.${randomUUID().replace(/-/g, "")}.tmp`;
|
|
232
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
233
|
+
writeFileSync(temp, JSON.stringify(record), { mode: 0o600 });
|
|
234
|
+
renameSync(temp, target);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Take the message for this call, once.
|
|
238
|
+
*
|
|
239
|
+
* The rename is the lock: exactly one caller can rename a given file, so two
|
|
240
|
+
* workers answering the same leg cannot both speak.
|
|
241
|
+
*/
|
|
242
|
+
pop(callId) {
|
|
243
|
+
const target = this.#path(callId);
|
|
244
|
+
const claimed = `${target}.${randomUUID().replace(/-/g, "")}.claimed`;
|
|
245
|
+
try {
|
|
246
|
+
renameSync(target, claimed);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return JSON.parse(readFileSync(claimed, "utf8"));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
try {
|
|
259
|
+
unlinkSync(claimed);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// already gone
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Take every message nobody answered in time.
|
|
268
|
+
*
|
|
269
|
+
* Used by the no-answer sweep: a parked message older than the ringing window
|
|
270
|
+
* means the callee never picked up, and what the agent wanted said should go
|
|
271
|
+
* to the chat it came from rather than evaporate.
|
|
272
|
+
*/
|
|
273
|
+
/**
|
|
274
|
+
* Every parked record, read without claiming any of them.
|
|
275
|
+
*
|
|
276
|
+
* For asking "am I already calling this person?" before ringing them again. A
|
|
277
|
+
* reserved record is deliberately absent: that call is already connected, so
|
|
278
|
+
* it is not one somebody is still waiting on.
|
|
279
|
+
*/
|
|
280
|
+
waiting() {
|
|
281
|
+
const out = [];
|
|
282
|
+
for (const name of readdirSync(this.#dir).sort()) {
|
|
283
|
+
if (!name.endsWith(".json"))
|
|
284
|
+
continue;
|
|
285
|
+
try {
|
|
286
|
+
out.push(JSON.parse(readFileSync(join(this.#dir, name), "utf8")));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// Unreadable is not this method's problem.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Take this record for a leg that is ringing, without deleting it.
|
|
296
|
+
*
|
|
297
|
+
* A rename, exactly like {@link pop}, so only one worker can hold it. The
|
|
298
|
+
* difference is what happens next: a reserved record can be given BACK. A leg
|
|
299
|
+
* that never gets answered has to leave the message where the sweep will find
|
|
300
|
+
* it, or the answer is lost because nobody picked up.
|
|
301
|
+
*
|
|
302
|
+
* While reserved the record is invisible to {@link claimStale}, which reads
|
|
303
|
+
* `.json`: the sweep must not post "I could not reach you" to a call that is
|
|
304
|
+
* still ringing.
|
|
305
|
+
*/
|
|
306
|
+
reserve(callId) {
|
|
307
|
+
const target = this.#path(callId);
|
|
308
|
+
const held = target.replace(/\.json$/, ".answering");
|
|
309
|
+
try {
|
|
310
|
+
renameSync(target, held);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
return JSON.parse(readFileSync(held, "utf8"));
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
rmSync(held, { force: true });
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** It was said. Retire the record. */
|
|
324
|
+
commit(callId) {
|
|
325
|
+
rmSync(this.#path(callId).replace(/\.json$/, ".answering"), {
|
|
326
|
+
force: true,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/** It was not said. Put it back for the sweep to deliver to chat. */
|
|
330
|
+
release(callId) {
|
|
331
|
+
const held = this.#path(callId).replace(/\.json$/, ".answering");
|
|
332
|
+
try {
|
|
333
|
+
if (existsSync(held))
|
|
334
|
+
renameSync(held, this.#path(callId));
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// The recovery below is the backstop.
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Give back reservations whose worker died holding them.
|
|
342
|
+
*
|
|
343
|
+
* Judged by the reservation's own age. Without this a process that dies
|
|
344
|
+
* mid-ring leaves the message reserved for ever, and the person who was
|
|
345
|
+
* promised an answer never gets one.
|
|
346
|
+
*/
|
|
347
|
+
recoverReservations(olderThanMs) {
|
|
348
|
+
const cutoff = Date.now() - olderThanMs;
|
|
349
|
+
let recovered = 0;
|
|
350
|
+
for (const name of readdirSync(this.#dir)) {
|
|
351
|
+
if (!name.endsWith(".answering"))
|
|
352
|
+
continue;
|
|
353
|
+
const path = join(this.#dir, name);
|
|
354
|
+
try {
|
|
355
|
+
if (statSync(path).mtimeMs > cutoff)
|
|
356
|
+
continue;
|
|
357
|
+
renameSync(path, path.replace(/\.answering$/, ".json"));
|
|
358
|
+
recovered += 1;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Gone, or taken by somebody else.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return recovered;
|
|
365
|
+
}
|
|
366
|
+
claimStale(olderThanMs) {
|
|
367
|
+
const cutoff = nowMs() - olderThanMs;
|
|
368
|
+
const taken = [];
|
|
369
|
+
for (const name of readdirSync(this.#dir).sort()) {
|
|
370
|
+
if (!name.endsWith(".json"))
|
|
371
|
+
continue;
|
|
372
|
+
let record;
|
|
373
|
+
try {
|
|
374
|
+
record = JSON.parse(readFileSync(join(this.#dir, name), "utf8"));
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if ((record.createdMs ?? 0) > cutoff)
|
|
380
|
+
continue;
|
|
381
|
+
const popped = this.pop(record.callId || name.replace(/\.json$/, ""));
|
|
382
|
+
if (popped)
|
|
383
|
+
taken.push(popped);
|
|
384
|
+
}
|
|
385
|
+
return taken;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Take back messages a crashed sweep claimed and never delivered.
|
|
389
|
+
*
|
|
390
|
+
* Judged by how long ago the CLAIM was made, which is why claiming writes a
|
|
391
|
+
* fresh file rather than renaming in place. Give this a longer window than
|
|
392
|
+
* {@link claimStale}, so an in-flight delivery is never taken from under a
|
|
393
|
+
* worker that is still working on it.
|
|
394
|
+
*/
|
|
395
|
+
recoverOrphans(olderThanMs) {
|
|
396
|
+
const cutoff = Date.now() - olderThanMs;
|
|
397
|
+
const taken = [];
|
|
398
|
+
for (const name of readdirSync(this.#dir).sort()) {
|
|
399
|
+
if (!name.endsWith(".claimed"))
|
|
400
|
+
continue;
|
|
401
|
+
const path = join(this.#dir, name);
|
|
402
|
+
try {
|
|
403
|
+
if (statSync(path).mtimeMs > cutoff)
|
|
404
|
+
continue;
|
|
405
|
+
taken.push(JSON.parse(readFileSync(path, "utf8")));
|
|
406
|
+
unlinkSync(path);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return taken;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** A filename that cannot escape the directory it belongs in. */
|
|
416
|
+
function safeName(callId) {
|
|
417
|
+
const cleaned = [...callId]
|
|
418
|
+
.map((c) => (/[A-Za-z0-9\-_.]/.test(c) ? c : "-"))
|
|
419
|
+
.join("")
|
|
420
|
+
.slice(0, 120);
|
|
421
|
+
return cleaned || "unnamed";
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Who this agent may ring, and how often.
|
|
425
|
+
*
|
|
426
|
+
* Separate from any inbound allowlist, and stricter, because the two answer
|
|
427
|
+
* different questions. Inbound asks "may this person talk to the agent?" and the
|
|
428
|
+
* person chose to dial. Outbound asks "may the agent ring this person?" and the
|
|
429
|
+
* agent was talked into it by whoever is on the call.
|
|
430
|
+
*
|
|
431
|
+
* So allowing every inbound caller allows no outbound target.
|
|
432
|
+
*/
|
|
433
|
+
export class OutboundPolicy {
|
|
434
|
+
allowed;
|
|
435
|
+
maxPerHour;
|
|
436
|
+
#placed = [];
|
|
437
|
+
constructor(options = {}) {
|
|
438
|
+
this.allowed = new Set(options.allowed ?? []);
|
|
439
|
+
this.maxPerHour = Math.max(0, options.maxPerHour ?? 6);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Read `STANDIN_OUTBOUND_ALLOW` and `STANDIN_OUTBOUND_MAX_PER_HOUR`.
|
|
443
|
+
*
|
|
444
|
+
* Unset means outbound calling is off, which is the right default for a
|
|
445
|
+
* capability that can ring a stranger.
|
|
446
|
+
*/
|
|
447
|
+
static fromEnv() {
|
|
448
|
+
const allowed = (process.env.STANDIN_OUTBOUND_ALLOW ?? "")
|
|
449
|
+
.split(",")
|
|
450
|
+
.map((part) => part.trim())
|
|
451
|
+
.filter(Boolean);
|
|
452
|
+
const raw = Number.parseInt(process.env.STANDIN_OUTBOUND_MAX_PER_HOUR ?? "6", 10);
|
|
453
|
+
return new OutboundPolicy({
|
|
454
|
+
allowed,
|
|
455
|
+
maxPerHour: Number.isFinite(raw) ? raw : 6,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/** Throws {@link OutboundError} unless this call may be placed now. */
|
|
459
|
+
check(userObjectId) {
|
|
460
|
+
// Folded on both sides below. A directory id is not case-sensitive, and a
|
|
461
|
+
// case mismatch would read as "not allowed" with nothing to say why.
|
|
462
|
+
const target = userObjectId.trim().toLowerCase();
|
|
463
|
+
const allowed = new Set([...this.allowed].map((entry) => entry.toLowerCase()));
|
|
464
|
+
if (!target)
|
|
465
|
+
throw new OutboundError("an outbound call needs the person's directory id");
|
|
466
|
+
if (this.allowed.size === 0) {
|
|
467
|
+
throw new OutboundError("outbound calling is off: set STANDIN_OUTBOUND_ALLOW to the directory ids " +
|
|
468
|
+
"this agent may ring");
|
|
469
|
+
}
|
|
470
|
+
if (!allowed.has(target)) {
|
|
471
|
+
throw new OutboundError("that person is not on this agent's outbound allowlist");
|
|
472
|
+
}
|
|
473
|
+
if (this.maxPerHour) {
|
|
474
|
+
const cutoff = Date.now() - 3_600_000;
|
|
475
|
+
this.#placed = this.#placed.filter((t) => t > cutoff);
|
|
476
|
+
if (this.#placed.length >= this.maxPerHour) {
|
|
477
|
+
throw new OutboundError(`this agent has already placed ${this.maxPerHour} calls in the last hour`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/** Count a placed call against the hourly cap. */
|
|
482
|
+
record() {
|
|
483
|
+
this.#placed.push(Date.now());
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// ------------------------------------------------------------------ the lane
|
|
487
|
+
/** How long a call may ring before nobody is going to answer it. */
|
|
488
|
+
export const DEFAULT_ANSWER_TIMEOUT_MS = 120_000;
|
|
489
|
+
/** How often the sweep looks for calls nobody answered. */
|
|
490
|
+
export const DEFAULT_SWEEP_INTERVAL_MS = 30_000;
|
|
491
|
+
/** After this, an undelivered answer is too old to be worth sending. */
|
|
492
|
+
export const DEFAULT_PENDING_TTL_MS = 3_600_000;
|
|
493
|
+
/** A reservation older than this belonged to a worker that died holding it. */
|
|
494
|
+
export const RESERVATION_STALE_MS = 600_000;
|
|
495
|
+
/**
|
|
496
|
+
* The answering leg can attach before the message has been parked, so attach
|
|
497
|
+
* waits a little rather than deciding there is nothing to say.
|
|
498
|
+
*/
|
|
499
|
+
export const PARK_GRACE_MS = 5_000;
|
|
500
|
+
export const PARK_POLL_MS = 250;
|
|
501
|
+
/** The same race on the outcome path. */
|
|
502
|
+
export const OUTCOME_GRACE_MS = 5_000;
|
|
503
|
+
/** How long after somebody's chat message the agent may ring them back. */
|
|
504
|
+
export const CHAT_CALLBACK_WINDOW_MS = 600_000;
|
|
505
|
+
/** What a model may park. It is read out loud on answer. */
|
|
506
|
+
export const MAX_PENDING_TEXT_CHARS = 4000;
|
|
507
|
+
/** How many times a failing chat delivery is retried before it is dropped. */
|
|
508
|
+
export const MAX_DELIVERY_ATTEMPTS = 5;
|
|
509
|
+
/** Outcomes that mean nobody took the call. */
|
|
510
|
+
export const UNANSWERED_OUTCOMES = new Set([
|
|
511
|
+
"no-answer",
|
|
512
|
+
"declined",
|
|
513
|
+
"busy",
|
|
514
|
+
"failed",
|
|
515
|
+
]);
|
|
516
|
+
/**
|
|
517
|
+
* What to say in chat for each of them. Written for the person who missed the
|
|
518
|
+
* call, not for an operator reading a log.
|
|
519
|
+
*/
|
|
520
|
+
export const OUTCOME_WORDING = {
|
|
521
|
+
"no-answer": "I tried to call you but couldn't reach you.",
|
|
522
|
+
declined: "You declined my call, no problem.",
|
|
523
|
+
busy: "I tried to call you but the line was busy.",
|
|
524
|
+
failed: "I tried to call you but the call could not be completed.",
|
|
525
|
+
};
|
|
526
|
+
/** Marks the fallback so it reads as a missed call rather than a stray message. */
|
|
527
|
+
export const NO_ANSWER_PREFIX = "\u{1F4DE} ";
|
|
528
|
+
/**
|
|
529
|
+
* Whether a live call has a chat its answer could go to instead.
|
|
530
|
+
*
|
|
531
|
+
* A one-to-one call has no meeting conversation, and the field then carries
|
|
532
|
+
* something that is not one. Posting to it would either fail or reach the wrong
|
|
533
|
+
* place, so a call without a real thread is parked with no fallback.
|
|
534
|
+
*/
|
|
535
|
+
export function callThreadIsPostable(threadId, callId) {
|
|
536
|
+
const thread = (threadId ?? "").trim();
|
|
537
|
+
return thread !== "" && thread.startsWith("19:") && thread !== callId;
|
|
538
|
+
}
|
|
539
|
+
export const CHAT_CALLBACK_TOOL = {
|
|
540
|
+
name: "call_me_with_the_answer",
|
|
541
|
+
description: "Ring the person you are talking to and tell them the answer out loud, instead of " +
|
|
542
|
+
"replying here. Use it when they ask you to call them, or when the answer is easier " +
|
|
543
|
+
"said than written.",
|
|
544
|
+
parameters: {
|
|
545
|
+
message: { type: "string", description: "What to say when they answer." },
|
|
546
|
+
},
|
|
547
|
+
required: ["message"],
|
|
548
|
+
};
|
|
549
|
+
export const CALL_BACK_TOOL = {
|
|
550
|
+
name: "call_me_back",
|
|
551
|
+
description: "Ring this caller again later and say something. Use it when the work will not finish " +
|
|
552
|
+
"while they are on the line and they asked to be called rather than messaged.",
|
|
553
|
+
parameters: {
|
|
554
|
+
message: { type: "string", description: "What to say when they answer." },
|
|
555
|
+
},
|
|
556
|
+
required: ["message"],
|
|
557
|
+
};
|
|
558
|
+
/**
|
|
559
|
+
* One answering leg, holding the message until somebody actually answers.
|
|
560
|
+
*
|
|
561
|
+
* Built by {@link OutboundLane.attach} from `onStart`. The plugin forwards two
|
|
562
|
+
* things and the leg does the rest.
|
|
563
|
+
*/
|
|
564
|
+
export class OutboundLeg {
|
|
565
|
+
#lane;
|
|
566
|
+
#session;
|
|
567
|
+
#speak;
|
|
568
|
+
#answerTimeoutMs;
|
|
569
|
+
#message;
|
|
570
|
+
#spoken = false;
|
|
571
|
+
#closed = false;
|
|
572
|
+
#watchdog;
|
|
573
|
+
constructor(lane, session, message, speak, answerTimeoutMs) {
|
|
574
|
+
this.#lane = lane;
|
|
575
|
+
this.#session = session;
|
|
576
|
+
this.#message = message;
|
|
577
|
+
this.#speak = speak;
|
|
578
|
+
this.#answerTimeoutMs = answerTimeoutMs;
|
|
579
|
+
}
|
|
580
|
+
/** What is waiting to be said, if anything. */
|
|
581
|
+
get message() {
|
|
582
|
+
return this.#message;
|
|
583
|
+
}
|
|
584
|
+
/** @internal Used by the lane when a late-arriving record is found. */
|
|
585
|
+
setMessage(message) {
|
|
586
|
+
this.#message = message;
|
|
587
|
+
}
|
|
588
|
+
/** Start watching. Called by the lane once the record is settled. */
|
|
589
|
+
arm() {
|
|
590
|
+
if (this.#message === undefined || this.#closed)
|
|
591
|
+
return;
|
|
592
|
+
this.#watchdog = setTimeout(() => void this.#giveUp(), this.#answerTimeoutMs);
|
|
593
|
+
this.#watchdog.unref?.();
|
|
594
|
+
// Answered before we even looked: a fast pickup beats the attach.
|
|
595
|
+
if (this.#session.recordingActive)
|
|
596
|
+
void this.#deliver();
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Forward every `onContext`. Recording going active is the answer.
|
|
600
|
+
*
|
|
601
|
+
* There is no "they picked up" message on the wire. Recording turning on is
|
|
602
|
+
* what happens when a Microsoft Teams call is actually connected, so that
|
|
603
|
+
* transition is the signal, and the plugin already receives it.
|
|
604
|
+
*/
|
|
605
|
+
async onContext() {
|
|
606
|
+
if (this.#session.recordingActive)
|
|
607
|
+
await this.#deliver();
|
|
608
|
+
}
|
|
609
|
+
/** Say it now. For a plugin with a better signal than the recording. */
|
|
610
|
+
async answered() {
|
|
611
|
+
await this.#deliver();
|
|
612
|
+
}
|
|
613
|
+
/** The leg is over. Anything unsaid goes back for the sweep. */
|
|
614
|
+
async aclose(reason = "call-ended") {
|
|
615
|
+
if (this.#closed)
|
|
616
|
+
return;
|
|
617
|
+
this.#closed = true;
|
|
618
|
+
clearTimeout(this.#watchdog);
|
|
619
|
+
if (this.#message !== undefined && !this.#spoken) {
|
|
620
|
+
// Released, not dropped: nobody heard it, so it still has to reach them
|
|
621
|
+
// somehow.
|
|
622
|
+
this.#lane.pending.release(this.#message.callId);
|
|
623
|
+
logger.info(`standin: outbound call ${safeName(this.#message.callId)} ended unanswered ` +
|
|
624
|
+
`(${reason}); the answer goes to chat`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async #deliver() {
|
|
628
|
+
if (this.#spoken || this.#closed || this.#message === undefined)
|
|
629
|
+
return;
|
|
630
|
+
this.#spoken = true;
|
|
631
|
+
clearTimeout(this.#watchdog);
|
|
632
|
+
try {
|
|
633
|
+
await this.#speak(this.#message);
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
// Saying it failed, so it was not said. Put it back rather than
|
|
637
|
+
// pretending the person was told.
|
|
638
|
+
this.#spoken = false;
|
|
639
|
+
logger.error(`standin: speaking the parked message failed: ${String(err)}`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
this.#lane.pending.commit(this.#message.callId);
|
|
643
|
+
this.#lane.finalized.add(this.#message.callId);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* End a leg that rings for ever.
|
|
647
|
+
*
|
|
648
|
+
* The idle watchdog cannot do this: a ringing leg carries no caller audio by
|
|
649
|
+
* definition, so to that watchdog every outbound call looks dead.
|
|
650
|
+
*/
|
|
651
|
+
async #giveUp() {
|
|
652
|
+
if (this.#spoken || this.#closed)
|
|
653
|
+
return;
|
|
654
|
+
logger.info(`standin: nobody answered outbound call ${safeName(this.#session.callId)} ` +
|
|
655
|
+
`within ${this.#answerTimeoutMs}ms`);
|
|
656
|
+
await this.#session.end("outbound-no-answer");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Placing a call, saying the thing, and what to do when nobody answers.
|
|
661
|
+
*
|
|
662
|
+
* The three are one capability, and splitting them is how the answer gets lost.
|
|
663
|
+
* A call is placed because somebody is owed something; if they do not pick up,
|
|
664
|
+
* they are still owed it.
|
|
665
|
+
*
|
|
666
|
+
* Everything durable is on disk, so a restart between the ring and the answer
|
|
667
|
+
* loses nothing.
|
|
668
|
+
*/
|
|
669
|
+
export class OutboundLane {
|
|
670
|
+
#caller;
|
|
671
|
+
#policy;
|
|
672
|
+
pending;
|
|
673
|
+
#chat;
|
|
674
|
+
#tenantId;
|
|
675
|
+
#answerTimeoutMs;
|
|
676
|
+
#sweepIntervalMs;
|
|
677
|
+
#ttlMs;
|
|
678
|
+
#maxInFlight;
|
|
679
|
+
#senders = new Map();
|
|
680
|
+
finalized = new Set();
|
|
681
|
+
#sweeper;
|
|
682
|
+
constructor(options = {}) {
|
|
683
|
+
this.#caller = options.caller;
|
|
684
|
+
this.#policy = options.policy ?? OutboundPolicy.fromEnv();
|
|
685
|
+
this.pending = options.pending ?? new PendingMessages();
|
|
686
|
+
this.#chat = options.chat;
|
|
687
|
+
this.#tenantId = options.tenantId ?? "";
|
|
688
|
+
this.#answerTimeoutMs =
|
|
689
|
+
options.answerTimeoutMs ?? DEFAULT_ANSWER_TIMEOUT_MS;
|
|
690
|
+
this.#sweepIntervalMs =
|
|
691
|
+
options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
692
|
+
this.#ttlMs = options.ttlMs ?? DEFAULT_PENDING_TTL_MS;
|
|
693
|
+
this.#maxInFlight = Math.max(1, options.maxInFlightPerTarget ?? 1);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Ring somebody and park what to say. Throws {@link OutboundError}.
|
|
697
|
+
*
|
|
698
|
+
* Everything that can be refused is refused BEFORE the call is placed, so a
|
|
699
|
+
* refusal never leaves somebody's phone ringing for a message that was never
|
|
700
|
+
* going to be sent.
|
|
701
|
+
*/
|
|
702
|
+
async place(options) {
|
|
703
|
+
const line = (options.text ?? "").trim();
|
|
704
|
+
if (line === "")
|
|
705
|
+
throw new OutboundError("there was nothing to say, so I did not call");
|
|
706
|
+
if (line.length > MAX_PENDING_TEXT_CHARS) {
|
|
707
|
+
throw new OutboundError(`that message is too long to deliver by phone (${line.length} characters)`);
|
|
708
|
+
}
|
|
709
|
+
this.#policy.check(options.userObjectId);
|
|
710
|
+
const target = options.userObjectId.trim().toLowerCase();
|
|
711
|
+
const inFlight = this.pending
|
|
712
|
+
.waiting()
|
|
713
|
+
.filter((m) => m.target === target).length;
|
|
714
|
+
if (inFlight >= this.#maxInFlight) {
|
|
715
|
+
throw new OutboundError("I am already calling that person about something else");
|
|
716
|
+
}
|
|
717
|
+
if (this.#caller === undefined) {
|
|
718
|
+
throw new OutboundError("this worker is not set up to place calls");
|
|
719
|
+
}
|
|
720
|
+
const tenantId = (options.tenantId ?? this.#tenantId).trim();
|
|
721
|
+
const placed = await this.#caller.placeCall({
|
|
722
|
+
userObjectId: options.userObjectId.trim(),
|
|
723
|
+
tenantId,
|
|
724
|
+
});
|
|
725
|
+
this.#policy.record();
|
|
726
|
+
// Only a real conversation. A call that has none is parked with no fallback
|
|
727
|
+
// rather than one that would fail or reach the wrong place.
|
|
728
|
+
const threadId = callThreadIsPostable(options.threadId ?? "", placed.callId)
|
|
729
|
+
? (options.threadId ?? "")
|
|
730
|
+
: "";
|
|
731
|
+
this.pending.park({
|
|
732
|
+
callId: placed.callId,
|
|
733
|
+
text: line,
|
|
734
|
+
threadId,
|
|
735
|
+
requestedBy: options.requestedBy ?? "",
|
|
736
|
+
createdMs: nowMs(),
|
|
737
|
+
metadata: options.metadata ?? {},
|
|
738
|
+
tenantId,
|
|
739
|
+
target,
|
|
740
|
+
});
|
|
741
|
+
// One audit line, and never the text: it is somebody's message.
|
|
742
|
+
logger.info(`standin: placed an outbound call to ${safeName(target)} (call ${safeName(placed.callId)}, ` +
|
|
743
|
+
`chat fallback ${threadId ? "yes" : "no"}, asked by ${safeName(options.requestedBy || "unknown")})`);
|
|
744
|
+
return placed;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Bind an answering leg to whatever was parked for it.
|
|
748
|
+
*
|
|
749
|
+
* Returns undefined on an inbound call, so a plugin can call it
|
|
750
|
+
* unconditionally from `onStart`.
|
|
751
|
+
*/
|
|
752
|
+
attach(session, speak) {
|
|
753
|
+
if (session.start.direction !== "outbound")
|
|
754
|
+
return undefined;
|
|
755
|
+
const held = this.pending.reserve(session.callId);
|
|
756
|
+
const leg = new OutboundLeg(this, session, held, speak, this.#answerTimeoutMs);
|
|
757
|
+
if (held !== undefined) {
|
|
758
|
+
leg.arm();
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
// The leg can be answered before place() has finished parking, so waiting
|
|
762
|
+
// a moment beats deciding there is nothing to say. Not awaited: onStart
|
|
763
|
+
// must not block the frame loop.
|
|
764
|
+
void this.#attachLater(session, leg);
|
|
765
|
+
}
|
|
766
|
+
return leg;
|
|
767
|
+
}
|
|
768
|
+
async #attachLater(session, leg) {
|
|
769
|
+
const deadline = Date.now() + PARK_GRACE_MS;
|
|
770
|
+
while (Date.now() < deadline) {
|
|
771
|
+
await new Promise((resolve) => {
|
|
772
|
+
const timer = setTimeout(resolve, PARK_POLL_MS);
|
|
773
|
+
timer.unref?.();
|
|
774
|
+
});
|
|
775
|
+
const held = this.pending.reserve(session.callId);
|
|
776
|
+
if (held !== undefined) {
|
|
777
|
+
leg.setMessage(held);
|
|
778
|
+
leg.arm();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// Already delivered or already given up on. Not a fresh call.
|
|
783
|
+
if (this.finalized.has(session.callId))
|
|
784
|
+
await session.end("outbound-expired");
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* What StandIn reports when an outbound call ended without an answer.
|
|
788
|
+
*
|
|
789
|
+
* Pass it to `new CallServer({ onCallOutcome })`. An outcome this does not
|
|
790
|
+
* recognise is logged and ignored: an unknown word is not a failure, and
|
|
791
|
+
* treating it as one would post "I could not reach you" to somebody who
|
|
792
|
+
* answered.
|
|
793
|
+
*/
|
|
794
|
+
async onOutcome(callId, outcome) {
|
|
795
|
+
const state = (outcome ?? "").trim().toLowerCase();
|
|
796
|
+
if (state === "answered")
|
|
797
|
+
return true;
|
|
798
|
+
if (!UNANSWERED_OUTCOMES.has(state)) {
|
|
799
|
+
logger.info(`standin: ignoring an outbound outcome this SDK does not know: ${state}`);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
if (this.finalized.has(callId)) {
|
|
803
|
+
// The sweep already told them. Waiting out the grace for a record that is
|
|
804
|
+
// gone delays nothing and helps nobody.
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
const deadline = Date.now() + OUTCOME_GRACE_MS;
|
|
808
|
+
for (;;) {
|
|
809
|
+
const held = this.pending.pop(callId);
|
|
810
|
+
if (held !== undefined)
|
|
811
|
+
return this.#deliverToChat(held, state);
|
|
812
|
+
if (Date.now() >= deadline)
|
|
813
|
+
return false;
|
|
814
|
+
await new Promise((resolve) => {
|
|
815
|
+
const timer = setTimeout(resolve, PARK_POLL_MS);
|
|
816
|
+
timer.unref?.();
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** Deliver what nobody answered. Returns how many went out. */
|
|
821
|
+
async sweep() {
|
|
822
|
+
this.pending.recoverReservations(RESERVATION_STALE_MS);
|
|
823
|
+
let delivered = 0;
|
|
824
|
+
for (const held of this.pending.claimStale(this.#answerTimeoutMs)) {
|
|
825
|
+
if (await this.#deliverToChat(held, "no-answer"))
|
|
826
|
+
delivered += 1;
|
|
827
|
+
if (this.#caller !== undefined && held.callId) {
|
|
828
|
+
// Fire and forget. A ring nobody will answer should stop, but a failure
|
|
829
|
+
// to stop it must not lose the message.
|
|
830
|
+
try {
|
|
831
|
+
await this.#caller.cancelCall(held.callId);
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
// Already gone, or unreachable. Either way the message is delivered.
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return delivered;
|
|
839
|
+
}
|
|
840
|
+
async #deliverToChat(held, outcome) {
|
|
841
|
+
this.finalized.add(held.callId);
|
|
842
|
+
if (!held.threadId || this.#chat === undefined) {
|
|
843
|
+
logger.warn(`standin: outbound call ${safeName(held.callId)} went unanswered and there is ` +
|
|
844
|
+
"no chat to tell them");
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
const body = `${NO_ANSWER_PREFIX}${OUTCOME_WORDING[outcome]} Here's what I had: ${held.text}`;
|
|
848
|
+
let sent = false;
|
|
849
|
+
try {
|
|
850
|
+
sent = await this.#chat.send({
|
|
851
|
+
tenantId: held.tenantId || this.#tenantId,
|
|
852
|
+
conversationId: held.threadId,
|
|
853
|
+
text: body,
|
|
854
|
+
// The timer and the outcome can both fire for one call. The same key
|
|
855
|
+
// means the person is told once.
|
|
856
|
+
idempotencyKey: `standin-noanswer-${held.callId}`,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
catch (err) {
|
|
860
|
+
logger.warn(`standin: posting an unanswered call's message failed: ${String(err)}`);
|
|
861
|
+
}
|
|
862
|
+
if (sent)
|
|
863
|
+
return true;
|
|
864
|
+
this.#requeue(held);
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
/** Put a failed delivery back, or give up loudly. */
|
|
868
|
+
#requeue(held) {
|
|
869
|
+
const attempts = (held.attempts ?? 0) + 1;
|
|
870
|
+
const ageMs = held.createdMs ? nowMs() - held.createdMs : 0;
|
|
871
|
+
if (attempts >= MAX_DELIVERY_ATTEMPTS || ageMs > this.#ttlMs) {
|
|
872
|
+
logger.error(`standin: giving up on delivering outbound call ${safeName(held.callId)} ` +
|
|
873
|
+
`after ${attempts} attempts`);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
this.finalized.delete(held.callId);
|
|
877
|
+
this.pending.park({ ...held, attempts });
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Record who last wrote in this conversation, from the message itself.
|
|
881
|
+
*
|
|
882
|
+
* The ONLY place a callback target comes from. Never from message text, and
|
|
883
|
+
* never from a tool parameter: an agent that can be told who to ring can be
|
|
884
|
+
* told to ring anybody.
|
|
885
|
+
*/
|
|
886
|
+
rememberChatSender(message) {
|
|
887
|
+
if (!message.senderAadId)
|
|
888
|
+
return;
|
|
889
|
+
this.#senders.set(message.conversationId, {
|
|
890
|
+
userObjectId: message.senderAadId,
|
|
891
|
+
tenantId: message.tenantId,
|
|
892
|
+
displayName: message.senderName ?? "",
|
|
893
|
+
atMs: nowMs(),
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Who to ring for this conversation, or a sentence saying why not.
|
|
898
|
+
*
|
|
899
|
+
* A sentence rather than an exception: the caller is a tool result that a
|
|
900
|
+
* model reads out loud.
|
|
901
|
+
*/
|
|
902
|
+
chatCallbackTarget(conversationId) {
|
|
903
|
+
const record = this.#senders.get(conversationId);
|
|
904
|
+
if (record === undefined)
|
|
905
|
+
return "I do not know who to call for this conversation.";
|
|
906
|
+
if (nowMs() - record.atMs > CHAT_CALLBACK_WINDOW_MS) {
|
|
907
|
+
return "That was a while ago. Ask me again and I can call you.";
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
userObjectId: record.userObjectId,
|
|
911
|
+
tenantId: record.tenantId,
|
|
912
|
+
conversationId,
|
|
913
|
+
displayName: record.displayName,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
/** Begin sweeping. Idempotent. */
|
|
917
|
+
start() {
|
|
918
|
+
if (this.#sweeper !== undefined)
|
|
919
|
+
return;
|
|
920
|
+
this.#sweeper = setInterval(() => {
|
|
921
|
+
void this.sweep().catch((err) => {
|
|
922
|
+
logger.error(`standin: the outbound sweep failed: ${String(err)}`);
|
|
923
|
+
});
|
|
924
|
+
}, this.#sweepIntervalMs);
|
|
925
|
+
this.#sweeper.unref?.();
|
|
926
|
+
}
|
|
927
|
+
/** Stop sweeping. Anything parked stays parked. */
|
|
928
|
+
async aclose() {
|
|
929
|
+
clearInterval(this.#sweeper);
|
|
930
|
+
this.#sweeper = undefined;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
//# sourceMappingURL=outbound.js.map
|