salt-agent-sdk 0.1.0 → 0.19.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 +3123 -35
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1267 -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 +627 -28
- package/dist/webhook.d.ts.map +1 -1
- package/dist/webhook.js +1388 -84
- 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", "team_lead"]);
|
|
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
|
-
|
|
214
|
-
|
|
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) {
|
|
612
|
+
return async (text, opts) => {
|
|
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,26 @@ 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
|
+
// Explicit `opts.mentions` (a team lead waking a member, a member waking the lead) win over the
|
|
644
|
+
// automatic addressing: the author wrote the "@handle" in the text, so nothing is prefixed.
|
|
645
|
+
const explicitMentions = opts?.mentions && opts.mentions.length > 0 ? opts.mentions : undefined;
|
|
646
|
+
const outgoing = !explicitMentions && addressHandle ? `@${addressHandle} ${text}` : text;
|
|
647
|
+
// The ids, not the text: Salt only ever sees ciphertext, so an "@handle"
|
|
648
|
+
// in the body is invisible to it and notifies nobody by itself.
|
|
649
|
+
const mentions = explicitMentions ?? (addressHandle ? [addressee.id] : undefined);
|
|
231
650
|
let encryptedMessage, senderMessage;
|
|
232
651
|
try {
|
|
233
|
-
encryptedMessage = await pgp.encryptFor(
|
|
234
|
-
senderMessage = await pgp.encryptFor(
|
|
652
|
+
encryptedMessage = await pgp.encryptFor(outgoing, recipientKeys);
|
|
653
|
+
senderMessage = await pgp.encryptFor(outgoing, [identity.publicKey]);
|
|
235
654
|
}
|
|
236
655
|
catch (err) {
|
|
237
656
|
logger.error(`[chat ${chatId}] encryption failed: ${err.message}`);
|
|
@@ -239,7 +658,7 @@ function createWebhookServer(options) {
|
|
|
239
658
|
}
|
|
240
659
|
const trail = delegations.drainTrail(identity.saltAppId, chatId);
|
|
241
660
|
try {
|
|
242
|
-
await client.postMessage(identity.apiKey, chatId, encryptedMessage, senderMessage, trail);
|
|
661
|
+
await client.postMessage(identity.apiKey, chatId, encryptedMessage, senderMessage, trail, mentions);
|
|
243
662
|
}
|
|
244
663
|
catch (err) {
|
|
245
664
|
logger.error(`[chat ${chatId}] posting reply failed: ${err.message}`);
|
|
@@ -252,7 +671,159 @@ function createWebhookServer(options) {
|
|
|
252
671
|
});
|
|
253
672
|
};
|
|
254
673
|
}
|
|
255
|
-
|
|
674
|
+
// ctx.ask/ctx.approve (K3, ask.ts) -- bound to one (identity, chatId) the
|
|
675
|
+
// same way makeReply above is, and handed to every context type that
|
|
676
|
+
// gets a reply(). The actual card post/update and pending-wait registry
|
|
677
|
+
// live in ask.ts; resolveCardInteraction/resolveMessage below (wired into
|
|
678
|
+
// handleCardInteraction/handleMessage) are what settle a wait this
|
|
679
|
+
// creates.
|
|
680
|
+
//
|
|
681
|
+
// `defaultAnswererId` (M2, security review) is each context's natural
|
|
682
|
+
// "whoever this turn is answering" -- see the individual ctx-construction
|
|
683
|
+
// call sites for what each context type passes (a message's senderRaw,
|
|
684
|
+
// chat_opened's openedBy, invoice_paid's buyer; the two hand-off contexts
|
|
685
|
+
// have no natural default and pass none, so their ask()/approve() calls
|
|
686
|
+
// must supply `answererId` explicitly). A caller's own `opts.answererId`
|
|
687
|
+
// always wins over this default.
|
|
688
|
+
function makeAsk(identity, chatId, defaultAnswererId) {
|
|
689
|
+
return (question, opts) => asks.ask(client, identity, chatId, question, { answererId: defaultAnswererId, ...opts });
|
|
690
|
+
}
|
|
691
|
+
function makeApprove(identity, chatId, defaultAnswererId) {
|
|
692
|
+
return (summary, opts) => asks.approve(client, identity, chatId, summary, { answererId: defaultAnswererId, ...opts });
|
|
693
|
+
}
|
|
694
|
+
// ctx.shareIdentity (identityShare.ts) -- bound to one (identity, chatId)
|
|
695
|
+
// the same way makeAsk/makeApprove above are.
|
|
696
|
+
function makeShareIdentity(identity, chatId) {
|
|
697
|
+
return (keys, opts) => identitySharer.share(identity, chatId, keys, opts);
|
|
698
|
+
}
|
|
699
|
+
// True when `chatId` has at least one human (account_type !== "Agent")
|
|
700
|
+
// member who is NOT a silent observer. A consult lane's human -- the
|
|
701
|
+
// delegation chain's auditor added per actions.ts's delegation-
|
|
702
|
+
// observability note -- is only ever an observer there, so a lane reads as
|
|
703
|
+
// agent-only even though a person can technically decrypt it. `chatMeta`'s
|
|
704
|
+
// own member list is used when a caller already supplied one (mainly
|
|
705
|
+
// tests); the real webhook payload's `chat` never carries members (see
|
|
706
|
+
// salt-api's WebhookJob#user_send allowlist), so this normally falls back
|
|
707
|
+
// to fetching the roster -- the same call makeReply above already makes to
|
|
708
|
+
// encrypt a reply.
|
|
709
|
+
async function chatHasNonObserverHuman(identity, chatId, chatMeta) {
|
|
710
|
+
const inlineMembers = Array.isArray(chatMeta?.users) ? chatMeta.users : undefined;
|
|
711
|
+
let members = inlineMembers;
|
|
712
|
+
if (!members) {
|
|
713
|
+
try {
|
|
714
|
+
members = await client.getChatMembers(identity.apiKey, chatId);
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
logger.error(`[chat ${chatId}] fetching members for mention-gating failed: ${err.message}`);
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return members.some((m) => m.account_type !== "Agent" && m.account_type !== "Device" && !m.observer);
|
|
722
|
+
}
|
|
723
|
+
// Runs `options.onIdentityAsk` for an incoming SALT-IDENTITY-ASK and
|
|
724
|
+
// answers it -- a SLICE (carrying `ask=parsed.id`) for the keys the
|
|
725
|
+
// handler chose to share, a DECLINE (same) for null/false/an empty
|
|
726
|
+
// array. Never lets a handler or send failure escape to the caller: an
|
|
727
|
+
// ask that can't be answered must not crash the webhook pass that
|
|
728
|
+
// received it, the same convention every other handler-invoking helper
|
|
729
|
+
// in this file follows.
|
|
730
|
+
async function handleIdentityAsk(identity, chatId, senderId, parsed) {
|
|
731
|
+
let decision;
|
|
732
|
+
try {
|
|
733
|
+
decision = await options.onIdentityAsk({ id: parsed.id, keys: parsed.keys, text: parsed.text, chatId, from: senderId });
|
|
734
|
+
}
|
|
735
|
+
catch (err) {
|
|
736
|
+
logger.error(`[chat ${chatId}] onIdentityAsk failed: ${err.message}`);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
if (Array.isArray(decision) && decision.length > 0) {
|
|
741
|
+
await identitySharer.share(identity, chatId, decision, { ask: parsed.id });
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
await identitySharer.decline(identity, chatId, parsed.id);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
logger.error(`[chat ${chatId}] answering identity ask ${parsed.id} failed: ${err.message}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Handles an incoming SLICE, DECLINE or REVOKE from someone else. A
|
|
752
|
+
// SLICE is verified against the sender's own public key (fetched fresh
|
|
753
|
+
// from the chat's member list -- the same call makeReply already makes
|
|
754
|
+
// to encrypt a reply) and, when verified, recorded on this (identity,
|
|
755
|
+
// chat) session before `onIdentityShared` fires; a REVOKE removes any
|
|
756
|
+
// slice recorded under the same id first. Never lets a verification,
|
|
757
|
+
// session, or handler failure escape -- same convention as
|
|
758
|
+
// handleIdentityAsk above.
|
|
759
|
+
async function handleIdentitySharedMarker(identity, chatId, senderId, parsed) {
|
|
760
|
+
if (parsed.kind === "slice") {
|
|
761
|
+
let verified = false;
|
|
762
|
+
try {
|
|
763
|
+
const members = await client.getChatMembers(identity.apiKey, chatId);
|
|
764
|
+
const sender = members.find((m) => (0, ids_js_1.sameId)(m.id, senderId));
|
|
765
|
+
if (sender?.public_key) {
|
|
766
|
+
verified = await pgp.verifyDetached((0, identity_js_1.canonicalizeJcs)(parsed.payload.sections), parsed.payload.signature, sender.public_key);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
logger.error(`[chat ${chatId}] verifying identity slice ${parsed.id} failed: ${err.message}`);
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
const session = await loadOrRebuildSession(identity, chatId);
|
|
774
|
+
sessions.recordReceivedIdentitySlice(session, senderId, {
|
|
775
|
+
id: parsed.id,
|
|
776
|
+
sections: parsed.payload.sections,
|
|
777
|
+
verified,
|
|
778
|
+
receivedAt: Date.now(),
|
|
779
|
+
});
|
|
780
|
+
session.updatedAt = Date.now();
|
|
781
|
+
await sessionStore.put(identity.saltAppId, chatId, session);
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
logger.error(`[session] recording identity slice ${parsed.id} for ${chatId} failed: ${err.message}`);
|
|
785
|
+
}
|
|
786
|
+
if (options.onIdentityShared) {
|
|
787
|
+
try {
|
|
788
|
+
await options.onIdentityShared({
|
|
789
|
+
kind: "slice",
|
|
790
|
+
id: parsed.id,
|
|
791
|
+
askId: parsed.askId,
|
|
792
|
+
from: senderId,
|
|
793
|
+
chatId,
|
|
794
|
+
sections: parsed.payload.sections,
|
|
795
|
+
verified,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
logger.error(`[chat ${chatId}] onIdentityShared (slice) failed: ${err.message}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (parsed.kind === "revoke") {
|
|
805
|
+
try {
|
|
806
|
+
const session = await loadOrRebuildSession(identity, chatId);
|
|
807
|
+
sessions.forgetReceivedIdentitySlice(session, senderId, parsed.id);
|
|
808
|
+
session.updatedAt = Date.now();
|
|
809
|
+
await sessionStore.put(identity.saltAppId, chatId, session);
|
|
810
|
+
}
|
|
811
|
+
catch (err) {
|
|
812
|
+
logger.error(`[session] forgetting revoked identity slice ${parsed.id} for ${chatId} failed: ${err.message}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (options.onIdentityShared) {
|
|
816
|
+
try {
|
|
817
|
+
await options.onIdentityShared(parsed.kind === "decline"
|
|
818
|
+
? { kind: "decline", id: parsed.id, askId: parsed.askId, from: senderId, chatId }
|
|
819
|
+
: { kind: "revoke", id: parsed.id, from: senderId, chatId });
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
logger.error(`[chat ${chatId}] onIdentityShared (${parsed.kind}) failed: ${err.message}`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
async function handleMessage(body, headerAgentId) {
|
|
256
827
|
const message = body.message;
|
|
257
828
|
const chatId = message.chat_id;
|
|
258
829
|
const chatMeta = body.chat;
|
|
@@ -260,15 +831,50 @@ function createWebhookServer(options) {
|
|
|
260
831
|
return;
|
|
261
832
|
if (message.event_type)
|
|
262
833
|
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.`);
|
|
834
|
+
const rawMessage = message.message;
|
|
835
|
+
if (typeof rawMessage !== "string")
|
|
269
836
|
return;
|
|
837
|
+
// Open rooms (salt-api 0.6x): a plain chat delivers `encrypted: false`
|
|
838
|
+
// and `message` is the text itself, not a PGP blob -- there is nothing
|
|
839
|
+
// to trial-decrypt, and no way to recover which hosted identity this
|
|
840
|
+
// delivery was addressed to except the header salt-api signs every
|
|
841
|
+
// callback with. Without it (a header-less transport, or a header
|
|
842
|
+
// naming an identity this process doesn't host) there is no safe
|
|
843
|
+
// fallback the way trial-decryption gives the ciphertext path below --
|
|
844
|
+
// guessing would risk this identity replying as if it heard a message
|
|
845
|
+
// that was actually addressed to a sibling identity sharing this
|
|
846
|
+
// process.
|
|
847
|
+
const isPlaintext = message.encrypted === false;
|
|
848
|
+
let identity;
|
|
849
|
+
let caption;
|
|
850
|
+
if (isPlaintext) {
|
|
851
|
+
const preferred = headerAgentId ? identities.get(headerAgentId) : undefined;
|
|
852
|
+
if (!preferred) {
|
|
853
|
+
logger.error(`[chat ${chatId}] no known identity for this plaintext message (X-Salt-Agent-Id ${headerAgentId ?? "missing"}); ignoring.`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
identity = preferred;
|
|
857
|
+
caption = rawMessage;
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
if (!PGP_MESSAGE_RE.test(rawMessage))
|
|
861
|
+
return;
|
|
862
|
+
const resolved = await resolveIdentity(rawMessage, headerAgentId);
|
|
863
|
+
if (!resolved) {
|
|
864
|
+
logger.error(`[chat ${chatId}] no known identity could decrypt this message; ignoring.`);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
identity = resolved.identity;
|
|
868
|
+
caption = resolved.plaintext;
|
|
270
869
|
}
|
|
271
|
-
|
|
870
|
+
// Wire protocol, never a prompt: a hand-off briefing is consumed by
|
|
871
|
+
// handleHandoffReceived's poll (above HANDOFF_BRIEFING_MARKER's
|
|
872
|
+
// definition), and a bare session-note line is consumed by whichever
|
|
873
|
+
// hand-off leg reads it back out (sessions.ts). Either one reaching
|
|
874
|
+
// onMessage reads to a viewer as this identity replying to its own
|
|
875
|
+
// colleague's internal handoff chatter.
|
|
876
|
+
if (caption.startsWith(exports.HANDOFF_BRIEFING_MARKER) || caption.startsWith(sessions.SESSION_NOTE_MARKER))
|
|
877
|
+
return;
|
|
272
878
|
// GACM routing fix: salt-api sends one webhook delivery PER agent
|
|
273
879
|
// member, but they all land on this shared server and message-id dedup
|
|
274
880
|
// collapses them into a single pass -- whose decrypt-resolved identity
|
|
@@ -277,51 +883,180 @@ function createWebhookServer(options) {
|
|
|
277
883
|
// THAT identity; the plaintext is identical under any key, only "who
|
|
278
884
|
// replies" changes.
|
|
279
885
|
if (chatMeta?.active_agent_id) {
|
|
280
|
-
const active = identities.get(
|
|
886
|
+
const active = identities.get(String(chatMeta.active_agent_id));
|
|
281
887
|
if (active)
|
|
282
888
|
identity = active;
|
|
283
889
|
}
|
|
890
|
+
// A message with no sender is not a prompt, and everything below -- the
|
|
891
|
+
// self-reply loop break, the delegation-reply match, the agent-to-agent
|
|
892
|
+
// cap -- is a question about who sent it. This used to fall through with
|
|
893
|
+
// senderId = NaN, which compared equal to nothing and unequal to
|
|
894
|
+
// everything, so all three guards inverted rather than being skipped. The
|
|
895
|
+
// context handed to consumers already asserted a sender existed.
|
|
284
896
|
const senderRaw = message.user;
|
|
285
|
-
|
|
897
|
+
if (!senderRaw)
|
|
898
|
+
return;
|
|
899
|
+
const senderId = String(senderRaw.id);
|
|
286
900
|
// Reply to a pending delegation call? Hand it to that waiting promise
|
|
287
901
|
// instead of starting a fresh reply cycle -- see delegations.ts.
|
|
288
902
|
if (delegations.resolveIfPending(chatId, senderId, caption))
|
|
289
903
|
return;
|
|
904
|
+
// The answer to a pending ctx.ask, typed rather than tapped? Settle
|
|
905
|
+
// that wait instead of starting a fresh reply cycle -- see ask.ts.
|
|
906
|
+
// Only a human's plain reply counts (an agent addressed by handle
|
|
907
|
+
// gets its own webhook/turn, same reasoning as the mention-gating
|
|
908
|
+
// below); `caption` is the raw decrypted text, same as delegations'
|
|
909
|
+
// check just above.
|
|
910
|
+
if (asks.resolveMessage(identity.saltAppId, chatId, senderId, senderRaw.account_type !== "Agent", caption))
|
|
911
|
+
return;
|
|
290
912
|
// Never reply to our own messages (the reply we post is itself
|
|
291
913
|
// delivered back to us as a webhook) -- this is what prevents an
|
|
292
914
|
// infinite loop.
|
|
293
|
-
if (senderId
|
|
915
|
+
if ((0, ids_js_1.sameId)(senderId, identity.saltAppId))
|
|
916
|
+
return;
|
|
917
|
+
// A device lane is wire protocol, not conversation: hand it to the device
|
|
918
|
+
// hook before any conversational gate can drop it (see onDeviceLaneMessage).
|
|
919
|
+
if (chatMeta?.lane_kind === "device" && options.onDeviceLaneMessage) {
|
|
920
|
+
try {
|
|
921
|
+
Promise.resolve(options.onDeviceLaneMessage({ identity, chatId, senderId, sender: senderRaw, text: caption, chatMeta })).catch((err) => {
|
|
922
|
+
logger.error(`[chat ${chatId}] onDeviceLaneMessage failed: ${err.message}`);
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
catch (err) {
|
|
926
|
+
logger.error(`[chat ${chatId}] onDeviceLaneMessage failed: ${err.message}`);
|
|
927
|
+
}
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
// request_floor (actions.ts): the consulted agent is asking to be
|
|
931
|
+
// brought into the room directly instead of relaying further through
|
|
932
|
+
// this lane. Wire protocol, never a prompt -- and only actionable by
|
|
933
|
+
// whichever hosted identity actually opened this lane as the asker.
|
|
934
|
+
if (caption.startsWith(delegations.FLOOR_REQUEST_MARKER)) {
|
|
935
|
+
const asker = delegations.consultAskerFor(chatId);
|
|
936
|
+
if (asker && (0, ids_js_1.sameId)(asker.askerId, identity.saltAppId)) {
|
|
937
|
+
const reason = caption.slice(delegations.FLOOR_REQUEST_MARKER.length).replace(/^\n/, "").trim() || undefined;
|
|
938
|
+
pendingConsultHandoffs.set(mapKey(asker.roomId), chatId);
|
|
939
|
+
try {
|
|
940
|
+
await client.handOff(identity.apiKey, asker.roomId, senderId, reason);
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
pendingConsultHandoffs.delete(mapKey(asker.roomId));
|
|
944
|
+
logger.error(`[chat ${asker.roomId}] auto hand-off on floor request failed: ${err.message}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
// Identity (R3/R4, identityShare.ts): a SALT-IDENTITY-ASK/SLICE/
|
|
950
|
+
// DECLINE/REVOKE is wire protocol, never a prompt -- with one
|
|
951
|
+
// deliberate exception. An ASK with no onIdentityAsk handler registered
|
|
952
|
+
// is NOT intercepted at all: it falls through as an ordinary message,
|
|
953
|
+
// marker stripped, so a consumer that hasn't wired up the handler yet
|
|
954
|
+
// sees whatever text (if any) rode alongside it rather than losing the
|
|
955
|
+
// message outright. SLICE/DECLINE/REVOKE are always intercepted,
|
|
956
|
+
// whether or not onIdentityShared is set -- there is no "ordinary
|
|
957
|
+
// text" reading of a signed slice or a revoke.
|
|
958
|
+
if (caption.startsWith(identityShare.IDENTITY_MARKER_PREFIX)) {
|
|
959
|
+
const parsed = identityShare.parseIdentityMarker(caption);
|
|
960
|
+
if (parsed?.kind === "ask") {
|
|
961
|
+
if (options.onIdentityAsk) {
|
|
962
|
+
await handleIdentityAsk(identity, chatId, senderId, parsed);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
caption = parsed.text || "";
|
|
966
|
+
}
|
|
967
|
+
else if (parsed) {
|
|
968
|
+
await handleIdentitySharedMarker(identity, chatId, senderId, parsed);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
// Starts with the prefix but didn't parse into anything usable --
|
|
973
|
+
// a garbled or foreign marker either way, never a prompt.
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
// Cancel (work.ts's [[SALT-WORK-CANCEL id=...]]): the person cancelling
|
|
978
|
+
// a scheduled report of theirs, sent as a whole message into the lane
|
|
979
|
+
// that report was posted into. Wire protocol, never a prompt -- always
|
|
980
|
+
// intercepted, whether or not onWorkCancel is set, same as a SLICE/
|
|
981
|
+
// DECLINE/REVOKE above (there's no "ordinary text" reading of it).
|
|
982
|
+
const cancelWorkId = (0, work_1.parseWorkCancel)(caption);
|
|
983
|
+
if (cancelWorkId) {
|
|
984
|
+
if (options.onWorkCancel) {
|
|
985
|
+
const cancelRoomId = chatMeta?.coaching_for_chat_id || chatId;
|
|
986
|
+
try {
|
|
987
|
+
await options.onWorkCancel({ identity, workId: cancelWorkId, chatId: cancelRoomId, laneId: chatId, sender: senderRaw });
|
|
988
|
+
}
|
|
989
|
+
catch (err) {
|
|
990
|
+
logger.error(`[chat ${chatId}] onWorkCancel failed: ${err.message}`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
294
993
|
return;
|
|
994
|
+
}
|
|
295
995
|
// Mediated Chat: this identity is the configured Mediator, and this
|
|
296
996
|
// message is in the SHARED chat it silently observes -- present for
|
|
297
997
|
// context, but it only actually speaks in each human's own private
|
|
298
998
|
// coaching chat (coaching_for_chat_id pointing back at this one).
|
|
299
|
-
const isMediator = !!mediatorAgentId && identity.saltAppId
|
|
999
|
+
const isMediator = !!mediatorAgentId && (0, ids_js_1.sameId)(identity.saltAppId, mediatorAgentId);
|
|
300
1000
|
if (isMediator && chatMeta?.mediator_agent_id && !chatMeta.coaching_for_chat_id)
|
|
301
1001
|
return;
|
|
302
1002
|
// Global Agent Chat Mode: when the chat declares an active agent and
|
|
303
1003
|
// it isn't this identity, stay silent. Lets retired/handed-off-from
|
|
304
1004
|
// agents remain in the room without ever double-replying.
|
|
305
|
-
if (chatMeta?.active_agent_id &&
|
|
1005
|
+
if (chatMeta?.active_agent_id && !(0, ids_js_1.sameId)(chatMeta.active_agent_id, identity.saltAppId))
|
|
306
1006
|
return;
|
|
307
1007
|
// A human sender always resets the count -- only agent-to-agent
|
|
308
1008
|
// volleys are capped, never a real conversation with a person.
|
|
309
|
-
const senderIsAgent = !!identities.get(senderId) || senderRaw
|
|
1009
|
+
const senderIsAgent = !!identities.get(senderId) || senderRaw.account_type === "Agent";
|
|
1010
|
+
const replyCountKey = mapKey(chatId);
|
|
1011
|
+
if (chatMeta?.coaching_for_chat_id) {
|
|
1012
|
+
laneRoomOf.set(replyCountKey, mapKey(chatMeta.coaching_for_chat_id));
|
|
1013
|
+
}
|
|
310
1014
|
if (senderIsAgent) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
1015
|
+
// A real person changes the rule entirely: an agent-authored message
|
|
1016
|
+
// is only ours to answer when it @mentions us -- the same gate
|
|
1017
|
+
// salt-api applies to whether the webhook is even sent in a group
|
|
1018
|
+
// chat, belt-and-suspenders for whatever still reaches us (a hand-off
|
|
1019
|
+
// farewell, a briefing after item 1 above missed a variant, another
|
|
1020
|
+
// agent's aside). Two agents alone in a lane have no one to perform
|
|
1021
|
+
// for, so the runaway cap below still governs those exactly as before.
|
|
1022
|
+
if (await chatHasNonObserverHuman(identity, chatId, chatMeta)) {
|
|
1023
|
+
const mentions = (Array.isArray(message.mentions) ? message.mentions : []).map(String);
|
|
1024
|
+
if (!mentions.some((id) => (0, ids_js_1.sameId)(id, identity.saltAppId))) {
|
|
1025
|
+
logger.error(`[chat ${chatId}] agent message not addressed to us in a chat with a person present; not auto-replying to ${senderId}.`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
const limit = chatMeta?.lane_kind === "consult" ? CONSULT_RUNAWAY_LIMIT : MAX_AGENT_TO_AGENT_REPLIES_PER_CHAT;
|
|
1031
|
+
const count = (agentToAgentReplyCounts.get(replyCountKey) || 0) + 1;
|
|
1032
|
+
agentToAgentReplyCounts.set(replyCountKey, count);
|
|
1033
|
+
if (count > limit) {
|
|
1034
|
+
logger.error(`[chat ${chatId}] agent-to-agent reply cap reached; not auto-replying to ${senderId} again.`);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
316
1037
|
}
|
|
317
1038
|
}
|
|
318
1039
|
else {
|
|
319
|
-
agentToAgentReplyCounts.delete(
|
|
1040
|
+
agentToAgentReplyCounts.delete(replyCountKey);
|
|
1041
|
+
// A human speaking in a ROOM also resets every consult lane rooted
|
|
1042
|
+
// there -- otherwise a lane capped mid-conversation stays capped even
|
|
1043
|
+
// right after the person who asked for it weighs in.
|
|
1044
|
+
for (const [laneKey, roomKey] of laneRoomOf) {
|
|
1045
|
+
if (roomKey === replyCountKey)
|
|
1046
|
+
agentToAgentReplyCounts.delete(laneKey);
|
|
1047
|
+
}
|
|
320
1048
|
}
|
|
321
|
-
const { depth, text:
|
|
1049
|
+
const { depth, text: afterDepthMarker } = delegations.parseIncoming(caption);
|
|
1050
|
+
const strippedCaption = delegations.stripConsultMarker(afterDepthMarker);
|
|
322
1051
|
let mediatorSharedContext;
|
|
323
1052
|
let attachment;
|
|
324
|
-
|
|
1053
|
+
// The shared conversation is fetched only for the MUTUAL Mediator lane,
|
|
1054
|
+
// where this identity is an observer on that chat and both parties can see
|
|
1055
|
+
// it is there. A private advisor lane is excluded: that advisor was never
|
|
1056
|
+
// added to the shared chat, so every message would fail to decrypt and be
|
|
1057
|
+
// skipped anyway -- but a guarantee that rests on a failed decrypt is one
|
|
1058
|
+
// membership bug away from being no guarantee, so we do not ask at all.
|
|
1059
|
+
if (isMediator && chatMeta?.coaching_for_chat_id && !chatMeta.private_lane) {
|
|
325
1060
|
mediatorSharedContext = await buildSharedChatContext(identity, chatMeta.coaching_for_chat_id);
|
|
326
1061
|
}
|
|
327
1062
|
else if (message.resource_type === "Attachment") {
|
|
@@ -329,20 +1064,63 @@ function createWebhookServer(options) {
|
|
|
329
1064
|
}
|
|
330
1065
|
if (!options.onMessage)
|
|
331
1066
|
return;
|
|
1067
|
+
const roomId = chatMeta?.coaching_for_chat_id || chatId;
|
|
1068
|
+
const session = await loadOrRebuildSession(identity, chatId, chatMeta);
|
|
1069
|
+
// Wraps the shared reply() closure to also remember what was sent, so
|
|
1070
|
+
// it can be appended to the session as an assistant turn once the
|
|
1071
|
+
// handler returns successfully -- see the persistence step below.
|
|
1072
|
+
// Nothing about what actually gets sent changes.
|
|
1073
|
+
const repliesForSession = [];
|
|
1074
|
+
const baseReply = makeReply(identity, chatId, senderRaw);
|
|
1075
|
+
const trackedReply = async (text, opts) => {
|
|
1076
|
+
repliesForSession.push(text);
|
|
1077
|
+
await baseReply(text, opts);
|
|
1078
|
+
};
|
|
332
1079
|
const ctx = {
|
|
333
1080
|
identity,
|
|
334
1081
|
chatId,
|
|
335
1082
|
senderId,
|
|
336
1083
|
sender: senderRaw,
|
|
337
1084
|
text: strippedCaption,
|
|
1085
|
+
encrypted: !isPlaintext,
|
|
1086
|
+
deliveredBecause: parseDeliveredBecause(message.delivered_because),
|
|
1087
|
+
replyTo: message.reply_to_message_id != null ? String(message.reply_to_message_id) : undefined,
|
|
1088
|
+
parent: parseReplyParent(message.parent),
|
|
338
1089
|
delegationDepth: depth,
|
|
339
1090
|
chatMeta,
|
|
1091
|
+
roomId,
|
|
340
1092
|
mediatorSharedContext,
|
|
341
1093
|
attachment,
|
|
342
|
-
|
|
1094
|
+
session,
|
|
1095
|
+
reply: trackedReply,
|
|
1096
|
+
messageId: message.message_id,
|
|
1097
|
+
react: (emoji) => client.react(identity.apiKey, message.message_id, emoji),
|
|
1098
|
+
// M2: default answerer is whoever sent THIS message -- never an
|
|
1099
|
+
// agent (an agent sender has its own webhook/turn; ctx.ask is for
|
|
1100
|
+
// asking a person).
|
|
1101
|
+
ask: makeAsk(identity, chatId, senderRaw.account_type !== "Agent" ? senderId : undefined),
|
|
1102
|
+
approve: makeApprove(identity, chatId, senderRaw.account_type !== "Agent" ? senderId : undefined),
|
|
1103
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
343
1104
|
};
|
|
344
1105
|
try {
|
|
345
1106
|
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onMessage(ctx)));
|
|
1107
|
+
try {
|
|
1108
|
+
sessions.appendTurn(session, {
|
|
1109
|
+
role: "user",
|
|
1110
|
+
content: strippedCaption,
|
|
1111
|
+
at: Date.now(),
|
|
1112
|
+
from: String(senderRaw.username || senderRaw.display_name || senderId),
|
|
1113
|
+
});
|
|
1114
|
+
for (const replyText of repliesForSession) {
|
|
1115
|
+
sessions.appendTurn(session, { role: "assistant", content: replyText, at: Date.now() });
|
|
1116
|
+
}
|
|
1117
|
+
session.note = sessions.boundNote(session.note);
|
|
1118
|
+
session.updatedAt = Date.now();
|
|
1119
|
+
await sessionStore.put(identity.saltAppId, chatId, session);
|
|
1120
|
+
}
|
|
1121
|
+
catch (err) {
|
|
1122
|
+
logger.error(`[session] persisting ${chatId} failed: ${err.message}`);
|
|
1123
|
+
}
|
|
346
1124
|
}
|
|
347
1125
|
catch (err) {
|
|
348
1126
|
logger.error(`[chat ${chatId}] onMessage failed: ${err.message}`);
|
|
@@ -352,9 +1130,19 @@ function createWebhookServer(options) {
|
|
|
352
1130
|
}
|
|
353
1131
|
}
|
|
354
1132
|
async function handleCardInteraction(body) {
|
|
355
|
-
|
|
1133
|
+
// A tap answering a pending ctx.ask's own card is wire-level bookkeeping
|
|
1134
|
+
// for ask.ts, never something the consumer's onCardInteraction should
|
|
1135
|
+
// also see -- checked before identity resolution/onCardInteraction even
|
|
1136
|
+
// matter, since this can settle an ask() with no consumer handler
|
|
1137
|
+
// registered for cards at all.
|
|
1138
|
+
if (asks.resolveCardInteraction(body.owner_id, body.chat_id, body.card_id, body.action_id, body.user))
|
|
1139
|
+
return;
|
|
1140
|
+
const identity = identities.get(String(body.owner_id));
|
|
356
1141
|
if (!identity || !options.onCardInteraction)
|
|
357
1142
|
return;
|
|
1143
|
+
// No reply() on this context (a card update IS the response), so just
|
|
1144
|
+
// load -- nothing new here to append or persist afterward.
|
|
1145
|
+
const session = await loadOrRebuildSession(identity, body.chat_id);
|
|
358
1146
|
try {
|
|
359
1147
|
await options.onCardInteraction({
|
|
360
1148
|
identity,
|
|
@@ -363,17 +1151,67 @@ function createWebhookServer(options) {
|
|
|
363
1151
|
actionId: body.action_id,
|
|
364
1152
|
user: body.user,
|
|
365
1153
|
blocks: body.state?.blocks,
|
|
1154
|
+
values: body.values || {},
|
|
1155
|
+
session,
|
|
366
1156
|
});
|
|
367
1157
|
}
|
|
368
1158
|
catch (err) {
|
|
369
1159
|
logger.error(`[chat ${body.chat_id}] onCardInteraction failed: ${err.message}`);
|
|
370
1160
|
}
|
|
371
1161
|
}
|
|
1162
|
+
async function handleAppAction(body, headerAgentId) {
|
|
1163
|
+
// No owner_id/seller_id in this body to fall back on (unlike
|
|
1164
|
+
// card_interaction/invoice_paid) -- X-Salt-Agent-Id is the only signal,
|
|
1165
|
+
// same as the mandate rail events further down. verifyEnvelope already
|
|
1166
|
+
// proved the signature against that exact identity's own key.
|
|
1167
|
+
const identity = headerAgentId != null ? identities.get(String(headerAgentId)) : undefined;
|
|
1168
|
+
if (!identity || !options.onAppAction)
|
|
1169
|
+
return;
|
|
1170
|
+
const chatId = body.chat_id ?? null;
|
|
1171
|
+
// A personal ("Your apps") installation has no chat at all -- nothing
|
|
1172
|
+
// for a reply to post into, same as salt.post's "Not in a chat."
|
|
1173
|
+
// rejection on the bridge side. Fails soft (logged), the same
|
|
1174
|
+
// convention every other reply() failure in this file follows.
|
|
1175
|
+
const reply = chatId != null
|
|
1176
|
+
? makeReply(identity, chatId)
|
|
1177
|
+
: async (_text) => {
|
|
1178
|
+
logger.error(`[app ${body.installation_id}] reply() has no chat -- this is a personal installation.`);
|
|
1179
|
+
};
|
|
1180
|
+
const ctx = {
|
|
1181
|
+
identity,
|
|
1182
|
+
installationId: body.installation_id,
|
|
1183
|
+
app: body.app,
|
|
1184
|
+
chatId,
|
|
1185
|
+
text: body.text,
|
|
1186
|
+
data: body.data,
|
|
1187
|
+
stateVersion: body.state_version,
|
|
1188
|
+
sender: body.user,
|
|
1189
|
+
reply,
|
|
1190
|
+
};
|
|
1191
|
+
try {
|
|
1192
|
+
if (chatId != null) {
|
|
1193
|
+
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onAppAction(ctx)));
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
await options.onAppAction(ctx);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
catch (err) {
|
|
1200
|
+
logger.error(`[app ${body.installation_id}] onAppAction failed: ${err.message}`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
372
1203
|
async function handleInvoicePaid(body) {
|
|
373
|
-
const identity = identities.get(
|
|
1204
|
+
const identity = identities.get(String(body.seller_id));
|
|
374
1205
|
if (!identity || !body.chat_id || !options.onInvoicePaid)
|
|
375
1206
|
return;
|
|
376
1207
|
const chatId = body.chat_id;
|
|
1208
|
+
const session = await loadOrRebuildSession(identity, chatId);
|
|
1209
|
+
const repliesForSession = [];
|
|
1210
|
+
const baseReply = makeReply(identity, chatId);
|
|
1211
|
+
const trackedReply = async (text, opts) => {
|
|
1212
|
+
repliesForSession.push(text);
|
|
1213
|
+
await baseReply(text, opts);
|
|
1214
|
+
};
|
|
377
1215
|
const ctx = {
|
|
378
1216
|
identity,
|
|
379
1217
|
chatId,
|
|
@@ -382,30 +1220,143 @@ function createWebhookServer(options) {
|
|
|
382
1220
|
amount: body.amount,
|
|
383
1221
|
isTopUp: !!body.billing_account_id,
|
|
384
1222
|
transferRequestId: body.transfer_request_id,
|
|
385
|
-
|
|
1223
|
+
session,
|
|
1224
|
+
reply: trackedReply,
|
|
1225
|
+
// M2: default answerer is the buyer (never an agent -- an agent
|
|
1226
|
+
// buyer has no need to be asked a question through its own purchase).
|
|
1227
|
+
ask: makeAsk(identity, chatId, body.buyer.account_type !== "Agent" ? body.buyer.id : undefined),
|
|
1228
|
+
approve: makeApprove(identity, chatId, body.buyer.account_type !== "Agent" ? body.buyer.id : undefined),
|
|
1229
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
386
1230
|
};
|
|
387
1231
|
try {
|
|
388
1232
|
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onInvoicePaid(ctx)));
|
|
1233
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
389
1234
|
}
|
|
390
1235
|
catch (err) {
|
|
391
1236
|
logger.error(`[chat ${chatId}] onInvoicePaid failed: ${err.message}`);
|
|
392
1237
|
}
|
|
393
1238
|
}
|
|
1239
|
+
async function handleChatOpened(body) {
|
|
1240
|
+
if (!options.onChatOpened)
|
|
1241
|
+
return;
|
|
1242
|
+
const chat = body.chat;
|
|
1243
|
+
const chatId = chat?.id;
|
|
1244
|
+
if (chatId == null)
|
|
1245
|
+
return;
|
|
1246
|
+
// Unlike card_interaction/invoice_paid, this payload carries no explicit
|
|
1247
|
+
// owner/seller id naming which hosted identity it's for -- salt-api
|
|
1248
|
+
// delivers it once per member with a callback URL, same as everything
|
|
1249
|
+
// else, but the only way this process (which can host several identities
|
|
1250
|
+
// on one endpoint) learns WHICH of its identities the event is about is
|
|
1251
|
+
// by noticing itself in the member list. Prefer the chat's declared
|
|
1252
|
+
// active agent when that's one of ours (GACM), the same tie-break
|
|
1253
|
+
// handleMessage uses, in case delegation ever puts two hosted identities
|
|
1254
|
+
// in the same chat.
|
|
1255
|
+
let identity;
|
|
1256
|
+
if (chat?.active_agent_id)
|
|
1257
|
+
identity = identities.get(String(chat.active_agent_id));
|
|
1258
|
+
if (!identity) {
|
|
1259
|
+
for (const member of body.members || []) {
|
|
1260
|
+
identity = identities.get(String(member.id));
|
|
1261
|
+
if (identity)
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (!identity) {
|
|
1266
|
+
logger.error(`[chat ${chatId}] chat_opened: no hosted identity found among members; ignoring.`);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (alreadyGreeted(identity.saltAppId, chatId))
|
|
1270
|
+
return;
|
|
1271
|
+
const session = await loadOrRebuildSession(identity, chatId, chat);
|
|
1272
|
+
const repliesForSession = [];
|
|
1273
|
+
const baseReply = makeReply(identity, chatId);
|
|
1274
|
+
const trackedReply = async (text, opts) => {
|
|
1275
|
+
repliesForSession.push(text);
|
|
1276
|
+
await baseReply(text, opts);
|
|
1277
|
+
};
|
|
1278
|
+
const ctx = {
|
|
1279
|
+
identity,
|
|
1280
|
+
chatId,
|
|
1281
|
+
chat: chat,
|
|
1282
|
+
encrypted: chat?.encrypted !== false,
|
|
1283
|
+
openedBy: body.opened_by,
|
|
1284
|
+
members: body.members || [],
|
|
1285
|
+
openedAt: body.opened_at,
|
|
1286
|
+
session,
|
|
1287
|
+
reply: trackedReply,
|
|
1288
|
+
// M2: default answerer is whoever opened the chat (never an agent).
|
|
1289
|
+
ask: makeAsk(identity, chatId, body.opened_by?.account_type !== "Agent" ? body.opened_by?.id : undefined),
|
|
1290
|
+
approve: makeApprove(identity, chatId, body.opened_by?.account_type !== "Agent" ? body.opened_by?.id : undefined),
|
|
1291
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
1292
|
+
};
|
|
1293
|
+
try {
|
|
1294
|
+
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onChatOpened(ctx)));
|
|
1295
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
1296
|
+
}
|
|
1297
|
+
catch (err) {
|
|
1298
|
+
logger.error(`[chat ${chatId}] onChatOpened failed: ${err.message}`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
394
1301
|
async function handleHandoffConfirmed(body) {
|
|
395
|
-
const identity = identities.get(
|
|
1302
|
+
const identity = identities.get(String(body.from_agent_id));
|
|
396
1303
|
if (!identity || !options.onHandoffConfirmed)
|
|
397
1304
|
return;
|
|
398
1305
|
const chatId = body.chat_id;
|
|
399
|
-
|
|
1306
|
+
// A request_floor call may have just auto-triggered this same hand-off
|
|
1307
|
+
// (webhook.ts's floor-request handler, above) -- if so, give the
|
|
1308
|
+
// briefing its consult lane's transcript.
|
|
1309
|
+
const laneId = pendingConsultHandoffs.get(mapKey(chatId));
|
|
1310
|
+
if (laneId)
|
|
1311
|
+
pendingConsultHandoffs.delete(mapKey(chatId));
|
|
1312
|
+
let consultTranscript;
|
|
1313
|
+
if (laneId) {
|
|
1314
|
+
try {
|
|
1315
|
+
consultTranscript = await buildConsultTranscript(identity, laneId);
|
|
1316
|
+
}
|
|
1317
|
+
catch (err) {
|
|
1318
|
+
logger.error(`[chat ${chatId}] fetching consult transcript for hand-off failed: ${err.message}`);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const session = await loadOrRebuildSession(identity, chatId);
|
|
1322
|
+
// Appends the outgoing session's note (if any) as the briefing's final
|
|
1323
|
+
// wire-protocol line -- see sessions.ts's SESSION_NOTE_MARKER and
|
|
1324
|
+
// handleHandoffReceived below, which parses it back out on the other
|
|
1325
|
+
// end. Transparent to the consumer: whatever text they reply() with is
|
|
1326
|
+
// what a human sees; this just rides along after it. Reads `session.note`
|
|
1327
|
+
// directly rather than re-fetching -- the same object ctx.session is, so
|
|
1328
|
+
// a handler that wrote to it before calling reply() is picked up.
|
|
1329
|
+
const repliesForSession = [];
|
|
1330
|
+
const baseReply = makeReply(identity, chatId);
|
|
1331
|
+
const reply = async (text, opts) => {
|
|
1332
|
+
repliesForSession.push(text);
|
|
1333
|
+
const noteLine = sessions.formatSessionNoteLine(session.note);
|
|
1334
|
+
await baseReply(noteLine ? `${text}\n${noteLine}` : text, opts);
|
|
1335
|
+
};
|
|
1336
|
+
const ctx = {
|
|
1337
|
+
identity,
|
|
1338
|
+
chatId,
|
|
1339
|
+
reason: body.reason,
|
|
1340
|
+
consultTranscript,
|
|
1341
|
+
session,
|
|
1342
|
+
reply,
|
|
1343
|
+
// M2: no natural default here (a hand-off has no single "whoever
|
|
1344
|
+
// this answers") -- ask()/approve() from this context must be given
|
|
1345
|
+
// answererId explicitly, or they throw synchronously.
|
|
1346
|
+
ask: makeAsk(identity, chatId),
|
|
1347
|
+
approve: makeApprove(identity, chatId),
|
|
1348
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
1349
|
+
};
|
|
400
1350
|
try {
|
|
401
1351
|
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onHandoffConfirmed(ctx)));
|
|
1352
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
402
1353
|
}
|
|
403
1354
|
catch (err) {
|
|
404
1355
|
logger.error(`[chat ${chatId}] onHandoffConfirmed failed: ${err.message}`);
|
|
405
1356
|
}
|
|
406
1357
|
}
|
|
407
1358
|
async function handleHandoffReceived(body) {
|
|
408
|
-
const identity = identities.get(
|
|
1359
|
+
const identity = identities.get(String(body.to_agent_id));
|
|
409
1360
|
if (!identity || !options.onHandoffReceived)
|
|
410
1361
|
return;
|
|
411
1362
|
const chatId = body.chat_id;
|
|
@@ -421,44 +1372,397 @@ function createWebhookServer(options) {
|
|
|
421
1372
|
break;
|
|
422
1373
|
await new Promise((r) => setTimeout(r, 2000));
|
|
423
1374
|
}
|
|
424
|
-
|
|
1375
|
+
// Seed this identity's own session for `chatId` from whatever's already
|
|
1376
|
+
// stored (a fresh one if nothing is), fold in the outgoing agent's note
|
|
1377
|
+
// if the briefing carried one, and persist that much right away --
|
|
1378
|
+
// independent of whether onHandoffReceived itself succeeds below, same
|
|
1379
|
+
// as before this session had a `session` field on its ctx at all.
|
|
1380
|
+
// Stripped from `context` either way: wire protocol, never something
|
|
1381
|
+
// the incoming agent's own prompt-building should see verbatim.
|
|
1382
|
+
let session;
|
|
1383
|
+
try {
|
|
1384
|
+
session = (await sessionStore.get(identity.saltAppId, chatId)) ?? sessions.emptySession(chatId, chatId);
|
|
1385
|
+
}
|
|
1386
|
+
catch (err) {
|
|
1387
|
+
logger.error(`[session] loading ${chatId} failed: ${err.message}`);
|
|
1388
|
+
session = sessions.emptySession(chatId, chatId);
|
|
1389
|
+
}
|
|
1390
|
+
const note = sessions.extractSessionNote(context);
|
|
1391
|
+
if (note) {
|
|
1392
|
+
session.note = sessions.boundNote(note);
|
|
1393
|
+
session.updatedAt = Date.now();
|
|
1394
|
+
try {
|
|
1395
|
+
await sessionStore.put(identity.saltAppId, chatId, session);
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
logger.error(`[session] persisting handed-off note for ${chatId} failed: ${err.message}`);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
context = sessions.stripSessionNoteLines(context);
|
|
1402
|
+
// Whatever the introduction reply() sends here is appended below,
|
|
1403
|
+
// exactly like onMessage does for its own reply -- so it's already in
|
|
1404
|
+
// transcriptTail the next time this chat's onMessage runs.
|
|
1405
|
+
const repliesForSession = [];
|
|
1406
|
+
const baseReply = makeReply(identity, chatId);
|
|
1407
|
+
const trackedReply = async (text, opts) => {
|
|
1408
|
+
repliesForSession.push(text);
|
|
1409
|
+
await baseReply(text, opts);
|
|
1410
|
+
};
|
|
1411
|
+
const ctx = {
|
|
1412
|
+
identity,
|
|
1413
|
+
chatId,
|
|
1414
|
+
reason: body.reason,
|
|
1415
|
+
context,
|
|
1416
|
+
session,
|
|
1417
|
+
reply: trackedReply,
|
|
1418
|
+
// M2: no natural default here either -- see HandoffConfirmedContext's comment above.
|
|
1419
|
+
ask: makeAsk(identity, chatId),
|
|
1420
|
+
approve: makeApprove(identity, chatId),
|
|
1421
|
+
shareIdentity: makeShareIdentity(identity, chatId),
|
|
1422
|
+
};
|
|
425
1423
|
try {
|
|
426
1424
|
await withTypingHeartbeat(identity, chatId, () => Promise.resolve(options.onHandoffReceived(ctx)));
|
|
1425
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
427
1426
|
}
|
|
428
1427
|
catch (err) {
|
|
429
1428
|
logger.error(`[chat ${chatId}] onHandoffReceived failed: ${err.message}`);
|
|
430
1429
|
}
|
|
431
1430
|
}
|
|
432
|
-
|
|
1431
|
+
// Resolves which of this process's hosted identities a mandate/exercise event is FOR. The
|
|
1432
|
+
// envelope's X-Salt-Agent-Id is authoritative (verifyEnvelope already checked the signature
|
|
1433
|
+
// against that exact identity's own key, so it names a real recipient) -- the `candidateIds`
|
|
1434
|
+
// fallback (party ids off the payload itself) only matters for a caller that runs the dispatcher
|
|
1435
|
+
// directly with no header (e.g. a test, or a future transport that doesn't have one).
|
|
1436
|
+
function resolveMandateIdentity(headerAgentId, ...candidateIds) {
|
|
1437
|
+
if (headerAgentId != null) {
|
|
1438
|
+
const byHeader = identities.get(String(headerAgentId));
|
|
1439
|
+
if (byHeader)
|
|
1440
|
+
return byHeader;
|
|
1441
|
+
}
|
|
1442
|
+
for (const id of candidateIds) {
|
|
1443
|
+
if (id == null)
|
|
1444
|
+
continue;
|
|
1445
|
+
const found = identities.get(String(id));
|
|
1446
|
+
if (found)
|
|
1447
|
+
return found;
|
|
1448
|
+
}
|
|
1449
|
+
return undefined;
|
|
1450
|
+
}
|
|
1451
|
+
async function handleMandateOffered(body, headerAgentId) {
|
|
1452
|
+
const identity = resolveMandateIdentity(headerAgentId, body.mandate?.grantee?.id);
|
|
1453
|
+
if (!identity || !options.onMandateOffered)
|
|
1454
|
+
return;
|
|
1455
|
+
const mandate = body.mandate;
|
|
1456
|
+
const ctx = {
|
|
1457
|
+
identity,
|
|
1458
|
+
mandate,
|
|
1459
|
+
accept: () => client.mandates.accept(identity.apiKey, mandate.id),
|
|
1460
|
+
};
|
|
1461
|
+
try {
|
|
1462
|
+
await options.onMandateOffered(ctx);
|
|
1463
|
+
}
|
|
1464
|
+
catch (err) {
|
|
1465
|
+
logger.error(`[mandate ${mandate.id}] onMandateOffered failed: ${err.message}`);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
async function handleMandateLifecycleEvent(kind, body, headerAgentId) {
|
|
1469
|
+
const mandate = body.mandate;
|
|
1470
|
+
const identity = resolveMandateIdentity(headerAgentId, mandate?.grantee?.id, mandate?.grantor?.id);
|
|
1471
|
+
const handler = kind === "activated" ? options.onMandateActivated : kind === "paused" ? options.onMandatePaused : options.onMandateRevoked;
|
|
1472
|
+
if (!identity || !handler)
|
|
1473
|
+
return;
|
|
1474
|
+
try {
|
|
1475
|
+
await handler({ identity, mandate });
|
|
1476
|
+
}
|
|
1477
|
+
catch (err) {
|
|
1478
|
+
logger.error(`[mandate ${mandate.id}] onMandate${kind[0].toUpperCase()}${kind.slice(1)} failed: ${err.message}`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
async function handleApprovalRequested(body, headerAgentId) {
|
|
1482
|
+
const exercise = body.exercise;
|
|
1483
|
+
const identity = resolveMandateIdentity(headerAgentId, exercise?.principal?.id);
|
|
1484
|
+
if (!identity || !options.onApprovalRequested)
|
|
1485
|
+
return;
|
|
1486
|
+
const ctx = {
|
|
1487
|
+
identity,
|
|
1488
|
+
exercise,
|
|
1489
|
+
decide: (decision, note) => client.mandates.decide(identity.apiKey, exercise.id, decision, note),
|
|
1490
|
+
};
|
|
1491
|
+
try {
|
|
1492
|
+
await options.onApprovalRequested(ctx);
|
|
1493
|
+
}
|
|
1494
|
+
catch (err) {
|
|
1495
|
+
logger.error(`[exercise ${exercise.id}] onApprovalRequested failed: ${err.message}`);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
async function handleApprovalDecided(body, headerAgentId) {
|
|
1499
|
+
const exercise = body.exercise;
|
|
1500
|
+
const identity = resolveMandateIdentity(headerAgentId, exercise?.actor?.id);
|
|
1501
|
+
if (!identity || !options.onApprovalDecided)
|
|
1502
|
+
return;
|
|
1503
|
+
try {
|
|
1504
|
+
await options.onApprovalDecided({ identity, exercise });
|
|
1505
|
+
}
|
|
1506
|
+
catch (err) {
|
|
1507
|
+
logger.error(`[exercise ${exercise.id}] onApprovalDecided failed: ${err.message}`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
async function handleDeviceSessionEnded(body, headerAgentId) {
|
|
1511
|
+
if (!options.onDeviceSessionEnded)
|
|
1512
|
+
return;
|
|
1513
|
+
const identity = resolveMandateIdentity(headerAgentId, body.agent_id);
|
|
1514
|
+
if (!identity)
|
|
1515
|
+
return;
|
|
1516
|
+
// The same delivery also goes to the computer itself; it is not the agent's to speak of.
|
|
1517
|
+
if ((0, ids_js_1.sameId)(identity.saltAppId, body.device_id))
|
|
1518
|
+
return;
|
|
1519
|
+
const sessionId = body.session_id ?? body.id;
|
|
1520
|
+
if (sessionId == null)
|
|
1521
|
+
return;
|
|
1522
|
+
const ctx = {
|
|
1523
|
+
identity,
|
|
1524
|
+
sessionId,
|
|
1525
|
+
deviceId: body.device_id,
|
|
1526
|
+
mandateId: body.mandate_id ?? null,
|
|
1527
|
+
laneChatId: body.chat_id ?? body.chat?.id ?? null,
|
|
1528
|
+
endReason: body.end_reason ?? null,
|
|
1529
|
+
lostContact: body.end_reason === exports.DEVICE_LOST_CONTACT,
|
|
1530
|
+
intent: typeof body.intent === "string" ? body.intent : null,
|
|
1531
|
+
endedAt: body.ended_at ?? null,
|
|
1532
|
+
body,
|
|
1533
|
+
};
|
|
1534
|
+
try {
|
|
1535
|
+
await options.onDeviceSessionEnded(ctx);
|
|
1536
|
+
}
|
|
1537
|
+
catch (err) {
|
|
1538
|
+
logger.error(`[device session ${sessionId}] onDeviceSessionEnded failed: ${err.message}`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
async function handleDeviceBack(body, headerAgentId) {
|
|
1542
|
+
if (!options.onDeviceBack)
|
|
1543
|
+
return;
|
|
1544
|
+
const identity = resolveMandateIdentity(headerAgentId);
|
|
1545
|
+
if (!identity)
|
|
1546
|
+
return;
|
|
1547
|
+
const chatId = body.chat_id ?? null;
|
|
1548
|
+
const session = chatId != null ? await loadOrRebuildSession(identity, chatId) : null;
|
|
1549
|
+
const repliesForSession = [];
|
|
1550
|
+
const baseReply = chatId != null ? makeReply(identity, chatId) : null;
|
|
1551
|
+
const ctx = {
|
|
1552
|
+
identity,
|
|
1553
|
+
deviceId: body.device_id,
|
|
1554
|
+
deviceName: body.device_name,
|
|
1555
|
+
previousSessionId: body.previous_session_id,
|
|
1556
|
+
offlineAt: body.offline_at ?? null,
|
|
1557
|
+
backAt: body.back_at,
|
|
1558
|
+
chatId,
|
|
1559
|
+
session,
|
|
1560
|
+
body,
|
|
1561
|
+
reply: async (text, opts) => {
|
|
1562
|
+
if (!baseReply)
|
|
1563
|
+
throw new Error("device_back: the person has no 1:1 with this agent (chat_id is null), so there is nowhere to reply");
|
|
1564
|
+
repliesForSession.push(text);
|
|
1565
|
+
await baseReply(text, opts);
|
|
1566
|
+
},
|
|
1567
|
+
};
|
|
1568
|
+
try {
|
|
1569
|
+
await options.onDeviceBack(ctx);
|
|
1570
|
+
if (chatId != null && session)
|
|
1571
|
+
await persistSessionAfterReply(identity.saltAppId, chatId, session, repliesForSession);
|
|
1572
|
+
}
|
|
1573
|
+
catch (err) {
|
|
1574
|
+
logger.error(`[device ${body.device_id}] onDeviceBack failed: ${err.message}`);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
async function handleDescribeYourself(body, headerAgentId) {
|
|
1578
|
+
const agent = (body.agent ?? {});
|
|
1579
|
+
const identity = resolveMandateIdentity(headerAgentId, agent?.id);
|
|
1580
|
+
if (!identity || !options.onDescribeYourself)
|
|
1581
|
+
return;
|
|
1582
|
+
const maxLength = typeof body.max_length === "number" ? body.max_length : 300;
|
|
1583
|
+
const ctx = {
|
|
1584
|
+
identity,
|
|
1585
|
+
reason: String(body.reason ?? "created"),
|
|
1586
|
+
agent,
|
|
1587
|
+
ownerDescription: typeof body.owner_description === "string" ? body.owner_description : null,
|
|
1588
|
+
currentDescription: typeof body.current_description === "string" ? body.current_description : null,
|
|
1589
|
+
capabilities: Array.isArray(body.capabilities) ? body.capabilities : [],
|
|
1590
|
+
guidance: String(body.guidance ?? ""),
|
|
1591
|
+
maxLength,
|
|
1592
|
+
write: async (text) => {
|
|
1593
|
+
const bio = String(text ?? "").replace(/\s+/g, " ").trim().slice(0, maxLength);
|
|
1594
|
+
if (!bio)
|
|
1595
|
+
return;
|
|
1596
|
+
await client.setIdentity(identity.apiKey, { bio });
|
|
1597
|
+
},
|
|
1598
|
+
};
|
|
1599
|
+
try {
|
|
1600
|
+
await options.onDescribeYourself(ctx);
|
|
1601
|
+
}
|
|
1602
|
+
catch (err) {
|
|
1603
|
+
logger.error(`[agent ${agent?.id}] onDescribeYourself failed: ${err.message}`);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
// Answers to a pending ctx.ask (or a pending delegation reply), resolved
|
|
1607
|
+
// WITHOUT running a handler. socket.ts feeds frames through one serial
|
|
1608
|
+
// queue, and the handler that called `await ctx.ask(...)` occupies it, so
|
|
1609
|
+
// the frame carrying the answer would wait behind the very handler that is
|
|
1610
|
+
// waiting for it. socket.ts therefore calls this at frame arrival, ahead
|
|
1611
|
+
// of the queue. Same checks, same order as handleMessage /
|
|
1612
|
+
// handleCardInteraction (which still run for anything this does not
|
|
1613
|
+
// consume), so nothing is answered by a sender those would not accept.
|
|
1614
|
+
// Returns true when the frame was consumed and must not be dispatched.
|
|
1615
|
+
async function resolveAnswer(body, headerAgentId) {
|
|
1616
|
+
if (!asks.hasPendingAsks())
|
|
1617
|
+
return false;
|
|
1618
|
+
if (body?.type === "card_interaction") {
|
|
1619
|
+
const b = body;
|
|
1620
|
+
return asks.resolveCardInteraction(b.owner_id, b.chat_id, b.card_id, b.action_id, b.user);
|
|
1621
|
+
}
|
|
1622
|
+
if (body?.type !== undefined || !body?.message)
|
|
1623
|
+
return false;
|
|
1624
|
+
const message = body.message;
|
|
1625
|
+
if (message.event_type)
|
|
1626
|
+
return false;
|
|
1627
|
+
const rawMessage = message.message;
|
|
1628
|
+
if (typeof rawMessage !== "string")
|
|
1629
|
+
return false;
|
|
1630
|
+
const chatId = message.chat_id;
|
|
1631
|
+
let identity;
|
|
1632
|
+
let caption;
|
|
1633
|
+
if (message.encrypted === false) {
|
|
1634
|
+
identity = headerAgentId ? identities.get(headerAgentId) : undefined;
|
|
1635
|
+
caption = rawMessage;
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
if (!PGP_MESSAGE_RE.test(rawMessage))
|
|
1639
|
+
return false;
|
|
1640
|
+
const resolved = await resolveIdentity(rawMessage, headerAgentId);
|
|
1641
|
+
identity = resolved?.identity;
|
|
1642
|
+
caption = resolved?.plaintext ?? "";
|
|
1643
|
+
}
|
|
1644
|
+
if (!identity)
|
|
1645
|
+
return false;
|
|
1646
|
+
const activeId = body.chat?.active_agent_id;
|
|
1647
|
+
if (activeId)
|
|
1648
|
+
identity = identities.get(String(activeId)) ?? identity;
|
|
1649
|
+
const senderRaw = message.user;
|
|
1650
|
+
if (!senderRaw)
|
|
1651
|
+
return false;
|
|
1652
|
+
const senderId = String(senderRaw.id);
|
|
1653
|
+
if (delegations.resolveIfPending(chatId, senderId, caption))
|
|
1654
|
+
return true;
|
|
1655
|
+
return asks.resolveMessage(identity.saltAppId, chatId, senderId, senderRaw.account_type !== "Agent", caption);
|
|
1656
|
+
}
|
|
1657
|
+
async function dispatch(body, headerAgentId) {
|
|
433
1658
|
if (body?.type === "card_interaction")
|
|
434
1659
|
return handleCardInteraction(body);
|
|
1660
|
+
if (body?.type === "app_action")
|
|
1661
|
+
return handleAppAction(body, headerAgentId);
|
|
435
1662
|
if (body?.type === "invoice_paid")
|
|
436
1663
|
return handleInvoicePaid(body);
|
|
1664
|
+
if (body?.type === "chat_opened")
|
|
1665
|
+
return handleChatOpened(body);
|
|
437
1666
|
if (body?.type === "handoff_confirmed")
|
|
438
1667
|
return handleHandoffConfirmed(body);
|
|
439
1668
|
if (body?.type === "handoff_received")
|
|
440
1669
|
return handleHandoffReceived(body);
|
|
1670
|
+
if (body?.type === "mandate_offered")
|
|
1671
|
+
return handleMandateOffered(body, headerAgentId);
|
|
1672
|
+
if (body?.type === "mandate_activated")
|
|
1673
|
+
return handleMandateLifecycleEvent("activated", body, headerAgentId);
|
|
1674
|
+
if (body?.type === "mandate_paused")
|
|
1675
|
+
return handleMandateLifecycleEvent("paused", body, headerAgentId);
|
|
1676
|
+
if (body?.type === "mandate_revoked")
|
|
1677
|
+
return handleMandateLifecycleEvent("revoked", body, headerAgentId);
|
|
1678
|
+
if (body?.type === "approval_requested")
|
|
1679
|
+
return handleApprovalRequested(body, headerAgentId);
|
|
1680
|
+
if (body?.type === "approval_decided")
|
|
1681
|
+
return handleApprovalDecided(body, headerAgentId);
|
|
1682
|
+
if (body?.type === "describe_yourself")
|
|
1683
|
+
return handleDescribeYourself(body, headerAgentId);
|
|
1684
|
+
// device_session_ended and device_back are the AGENT's moments and must stay
|
|
1685
|
+
// ahead of the device_session* catch-all below. device_session_ended also
|
|
1686
|
+
// goes on to onDeviceDelivery (set only by a process that hosts a device):
|
|
1687
|
+
// the session controller there needs the end too, and handleDeviceSessionEnded
|
|
1688
|
+
// itself ignores the device's own copy.
|
|
1689
|
+
if (body?.type === "device_session_ended") {
|
|
1690
|
+
await handleDeviceSessionEnded(body, headerAgentId);
|
|
1691
|
+
await options.onDeviceDelivery?.(body.type, body);
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (body?.type === "device_back")
|
|
1695
|
+
return handleDeviceBack(body, headerAgentId);
|
|
1696
|
+
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")) {
|
|
1697
|
+
await options.onDeviceDelivery?.(body.type, body);
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
441
1700
|
if (body?.message)
|
|
442
|
-
return handleMessage(body);
|
|
1701
|
+
return handleMessage(body, headerAgentId);
|
|
443
1702
|
}
|
|
1703
|
+
return { dispatch, verifyEnvelope, resolveAnswer };
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Creates the Express app for a Salt agent's webhook endpoint. Mount it
|
|
1707
|
+
* yourself (e.g. `createWebhookServer(opts).listen(port)`, or grab `.app`
|
|
1708
|
+
* to compose it into a bigger server).
|
|
1709
|
+
*
|
|
1710
|
+
* A thin transport wrapper around createDispatcher -- everything about the
|
|
1711
|
+
* Salt protocol itself lives there. Switching a consumer from this to
|
|
1712
|
+
* socket.ts's createSocketClient (K2, an agent with no public URL) is the
|
|
1713
|
+
* same `options` object with `.listen(port)` replaced by `.start()`;
|
|
1714
|
+
* onMessage/onCardInteraction/etc. never change.
|
|
1715
|
+
*/
|
|
1716
|
+
function createWebhookServer(options) {
|
|
1717
|
+
const logger = options.logger ?? consoleLogger;
|
|
1718
|
+
const dispatcher = createDispatcher(options);
|
|
1719
|
+
const app = (0, express_1.default)();
|
|
1720
|
+
// Keep the raw bytes: the HMAC covers exactly what salt-api sent, and
|
|
1721
|
+
// re-serialising the parsed object would digest different bytes (key order,
|
|
1722
|
+
// whitespace, number formatting) and never match.
|
|
1723
|
+
app.use(express_1.default.json({
|
|
1724
|
+
verify: (req, _res, buf) => {
|
|
1725
|
+
req.rawBody = buf.toString("utf8");
|
|
1726
|
+
},
|
|
1727
|
+
}));
|
|
1728
|
+
app.get("/health", (_req, res) => {
|
|
1729
|
+
res.json({
|
|
1730
|
+
status: "ok",
|
|
1731
|
+
identities: options.identities.all().map((i) => ({ salt_app_id: i.saltAppId, username: i.username })),
|
|
1732
|
+
...(options.healthExtra ? options.healthExtra() : {}),
|
|
1733
|
+
});
|
|
1734
|
+
});
|
|
444
1735
|
// 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.
|
|
1736
|
+
// event body for card/invoice/chat_opened/handoff). Ack immediately (200)
|
|
1737
|
+
// and process in the background so a slow agent-loop call can't make
|
|
1738
|
+
// Salt's webhook delivery time out.
|
|
448
1739
|
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// jobs send. Unset (local dev), the check is skipped entirely.
|
|
1740
|
+
// When this server is publicly reachable, anyone can POST here. Encrypted-
|
|
1741
|
+
// message webhooks fail safe (forged ciphertext never decrypts), but
|
|
1742
|
+
// card_interaction/invoice_paid/chat_opened payloads are PLAINTEXT and
|
|
1743
|
+
// would be acted on -- so every POST must carry a valid HMAC signed with
|
|
1744
|
+
// the RECIPIENT identity's own key (see Dispatcher#verifyEnvelope above).
|
|
455
1745
|
app.post("/", (req, res) => {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
1746
|
+
const rawBody = req.rawBody ?? "";
|
|
1747
|
+
// Verify BEFORE acknowledging: an unsigned POST must never reach dispatch,
|
|
1748
|
+
// because card_interaction/invoice_paid/chat_opened are plaintext and
|
|
1749
|
+
// actionable.
|
|
1750
|
+
dispatcher
|
|
1751
|
+
.verifyEnvelope(req.get("X-Salt-Agent-Id") || undefined, req.get("X-Salt-Signature") || undefined, rawBody)
|
|
1752
|
+
.then((result) => {
|
|
1753
|
+
if (!result.ok) {
|
|
1754
|
+
logger.error(`[webhook] rejected: ${result.reason}`);
|
|
1755
|
+
res.status(401).json({ error: "invalid signature" });
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
res.status(200).json({ status: "accepted" });
|
|
1759
|
+
const headerAgentId = req.get("X-Salt-Agent-Id") || undefined;
|
|
1760
|
+
dispatcher.dispatch(req.body, headerAgentId).catch((err) => logger.error(`[webhook] unhandled error: ${err.message}`));
|
|
1761
|
+
})
|
|
1762
|
+
.catch((err) => {
|
|
1763
|
+
logger.error(`[webhook] verification failed: ${err.message}`);
|
|
1764
|
+
res.status(401).json({ error: "invalid signature" });
|
|
1765
|
+
});
|
|
462
1766
|
});
|
|
463
1767
|
return {
|
|
464
1768
|
app,
|