salt-agent-sdk 0.1.0 → 0.18.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 +941 -30
- package/dist/actions.d.ts +21 -5
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +673 -54
- package/dist/actions.js.map +1 -1
- package/dist/ask.d.ts +83 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +215 -0
- package/dist/ask.js.map +1 -0
- package/dist/chatPrivacy.d.ts +34 -0
- package/dist/chatPrivacy.d.ts.map +1 -0
- package/dist/chatPrivacy.js +60 -0
- package/dist/chatPrivacy.js.map +1 -0
- package/dist/client.d.ts +2783 -35
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1200 -143
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +16 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -5
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +62 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +121 -0
- package/dist/crypto.js.map +1 -1
- package/dist/delegations.d.ts +25 -8
- package/dist/delegations.d.ts.map +1 -1
- package/dist/delegations.js +67 -11
- package/dist/delegations.js.map +1 -1
- package/dist/device/client.d.ts +161 -0
- package/dist/device/client.d.ts.map +1 -0
- package/dist/device/client.js +601 -0
- package/dist/device/client.js.map +1 -0
- package/dist/device/host.d.ts +222 -0
- package/dist/device/host.d.ts.map +1 -0
- package/dist/device/host.js +891 -0
- package/dist/device/host.js.map +1 -0
- package/dist/device/http.d.ts +12 -0
- package/dist/device/http.d.ts.map +1 -0
- package/dist/device/http.js +124 -0
- package/dist/device/http.js.map +1 -0
- package/dist/device/index.d.ts +5 -0
- package/dist/device/index.d.ts.map +1 -0
- package/dist/device/index.js +27 -0
- package/dist/device/index.js.map +1 -0
- package/dist/device/protocol.d.ts +312 -0
- package/dist/device/protocol.d.ts.map +1 -0
- package/dist/device/protocol.js +435 -0
- package/dist/device/protocol.js.map +1 -0
- package/dist/identities.d.ts +9 -2
- package/dist/identities.d.ts.map +1 -1
- package/dist/identities.js +33 -3
- package/dist/identities.js.map +1 -1
- package/dist/identity.d.ts +155 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +196 -0
- package/dist/identity.js.map +1 -0
- package/dist/identityShare.d.ts +129 -0
- package/dist/identityShare.d.ts.map +1 -0
- package/dist/identityShare.js +308 -0
- package/dist/identityShare.js.map +1 -0
- package/dist/ids.d.ts +11 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +17 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/reconcile.d.ts +25 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +60 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/recovery.d.ts +51 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +59 -0
- package/dist/recovery.js.map +1 -0
- package/dist/register.d.ts +32 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +65 -0
- package/dist/register.js.map +1 -0
- package/dist/scriptJob.d.ts +92 -0
- package/dist/scriptJob.d.ts.map +1 -0
- package/dist/scriptJob.js +270 -0
- package/dist/scriptJob.js.map +1 -0
- package/dist/sessions.d.ts +131 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +240 -0
- package/dist/sessions.js.map +1 -0
- package/dist/socket.d.ts +115 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/socket.js +1038 -0
- package/dist/socket.js.map +1 -0
- package/dist/webhook.d.ts +613 -24
- package/dist/webhook.d.ts.map +1 -1
- package/dist/webhook.js +1384 -83
- package/dist/webhook.js.map +1 -1
- package/dist/work.d.ts +57 -0
- package/dist/work.d.ts.map +1 -0
- package/dist/work.js +213 -0
- package/dist/work.js.map +1 -0
- package/package.json +26 -4
package/dist/webhook.js
CHANGED
|
@@ -50,31 +50,237 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
50
50
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
51
51
|
};
|
|
52
52
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
53
|
-
exports.HANDOFF_BRIEFING_MARKER = void 0;
|
|
53
|
+
exports.DEVICE_LOST_CONTACT = exports.HANDOFF_BRIEFING_MARKER = void 0;
|
|
54
|
+
exports.createDispatcher = createDispatcher;
|
|
54
55
|
exports.createWebhookServer = createWebhookServer;
|
|
56
|
+
const node_crypto_1 = require("node:crypto");
|
|
55
57
|
const express_1 = __importDefault(require("express"));
|
|
58
|
+
const asks = __importStar(require("./ask.js"));
|
|
56
59
|
const pgp = __importStar(require("./crypto"));
|
|
57
60
|
const delegations = __importStar(require("./delegations"));
|
|
61
|
+
const identityShare = __importStar(require("./identityShare.js"));
|
|
62
|
+
const identity_js_1 = require("./identity.js");
|
|
63
|
+
const ids_js_1 = require("./ids.js");
|
|
64
|
+
const reconcile_1 = require("./reconcile");
|
|
65
|
+
const sessions = __importStar(require("./sessions"));
|
|
66
|
+
const work_1 = require("./work");
|
|
58
67
|
const PGP_MESSAGE_RE = /^-----BEGIN PGP MESSAGE/;
|
|
68
|
+
const DELIVERED_BECAUSE_VALUES = new Set(["mention", "reply", "keyword", "all"]);
|
|
69
|
+
/** Narrows salt-api's `message.delivered_because` (a plain string, only
|
|
70
|
+
* ever present on an open-room delivery) to the known union -- anything
|
|
71
|
+
* else (missing, or a value this SDK version doesn't recognise yet)
|
|
72
|
+
* becomes undefined rather than an unchecked cast, so a future kind added
|
|
73
|
+
* server-side degrades to "unknown reason" instead of lying about it. */
|
|
74
|
+
function parseDeliveredBecause(raw) {
|
|
75
|
+
return typeof raw === "string" && DELIVERED_BECAUSE_VALUES.has(raw) ? raw : undefined;
|
|
76
|
+
}
|
|
77
|
+
function parseReplyParent(raw) {
|
|
78
|
+
if (!raw || typeof raw !== "object")
|
|
79
|
+
return undefined;
|
|
80
|
+
const p = raw;
|
|
81
|
+
if (p.id == null)
|
|
82
|
+
return undefined;
|
|
83
|
+
return { id: String(p.id), author: p.author ?? null, excerpt: typeof p.excerpt === "string" ? p.excerpt : "" };
|
|
84
|
+
}
|
|
59
85
|
/** The exact marker a hand-off briefing must start with, so the incoming
|
|
60
86
|
* agent's polling loop (below) can detect it arrived. Consumers building
|
|
61
87
|
* the outgoing briefing prompt must instruct their model to emit this
|
|
62
88
|
* exact line first. */
|
|
63
89
|
exports.HANDOFF_BRIEFING_MARKER = "[[SALT-HANDOFF-BRIEFING]]";
|
|
90
|
+
/** Content types decrypted straight to UTF-8 text in `DecryptedAttachment.text`,
|
|
91
|
+
* in addition to the raw bytes in `data`. Deliberately excludes PDF (see
|
|
92
|
+
* DecryptedAttachment.text's own comment) and images/audio/video (never
|
|
93
|
+
* meaningfully "text"). */
|
|
94
|
+
function isTextLikeAttachment(contentType) {
|
|
95
|
+
if (contentType.startsWith("text/"))
|
|
96
|
+
return true;
|
|
97
|
+
return ["application/json", "application/csv", "application/markdown"].includes(contentType);
|
|
98
|
+
}
|
|
99
|
+
// --- Device moments --------------------------------------------------------
|
|
100
|
+
//
|
|
101
|
+
// Two agent-rail deliveries (kind `device_session`) tell an agent that the
|
|
102
|
+
// person's computer went quiet in the middle of its work and that it came
|
|
103
|
+
// back. Plaintext metadata only -- ids, the computer's name, timestamps --
|
|
104
|
+
// delivered like card_interaction/invoice_paid, never a chat message. Source
|
|
105
|
+
// of truth: salt-api docs/DEVICE_PROTOCOL.md.
|
|
106
|
+
/** The `end_reason` of a device session. `lost_contact` is the one this SDK names: the computer stopped answering for 15 s mid-session. Any other value is passed through verbatim. */
|
|
107
|
+
exports.DEVICE_LOST_CONTACT = "lost_contact";
|
|
64
108
|
const consoleLogger = {
|
|
65
109
|
info: (msg) => console.log(msg),
|
|
66
110
|
error: (msg) => console.error(msg),
|
|
67
111
|
};
|
|
68
112
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
113
|
+
* Everything about the Salt protocol that is NOT "how do bytes get to this
|
|
114
|
+
* process" -- signature verification and the decrypt/route/dedup/reply
|
|
115
|
+
* logic for every webhook event kind. `createWebhookServer` below wraps
|
|
116
|
+
* this in an Express POST route (the only transport before K2); socket.ts's
|
|
117
|
+
* `createSocketClient` wraps the SAME dispatcher around a websocket push
|
|
118
|
+
* connection instead, so a consumer switches delivery mode with no change
|
|
119
|
+
* to onMessage/onCardInteraction/etc. -- see socket.ts's own doc comment.
|
|
72
120
|
*/
|
|
73
|
-
function
|
|
74
|
-
const { client, identities, pgpPassphrase,
|
|
121
|
+
function createDispatcher(options) {
|
|
122
|
+
const { client, identities, pgpPassphrase, mediatorAgentId } = options;
|
|
75
123
|
const logger = options.logger ?? consoleLogger;
|
|
76
|
-
const
|
|
77
|
-
|
|
124
|
+
const verifySignatures = options.verifySignatures !== false;
|
|
125
|
+
const signatureTolerance = options.signatureToleranceSeconds ?? 300;
|
|
126
|
+
const sessionStore = options.sessionStore ?? sessions.MemorySessionStore();
|
|
127
|
+
const identitySharer = identityShare.createIdentitySharer(client, pgpPassphrase);
|
|
128
|
+
const mapKey = (id) => String(id).toLowerCase();
|
|
129
|
+
// A lookup miss on a signed request is the signature of a stale store: the
|
|
130
|
+
// id salt-api signs with is not the one the identity was registered under
|
|
131
|
+
// (see reconcile.ts). Ask the API once, then look again. Throttled so an
|
|
132
|
+
// unknown-agent POST from the open internet costs at most one round of
|
|
133
|
+
// whoAmI calls a minute, and single-flight so a burst shares one pass.
|
|
134
|
+
const RECONCILE_MIN_INTERVAL_MS = 60_000;
|
|
135
|
+
let lastReconcileAt = 0;
|
|
136
|
+
let reconcileInFlight = null;
|
|
137
|
+
function reconcileOnMiss() {
|
|
138
|
+
if (reconcileInFlight)
|
|
139
|
+
return reconcileInFlight;
|
|
140
|
+
if (Date.now() - lastReconcileAt < RECONCILE_MIN_INTERVAL_MS)
|
|
141
|
+
return Promise.resolve();
|
|
142
|
+
lastReconcileAt = Date.now();
|
|
143
|
+
reconcileInFlight = (0, reconcile_1.reconcileIdentityIds)(identities, client, logger)
|
|
144
|
+
.catch((err) => logger.error(`[webhook] identity reconcile failed: ${err.message}`))
|
|
145
|
+
.finally(() => {
|
|
146
|
+
reconcileInFlight = null;
|
|
147
|
+
});
|
|
148
|
+
return reconcileInFlight;
|
|
149
|
+
}
|
|
150
|
+
// One signing key per identity, fetched lazily with that identity's own api
|
|
151
|
+
// key and cached. A miss is not fatal on its own -- verifyRequest decides.
|
|
152
|
+
const secretCache = new Map();
|
|
153
|
+
async function secretForAgent(agentId) {
|
|
154
|
+
const cacheKey = String(agentId).toLowerCase();
|
|
155
|
+
const cached = secretCache.get(cacheKey);
|
|
156
|
+
if (cached)
|
|
157
|
+
return cached;
|
|
158
|
+
let identity = identities.get(agentId);
|
|
159
|
+
if (!identity) {
|
|
160
|
+
await reconcileOnMiss();
|
|
161
|
+
identity = identities.get(agentId);
|
|
162
|
+
}
|
|
163
|
+
if (!identity)
|
|
164
|
+
return undefined;
|
|
165
|
+
try {
|
|
166
|
+
const secret = await client.getWebhookSecret(identity.apiKey);
|
|
167
|
+
if (secret)
|
|
168
|
+
secretCache.set(cacheKey, secret);
|
|
169
|
+
return secret;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
logger.error(`[webhook] could not fetch signing key for agent ${agentId}: ${err.message}`);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// R1 (round 3, 2026-09-18): a bounded, rate-limited recheck for the ONE
|
|
177
|
+
// case that can still legitimately race a rotation now that salt-api
|
|
178
|
+
// signs socket-mode envelopes fresh at serve time (LANES.md) -- a real
|
|
179
|
+
// webhook POST, which IS signed at send time by the enqueuing job, can
|
|
180
|
+
// still land here with a signature made against a secret that rotated
|
|
181
|
+
// a moment after the job read it but before this cache picked up the
|
|
182
|
+
// change. At most one uncached fetch per agent per 60s, single-flight
|
|
183
|
+
// (a burst of bad signatures for one agent shares the one in-flight
|
|
184
|
+
// check rather than each firing its own), and it NEVER evicts/replaces
|
|
185
|
+
// the cached secret unless the freshly fetched one actually verifies
|
|
186
|
+
// the failing envelope -- a fetch that doesn't fix the problem leaves
|
|
187
|
+
// the cache exactly as it was. Round 2's per-envelope evict-and-refetch
|
|
188
|
+
// (and its one-time "transient" grace) is gone entirely: with
|
|
189
|
+
// serve-time signing the socket path never needs it, and this bounded
|
|
190
|
+
// version is what's left for the webhook path.
|
|
191
|
+
const UNCACHED_CHECK_MIN_INTERVAL_MS = 60_000;
|
|
192
|
+
const lastUncachedCheckAt = new Map();
|
|
193
|
+
const uncachedCheckInFlight = new Map();
|
|
194
|
+
function uncachedSecretCheck(agentId, cacheKey) {
|
|
195
|
+
const inFlight = uncachedCheckInFlight.get(cacheKey);
|
|
196
|
+
if (inFlight)
|
|
197
|
+
return inFlight;
|
|
198
|
+
const lastAt = lastUncachedCheckAt.get(cacheKey) ?? 0;
|
|
199
|
+
if (Date.now() - lastAt < UNCACHED_CHECK_MIN_INTERVAL_MS)
|
|
200
|
+
return Promise.resolve(undefined);
|
|
201
|
+
lastUncachedCheckAt.set(cacheKey, Date.now());
|
|
202
|
+
const promise = (async () => {
|
|
203
|
+
const identity = identities.get(agentId);
|
|
204
|
+
if (!identity)
|
|
205
|
+
return undefined;
|
|
206
|
+
try {
|
|
207
|
+
return await client.getWebhookSecret(identity.apiKey);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
logger.error(`[webhook] uncached signing-key recheck for agent ${agentId} failed: ${err.message}`);
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
})().finally(() => {
|
|
214
|
+
uncachedCheckInFlight.delete(cacheKey);
|
|
215
|
+
});
|
|
216
|
+
uncachedCheckInFlight.set(cacheKey, promise);
|
|
217
|
+
return promise;
|
|
218
|
+
}
|
|
219
|
+
// Verifies one envelope's signature. Takes the two headers and the raw
|
|
220
|
+
// body explicitly rather than an Express Request -- the SAME check runs
|
|
221
|
+
// for a webhook POST (createWebhookServer passes req.get(...) and the
|
|
222
|
+
// captured rawBody) and for a socket-mode envelope (socket.ts passes the
|
|
223
|
+
// AgentUpdate row's `headers`/`body` fields verbatim), so this function
|
|
224
|
+
// must not assume either transport.
|
|
225
|
+
//
|
|
226
|
+
// `toleranceSeconds` overrides the default (signatureTolerance, ~300s)
|
|
227
|
+
// for a caller with its own reason to -- round 3 removed socket.ts's
|
|
228
|
+
// widened one (serve-time signing means a socket envelope's timestamp
|
|
229
|
+
// is always fresh too), so both transports use the same standard window
|
|
230
|
+
// by default now.
|
|
231
|
+
//
|
|
232
|
+
// `transient: true` marks a failure that might resolve on retry (right
|
|
233
|
+
// now, only "no signing key" -- secretForAgent already swallows its own
|
|
234
|
+
// network errors and returns undefined either way, so this can't
|
|
235
|
+
// distinguish "genuinely no such identity" from "the fetch failed" any
|
|
236
|
+
// more finely than that) -- socket.ts must not advance its cursor past a
|
|
237
|
+
// row that failed for a transient reason, or a real update sitting
|
|
238
|
+
// behind a network blip would be skipped forever.
|
|
239
|
+
async function verifyEnvelope(agentId, signature, rawBody, opts) {
|
|
240
|
+
if (!verifySignatures)
|
|
241
|
+
return { ok: true };
|
|
242
|
+
// Taken as sent. `Number(...)` here made agentId NaN for every real
|
|
243
|
+
// request, NaN is falsy, and so EVERY signed webhook was rejected as
|
|
244
|
+
// "missing signature" -- the header was present and correctly formed.
|
|
245
|
+
if (!agentId || !signature)
|
|
246
|
+
return { ok: false, reason: "missing signature", transient: false };
|
|
247
|
+
const t = /t=(\d+)/.exec(signature)?.[1];
|
|
248
|
+
const v1 = /v1=([0-9a-f]+)/.exec(signature)?.[1];
|
|
249
|
+
if (!t || !v1)
|
|
250
|
+
return { ok: false, reason: "malformed signature", transient: false };
|
|
251
|
+
// Replay window. Also rejects a clock far in the future.
|
|
252
|
+
const tolerance = opts?.toleranceSeconds ?? signatureTolerance;
|
|
253
|
+
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
|
|
254
|
+
if (age > tolerance)
|
|
255
|
+
return { ok: false, reason: `stale signature (${age}s old)`, transient: false };
|
|
256
|
+
const secret = await secretForAgent(agentId);
|
|
257
|
+
if (!secret)
|
|
258
|
+
return { ok: false, reason: `no signing key for agent ${agentId}`, transient: true };
|
|
259
|
+
const cacheKey = String(agentId).toLowerCase();
|
|
260
|
+
// Constant-time: a fast string compare leaks the digest a byte at a time.
|
|
261
|
+
const verify = (withSecret) => {
|
|
262
|
+
const expected = (0, node_crypto_1.createHmac)("sha256", withSecret).update(`${t}.${rawBody}`).digest("hex");
|
|
263
|
+
const a = Buffer.from(v1, "utf8");
|
|
264
|
+
const b = Buffer.from(expected, "utf8");
|
|
265
|
+
return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
266
|
+
};
|
|
267
|
+
if (verify(secret))
|
|
268
|
+
return { ok: true };
|
|
269
|
+
// Pre-round-2 behaviour, restored: a bad signature is definitive --
|
|
270
|
+
// UNLESS the bounded recheck above finds a genuinely different
|
|
271
|
+
// secret that verifies it, in which case it self-heals silently and
|
|
272
|
+
// the cache picks up the new value. Never transient, never retried.
|
|
273
|
+
const fresh = await uncachedSecretCheck(agentId, cacheKey);
|
|
274
|
+
// The fresh secret comes from salt-api under the agent's own key, so it is
|
|
275
|
+
// authoritative: adopt it whenever it differs, even if THIS envelope doesn't
|
|
276
|
+
// verify under it, or a leaked old secret stays accepted after a rotation.
|
|
277
|
+
if (fresh && fresh !== secret) {
|
|
278
|
+
secretCache.set(cacheKey, fresh);
|
|
279
|
+
if (verify(fresh))
|
|
280
|
+
return { ok: true };
|
|
281
|
+
}
|
|
282
|
+
return { ok: false, reason: "bad signature", transient: false };
|
|
283
|
+
}
|
|
78
284
|
// Salt fires one webhook per chat member with a callback URL
|
|
79
285
|
// (Message#send_webhook in salt-api). When two or more identities THIS
|
|
80
286
|
// process hosts share a chat -- which delegation makes possible -- that's
|
|
@@ -104,15 +310,51 @@ function createWebhookServer(options) {
|
|
|
104
310
|
// THIS process hosts crosses this many auto-replies, the receiving
|
|
105
311
|
// identity just stops answering the other one there, until a human sends
|
|
106
312
|
// something new (which resets the count).
|
|
107
|
-
const MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT = 2;
|
|
313
|
+
const MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT = options.maxAgentToAgentReplies ?? 2;
|
|
314
|
+
if (!Number.isInteger(MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT) || MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT < 1) {
|
|
315
|
+
throw new Error(`maxAgentToAgentReplies must be a positive integer, got ${String(options.maxAgentToAgentReplies)}`);
|
|
316
|
+
}
|
|
317
|
+
// A consult lane (actions.ts's consult_agent, chatMeta.lane_kind ===
|
|
318
|
+
// "consult") is a real working conversation between two agents, not an
|
|
319
|
+
// accidental loop -- it needs real back-and-forth room, so it gets its
|
|
320
|
+
// own, much higher ceiling instead of the ordinary chat's cap.
|
|
321
|
+
const CONSULT_RUNAWAY_LIMIT = 20;
|
|
322
|
+
// Keyed by the lowercased chat id, like every other id-keyed map here.
|
|
108
323
|
const agentToAgentReplyCounts = new Map();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
324
|
+
// laneChatId (lowercased) -> its room's chat id (lowercased), learned the
|
|
325
|
+
// first time a message is seen in that lane. A human speaking in the ROOM
|
|
326
|
+
// should reset every consult lane rooted there too -- a lane's own count
|
|
327
|
+
// otherwise only resets from someone speaking IN the lane itself, and a
|
|
328
|
+
// busy pair of agents burning through CONSULT_RUNAWAY_LIMIT would stay
|
|
329
|
+
// capped even right after the person who asked for the consult weighs in.
|
|
330
|
+
const laneRoomOf = new Map();
|
|
331
|
+
// roomId (lowercased) -> the consult lane chat id a request_floor message
|
|
332
|
+
// just triggered an automatic hand-off from. Set by the floor-request
|
|
333
|
+
// handler right before calling client.handOff, consumed by
|
|
334
|
+
// handleHandoffConfirmed once salt-api's own handoff_confirmed webhook
|
|
335
|
+
// arrives for that room -- that's what lets it attach consultTranscript.
|
|
336
|
+
// Best-effort: a second floor request for the same room before the first
|
|
337
|
+
// hand-off confirms would overwrite this, which is an acceptable rarity.
|
|
338
|
+
const pendingConsultHandoffs = new Map();
|
|
339
|
+
// chat_opened has no message_id to dedupe on (it isn't a Message at all),
|
|
340
|
+
// and delivery can be retried -- so a retried delivery must not make this
|
|
341
|
+
// identity greet the same chat twice. Keyed by identity+chat since one
|
|
342
|
+
// process can host several identities and a chat could in principle be
|
|
343
|
+
// opened for more than one of them. Bounded the same way seenMessageIds is.
|
|
344
|
+
const MAX_SEEN_CHAT_OPENED = 2000;
|
|
345
|
+
const seenChatOpened = new Set();
|
|
346
|
+
function alreadyGreeted(identityId, chatId) {
|
|
347
|
+
const key = `${String(identityId).toLowerCase()}:${String(chatId).toLowerCase()}`;
|
|
348
|
+
if (seenChatOpened.has(key))
|
|
349
|
+
return true;
|
|
350
|
+
seenChatOpened.add(key);
|
|
351
|
+
if (seenChatOpened.size > MAX_SEEN_CHAT_OPENED) {
|
|
352
|
+
const oldest = seenChatOpened.values().next().value;
|
|
353
|
+
if (oldest !== undefined)
|
|
354
|
+
seenChatOpened.delete(oldest);
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
116
358
|
// Every identity this process hosts shares this one endpoint, so the
|
|
117
359
|
// first step is figuring out *which* identity a given message is
|
|
118
360
|
// addressed to. Salt encrypts the message to every chat member's public
|
|
@@ -120,8 +362,30 @@ function createWebhookServer(options) {
|
|
|
120
362
|
// private key that was actually a member of that chat, so trying each
|
|
121
363
|
// known identity in turn and keeping whichever one succeeds is both
|
|
122
364
|
// correct and cheap for the small number of identities a process hosts.
|
|
123
|
-
async function resolveIdentity(ciphertext) {
|
|
365
|
+
async function resolveIdentity(ciphertext, headerAgentId) {
|
|
366
|
+
// salt-api signs every callback for a specific RECIPIENT identity and
|
|
367
|
+
// names it in X-Salt-Agent-Id (see rejectionReason above) -- when that
|
|
368
|
+
// names one of ours, try it first. Skips the ambiguity trial-decryption
|
|
369
|
+
// has when this process hosts more than one identity that's a member of
|
|
370
|
+
// the same chat (a delegation or consult lane makes that possible): any
|
|
371
|
+
// member's key opens the multi-recipient blob, so without the header,
|
|
372
|
+
// which one "wins" is just whichever was registered first.
|
|
373
|
+
if (headerAgentId) {
|
|
374
|
+
const preferred = identities.get(headerAgentId);
|
|
375
|
+
if (preferred) {
|
|
376
|
+
try {
|
|
377
|
+
const plaintext = await pgp.decrypt(ciphertext, preferred.privateKey, pgpPassphrase);
|
|
378
|
+
return { identity: preferred, plaintext };
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// The header named a real hosted identity, but this ciphertext
|
|
382
|
+
// isn't addressed to it -- fall through to trial decryption.
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
124
386
|
for (const candidate of identities.all()) {
|
|
387
|
+
if (headerAgentId && (0, ids_js_1.sameId)(candidate.saltAppId, headerAgentId))
|
|
388
|
+
continue; // already tried above
|
|
125
389
|
try {
|
|
126
390
|
const plaintext = await pgp.decrypt(ciphertext, candidate.privateKey, pgpPassphrase);
|
|
127
391
|
return { identity: candidate, plaintext };
|
|
@@ -149,39 +413,162 @@ function createWebhookServer(options) {
|
|
|
149
413
|
const lines = [];
|
|
150
414
|
for (const raw of messages) {
|
|
151
415
|
const m = raw;
|
|
152
|
-
if (m.event_type || typeof m.message !== "string"
|
|
416
|
+
if (m.event_type || typeof m.message !== "string")
|
|
153
417
|
continue;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
418
|
+
let text;
|
|
419
|
+
if (m.encrypted === false) {
|
|
420
|
+
// Open room (salt-api 0.6x): already plaintext, nothing to decrypt.
|
|
421
|
+
text = m.message;
|
|
157
422
|
}
|
|
158
|
-
|
|
159
|
-
|
|
423
|
+
else {
|
|
424
|
+
if (!PGP_MESSAGE_RE.test(m.message))
|
|
425
|
+
continue;
|
|
426
|
+
try {
|
|
427
|
+
text = await pgp.decrypt(m.message, identity.privateKey, pgpPassphrase);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
continue; // predates this identity joining the chat -- skip silently.
|
|
431
|
+
}
|
|
160
432
|
}
|
|
433
|
+
lines.push(`${m.user?.display_name || "someone"}: ${text}`);
|
|
161
434
|
}
|
|
162
435
|
return lines.length ? lines.join("\n") : "(no messages yet)";
|
|
163
436
|
}
|
|
437
|
+
// Same idea as buildSharedChatContext, but for a session's cold-start
|
|
438
|
+
// rebuild: structured turns instead of joined display lines, capped to
|
|
439
|
+
// the same MAX_TRANSCRIPT_TURNS a live session is bounded to, and this
|
|
440
|
+
// identity's own messages come back tagged "assistant" rather than folded
|
|
441
|
+
// into "someone: ...". Messages from before `identity` joined fail to
|
|
442
|
+
// decrypt and are skipped -- same degrade-gracefully convention.
|
|
443
|
+
async function rebuildTranscriptTail(identity, chatId) {
|
|
444
|
+
let messages;
|
|
445
|
+
try {
|
|
446
|
+
messages = await client.getChatMessages(identity.apiKey, chatId);
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
logger.error(`[session] fetching chat ${chatId} for cold-start rebuild failed: ${err.message}`);
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
const turns = [];
|
|
453
|
+
for (const raw of messages) {
|
|
454
|
+
const m = raw;
|
|
455
|
+
if (m.event_type || typeof m.message !== "string")
|
|
456
|
+
continue;
|
|
457
|
+
let plaintext;
|
|
458
|
+
if (m.encrypted === false) {
|
|
459
|
+
// Open room (salt-api 0.6x): already plaintext, nothing to decrypt.
|
|
460
|
+
plaintext = m.message;
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
if (!PGP_MESSAGE_RE.test(m.message))
|
|
464
|
+
continue;
|
|
465
|
+
try {
|
|
466
|
+
plaintext = await pgp.decrypt(m.message, identity.privateKey, pgpPassphrase);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
continue; // predates this identity joining the chat -- skip silently.
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const { text: afterDepth } = delegations.parseIncoming(plaintext);
|
|
473
|
+
const content = delegations.stripConsultMarker(afterDepth);
|
|
474
|
+
const isSelf = (0, ids_js_1.sameId)(m.user?.id, identity.saltAppId);
|
|
475
|
+
turns.push({
|
|
476
|
+
role: isSelf ? "assistant" : "user",
|
|
477
|
+
content,
|
|
478
|
+
at: m.created_at ? Date.parse(m.created_at) : Date.now(),
|
|
479
|
+
from: isSelf ? undefined : m.user?.username || m.user?.display_name || String(m.user?.id ?? "someone"),
|
|
480
|
+
// Only meaningful for the OTHER party's turn -- this identity's own
|
|
481
|
+
// messages have no "reason it was delivered to me" to report.
|
|
482
|
+
deliveredBecause: isSelf ? undefined : parseDeliveredBecause(m.delivered_because),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return turns.length > sessions.MAX_TRANSCRIPT_TURNS ? turns.slice(-sessions.MAX_TRANSCRIPT_TURNS) : turns;
|
|
486
|
+
}
|
|
487
|
+
// Loads the stored session for (identity, chatId), or -- on a cold start --
|
|
488
|
+
// builds a fresh one from the chat's own history. Never throws: a session
|
|
489
|
+
// store or rebuild failure falls back to an empty session rather than
|
|
490
|
+
// blocking the message it's here to support.
|
|
491
|
+
async function loadOrRebuildSession(identity, chatId, chatMeta) {
|
|
492
|
+
const roomId = chatMeta?.coaching_for_chat_id || chatId;
|
|
493
|
+
try {
|
|
494
|
+
const existing = await sessionStore.get(identity.saltAppId, chatId);
|
|
495
|
+
if (existing)
|
|
496
|
+
return existing;
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
logger.error(`[session] loading ${chatId} failed: ${err.message}`);
|
|
500
|
+
}
|
|
501
|
+
const role = chatMeta?.lane_kind === "consult" ? "consult" : chatMeta?.coaching_for_chat_id ? "lane" : "active";
|
|
502
|
+
const session = sessions.emptySession(chatId, roomId, role);
|
|
503
|
+
try {
|
|
504
|
+
session.transcriptTail = await rebuildTranscriptTail(identity, chatId);
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
logger.error(`[session] rebuilding ${chatId} failed: ${err.message}`);
|
|
508
|
+
}
|
|
509
|
+
return session;
|
|
510
|
+
}
|
|
511
|
+
// Appends each of `repliesForSession` to `session` as an assistant turn,
|
|
512
|
+
// bounds the note, and persists -- shared by every context type that has
|
|
513
|
+
// a reply() worth remembering (onMessage additionally appends its own
|
|
514
|
+
// incoming "user" turn first; see handleMessage, which does that inline
|
|
515
|
+
// since it's the only one with an incoming turn to record at all). Never
|
|
516
|
+
// throws: a persistence failure here must not turn into an unhandled
|
|
517
|
+
// rejection on top of a webhook handler that already ran successfully.
|
|
518
|
+
async function persistSessionAfterReply(identityId, chatId, session, repliesForSession) {
|
|
519
|
+
try {
|
|
520
|
+
for (const replyText of repliesForSession) {
|
|
521
|
+
sessions.appendTurn(session, { role: "assistant", content: replyText, at: Date.now() });
|
|
522
|
+
}
|
|
523
|
+
session.note = sessions.boundNote(session.note);
|
|
524
|
+
session.updatedAt = Date.now();
|
|
525
|
+
await sessionStore.put(identityId, chatId, session);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
logger.error(`[session] persisting ${chatId} failed: ${err.message}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// The consult lane's own transcript, for a hand-off briefing that was
|
|
532
|
+
// triggered by a request_floor call rather than chosen by a model turn --
|
|
533
|
+
// see handleHandoffConfirmed. Capped to the last few exchanges; a consult
|
|
534
|
+
// lane that ran long doesn't need its entire history in a briefing.
|
|
535
|
+
const MAX_CONSULT_TRANSCRIPT_LINES = 30;
|
|
536
|
+
async function buildConsultTranscript(identity, laneId) {
|
|
537
|
+
const full = await buildSharedChatContext(identity, laneId);
|
|
538
|
+
const lines = full.split("\n");
|
|
539
|
+
return lines.length > MAX_CONSULT_TRANSCRIPT_LINES ? lines.slice(-MAX_CONSULT_TRANSCRIPT_LINES).join("\n") : full;
|
|
540
|
+
}
|
|
164
541
|
// Decrypts an attachment's PGP metadata blob (key/iv/filename/content_type),
|
|
165
|
-
// downloads the ciphertext, and AES-GCM-decrypts it
|
|
166
|
-
//
|
|
167
|
-
//
|
|
542
|
+
// downloads the ciphertext, and AES-GCM-decrypts it -- for any content
|
|
543
|
+
// type, up to pgp.MAX_ATTACHMENT_BYTES (the declared `size` is checked
|
|
544
|
+
// BEFORE downloading/decrypting, so a metadata blob that lies about its
|
|
545
|
+
// own size can't make this pull down and hold an unbounded blob). Text-
|
|
546
|
+
// shaped content (text/*, JSON, CSV, Markdown) also gets a decoded `text`
|
|
547
|
+
// field alongside the raw bytes; a PDF gets bytes only (see
|
|
548
|
+
// DecryptedAttachment.text's comment on why text extraction stops there).
|
|
168
549
|
async function decryptAttachmentIfPresent(identity, message) {
|
|
169
550
|
if (!message.resource?.encrypted_key)
|
|
170
551
|
return undefined;
|
|
171
552
|
try {
|
|
172
553
|
const metaJson = await pgp.decrypt(message.resource.encrypted_key, identity.privateKey, pgpPassphrase);
|
|
173
554
|
const meta = JSON.parse(metaJson);
|
|
174
|
-
|
|
555
|
+
const filename = meta.filename;
|
|
556
|
+
const contentType = meta.content_type || "unknown";
|
|
557
|
+
if (meta.size > pgp.MAX_ATTACHMENT_BYTES) {
|
|
175
558
|
return {
|
|
176
|
-
filename
|
|
177
|
-
contentType
|
|
559
|
+
filename,
|
|
560
|
+
contentType,
|
|
178
561
|
size: meta.size,
|
|
179
|
-
unsupportedNote: `attached file: ${
|
|
562
|
+
unsupportedNote: `attached file: ${filename}, ${contentType}, ${meta.size} bytes -- too large to fetch (cap ${pgp.MAX_ATTACHMENT_BYTES} bytes).`,
|
|
180
563
|
};
|
|
181
564
|
}
|
|
182
565
|
const ciphertextBytes = await client.getAttachment(identity.apiKey, message.message_id);
|
|
183
566
|
const plaintext = pgp.decryptAttachment(ciphertextBytes, meta.key, meta.iv);
|
|
184
|
-
|
|
567
|
+
const result = { filename, contentType, size: meta.size, data: plaintext };
|
|
568
|
+
if (isTextLikeAttachment(contentType)) {
|
|
569
|
+
result.text = plaintext.toString("utf8");
|
|
570
|
+
}
|
|
571
|
+
return result;
|
|
185
572
|
}
|
|
186
573
|
catch (err) {
|
|
187
574
|
logger.error(`[attachment] failed to decrypt: ${err.message}`);
|
|
@@ -210,14 +597,30 @@ function createWebhookServer(options) {
|
|
|
210
597
|
// trail onto it + fire-and-forget metrics. Does NOT itself manage the
|
|
211
598
|
// typing indicator -- see withTypingHeartbeat, which wraps the whole
|
|
212
599
|
// callback (including whatever happens before reply() is even called).
|
|
213
|
-
|
|
600
|
+
// `addressee` is whoever this reply is answering, when there is one. In a
|
|
601
|
+
// GROUP chat the reply is addressed back to them by handle -- being spoken
|
|
602
|
+
// to by name is how a person tells which of several messages in a busy room
|
|
603
|
+
// is meant for them, and an agent that answers into the middle of a group
|
|
604
|
+
// without naming anyone reads as talking to the room.
|
|
605
|
+
//
|
|
606
|
+
// Two deliberate limits. Only in a group: in a 1:1 there is exactly one
|
|
607
|
+
// person it could be for, and "@dan" on every line is noise. And only for a
|
|
608
|
+
// HUMAN: an agent addressed by handle receives a webhook, so auto-addressing
|
|
609
|
+
// another agent invites exactly the ping-pong the loop caps in this file
|
|
610
|
+
// exist to stop.
|
|
611
|
+
function makeReply(identity, chatId, addressee) {
|
|
214
612
|
return async (text) => {
|
|
215
613
|
const startedAt = Date.now();
|
|
216
614
|
let recipientKeys;
|
|
615
|
+
let memberCount = 0;
|
|
217
616
|
try {
|
|
218
617
|
const members = await client.getChatMembers(identity.apiKey, chatId);
|
|
618
|
+
memberCount = members.length;
|
|
219
619
|
recipientKeys = members
|
|
220
|
-
|
|
620
|
+
// Everyone but ourselves. parseInt made both sides NaN, and
|
|
621
|
+
// `NaN !== NaN` is true, so the sender's own key was left in the
|
|
622
|
+
// recipient list rather than filtered out of it.
|
|
623
|
+
.filter((u) => !(0, ids_js_1.sameId)(u.id, identity.saltAppId) && u.public_key)
|
|
221
624
|
.map((u) => u.public_key);
|
|
222
625
|
}
|
|
223
626
|
catch (err) {
|
|
@@ -228,10 +631,23 @@ function createWebhookServer(options) {
|
|
|
228
631
|
logger.error(`[chat ${chatId}] no recipient public keys; not sending.`);
|
|
229
632
|
return;
|
|
230
633
|
}
|
|
634
|
+
// Group + human + a handle to use, and not already addressed by whoever
|
|
635
|
+
// wrote the reply (an agent that writes its own "@dan" keeps it, and
|
|
636
|
+
// does not get a second one bolted on the front).
|
|
637
|
+
const addressHandle = memberCount > 2 &&
|
|
638
|
+
addressee?.username &&
|
|
639
|
+
addressee.account_type !== "Agent" &&
|
|
640
|
+
!new RegExp(`(^|\\s)@${addressee.username}(?![\\w.-])`).test(text)
|
|
641
|
+
? addressee.username
|
|
642
|
+
: null;
|
|
643
|
+
const outgoing = addressHandle ? `@${addressHandle} ${text}` : text;
|
|
644
|
+
// The ids, not the text: Salt only ever sees ciphertext, so an "@handle"
|
|
645
|
+
// in the body is invisible to it and notifies nobody by itself.
|
|
646
|
+
const mentions = addressHandle ? [addressee.id] : undefined;
|
|
231
647
|
let encryptedMessage, senderMessage;
|
|
232
648
|
try {
|
|
233
|
-
encryptedMessage = await pgp.encryptFor(
|
|
234
|
-
senderMessage = await pgp.encryptFor(
|
|
649
|
+
encryptedMessage = await pgp.encryptFor(outgoing, recipientKeys);
|
|
650
|
+
senderMessage = await pgp.encryptFor(outgoing, [identity.publicKey]);
|
|
235
651
|
}
|
|
236
652
|
catch (err) {
|
|
237
653
|
logger.error(`[chat ${chatId}] encryption failed: ${err.message}`);
|
|
@@ -239,7 +655,7 @@ function createWebhookServer(options) {
|
|
|
239
655
|
}
|
|
240
656
|
const trail = delegations.drainTrail(identity.saltAppId, chatId);
|
|
241
657
|
try {
|
|
242
|
-
await client.postMessage(identity.apiKey, chatId, encryptedMessage, senderMessage, trail);
|
|
658
|
+
await client.postMessage(identity.apiKey, chatId, encryptedMessage, senderMessage, trail, mentions);
|
|
243
659
|
}
|
|
244
660
|
catch (err) {
|
|
245
661
|
logger.error(`[chat ${chatId}] posting reply failed: ${err.message}`);
|
|
@@ -252,7 +668,159 @@ function createWebhookServer(options) {
|
|
|
252
668
|
});
|
|
253
669
|
};
|
|
254
670
|
}
|
|
255
|
-
|
|
671
|
+
// ctx.ask/ctx.approve (K3, ask.ts) -- bound to one (identity, chatId) the
|
|
672
|
+
// same way makeReply above is, and handed to every context type that
|
|
673
|
+
// gets a reply(). The actual card post/update and pending-wait registry
|
|
674
|
+
// live in ask.ts; resolveCardInteraction/resolveMessage below (wired into
|
|
675
|
+
// handleCardInteraction/handleMessage) are what settle a wait this
|
|
676
|
+
// creates.
|
|
677
|
+
//
|
|
678
|
+
// `defaultAnswererId` (M2, security review) is each context's natural
|
|
679
|
+
// "whoever this turn is answering" -- see the individual ctx-construction
|
|
680
|
+
// call sites for what each context type passes (a message's senderRaw,
|
|
681
|
+
// chat_opened's openedBy, invoice_paid's buyer; the two hand-off contexts
|
|
682
|
+
// have no natural default and pass none, so their ask()/approve() calls
|
|
683
|
+
// must supply `answererId` explicitly). A caller's own `opts.answererId`
|
|
684
|
+
// always wins over this default.
|
|
685
|
+
function makeAsk(identity, chatId, defaultAnswererId) {
|
|
686
|
+
return (question, opts) => asks.ask(client, identity, chatId, question, { answererId: defaultAnswererId, ...opts });
|
|
687
|
+
}
|
|
688
|
+
function makeApprove(identity, chatId, defaultAnswererId) {
|
|
689
|
+
return (summary, opts) => asks.approve(client, identity, chatId, summary, { answererId: defaultAnswererId, ...opts });
|
|
690
|
+
}
|
|
691
|
+
// ctx.shareIdentity (identityShare.ts) -- bound to one (identity, chatId)
|
|
692
|
+
// the same way makeAsk/makeApprove above are.
|
|
693
|
+
function makeShareIdentity(identity, chatId) {
|
|
694
|
+
return (keys, opts) => identitySharer.share(identity, chatId, keys, opts);
|
|
695
|
+
}
|
|
696
|
+
// True when `chatId` has at least one human (account_type !== "Agent")
|
|
697
|
+
// member who is NOT a silent observer. A consult lane's human -- the
|
|
698
|
+
// delegation chain's auditor added per actions.ts's delegation-
|
|
699
|
+
// observability note -- is only ever an observer there, so a lane reads as
|
|
700
|
+
// agent-only even though a person can technically decrypt it. `chatMeta`'s
|
|
701
|
+
// own member list is used when a caller already supplied one (mainly
|
|
702
|
+
// tests); the real webhook payload's `chat` never carries members (see
|
|
703
|
+
// salt-api's WebhookJob#user_send allowlist), so this normally falls back
|
|
704
|
+
// to fetching the roster -- the same call makeReply above already makes to
|
|
705
|
+
// encrypt a reply.
|
|
706
|
+
async function chatHasNonObserverHuman(identity, chatId, chatMeta) {
|
|
707
|
+
const inlineMembers = Array.isArray(chatMeta?.users) ? chatMeta.users : undefined;
|
|
708
|
+
let members = inlineMembers;
|
|
709
|
+
if (!members) {
|
|
710
|
+
try {
|
|
711
|
+
members = await client.getChatMembers(identity.apiKey, chatId);
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
logger.error(`[chat ${chatId}] fetching members for mention-gating failed: ${err.message}`);
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return members.some((m) => m.account_type !== "Agent" && m.account_type !== "Device" && !m.observer);
|
|
719
|
+
}
|
|
720
|
+
// Runs `options.onIdentityAsk` for an incoming SALT-IDENTITY-ASK and
|
|
721
|
+
// answers it -- a SLICE (carrying `ask=parsed.id`) for the keys the
|
|
722
|
+
// handler chose to share, a DECLINE (same) for null/false/an empty
|
|
723
|
+
// array. Never lets a handler or send failure escape to the caller: an
|
|
724
|
+
// ask that can't be answered must not crash the webhook pass that
|
|
725
|
+
// received it, the same convention every other handler-invoking helper
|
|
726
|
+
// in this file follows.
|
|
727
|
+
async function handleIdentityAsk(identity, chatId, senderId, parsed) {
|
|
728
|
+
let decision;
|
|
729
|
+
try {
|
|
730
|
+
decision = await options.onIdentityAsk({ id: parsed.id, keys: parsed.keys, text: parsed.text, chatId, from: senderId });
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
logger.error(`[chat ${chatId}] onIdentityAsk failed: ${err.message}`);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
if (Array.isArray(decision) && decision.length > 0) {
|
|
738
|
+
await identitySharer.share(identity, chatId, decision, { ask: parsed.id });
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
await identitySharer.decline(identity, chatId, parsed.id);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
logger.error(`[chat ${chatId}] answering identity ask ${parsed.id} failed: ${err.message}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// Handles an incoming SLICE, DECLINE or REVOKE from someone else. A
|
|
749
|
+
// SLICE is verified against the sender's own public key (fetched fresh
|
|
750
|
+
// from the chat's member list -- the same call makeReply already makes
|
|
751
|
+
// to encrypt a reply) and, when verified, recorded on this (identity,
|
|
752
|
+
// chat) session before `onIdentityShared` fires; a REVOKE removes any
|
|
753
|
+
// slice recorded under the same id first. Never lets a verification,
|
|
754
|
+
// session, or handler failure escape -- same convention as
|
|
755
|
+
// handleIdentityAsk above.
|
|
756
|
+
async function handleIdentitySharedMarker(identity, chatId, senderId, parsed) {
|
|
757
|
+
if (parsed.kind === "slice") {
|
|
758
|
+
let verified = false;
|
|
759
|
+
try {
|
|
760
|
+
const members = await client.getChatMembers(identity.apiKey, chatId);
|
|
761
|
+
const sender = members.find((m) => (0, ids_js_1.sameId)(m.id, senderId));
|
|
762
|
+
if (sender?.public_key) {
|
|
763
|
+
verified = await pgp.verifyDetached((0, identity_js_1.canonicalizeJcs)(parsed.payload.sections), parsed.payload.signature, sender.public_key);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
logger.error(`[chat ${chatId}] verifying identity slice ${parsed.id} failed: ${err.message}`);
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
const session = await loadOrRebuildSession(identity, chatId);
|
|
771
|
+
sessions.recordReceivedIdentitySlice(session, senderId, {
|
|
772
|
+
id: parsed.id,
|
|
773
|
+
sections: parsed.payload.sections,
|
|
774
|
+
verified,
|
|
775
|
+
receivedAt: Date.now(),
|
|
776
|
+
});
|
|
777
|
+
session.updatedAt = Date.now();
|
|
778
|
+
await sessionStore.put(identity.saltAppId, chatId, session);
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
logger.error(`[session] recording identity slice ${parsed.id} for ${chatId} failed: ${err.message}`);
|
|
782
|
+
}
|
|
783
|
+
if (options.onIdentityShared) {
|
|
784
|
+
try {
|
|
785
|
+
await options.onIdentityShared({
|
|
786
|
+
kind: "slice",
|
|
787
|
+
id: parsed.id,
|
|
788
|
+
askId: parsed.askId,
|
|
789
|
+
from: senderId,
|
|
790
|
+
chatId,
|
|
791
|
+
sections: parsed.payload.sections,
|
|
792
|
+
verified,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
logger.error(`[chat ${chatId}] onIdentityShared (slice) failed: ${err.message}`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (parsed.kind === "revoke") {
|
|
802
|
+
try {
|
|
803
|
+
const session = await loadOrRebuildSession(identity, chatId);
|
|
804
|
+
sessions.forgetReceivedIdentitySlice(session, senderId, parsed.id);
|
|
805
|
+
session.updatedAt = Date.now();
|
|
806
|
+
await sessionStore.put(identity.saltAppId, chatId, session);
|
|
807
|
+
}
|
|
808
|
+
catch (err) {
|
|
809
|
+
logger.error(`[session] forgetting revoked identity slice ${parsed.id} for ${chatId} failed: ${err.message}`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (options.onIdentityShared) {
|
|
813
|
+
try {
|
|
814
|
+
await options.onIdentityShared(parsed.kind === "decline"
|
|
815
|
+
? { kind: "decline", id: parsed.id, askId: parsed.askId, from: senderId, chatId }
|
|
816
|
+
: { kind: "revoke", id: parsed.id, from: senderId, chatId });
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
logger.error(`[chat ${chatId}] onIdentityShared (${parsed.kind}) failed: ${err.message}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
async function handleMessage(body, headerAgentId) {
|
|
256
824
|
const message = body.message;
|
|
257
825
|
const chatId = message.chat_id;
|
|
258
826
|
const chatMeta = body.chat;
|
|
@@ -260,15 +828,50 @@ function createWebhookServer(options) {
|
|
|
260
828
|
return;
|
|
261
829
|
if (message.event_type)
|
|
262
830
|
return; // system events aren't prompts
|
|
263
|
-
const
|
|
264
|
-
if (typeof
|
|
265
|
-
return;
|
|
266
|
-
const resolved = await resolveIdentity(ciphertext);
|
|
267
|
-
if (!resolved) {
|
|
268
|
-
logger.error(`[chat ${chatId}] no known identity could decrypt this message; ignoring.`);
|
|
831
|
+
const rawMessage = message.message;
|
|
832
|
+
if (typeof rawMessage !== "string")
|
|
269
833
|
return;
|
|
834
|
+
// Open rooms (salt-api 0.6x): a plain chat delivers `encrypted: false`
|
|
835
|
+
// and `message` is the text itself, not a PGP blob -- there is nothing
|
|
836
|
+
// to trial-decrypt, and no way to recover which hosted identity this
|
|
837
|
+
// delivery was addressed to except the header salt-api signs every
|
|
838
|
+
// callback with. Without it (a header-less transport, or a header
|
|
839
|
+
// naming an identity this process doesn't host) there is no safe
|
|
840
|
+
// fallback the way trial-decryption gives the ciphertext path below --
|
|
841
|
+
// guessing would risk this identity replying as if it heard a message
|
|
842
|
+
// that was actually addressed to a sibling identity sharing this
|
|
843
|
+
// process.
|
|
844
|
+
const isPlaintext = message.encrypted === false;
|
|
845
|
+
let identity;
|
|
846
|
+
let caption;
|
|
847
|
+
if (isPlaintext) {
|
|
848
|
+
const preferred = headerAgentId ? identities.get(headerAgentId) : undefined;
|
|
849
|
+
if (!preferred) {
|
|
850
|
+
logger.error(`[chat ${chatId}] no known identity for this plaintext message (X-Salt-Agent-Id ${headerAgentId ?? "missing"}); ignoring.`);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
identity = preferred;
|
|
854
|
+
caption = rawMessage;
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
if (!PGP_MESSAGE_RE.test(rawMessage))
|
|
858
|
+
return;
|
|
859
|
+
const resolved = await resolveIdentity(rawMessage, headerAgentId);
|
|
860
|
+
if (!resolved) {
|
|
861
|
+
logger.error(`[chat ${chatId}] no known identity could decrypt this message; ignoring.`);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
identity = resolved.identity;
|
|
865
|
+
caption = resolved.plaintext;
|
|
270
866
|
}
|
|
271
|
-
|
|
867
|
+
// Wire protocol, never a prompt: a hand-off briefing is consumed by
|
|
868
|
+
// handleHandoffReceived's poll (above HANDOFF_BRIEFING_MARKER's
|
|
869
|
+
// definition), and a bare session-note line is consumed by whichever
|
|
870
|
+
// hand-off leg reads it back out (sessions.ts). Either one reaching
|
|
871
|
+
// onMessage reads to a viewer as this identity replying to its own
|
|
872
|
+
// colleague's internal handoff chatter.
|
|
873
|
+
if (caption.startsWith(exports.HANDOFF_BRIEFING_MARKER) || caption.startsWith(sessions.SESSION_NOTE_MARKER))
|
|
874
|
+
return;
|
|
272
875
|
// GACM routing fix: salt-api sends one webhook delivery PER agent
|
|
273
876
|
// member, but they all land on this shared server and message-id dedup
|
|
274
877
|
// collapses them into a single pass -- whose decrypt-resolved identity
|
|
@@ -277,51 +880,180 @@ function createWebhookServer(options) {
|
|
|
277
880
|
// THAT identity; the plaintext is identical under any key, only "who
|
|
278
881
|
// replies" changes.
|
|
279
882
|
if (chatMeta?.active_agent_id) {
|
|
280
|
-
const active = identities.get(
|
|
883
|
+
const active = identities.get(String(chatMeta.active_agent_id));
|
|
281
884
|
if (active)
|
|
282
885
|
identity = active;
|
|
283
886
|
}
|
|
887
|
+
// A message with no sender is not a prompt, and everything below -- the
|
|
888
|
+
// self-reply loop break, the delegation-reply match, the agent-to-agent
|
|
889
|
+
// cap -- is a question about who sent it. This used to fall through with
|
|
890
|
+
// senderId = NaN, which compared equal to nothing and unequal to
|
|
891
|
+
// everything, so all three guards inverted rather than being skipped. The
|
|
892
|
+
// context handed to consumers already asserted a sender existed.
|
|
284
893
|
const senderRaw = message.user;
|
|
285
|
-
|
|
894
|
+
if (!senderRaw)
|
|
895
|
+
return;
|
|
896
|
+
const senderId = String(senderRaw.id);
|
|
286
897
|
// Reply to a pending delegation call? Hand it to that waiting promise
|
|
287
898
|
// instead of starting a fresh reply cycle -- see delegations.ts.
|
|
288
899
|
if (delegations.resolveIfPending(chatId, senderId, caption))
|
|
289
900
|
return;
|
|
901
|
+
// The answer to a pending ctx.ask, typed rather than tapped? Settle
|
|
902
|
+
// that wait instead of starting a fresh reply cycle -- see ask.ts.
|
|
903
|
+
// Only a human's plain reply counts (an agent addressed by handle
|
|
904
|
+
// gets its own webhook/turn, same reasoning as the mention-gating
|
|
905
|
+
// below); `caption` is the raw decrypted text, same as delegations'
|
|
906
|
+
// check just above.
|
|
907
|
+
if (asks.resolveMessage(identity.saltAppId, chatId, senderId, senderRaw.account_type !== "Agent", caption))
|
|
908
|
+
return;
|
|
290
909
|
// Never reply to our own messages (the reply we post is itself
|
|
291
910
|
// delivered back to us as a webhook) -- this is what prevents an
|
|
292
911
|
// infinite loop.
|
|
293
|
-
if (senderId
|
|
912
|
+
if ((0, ids_js_1.sameId)(senderId, identity.saltAppId))
|
|
913
|
+
return;
|
|
914
|
+
// A device lane is wire protocol, not conversation: hand it to the device
|
|
915
|
+
// hook before any conversational gate can drop it (see onDeviceLaneMessage).
|
|
916
|
+
if (chatMeta?.lane_kind === "device" && options.onDeviceLaneMessage) {
|
|
917
|
+
try {
|
|
918
|
+
Promise.resolve(options.onDeviceLaneMessage({ identity, chatId, senderId, sender: senderRaw, text: caption, chatMeta })).catch((err) => {
|
|
919
|
+
logger.error(`[chat ${chatId}] onDeviceLaneMessage failed: ${err.message}`);
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
logger.error(`[chat ${chatId}] onDeviceLaneMessage failed: ${err.message}`);
|
|
924
|
+
}
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
// request_floor (actions.ts): the consulted agent is asking to be
|
|
928
|
+
// brought into the room directly instead of relaying further through
|
|
929
|
+
// this lane. Wire protocol, never a prompt -- and only actionable by
|
|
930
|
+
// whichever hosted identity actually opened this lane as the asker.
|
|
931
|
+
if (caption.startsWith(delegations.FLOOR_REQUEST_MARKER)) {
|
|
932
|
+
const asker = delegations.consultAskerFor(chatId);
|
|
933
|
+
if (asker && (0, ids_js_1.sameId)(asker.askerId, identity.saltAppId)) {
|
|
934
|
+
const reason = caption.slice(delegations.FLOOR_REQUEST_MARKER.length).replace(/^\n/, "").trim() || undefined;
|
|
935
|
+
pendingConsultHandoffs.set(mapKey(asker.roomId), chatId);
|
|
936
|
+
try {
|
|
937
|
+
await client.handOff(identity.apiKey, asker.roomId, senderId, reason);
|
|
938
|
+
}
|
|
939
|
+
catch (err) {
|
|
940
|
+
pendingConsultHandoffs.delete(mapKey(asker.roomId));
|
|
941
|
+
logger.error(`[chat ${asker.roomId}] auto hand-off on floor request failed: ${err.message}`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
// Identity (R3/R4, identityShare.ts): a SALT-IDENTITY-ASK/SLICE/
|
|
947
|
+
// DECLINE/REVOKE is wire protocol, never a prompt -- with one
|
|
948
|
+
// deliberate exception. An ASK with no onIdentityAsk handler registered
|
|
949
|
+
// is NOT intercepted at all: it falls through as an ordinary message,
|
|
950
|
+
// marker stripped, so a consumer that hasn't wired up the handler yet
|
|
951
|
+
// sees whatever text (if any) rode alongside it rather than losing the
|
|
952
|
+
// message outright. SLICE/DECLINE/REVOKE are always intercepted,
|
|
953
|
+
// whether or not onIdentityShared is set -- there is no "ordinary
|
|
954
|
+
// text" reading of a signed slice or a revoke.
|
|
955
|
+
if (caption.startsWith(identityShare.IDENTITY_MARKER_PREFIX)) {
|
|
956
|
+
const parsed = identityShare.parseIdentityMarker(caption);
|
|
957
|
+
if (parsed?.kind === "ask") {
|
|
958
|
+
if (options.onIdentityAsk) {
|
|
959
|
+
await handleIdentityAsk(identity, chatId, senderId, parsed);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
caption = parsed.text || "";
|
|
963
|
+
}
|
|
964
|
+
else if (parsed) {
|
|
965
|
+
await handleIdentitySharedMarker(identity, chatId, senderId, parsed);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
// Starts with the prefix but didn't parse into anything usable --
|
|
970
|
+
// a garbled or foreign marker either way, never a prompt.
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// Cancel (work.ts's [[SALT-WORK-CANCEL id=...]]): the person cancelling
|
|
975
|
+
// a scheduled report of theirs, sent as a whole message into the lane
|
|
976
|
+
// that report was posted into. Wire protocol, never a prompt -- always
|
|
977
|
+
// intercepted, whether or not onWorkCancel is set, same as a SLICE/
|
|
978
|
+
// DECLINE/REVOKE above (there's no "ordinary text" reading of it).
|
|
979
|
+
const cancelWorkId = (0, work_1.parseWorkCancel)(caption);
|
|
980
|
+
if (cancelWorkId) {
|
|
981
|
+
if (options.onWorkCancel) {
|
|
982
|
+
const cancelRoomId = chatMeta?.coaching_for_chat_id || chatId;
|
|
983
|
+
try {
|
|
984
|
+
await options.onWorkCancel({ identity, workId: cancelWorkId, chatId: cancelRoomId, laneId: chatId, sender: senderRaw });
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
logger.error(`[chat ${chatId}] onWorkCancel failed: ${err.message}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
294
990
|
return;
|
|
991
|
+
}
|
|
295
992
|
// Mediated Chat: this identity is the configured Mediator, and this
|
|
296
993
|
// message is in the SHARED chat it silently observes -- present for
|
|
297
994
|
// context, but it only actually speaks in each human's own private
|
|
298
995
|
// coaching chat (coaching_for_chat_id pointing back at this one).
|
|
299
|
-
const isMediator = !!mediatorAgentId && identity.saltAppId
|
|
996
|
+
const isMediator = !!mediatorAgentId && (0, ids_js_1.sameId)(identity.saltAppId, mediatorAgentId);
|
|
300
997
|
if (isMediator && chatMeta?.mediator_agent_id && !chatMeta.coaching_for_chat_id)
|
|
301
998
|
return;
|
|
302
999
|
// Global Agent Chat Mode: when the chat declares an active agent and
|
|
303
1000
|
// it isn't this identity, stay silent. Lets retired/handed-off-from
|
|
304
1001
|
// agents remain in the room without ever double-replying.
|
|
305
|
-
if (chatMeta?.active_agent_id &&
|
|
1002
|
+
if (chatMeta?.active_agent_id && !(0, ids_js_1.sameId)(chatMeta.active_agent_id, identity.saltAppId))
|
|
306
1003
|
return;
|
|
307
1004
|
// A human sender always resets the count -- only agent-to-agent
|
|
308
1005
|
// volleys are capped, never a real conversation with a person.
|
|
309
|
-
const senderIsAgent = !!identities.get(senderId) || senderRaw
|
|
1006
|
+
const senderIsAgent = !!identities.get(senderId) || senderRaw.account_type === "Agent";
|
|
1007
|
+
const replyCountKey = mapKey(chatId);
|
|
1008
|
+
if (chatMeta?.coaching_for_chat_id) {
|
|
1009
|
+
laneRoomOf.set(replyCountKey, mapKey(chatMeta.coaching_for_chat_id));
|
|
1010
|
+
}
|
|
310
1011
|
if (senderIsAgent) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
1012
|
+
// A real person changes the rule entirely: an agent-authored message
|
|
1013
|
+
// is only ours to answer when it @mentions us -- the same gate
|
|
1014
|
+
// salt-api applies to whether the webhook is even sent in a group
|
|
1015
|
+
// chat, belt-and-suspenders for whatever still reaches us (a hand-off
|
|
1016
|
+
// farewell, a briefing after item 1 above missed a variant, another
|
|
1017
|
+
// agent's aside). Two agents alone in a lane have no one to perform
|
|
1018
|
+
// for, so the runaway cap below still governs those exactly as before.
|
|
1019
|
+
if (await chatHasNonObserverHuman(identity, chatId, chatMeta)) {
|
|
1020
|
+
const mentions = (Array.isArray(message.mentions) ? message.mentions : []).map(String);
|
|
1021
|
+
if (!mentions.some((id) => (0, ids_js_1.sameId)(id, identity.saltAppId))) {
|
|
1022
|
+
logger.error(`[chat ${chatId}] agent message not addressed to us in a chat with a person present; not auto-replying to ${senderId}.`);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
const limit = chatMeta?.lane_kind === "consult" ? CONSULT_RUNAWAY_LIMIT : MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT;
|
|
1028
|
+
const count = (agentToAgentReplyCounts.get(replyCountKey) || 0) + 1;
|
|
1029
|
+
agentToAgentReplyCounts.set(replyCountKey, count);
|
|
1030
|
+
if (count > limit) {
|
|
1031
|
+
logger.error(`[chat ${chatId}] agent-to-agent reply cap reached; not auto-replying to ${senderId} again.`);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
316
1034
|
}
|
|
317
1035
|
}
|
|
318
1036
|
else {
|
|
319
|
-
agentToAgentReplyCounts.delete(
|
|
1037
|
+
agentToAgentReplyCounts.delete(replyCountKey);
|
|
1038
|
+
// A human speaking in a ROOM also resets every consult lane rooted
|
|
1039
|
+
// there -- otherwise a lane capped mid-conversation stays capped even
|
|
1040
|
+
// right after the person who asked for it weighs in.
|
|
1041
|
+
for (const [laneKey, roomKey] of laneRoomOf) {
|
|
1042
|
+
if (roomKey === replyCountKey)
|
|
1043
|
+
agentToAgentReplyCounts.delete(laneKey);
|
|
1044
|
+
}
|
|
320
1045
|
}
|
|
321
|
-
const { depth, text:
|
|
1046
|
+
const { depth, text: afterDepthMarker } = delegations.parseIncoming(caption);
|
|
1047
|
+
const strippedCaption = delegations.stripConsultMarker(afterDepthMarker);
|
|
322
1048
|
let mediatorSharedContext;
|
|
323
1049
|
let attachment;
|
|
324
|
-
|
|
1050
|
+
// The shared conversation is fetched only for the MUTUAL Mediator lane,
|
|
1051
|
+
// where this identity is an observer on that chat and both parties can see
|
|
1052
|
+
// it is there. A private advisor lane is excluded: that advisor was never
|
|
1053
|
+
// added to the shared chat, so every message would fail to decrypt and be
|
|
1054
|
+
// skipped anyway -- but a guarantee that rests on a failed decrypt is one
|
|
1055
|
+
// membership bug away from being no guarantee, so we do not ask at all.
|
|
1056
|
+
if (isMediator && chatMeta?.coaching_for_chat_id && !chatMeta.private_lane) {
|
|
325
1057
|
mediatorSharedContext = await buildSharedChatContext(identity, chatMeta.coaching_for_chat_id);
|
|
326
1058
|
}
|
|
327
1059
|
else if (message.resource_type === "Attachment") {
|
|
@@ -329,20 +1061,63 @@ function createWebhookServer(options) {
|
|
|
329
1061
|
}
|
|
330
1062
|
if (!options.onMessage)
|
|
331
1063
|
return;
|
|
1064
|
+
const roomId = chatMeta?.coaching_for_chat_id || chatId;
|
|
1065
|
+
const session = await loadOrRebuildSession(identity, chatId, chatMeta);
|
|
1066
|
+
// Wraps the shared reply() closure to also remember what was sent, so
|
|
1067
|
+
// it can be appended to the session as an assistant turn once the
|
|
1068
|
+
// handler returns successfully -- see the persistence step below.
|
|
1069
|
+
// Nothing about what actually gets sent changes.
|
|
1070
|
+
const repliesForSession = [];
|
|
1071
|
+
const baseReply = makeReply(identity, chatId, senderRaw);
|
|
1072
|
+
const trackedReply = async (text) => {
|
|
1073
|
+
repliesForSession.push(text);
|
|
1074
|
+
await baseReply(text);
|
|
1075
|
+
};
|
|
332
1076
|
const ctx = {
|
|
333
1077
|
identity,
|
|
334
1078
|
chatId,
|
|
335
1079
|
senderId,
|
|
336
1080
|
sender: senderRaw,
|
|
337
1081
|
text: strippedCaption,
|
|
1082
|
+
encrypted: !isPlaintext,
|
|
1083
|
+
deliveredBecause: parseDeliveredBecause(message.delivered_because),
|
|
1084
|
+
replyTo: message.reply_to_message_id != null ? String(message.reply_to_message_id) : undefined,
|
|
1085
|
+
parent: parseReplyParent(message.parent),
|
|
338
1086
|
delegationDepth: depth,
|
|
339
1087
|
chatMeta,
|
|
1088
|
+
roomId,
|
|
340
1089
|
mediatorSharedContext,
|
|
341
1090
|
attachment,
|
|
342
|
-
|
|
1091
|
+
session,
|
|
1092
|
+
reply: trackedReply,
|
|
1093
|
+
messageId: message.message_id,
|
|
1094
|
+
react: (emoji) => client.react(identity.apiKey, message.message_id, emoji),
|
|
1095
|
+
// M2: default answerer is whoever sent THIS message -- never an
|
|
1096
|
+
// agent (an agent sender has its own webhook/turn; ctx.ask is for
|
|
1097
|
+
// asking a person).
|
|
1098
|
+
ask: makeAsk(identity, chatId, senderRaw.account_type !== "Agent" ? senderId : undefined),
|
|
1099
|
+
approve: makeApprove(identity, chatId, senderRaw.account_type !== "Agent" ? senderId : undefined),
|
|
1100
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
343
1101
|
};
|
|
344
1102
|
try {
|
|
345
1103
|
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onMessage(ctx)));
|
|
1104
|
+
try {
|
|
1105
|
+
sessions.appendTurn(session, {
|
|
1106
|
+
role: "user",
|
|
1107
|
+
content: strippedCaption,
|
|
1108
|
+
at: Date.now(),
|
|
1109
|
+
from: String(senderRaw.username || senderRaw.display_name || senderId),
|
|
1110
|
+
});
|
|
1111
|
+
for (const replyText of repliesForSession) {
|
|
1112
|
+
sessions.appendTurn(session, { role: "assistant", content: replyText, at: Date.now() });
|
|
1113
|
+
}
|
|
1114
|
+
session.note = sessions.boundNote(session.note);
|
|
1115
|
+
session.updatedAt = Date.now();
|
|
1116
|
+
await sessionStore.put(identity.saltAppId, chatId, session);
|
|
1117
|
+
}
|
|
1118
|
+
catch (err) {
|
|
1119
|
+
logger.error(`[session] persisting ${chatId} failed: ${err.message}`);
|
|
1120
|
+
}
|
|
346
1121
|
}
|
|
347
1122
|
catch (err) {
|
|
348
1123
|
logger.error(`[chat ${chatId}] onMessage failed: ${err.message}`);
|
|
@@ -352,9 +1127,19 @@ function createWebhookServer(options) {
|
|
|
352
1127
|
}
|
|
353
1128
|
}
|
|
354
1129
|
async function handleCardInteraction(body) {
|
|
355
|
-
|
|
1130
|
+
// A tap answering a pending ctx.ask's own card is wire-level bookkeeping
|
|
1131
|
+
// for ask.ts, never something the consumer's onCardInteraction should
|
|
1132
|
+
// also see -- checked before identity resolution/onCardInteraction even
|
|
1133
|
+
// matter, since this can settle an ask() with no consumer handler
|
|
1134
|
+
// registered for cards at all.
|
|
1135
|
+
if (asks.resolveCardInteraction(body.owner_id, body.chat_id, body.card_id, body.action_id, body.user))
|
|
1136
|
+
return;
|
|
1137
|
+
const identity = identities.get(String(body.owner_id));
|
|
356
1138
|
if (!identity || !options.onCardInteraction)
|
|
357
1139
|
return;
|
|
1140
|
+
// No reply() on this context (a card update IS the response), so just
|
|
1141
|
+
// load -- nothing new here to append or persist afterward.
|
|
1142
|
+
const session = await loadOrRebuildSession(identity, body.chat_id);
|
|
358
1143
|
try {
|
|
359
1144
|
await options.onCardInteraction({
|
|
360
1145
|
identity,
|
|
@@ -363,17 +1148,67 @@ function createWebhookServer(options) {
|
|
|
363
1148
|
actionId: body.action_id,
|
|
364
1149
|
user: body.user,
|
|
365
1150
|
blocks: body.state?.blocks,
|
|
1151
|
+
values: body.values || {},
|
|
1152
|
+
session,
|
|
366
1153
|
});
|
|
367
1154
|
}
|
|
368
1155
|
catch (err) {
|
|
369
1156
|
logger.error(`[chat ${body.chat_id}] onCardInteraction failed: ${err.message}`);
|
|
370
1157
|
}
|
|
371
1158
|
}
|
|
1159
|
+
async function handleAppAction(body, headerAgentId) {
|
|
1160
|
+
// No owner_id/seller_id in this body to fall back on (unlike
|
|
1161
|
+
// card_interaction/invoice_paid) -- X-Salt-Agent-Id is the only signal,
|
|
1162
|
+
// same as the mandate rail events further down. verifyEnvelope already
|
|
1163
|
+
// proved the signature against that exact identity's own key.
|
|
1164
|
+
const identity = headerAgentId != null ? identities.get(String(headerAgentId)) : undefined;
|
|
1165
|
+
if (!identity || !options.onAppAction)
|
|
1166
|
+
return;
|
|
1167
|
+
const chatId = body.chat_id ?? null;
|
|
1168
|
+
// A personal ("Your apps") installation has no chat at all -- nothing
|
|
1169
|
+
// for a reply to post into, same as salt.post's "Not in a chat."
|
|
1170
|
+
// rejection on the bridge side. Fails soft (logged), the same
|
|
1171
|
+
// convention every other reply() failure in this file follows.
|
|
1172
|
+
const reply = chatId != null
|
|
1173
|
+
? makeReply(identity, chatId)
|
|
1174
|
+
: async (_text) => {
|
|
1175
|
+
logger.error(`[app ${body.installation_id}] reply() has no chat -- this is a personal installation.`);
|
|
1176
|
+
};
|
|
1177
|
+
const ctx = {
|
|
1178
|
+
identity,
|
|
1179
|
+
installationId: body.installation_id,
|
|
1180
|
+
app: body.app,
|
|
1181
|
+
chatId,
|
|
1182
|
+
text: body.text,
|
|
1183
|
+
data: body.data,
|
|
1184
|
+
stateVersion: body.state_version,
|
|
1185
|
+
sender: body.user,
|
|
1186
|
+
reply,
|
|
1187
|
+
};
|
|
1188
|
+
try {
|
|
1189
|
+
if (chatId != null) {
|
|
1190
|
+
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onAppAction(ctx)));
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
await options.onAppAction(ctx);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
catch (err) {
|
|
1197
|
+
logger.error(`[app ${body.installation_id}] onAppAction failed: ${err.message}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
372
1200
|
async function handleInvoicePaid(body) {
|
|
373
|
-
const identity = identities.get(
|
|
1201
|
+
const identity = identities.get(String(body.seller_id));
|
|
374
1202
|
if (!identity || !body.chat_id || !options.onInvoicePaid)
|
|
375
1203
|
return;
|
|
376
1204
|
const chatId = body.chat_id;
|
|
1205
|
+
const session = await loadOrRebuildSession(identity, chatId);
|
|
1206
|
+
const repliesForSession = [];
|
|
1207
|
+
const baseReply = makeReply(identity, chatId);
|
|
1208
|
+
const trackedReply = async (text) => {
|
|
1209
|
+
repliesForSession.push(text);
|
|
1210
|
+
await baseReply(text);
|
|
1211
|
+
};
|
|
377
1212
|
const ctx = {
|
|
378
1213
|
identity,
|
|
379
1214
|
chatId,
|
|
@@ -382,30 +1217,143 @@ function createWebhookServer(options) {
|
|
|
382
1217
|
amount: body.amount,
|
|
383
1218
|
isTopUp: !!body.billing_account_id,
|
|
384
1219
|
transferRequestId: body.transfer_request_id,
|
|
385
|
-
|
|
1220
|
+
session,
|
|
1221
|
+
reply: trackedReply,
|
|
1222
|
+
// M2: default answerer is the buyer (never an agent -- an agent
|
|
1223
|
+
// buyer has no need to be asked a question through its own purchase).
|
|
1224
|
+
ask: makeAsk(identity, chatId, body.buyer.account_type !== "Agent" ? body.buyer.id : undefined),
|
|
1225
|
+
approve: makeApprove(identity, chatId, body.buyer.account_type !== "Agent" ? body.buyer.id : undefined),
|
|
1226
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
386
1227
|
};
|
|
387
1228
|
try {
|
|
388
1229
|
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onInvoicePaid(ctx)));
|
|
1230
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
389
1231
|
}
|
|
390
1232
|
catch (err) {
|
|
391
1233
|
logger.error(`[chat ${chatId}] onInvoicePaid failed: ${err.message}`);
|
|
392
1234
|
}
|
|
393
1235
|
}
|
|
1236
|
+
async function handleChatOpened(body) {
|
|
1237
|
+
if (!options.onChatOpened)
|
|
1238
|
+
return;
|
|
1239
|
+
const chat = body.chat;
|
|
1240
|
+
const chatId = chat?.id;
|
|
1241
|
+
if (chatId == null)
|
|
1242
|
+
return;
|
|
1243
|
+
// Unlike card_interaction/invoice_paid, this payload carries no explicit
|
|
1244
|
+
// owner/seller id naming which hosted identity it's for -- salt-api
|
|
1245
|
+
// delivers it once per member with a callback URL, same as everything
|
|
1246
|
+
// else, but the only way this process (which can host several identities
|
|
1247
|
+
// on one endpoint) learns WHICH of its identities the event is about is
|
|
1248
|
+
// by noticing itself in the member list. Prefer the chat's declared
|
|
1249
|
+
// active agent when that's one of ours (GACM), the same tie-break
|
|
1250
|
+
// handleMessage uses, in case delegation ever puts two hosted identities
|
|
1251
|
+
// in the same chat.
|
|
1252
|
+
let identity;
|
|
1253
|
+
if (chat?.active_agent_id)
|
|
1254
|
+
identity = identities.get(String(chat.active_agent_id));
|
|
1255
|
+
if (!identity) {
|
|
1256
|
+
for (const member of body.members || []) {
|
|
1257
|
+
identity = identities.get(String(member.id));
|
|
1258
|
+
if (identity)
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (!identity) {
|
|
1263
|
+
logger.error(`[chat ${chatId}] chat_opened: no hosted identity found among members; ignoring.`);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (alreadyGreeted(identity.saltAppId, chatId))
|
|
1267
|
+
return;
|
|
1268
|
+
const session = await loadOrRebuildSession(identity, chatId, chat);
|
|
1269
|
+
const repliesForSession = [];
|
|
1270
|
+
const baseReply = makeReply(identity, chatId);
|
|
1271
|
+
const trackedReply = async (text) => {
|
|
1272
|
+
repliesForSession.push(text);
|
|
1273
|
+
await baseReply(text);
|
|
1274
|
+
};
|
|
1275
|
+
const ctx = {
|
|
1276
|
+
identity,
|
|
1277
|
+
chatId,
|
|
1278
|
+
chat: chat,
|
|
1279
|
+
encrypted: chat?.encrypted !== false,
|
|
1280
|
+
openedBy: body.opened_by,
|
|
1281
|
+
members: body.members || [],
|
|
1282
|
+
openedAt: body.opened_at,
|
|
1283
|
+
session,
|
|
1284
|
+
reply: trackedReply,
|
|
1285
|
+
// M2: default answerer is whoever opened the chat (never an agent).
|
|
1286
|
+
ask: makeAsk(identity, chatId, body.opened_by?.account_type !== "Agent" ? body.opened_by?.id : undefined),
|
|
1287
|
+
approve: makeApprove(identity, chatId, body.opened_by?.account_type !== "Agent" ? body.opened_by?.id : undefined),
|
|
1288
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
1289
|
+
};
|
|
1290
|
+
try {
|
|
1291
|
+
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onChatOpened(ctx)));
|
|
1292
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
1293
|
+
}
|
|
1294
|
+
catch (err) {
|
|
1295
|
+
logger.error(`[chat ${chatId}] onChatOpened failed: ${err.message}`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
394
1298
|
async function handleHandoffConfirmed(body) {
|
|
395
|
-
const identity = identities.get(
|
|
1299
|
+
const identity = identities.get(String(body.from_agent_id));
|
|
396
1300
|
if (!identity || !options.onHandoffConfirmed)
|
|
397
1301
|
return;
|
|
398
1302
|
const chatId = body.chat_id;
|
|
399
|
-
|
|
1303
|
+
// A request_floor call may have just auto-triggered this same hand-off
|
|
1304
|
+
// (webhook.ts's floor-request handler, above) -- if so, give the
|
|
1305
|
+
// briefing its consult lane's transcript.
|
|
1306
|
+
const laneId = pendingConsultHandoffs.get(mapKey(chatId));
|
|
1307
|
+
if (laneId)
|
|
1308
|
+
pendingConsultHandoffs.delete(mapKey(chatId));
|
|
1309
|
+
let consultTranscript;
|
|
1310
|
+
if (laneId) {
|
|
1311
|
+
try {
|
|
1312
|
+
consultTranscript = await buildConsultTranscript(identity, laneId);
|
|
1313
|
+
}
|
|
1314
|
+
catch (err) {
|
|
1315
|
+
logger.error(`[chat ${chatId}] fetching consult transcript for hand-off failed: ${err.message}`);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
const session = await loadOrRebuildSession(identity, chatId);
|
|
1319
|
+
// Appends the outgoing session's note (if any) as the briefing's final
|
|
1320
|
+
// wire-protocol line -- see sessions.ts's SESSION_NOTE_MARKER and
|
|
1321
|
+
// handleHandoffReceived below, which parses it back out on the other
|
|
1322
|
+
// end. Transparent to the consumer: whatever text they reply() with is
|
|
1323
|
+
// what a human sees; this just rides along after it. Reads `session.note`
|
|
1324
|
+
// directly rather than re-fetching -- the same object ctx.session is, so
|
|
1325
|
+
// a handler that wrote to it before calling reply() is picked up.
|
|
1326
|
+
const repliesForSession = [];
|
|
1327
|
+
const baseReply = makeReply(identity, chatId);
|
|
1328
|
+
const reply = async (text) => {
|
|
1329
|
+
repliesForSession.push(text);
|
|
1330
|
+
const noteLine = sessions.formatSessionNoteLine(session.note);
|
|
1331
|
+
await baseReply(noteLine ? `${text}\n${noteLine}` : text);
|
|
1332
|
+
};
|
|
1333
|
+
const ctx = {
|
|
1334
|
+
identity,
|
|
1335
|
+
chatId,
|
|
1336
|
+
reason: body.reason,
|
|
1337
|
+
consultTranscript,
|
|
1338
|
+
session,
|
|
1339
|
+
reply,
|
|
1340
|
+
// M2: no natural default here (a hand-off has no single "whoever
|
|
1341
|
+
// this answers") -- ask()/approve() from this context must be given
|
|
1342
|
+
// answererId explicitly, or they throw synchronously.
|
|
1343
|
+
ask: makeAsk(identity, chatId),
|
|
1344
|
+
approve: makeApprove(identity, chatId),
|
|
1345
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
1346
|
+
};
|
|
400
1347
|
try {
|
|
401
1348
|
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onHandoffConfirmed(ctx)));
|
|
1349
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
402
1350
|
}
|
|
403
1351
|
catch (err) {
|
|
404
1352
|
logger.error(`[chat ${chatId}] onHandoffConfirmed failed: ${err.message}`);
|
|
405
1353
|
}
|
|
406
1354
|
}
|
|
407
1355
|
async function handleHandoffReceived(body) {
|
|
408
|
-
const identity = identities.get(
|
|
1356
|
+
const identity = identities.get(String(body.to_agent_id));
|
|
409
1357
|
if (!identity || !options.onHandoffReceived)
|
|
410
1358
|
return;
|
|
411
1359
|
const chatId = body.chat_id;
|
|
@@ -421,44 +1369,397 @@ function createWebhookServer(options) {
|
|
|
421
1369
|
break;
|
|
422
1370
|
await new Promise((r) => setTimeout(r, 2000));
|
|
423
1371
|
}
|
|
424
|
-
|
|
1372
|
+
// Seed this identity's own session for `chatId` from whatever's already
|
|
1373
|
+
// stored (a fresh one if nothing is), fold in the outgoing agent's note
|
|
1374
|
+
// if the briefing carried one, and persist that much right away --
|
|
1375
|
+
// independent of whether onHandoffReceived itself succeeds below, same
|
|
1376
|
+
// as before this session had a `session` field on its ctx at all.
|
|
1377
|
+
// Stripped from `context` either way: wire protocol, never something
|
|
1378
|
+
// the incoming agent's own prompt-building should see verbatim.
|
|
1379
|
+
let session;
|
|
1380
|
+
try {
|
|
1381
|
+
session = (await sessionStore.get(identity.saltAppId, chatId)) ?? sessions.emptySession(chatId, chatId);
|
|
1382
|
+
}
|
|
1383
|
+
catch (err) {
|
|
1384
|
+
logger.error(`[session] loading ${chatId} failed: ${err.message}`);
|
|
1385
|
+
session = sessions.emptySession(chatId, chatId);
|
|
1386
|
+
}
|
|
1387
|
+
const note = sessions.extractSessionNote(context);
|
|
1388
|
+
if (note) {
|
|
1389
|
+
session.note = sessions.boundNote(note);
|
|
1390
|
+
session.updatedAt = Date.now();
|
|
1391
|
+
try {
|
|
1392
|
+
await sessionStore.put(identity.saltAppId, chatId, session);
|
|
1393
|
+
}
|
|
1394
|
+
catch (err) {
|
|
1395
|
+
logger.error(`[session] persisting handed-off note for ${chatId} failed: ${err.message}`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
context = sessions.stripSessionNoteLines(context);
|
|
1399
|
+
// Whatever the introduction reply() sends here is appended below,
|
|
1400
|
+
// exactly like onMessage does for its own reply -- so it's already in
|
|
1401
|
+
// transcriptTail the next time this chat's onMessage runs.
|
|
1402
|
+
const repliesForSession = [];
|
|
1403
|
+
const baseReply = makeReply(identity, chatId);
|
|
1404
|
+
const trackedReply = async (text) => {
|
|
1405
|
+
repliesForSession.push(text);
|
|
1406
|
+
await baseReply(text);
|
|
1407
|
+
};
|
|
1408
|
+
const ctx = {
|
|
1409
|
+
identity,
|
|
1410
|
+
chatId,
|
|
1411
|
+
reason: body.reason,
|
|
1412
|
+
context,
|
|
1413
|
+
session,
|
|
1414
|
+
reply: trackedReply,
|
|
1415
|
+
// M2: no natural default here either -- see HandoffConfirmedContext's comment above.
|
|
1416
|
+
ask: makeAsk(identity, chatId),
|
|
1417
|
+
approve: makeApprove(identity, chatId),
|
|
1418
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
1419
|
+
};
|
|
425
1420
|
try {
|
|
426
1421
|
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onHandoffReceived(ctx)));
|
|
1422
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
427
1423
|
}
|
|
428
1424
|
catch (err) {
|
|
429
1425
|
logger.error(`[chat ${chatId}] onHandoffReceived failed: ${err.message}`);
|
|
430
1426
|
}
|
|
431
1427
|
}
|
|
432
|
-
|
|
1428
|
+
// Resolves which of this process's hosted identities a mandate/exercise event is FOR. The
|
|
1429
|
+
// envelope's X-Salt-Agent-Id is authoritative (verifyEnvelope already checked the signature
|
|
1430
|
+
// against that exact identity's own key, so it names a real recipient) -- the `candidateIds`
|
|
1431
|
+
// fallback (party ids off the payload itself) only matters for a caller that runs the dispatcher
|
|
1432
|
+
// directly with no header (e.g. a test, or a future transport that doesn't have one).
|
|
1433
|
+
function resolveMandateIdentity(headerAgentId, ...candidateIds) {
|
|
1434
|
+
if (headerAgentId != null) {
|
|
1435
|
+
const byHeader = identities.get(String(headerAgentId));
|
|
1436
|
+
if (byHeader)
|
|
1437
|
+
return byHeader;
|
|
1438
|
+
}
|
|
1439
|
+
for (const id of candidateIds) {
|
|
1440
|
+
if (id == null)
|
|
1441
|
+
continue;
|
|
1442
|
+
const found = identities.get(String(id));
|
|
1443
|
+
if (found)
|
|
1444
|
+
return found;
|
|
1445
|
+
}
|
|
1446
|
+
return undefined;
|
|
1447
|
+
}
|
|
1448
|
+
async function handleMandateOffered(body, headerAgentId) {
|
|
1449
|
+
const identity = resolveMandateIdentity(headerAgentId, body.mandate?.grantee?.id);
|
|
1450
|
+
if (!identity || !options.onMandateOffered)
|
|
1451
|
+
return;
|
|
1452
|
+
const mandate = body.mandate;
|
|
1453
|
+
const ctx = {
|
|
1454
|
+
identity,
|
|
1455
|
+
mandate,
|
|
1456
|
+
accept: () => client.mandates.accept(identity.apiKey, mandate.id),
|
|
1457
|
+
};
|
|
1458
|
+
try {
|
|
1459
|
+
await options.onMandateOffered(ctx);
|
|
1460
|
+
}
|
|
1461
|
+
catch (err) {
|
|
1462
|
+
logger.error(`[mandate ${mandate.id}] onMandateOffered failed: ${err.message}`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
async function handleMandateLifecycleEvent(kind, body, headerAgentId) {
|
|
1466
|
+
const mandate = body.mandate;
|
|
1467
|
+
const identity = resolveMandateIdentity(headerAgentId, mandate?.grantee?.id, mandate?.grantor?.id);
|
|
1468
|
+
const handler = kind === "activated" ? options.onMandateActivated : kind === "paused" ? options.onMandatePaused : options.onMandateRevoked;
|
|
1469
|
+
if (!identity || !handler)
|
|
1470
|
+
return;
|
|
1471
|
+
try {
|
|
1472
|
+
await handler({ identity, mandate });
|
|
1473
|
+
}
|
|
1474
|
+
catch (err) {
|
|
1475
|
+
logger.error(`[mandate ${mandate.id}] onMandate${kind[0].toUpperCase()}${kind.slice(1)} failed: ${err.message}`);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
async function handleApprovalRequested(body, headerAgentId) {
|
|
1479
|
+
const exercise = body.exercise;
|
|
1480
|
+
const identity = resolveMandateIdentity(headerAgentId, exercise?.principal?.id);
|
|
1481
|
+
if (!identity || !options.onApprovalRequested)
|
|
1482
|
+
return;
|
|
1483
|
+
const ctx = {
|
|
1484
|
+
identity,
|
|
1485
|
+
exercise,
|
|
1486
|
+
decide: (decision, note) => client.mandates.decide(identity.apiKey, exercise.id, decision, note),
|
|
1487
|
+
};
|
|
1488
|
+
try {
|
|
1489
|
+
await options.onApprovalRequested(ctx);
|
|
1490
|
+
}
|
|
1491
|
+
catch (err) {
|
|
1492
|
+
logger.error(`[exercise ${exercise.id}] onApprovalRequested failed: ${err.message}`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async function handleApprovalDecided(body, headerAgentId) {
|
|
1496
|
+
const exercise = body.exercise;
|
|
1497
|
+
const identity = resolveMandateIdentity(headerAgentId, exercise?.actor?.id);
|
|
1498
|
+
if (!identity || !options.onApprovalDecided)
|
|
1499
|
+
return;
|
|
1500
|
+
try {
|
|
1501
|
+
await options.onApprovalDecided({ identity, exercise });
|
|
1502
|
+
}
|
|
1503
|
+
catch (err) {
|
|
1504
|
+
logger.error(`[exercise ${exercise.id}] onApprovalDecided failed: ${err.message}`);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async function handleDeviceSessionEnded(body, headerAgentId) {
|
|
1508
|
+
if (!options.onDeviceSessionEnded)
|
|
1509
|
+
return;
|
|
1510
|
+
const identity = resolveMandateIdentity(headerAgentId, body.agent_id);
|
|
1511
|
+
if (!identity)
|
|
1512
|
+
return;
|
|
1513
|
+
// The same delivery also goes to the computer itself; it is not the agent's to speak of.
|
|
1514
|
+
if ((0, ids_js_1.sameId)(identity.saltAppId, body.device_id))
|
|
1515
|
+
return;
|
|
1516
|
+
const sessionId = body.session_id ?? body.id;
|
|
1517
|
+
if (sessionId == null)
|
|
1518
|
+
return;
|
|
1519
|
+
const ctx = {
|
|
1520
|
+
identity,
|
|
1521
|
+
sessionId,
|
|
1522
|
+
deviceId: body.device_id,
|
|
1523
|
+
mandateId: body.mandate_id ?? null,
|
|
1524
|
+
laneChatId: body.chat_id ?? body.chat?.id ?? null,
|
|
1525
|
+
endReason: body.end_reason ?? null,
|
|
1526
|
+
lostContact: body.end_reason === exports.DEVICE_LOST_CONTACT,
|
|
1527
|
+
intent: typeof body.intent === "string" ? body.intent : null,
|
|
1528
|
+
endedAt: body.ended_at ?? null,
|
|
1529
|
+
body,
|
|
1530
|
+
};
|
|
1531
|
+
try {
|
|
1532
|
+
await options.onDeviceSessionEnded(ctx);
|
|
1533
|
+
}
|
|
1534
|
+
catch (err) {
|
|
1535
|
+
logger.error(`[device session ${sessionId}] onDeviceSessionEnded failed: ${err.message}`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
async function handleDeviceBack(body, headerAgentId) {
|
|
1539
|
+
if (!options.onDeviceBack)
|
|
1540
|
+
return;
|
|
1541
|
+
const identity = resolveMandateIdentity(headerAgentId);
|
|
1542
|
+
if (!identity)
|
|
1543
|
+
return;
|
|
1544
|
+
const chatId = body.chat_id ?? null;
|
|
1545
|
+
const session = chatId != null ? await loadOrRebuildSession(identity, chatId) : null;
|
|
1546
|
+
const repliesForSession = [];
|
|
1547
|
+
const baseReply = chatId != null ? makeReply(identity, chatId) : null;
|
|
1548
|
+
const ctx = {
|
|
1549
|
+
identity,
|
|
1550
|
+
deviceId: body.device_id,
|
|
1551
|
+
deviceName: body.device_name,
|
|
1552
|
+
previousSessionId: body.previous_session_id,
|
|
1553
|
+
offlineAt: body.offline_at ?? null,
|
|
1554
|
+
backAt: body.back_at,
|
|
1555
|
+
chatId,
|
|
1556
|
+
session,
|
|
1557
|
+
body,
|
|
1558
|
+
reply: async (text) => {
|
|
1559
|
+
if (!baseReply)
|
|
1560
|
+
throw new Error("device_back: the person has no 1:1 with this agent (chat_id is null), so there is nowhere to reply");
|
|
1561
|
+
repliesForSession.push(text);
|
|
1562
|
+
await baseReply(text);
|
|
1563
|
+
},
|
|
1564
|
+
};
|
|
1565
|
+
try {
|
|
1566
|
+
await options.onDeviceBack(ctx);
|
|
1567
|
+
if (chatId != null && session)
|
|
1568
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
1569
|
+
}
|
|
1570
|
+
catch (err) {
|
|
1571
|
+
logger.error(`[device ${body.device_id}] onDeviceBack failed: ${err.message}`);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
async function handleDescribeYourself(body, headerAgentId) {
|
|
1575
|
+
const agent = (body.agent ?? {});
|
|
1576
|
+
const identity = resolveMandateIdentity(headerAgentId, agent?.id);
|
|
1577
|
+
if (!identity || !options.onDescribeYourself)
|
|
1578
|
+
return;
|
|
1579
|
+
const maxLength = typeof body.max_length === "number" ? body.max_length : 300;
|
|
1580
|
+
const ctx = {
|
|
1581
|
+
identity,
|
|
1582
|
+
reason: String(body.reason ?? "created"),
|
|
1583
|
+
agent,
|
|
1584
|
+
ownerDescription: typeof body.owner_description === "string" ? body.owner_description : null,
|
|
1585
|
+
currentDescription: typeof body.current_description === "string" ? body.current_description : null,
|
|
1586
|
+
capabilities: Array.isArray(body.capabilities) ? body.capabilities : [],
|
|
1587
|
+
guidance: String(body.guidance ?? ""),
|
|
1588
|
+
maxLength,
|
|
1589
|
+
write: async (text) => {
|
|
1590
|
+
const bio = String(text ?? "").replace(/\s+/g, " ").trim().slice(0, maxLength);
|
|
1591
|
+
if (!bio)
|
|
1592
|
+
return;
|
|
1593
|
+
await client.setIdentity(identity.apiKey, { bio });
|
|
1594
|
+
},
|
|
1595
|
+
};
|
|
1596
|
+
try {
|
|
1597
|
+
await options.onDescribeYourself(ctx);
|
|
1598
|
+
}
|
|
1599
|
+
catch (err) {
|
|
1600
|
+
logger.error(`[agent ${agent?.id}] onDescribeYourself failed: ${err.message}`);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
// Answers to a pending ctx.ask (or a pending delegation reply), resolved
|
|
1604
|
+
// WITHOUT running a handler. socket.ts feeds frames through one serial
|
|
1605
|
+
// queue, and the handler that called `await ctx.ask(...)` occupies it, so
|
|
1606
|
+
// the frame carrying the answer would wait behind the very handler that is
|
|
1607
|
+
// waiting for it. socket.ts therefore calls this at frame arrival, ahead
|
|
1608
|
+
// of the queue. Same checks, same order as handleMessage /
|
|
1609
|
+
// handleCardInteraction (which still run for anything this does not
|
|
1610
|
+
// consume), so nothing is answered by a sender those would not accept.
|
|
1611
|
+
// Returns true when the frame was consumed and must not be dispatched.
|
|
1612
|
+
async function resolveAnswer(body, headerAgentId) {
|
|
1613
|
+
if (!asks.hasPendingAsks())
|
|
1614
|
+
return false;
|
|
1615
|
+
if (body?.type === "card_interaction") {
|
|
1616
|
+
const b = body;
|
|
1617
|
+
return asks.resolveCardInteraction(b.owner_id, b.chat_id, b.card_id, b.action_id, b.user);
|
|
1618
|
+
}
|
|
1619
|
+
if (body?.type !== undefined || !body?.message)
|
|
1620
|
+
return false;
|
|
1621
|
+
const message = body.message;
|
|
1622
|
+
if (message.event_type)
|
|
1623
|
+
return false;
|
|
1624
|
+
const rawMessage = message.message;
|
|
1625
|
+
if (typeof rawMessage !== "string")
|
|
1626
|
+
return false;
|
|
1627
|
+
const chatId = message.chat_id;
|
|
1628
|
+
let identity;
|
|
1629
|
+
let caption;
|
|
1630
|
+
if (message.encrypted === false) {
|
|
1631
|
+
identity = headerAgentId ? identities.get(headerAgentId) : undefined;
|
|
1632
|
+
caption = rawMessage;
|
|
1633
|
+
}
|
|
1634
|
+
else {
|
|
1635
|
+
if (!PGP_MESSAGE_RE.test(rawMessage))
|
|
1636
|
+
return false;
|
|
1637
|
+
const resolved = await resolveIdentity(rawMessage, headerAgentId);
|
|
1638
|
+
identity = resolved?.identity;
|
|
1639
|
+
caption = resolved?.plaintext ?? "";
|
|
1640
|
+
}
|
|
1641
|
+
if (!identity)
|
|
1642
|
+
return false;
|
|
1643
|
+
const activeId = body.chat?.active_agent_id;
|
|
1644
|
+
if (activeId)
|
|
1645
|
+
identity = identities.get(String(activeId)) ?? identity;
|
|
1646
|
+
const senderRaw = message.user;
|
|
1647
|
+
if (!senderRaw)
|
|
1648
|
+
return false;
|
|
1649
|
+
const senderId = String(senderRaw.id);
|
|
1650
|
+
if (delegations.resolveIfPending(chatId, senderId, caption))
|
|
1651
|
+
return true;
|
|
1652
|
+
return asks.resolveMessage(identity.saltAppId, chatId, senderId, senderRaw.account_type !== "Agent", caption);
|
|
1653
|
+
}
|
|
1654
|
+
async function dispatch(body, headerAgentId) {
|
|
433
1655
|
if (body?.type === "card_interaction")
|
|
434
1656
|
return handleCardInteraction(body);
|
|
1657
|
+
if (body?.type === "app_action")
|
|
1658
|
+
return handleAppAction(body, headerAgentId);
|
|
435
1659
|
if (body?.type === "invoice_paid")
|
|
436
1660
|
return handleInvoicePaid(body);
|
|
1661
|
+
if (body?.type === "chat_opened")
|
|
1662
|
+
return handleChatOpened(body);
|
|
437
1663
|
if (body?.type === "handoff_confirmed")
|
|
438
1664
|
return handleHandoffConfirmed(body);
|
|
439
1665
|
if (body?.type === "handoff_received")
|
|
440
1666
|
return handleHandoffReceived(body);
|
|
1667
|
+
if (body?.type === "mandate_offered")
|
|
1668
|
+
return handleMandateOffered(body, headerAgentId);
|
|
1669
|
+
if (body?.type === "mandate_activated")
|
|
1670
|
+
return handleMandateLifecycleEvent("activated", body, headerAgentId);
|
|
1671
|
+
if (body?.type === "mandate_paused")
|
|
1672
|
+
return handleMandateLifecycleEvent("paused", body, headerAgentId);
|
|
1673
|
+
if (body?.type === "mandate_revoked")
|
|
1674
|
+
return handleMandateLifecycleEvent("revoked", body, headerAgentId);
|
|
1675
|
+
if (body?.type === "approval_requested")
|
|
1676
|
+
return handleApprovalRequested(body, headerAgentId);
|
|
1677
|
+
if (body?.type === "approval_decided")
|
|
1678
|
+
return handleApprovalDecided(body, headerAgentId);
|
|
1679
|
+
if (body?.type === "describe_yourself")
|
|
1680
|
+
return handleDescribeYourself(body, headerAgentId);
|
|
1681
|
+
// device_session_ended and device_back are the AGENT's moments and must stay
|
|
1682
|
+
// ahead of the device_session* catch-all below. device_session_ended also
|
|
1683
|
+
// goes on to onDeviceDelivery (set only by a process that hosts a device):
|
|
1684
|
+
// the session controller there needs the end too, and handleDeviceSessionEnded
|
|
1685
|
+
// itself ignores the device's own copy.
|
|
1686
|
+
if (body?.type === "device_session_ended") {
|
|
1687
|
+
await handleDeviceSessionEnded(body, headerAgentId);
|
|
1688
|
+
await options.onDeviceDelivery?.(body.type, body);
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
if (body?.type === "device_back")
|
|
1692
|
+
return handleDeviceBack(body, headerAgentId);
|
|
1693
|
+
if (typeof body?.type === "string" && (body.type.startsWith("device_session") || body.type === "device_queue" || body.type === "device_grants_changed" || body.type === "device_owner_changed" || body.type === "device_ask_answered")) {
|
|
1694
|
+
await options.onDeviceDelivery?.(body.type, body);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
441
1697
|
if (body?.message)
|
|
442
|
-
return handleMessage(body);
|
|
1698
|
+
return handleMessage(body, headerAgentId);
|
|
443
1699
|
}
|
|
1700
|
+
return { dispatch, verifyEnvelope, resolveAnswer };
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Creates the Express app for a Salt agent's webhook endpoint. Mount it
|
|
1704
|
+
* yourself (e.g. `createWebhookServer(opts).listen(port)`, or grab `.app`
|
|
1705
|
+
* to compose it into a bigger server).
|
|
1706
|
+
*
|
|
1707
|
+
* A thin transport wrapper around createDispatcher -- everything about the
|
|
1708
|
+
* Salt protocol itself lives there. Switching a consumer from this to
|
|
1709
|
+
* socket.ts's createSocketClient (K2, an agent with no public URL) is the
|
|
1710
|
+
* same `options` object with `.listen(port)` replaced by `.start()`;
|
|
1711
|
+
* onMessage/onCardInteraction/etc. never change.
|
|
1712
|
+
*/
|
|
1713
|
+
function createWebhookServer(options) {
|
|
1714
|
+
const logger = options.logger ?? consoleLogger;
|
|
1715
|
+
const dispatcher = createDispatcher(options);
|
|
1716
|
+
const app = (0, express_1.default)();
|
|
1717
|
+
// Keep the raw bytes: the HMAC covers exactly what salt-api sent, and
|
|
1718
|
+
// re-serialising the parsed object would digest different bytes (key order,
|
|
1719
|
+
// whitespace, number formatting) and never match.
|
|
1720
|
+
app.use(express_1.default.json({
|
|
1721
|
+
verify: (req, _res, buf) => {
|
|
1722
|
+
req.rawBody = buf.toString("utf8");
|
|
1723
|
+
},
|
|
1724
|
+
}));
|
|
1725
|
+
app.get("/health", (_req, res) => {
|
|
1726
|
+
res.json({
|
|
1727
|
+
status: "ok",
|
|
1728
|
+
identities: options.identities.all().map((i) => ({ salt_app_id: i.saltAppId, username: i.username })),
|
|
1729
|
+
...(options.healthExtra ? options.healthExtra() : {}),
|
|
1730
|
+
});
|
|
1731
|
+
});
|
|
444
1732
|
// salt-api's WebhookJob POSTs here with { chat, message } (or a typed
|
|
445
|
-
// event body for card/invoice/handoff). Ack immediately (200)
|
|
446
|
-
// in the background so a slow agent-loop call can't make
|
|
447
|
-
// delivery time out.
|
|
1733
|
+
// event body for card/invoice/chat_opened/handoff). Ack immediately (200)
|
|
1734
|
+
// and process in the background so a slow agent-loop call can't make
|
|
1735
|
+
// Salt's webhook delivery time out.
|
|
448
1736
|
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// jobs send. Unset (local dev), the check is skipped entirely.
|
|
1737
|
+
// When this server is publicly reachable, anyone can POST here. Encrypted-
|
|
1738
|
+
// message webhooks fail safe (forged ciphertext never decrypts), but
|
|
1739
|
+
// card_interaction/invoice_paid/chat_opened payloads are PLAINTEXT and
|
|
1740
|
+
// would be acted on -- so every POST must carry a valid HMAC signed with
|
|
1741
|
+
// the RECIPIENT identity's own key (see Dispatcher#verifyEnvelope above).
|
|
455
1742
|
app.post("/", (req, res) => {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
1743
|
+
const rawBody = req.rawBody ?? "";
|
|
1744
|
+
// Verify BEFORE acknowledging: an unsigned POST must never reach dispatch,
|
|
1745
|
+
// because card_interaction/invoice_paid/chat_opened are plaintext and
|
|
1746
|
+
// actionable.
|
|
1747
|
+
dispatcher
|
|
1748
|
+
.verifyEnvelope(req.get("X-Salt-Agent-Id") || undefined, req.get("X-Salt-Signature") || undefined, rawBody)
|
|
1749
|
+
.then((result) => {
|
|
1750
|
+
if (!result.ok) {
|
|
1751
|
+
logger.error(`[webhook] rejected: ${result.reason}`);
|
|
1752
|
+
res.status(401).json({ error: "invalid signature" });
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
res.status(200).json({ status: "accepted" });
|
|
1756
|
+
const headerAgentId = req.get("X-Salt-Agent-Id") || undefined;
|
|
1757
|
+
dispatcher.dispatch(req.body, headerAgentId).catch((err) => logger.error(`[webhook] unhandled error: ${err.message}`));
|
|
1758
|
+
})
|
|
1759
|
+
.catch((err) => {
|
|
1760
|
+
logger.error(`[webhook] verification failed: ${err.message}`);
|
|
1761
|
+
res.status(401).json({ error: "invalid signature" });
|
|
1762
|
+
});
|
|
462
1763
|
});
|
|
463
1764
|
return {
|
|
464
1765
|
app,
|