@runwingman/flightdeck-cli 0.2.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/Dockerfile +21 -0
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/docs/to_fdcli.md +78 -0
- package/install.sh +86 -0
- package/package.json +53 -0
- package/src/bot-helpers.js +774 -0
- package/src/chat-runtime.js +782 -0
- package/src/cli.js +2767 -0
- package/src/client.js +285 -0
- package/src/config.js +117 -0
- package/src/db.js +653 -0
- package/src/flow-steps.js +215 -0
- package/src/nostr.js +160 -0
- package/src/render.js +34 -0
- package/src/sqlite-runtime.js +17 -0
- package/src/storage.js +191 -0
- package/src/sync.js +900 -0
- package/src/token.js +72 -0
- package/src/translators.js +1652 -0
- package/src/workspace-keys.js +214 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot-safe Yoke helper surface for Wingmen (Agent Chat phase 1).
|
|
3
|
+
*
|
|
4
|
+
* CONTRACT
|
|
5
|
+
* --------
|
|
6
|
+
* This module is the single import boundary Wingmen is allowed to use from
|
|
7
|
+
* Yoke for bot-first workspace bootstrap, auth, group-key loading, and
|
|
8
|
+
* chat-record decrypt/routing normalization. It is designed so a bot actor
|
|
9
|
+
* can run without invoking the interactive Yoke CLI, without touching the
|
|
10
|
+
* local SQLite mirror, and without depending on any CLI global state.
|
|
11
|
+
*
|
|
12
|
+
* Safe for Wingmen import:
|
|
13
|
+
* - BotHelperError (typed error with .code)
|
|
14
|
+
* - createBotWorkspaceKey
|
|
15
|
+
* - loadBotWorkspaceKey
|
|
16
|
+
* - signBotRequest
|
|
17
|
+
* - signWorkspaceRequest
|
|
18
|
+
* - fetchBotGroupKeys
|
|
19
|
+
* - loadBotGroupKeys
|
|
20
|
+
* - decryptChatRecord
|
|
21
|
+
* - extractChatReadableGroups
|
|
22
|
+
* - normalizeThreadId
|
|
23
|
+
* - normalizeChannelParticipants
|
|
24
|
+
* - normalizeChatRoutingContext
|
|
25
|
+
* - normalizeChatInterceptContext
|
|
26
|
+
* - buildAgentInterceptKey
|
|
27
|
+
*
|
|
28
|
+
* Explicitly NOT part of this contract (remain Yoke-only):
|
|
29
|
+
* - src/cli.js and any interactive command entrypoints
|
|
30
|
+
* - src/sync.js long-running sync loop
|
|
31
|
+
* - src/db.js SQLite schema, migrations, and local maintenance
|
|
32
|
+
* - src/config.js CLI/environment bootstrap
|
|
33
|
+
* - src/nostr.js ambient nsec loaders (getConfiguredNsec, bitwarden, etc.)
|
|
34
|
+
*
|
|
35
|
+
* Wingmen must not import those modules for runtime chat interception. If a
|
|
36
|
+
* new capability is needed, extend this helper surface rather than reaching
|
|
37
|
+
* into CLI internals.
|
|
38
|
+
*
|
|
39
|
+
* Error codes emitted by this module:
|
|
40
|
+
* - workspace_auth_failed : ws key blob cannot be loaded for this bot
|
|
41
|
+
* - group_key_missing : no wrapped group key available to decrypt
|
|
42
|
+
* - thread_unresolved : chat message thread_id cannot be normalized
|
|
43
|
+
* - record_decrypt_failed : wrapped payload decrypt rejected
|
|
44
|
+
* - intercept_context_invalid : agent-first intercept helper inputs are incomplete
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { getPublicKey, nip19 } from 'nostr-tools';
|
|
48
|
+
import {
|
|
49
|
+
createNip98AuthHeader,
|
|
50
|
+
decodeNsec,
|
|
51
|
+
} from './nostr.js';
|
|
52
|
+
import {
|
|
53
|
+
generateWorkspaceKey,
|
|
54
|
+
decryptWorkspaceKey,
|
|
55
|
+
buildWorkspaceSession,
|
|
56
|
+
} from './workspace-keys.js';
|
|
57
|
+
import {
|
|
58
|
+
decryptRecordPayload,
|
|
59
|
+
inboundChatMessage,
|
|
60
|
+
inboundChannel,
|
|
61
|
+
loadGroupKeyMap,
|
|
62
|
+
} from './translators.js';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Typed error for the bot helper surface. `code` is one of the shared error
|
|
66
|
+
* codes in the Agent Chat design doc.
|
|
67
|
+
*/
|
|
68
|
+
export class BotHelperError extends Error {
|
|
69
|
+
constructor(code, message, { cause } = {}) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = 'BotHelperError';
|
|
72
|
+
this.code = code;
|
|
73
|
+
if (cause) this.cause = cause;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeGroupRef(value) {
|
|
78
|
+
const ref = typeof value === 'string' ? value.trim() : '';
|
|
79
|
+
return ref || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function dedupeSorted(values = []) {
|
|
83
|
+
return [...new Set(values.filter(Boolean))].sort();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveSourceAppNpub(record, chatMessage = null) {
|
|
87
|
+
const explicit = typeof chatMessage?.source_app_npub === 'string' ? chatMessage.source_app_npub.trim() : '';
|
|
88
|
+
if (explicit) return explicit;
|
|
89
|
+
|
|
90
|
+
const familyHash = typeof record?.record_family_hash === 'string' ? record.record_family_hash.trim() : '';
|
|
91
|
+
if (!familyHash) return null;
|
|
92
|
+
const separator = familyHash.indexOf(':');
|
|
93
|
+
if (separator <= 0) return null;
|
|
94
|
+
const appNpub = familyHash.slice(0, separator).trim();
|
|
95
|
+
return appNpub || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requireBotActor({ botSecret, botNpub }) {
|
|
99
|
+
if (!(botSecret instanceof Uint8Array) || botSecret.byteLength !== 32) {
|
|
100
|
+
throw new BotHelperError(
|
|
101
|
+
'workspace_auth_failed',
|
|
102
|
+
'Bot secret must be a 32-byte Uint8Array.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (typeof botNpub !== 'string' || !botNpub.startsWith('npub1')) {
|
|
106
|
+
throw new BotHelperError(
|
|
107
|
+
'workspace_auth_failed',
|
|
108
|
+
'Bot npub must be a bech32 npub string.',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const derivedNpub = nip19.npubEncode(getPublicKey(botSecret));
|
|
112
|
+
if (derivedNpub !== botNpub) {
|
|
113
|
+
throw new BotHelperError(
|
|
114
|
+
'workspace_auth_failed',
|
|
115
|
+
`Bot npub ${botNpub} does not match secret-derived npub ${derivedNpub}.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a fresh workspace session keypair for a bot actor. The bot's
|
|
122
|
+
* runtime secret is used to encrypt the derived ws-key nsec so the bot can
|
|
123
|
+
* reload the same workspace session on the next run without depending on the
|
|
124
|
+
* human root key.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} params
|
|
127
|
+
* @param {Uint8Array} params.botSecret
|
|
128
|
+
* @param {string} params.botNpub
|
|
129
|
+
* @param {string} params.workspaceOwnerNpub
|
|
130
|
+
* @returns {{ blob: object, wsSession: object }}
|
|
131
|
+
*/
|
|
132
|
+
export function createBotWorkspaceKey({ botSecret, botNpub, workspaceOwnerNpub }) {
|
|
133
|
+
requireBotActor({ botSecret, botNpub });
|
|
134
|
+
if (typeof workspaceOwnerNpub !== 'string' || !workspaceOwnerNpub.startsWith('npub1')) {
|
|
135
|
+
throw new BotHelperError(
|
|
136
|
+
'workspace_auth_failed',
|
|
137
|
+
'workspaceOwnerNpub must be a bech32 npub string.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const { blob, wsKeySecret, wsKeyNpub } = generateWorkspaceKey(
|
|
141
|
+
botSecret,
|
|
142
|
+
botNpub,
|
|
143
|
+
workspaceOwnerNpub,
|
|
144
|
+
);
|
|
145
|
+
const wsSession = buildWorkspaceSession(wsKeySecret, wsKeyNpub, blob.ws_key_epoch ?? 1, botNpub);
|
|
146
|
+
return { blob, wsSession };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build a NIP-98 Authorization header signed by the bot's real identity.
|
|
151
|
+
* Used for privileged bootstrap operations like ws-key registration.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} params
|
|
154
|
+
* @param {Uint8Array} params.botSecret
|
|
155
|
+
* @param {string} params.botNpub
|
|
156
|
+
* @param {string} params.url
|
|
157
|
+
* @param {string} params.method
|
|
158
|
+
* @param {object|string|null} [params.body]
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
export function signBotRequest({ botSecret, botNpub, url, method, body = null }) {
|
|
162
|
+
requireBotActor({ botSecret, botNpub });
|
|
163
|
+
if (typeof url !== 'string' || !url) {
|
|
164
|
+
throw new BotHelperError('workspace_auth_failed', 'signBotRequest url is required.');
|
|
165
|
+
}
|
|
166
|
+
if (typeof method !== 'string' || !method) {
|
|
167
|
+
throw new BotHelperError('workspace_auth_failed', 'signBotRequest method is required.');
|
|
168
|
+
}
|
|
169
|
+
return createNip98AuthHeader(url, method, body, botSecret);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reload an existing bot workspace session from a persisted blob.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} params
|
|
176
|
+
* @param {object} params.blob - the envelope previously returned by createBotWorkspaceKey
|
|
177
|
+
* @param {Uint8Array} params.botSecret
|
|
178
|
+
* @param {string} params.botNpub
|
|
179
|
+
* @returns {{ wsSession: object }}
|
|
180
|
+
*/
|
|
181
|
+
export function loadBotWorkspaceKey({ blob, botSecret, botNpub }) {
|
|
182
|
+
requireBotActor({ botSecret, botNpub });
|
|
183
|
+
if (!blob || typeof blob !== 'object') {
|
|
184
|
+
throw new BotHelperError('workspace_auth_failed', 'Missing workspace key blob.');
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const { wsKeySecret, wsKeyNpub, wsKeyEpoch } = decryptWorkspaceKey(
|
|
188
|
+
blob,
|
|
189
|
+
botSecret,
|
|
190
|
+
botNpub,
|
|
191
|
+
);
|
|
192
|
+
const wsSession = buildWorkspaceSession(wsKeySecret, wsKeyNpub, wsKeyEpoch, botNpub);
|
|
193
|
+
return { wsSession };
|
|
194
|
+
} catch (cause) {
|
|
195
|
+
throw new BotHelperError(
|
|
196
|
+
'workspace_auth_failed',
|
|
197
|
+
`Unable to load workspace key for ${botNpub}: ${cause.message}`,
|
|
198
|
+
{ cause },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build a NIP-98 Authorization header for a Tower request, signed by the bot's
|
|
205
|
+
* per-workspace ws key. Wingmen uses this when it does its own HTTP calls
|
|
206
|
+
* without the Yoke client class.
|
|
207
|
+
*
|
|
208
|
+
* @param {object} params
|
|
209
|
+
* @param {object} params.wsSession - from createBotWorkspaceKey/loadBotWorkspaceKey
|
|
210
|
+
* @param {string} params.url
|
|
211
|
+
* @param {string} params.method
|
|
212
|
+
* @param {object|string|null} [params.body]
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function signWorkspaceRequest({ wsSession, url, method, body = null }) {
|
|
216
|
+
if (!wsSession?.secret || !wsSession?.isWorkspaceKey) {
|
|
217
|
+
throw new BotHelperError(
|
|
218
|
+
'workspace_auth_failed',
|
|
219
|
+
'signWorkspaceRequest requires a ws session from createBotWorkspaceKey/loadBotWorkspaceKey.',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (typeof url !== 'string' || !url) {
|
|
223
|
+
throw new BotHelperError('workspace_auth_failed', 'signWorkspaceRequest url is required.');
|
|
224
|
+
}
|
|
225
|
+
if (typeof method !== 'string' || !method) {
|
|
226
|
+
throw new BotHelperError('workspace_auth_failed', 'signWorkspaceRequest method is required.');
|
|
227
|
+
}
|
|
228
|
+
return createNip98AuthHeader(url, method, body, wsSession.secret);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Fetch wrapped group key rows from Tower for the bot actor.
|
|
233
|
+
*
|
|
234
|
+
* This helper is intentionally transport-minimal: it takes a `fetchImpl` so
|
|
235
|
+
* Wingmen can inject its own HTTP/SSE-aware fetch wrapper, and a
|
|
236
|
+
* `backendBaseUrl` so it does not depend on Yoke config singletons.
|
|
237
|
+
*
|
|
238
|
+
* @param {object} params
|
|
239
|
+
* @param {object} params.wsSession
|
|
240
|
+
* @param {string} params.backendBaseUrl - e.g. https://tower.example.com
|
|
241
|
+
* @param {typeof fetch} [params.fetchImpl=fetch]
|
|
242
|
+
* @returns {Promise<Array>} raw wrapped group key rows as returned by Tower
|
|
243
|
+
*/
|
|
244
|
+
export async function fetchBotGroupKeys({ wsSession, backendBaseUrl, fetchImpl = fetch }) {
|
|
245
|
+
if (!wsSession?.secret || !wsSession?.isWorkspaceKey) {
|
|
246
|
+
throw new BotHelperError(
|
|
247
|
+
'workspace_auth_failed',
|
|
248
|
+
'fetchBotGroupKeys requires a bot ws session.',
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (typeof backendBaseUrl !== 'string' || !backendBaseUrl) {
|
|
252
|
+
throw new BotHelperError(
|
|
253
|
+
'workspace_auth_failed',
|
|
254
|
+
'fetchBotGroupKeys requires backendBaseUrl.',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const url = new URL(
|
|
258
|
+
`/api/v4/groups/keys?member_npub=${encodeURIComponent(wsSession.npub)}`,
|
|
259
|
+
backendBaseUrl,
|
|
260
|
+
).toString();
|
|
261
|
+
const authorization = signWorkspaceRequest({ wsSession, url, method: 'GET' });
|
|
262
|
+
let response;
|
|
263
|
+
try {
|
|
264
|
+
response = await fetchImpl(url, {
|
|
265
|
+
method: 'GET',
|
|
266
|
+
headers: { Authorization: authorization },
|
|
267
|
+
});
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
throw new BotHelperError(
|
|
270
|
+
'workspace_auth_failed',
|
|
271
|
+
`Tower group-key fetch transport error: ${cause.message}`,
|
|
272
|
+
{ cause },
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
const text = await response.text().catch(() => '');
|
|
277
|
+
throw new BotHelperError(
|
|
278
|
+
'workspace_auth_failed',
|
|
279
|
+
`Tower group-key fetch rejected (${response.status}): ${text || response.statusText}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const payload = await response.json();
|
|
283
|
+
const rows = Array.isArray(payload)
|
|
284
|
+
? payload
|
|
285
|
+
: Array.isArray(payload?.keys)
|
|
286
|
+
? payload.keys
|
|
287
|
+
: Array.isArray(payload?.group_keys)
|
|
288
|
+
? payload.group_keys
|
|
289
|
+
: [];
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Load wrapped key rows into an in-memory group key map the decrypt helper
|
|
295
|
+
* can consume. Does not touch SQLite.
|
|
296
|
+
*
|
|
297
|
+
* @param {object} params
|
|
298
|
+
* @param {object} params.wsSession
|
|
299
|
+
* @param {Uint8Array} params.botSecret
|
|
300
|
+
* @param {string} params.botNpub
|
|
301
|
+
* @param {Array} params.keyRows - wrapped key rows from fetchBotGroupKeys
|
|
302
|
+
* @returns {object} group key map (see translators.loadGroupKeyMap)
|
|
303
|
+
*/
|
|
304
|
+
export function loadBotGroupKeys({ wsSession, botSecret, botNpub, keyRows }) {
|
|
305
|
+
if (!wsSession?.secret) {
|
|
306
|
+
throw new BotHelperError(
|
|
307
|
+
'workspace_auth_failed',
|
|
308
|
+
'loadBotGroupKeys requires a bot ws session.',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
requireBotActor({ botSecret, botNpub });
|
|
312
|
+
if (!Array.isArray(keyRows)) {
|
|
313
|
+
throw new BotHelperError(
|
|
314
|
+
'group_key_missing',
|
|
315
|
+
'loadBotGroupKeys requires an array of wrapped key rows.',
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
return loadGroupKeyMap({ secret: botSecret, npub: botNpub }, keyRows, decodeNsec);
|
|
320
|
+
} catch (cause) {
|
|
321
|
+
throw new BotHelperError(
|
|
322
|
+
'group_key_missing',
|
|
323
|
+
`Unable to unwrap group keys for bot ${botNpub}: ${cause.message}`,
|
|
324
|
+
{ cause },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Decrypt a chat_message record that Tower SSE announced to the bot.
|
|
331
|
+
*
|
|
332
|
+
* Returns the normalized chat-message model (see translators.inboundChatMessage)
|
|
333
|
+
* so the caller can feed it directly into thread normalization and routing.
|
|
334
|
+
*
|
|
335
|
+
* @param {object} params
|
|
336
|
+
* @param {object} params.record
|
|
337
|
+
* @param {object} params.wsSession
|
|
338
|
+
* @param {object} params.groupKeys - from loadBotGroupKeys
|
|
339
|
+
* @returns {object} normalized inbound chat message
|
|
340
|
+
*/
|
|
341
|
+
export function decryptChatRecord({ record, wsSession, groupKeys }) {
|
|
342
|
+
if (!record || typeof record !== 'object') {
|
|
343
|
+
throw new BotHelperError('record_decrypt_failed', 'decryptChatRecord requires a record.');
|
|
344
|
+
}
|
|
345
|
+
if (!wsSession?.secret) {
|
|
346
|
+
throw new BotHelperError(
|
|
347
|
+
'workspace_auth_failed',
|
|
348
|
+
'decryptChatRecord requires a bot ws session.',
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (!groupKeys?.get) {
|
|
352
|
+
throw new BotHelperError(
|
|
353
|
+
'group_key_missing',
|
|
354
|
+
'decryptChatRecord requires a group key map from loadBotGroupKeys.',
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
let payload;
|
|
358
|
+
try {
|
|
359
|
+
payload = decryptRecordPayload(record, wsSession, groupKeys, wsSession);
|
|
360
|
+
} catch (cause) {
|
|
361
|
+
const isGroupKey = /no matching group key/i.test(cause.message || '');
|
|
362
|
+
throw new BotHelperError(
|
|
363
|
+
isGroupKey ? 'group_key_missing' : 'record_decrypt_failed',
|
|
364
|
+
`Chat record ${record.record_id ?? '<unknown>'} could not be decrypted: ${cause.message}`,
|
|
365
|
+
{ cause },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return inboundChatMessage(record, payload);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Extract the group identities carried on a chat record and identify which of
|
|
373
|
+
* those groups are locally readable with the currently loaded wrapped-key map.
|
|
374
|
+
*
|
|
375
|
+
* This lets Wingmen do two adjacent agent-first tasks without CLI shell-outs:
|
|
376
|
+
* - inspect the full message encryption-group set for candidate selection
|
|
377
|
+
* - inspect the subset currently readable by the local bot for diagnostics
|
|
378
|
+
*
|
|
379
|
+
* @param {object} params
|
|
380
|
+
* @param {object} params.record
|
|
381
|
+
* @param {object} [params.groupKeys]
|
|
382
|
+
* @returns {{
|
|
383
|
+
* message_group_ids: string[],
|
|
384
|
+
* message_group_npubs: string[],
|
|
385
|
+
* readable_group_ids: string[],
|
|
386
|
+
* readable_group_npubs: string[],
|
|
387
|
+
* }}
|
|
388
|
+
*/
|
|
389
|
+
export function extractChatReadableGroups({ record, groupKeys = null }) {
|
|
390
|
+
if (!record || typeof record !== 'object') {
|
|
391
|
+
throw new BotHelperError(
|
|
392
|
+
'intercept_context_invalid',
|
|
393
|
+
'extractChatReadableGroups requires a record.',
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const groupPayloads = Array.isArray(record.group_payloads) ? record.group_payloads : [];
|
|
398
|
+
const messageGroupIds = dedupeSorted(groupPayloads.map((payload) => normalizeGroupRef(payload?.group_id)));
|
|
399
|
+
const messageGroupNpubs = dedupeSorted(groupPayloads.map((payload) => normalizeGroupRef(payload?.group_npub)));
|
|
400
|
+
|
|
401
|
+
if (!groupKeys?.get) {
|
|
402
|
+
return {
|
|
403
|
+
message_group_ids: messageGroupIds,
|
|
404
|
+
message_group_npubs: messageGroupNpubs,
|
|
405
|
+
readable_group_ids: [],
|
|
406
|
+
readable_group_npubs: [],
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const readableGroupIds = [];
|
|
411
|
+
const readableGroupNpubs = [];
|
|
412
|
+
for (const payload of groupPayloads) {
|
|
413
|
+
const keyVersion = Number.isInteger(payload?.group_epoch) ? payload.group_epoch : undefined;
|
|
414
|
+
const entry = groupKeys.get(payload?.group_id, keyVersion != null ? { keyVersion } : {})
|
|
415
|
+
|| groupKeys.get(payload?.group_npub, keyVersion != null ? { keyVersion } : {});
|
|
416
|
+
if (!entry) continue;
|
|
417
|
+
const groupId = normalizeGroupRef(entry.groupId) || normalizeGroupRef(payload?.group_id);
|
|
418
|
+
const groupNpub = normalizeGroupRef(entry.groupNpub) || normalizeGroupRef(payload?.group_npub);
|
|
419
|
+
if (groupId) readableGroupIds.push(groupId);
|
|
420
|
+
if (groupNpub) readableGroupNpubs.push(groupNpub);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
message_group_ids: messageGroupIds,
|
|
425
|
+
message_group_npubs: messageGroupNpubs,
|
|
426
|
+
readable_group_ids: dedupeSorted(readableGroupIds),
|
|
427
|
+
readable_group_npubs: dedupeSorted(readableGroupNpubs),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Normalize a chat message to its canonical thread_id for Agent Chat routing.
|
|
433
|
+
*
|
|
434
|
+
* Resolution rules:
|
|
435
|
+
* - if the decrypted message already exposes an explicit thread_id, trust it
|
|
436
|
+
* - else if there is no parent_message_id, the message is itself the thread root
|
|
437
|
+
* - else walk parent_message_id via `lookupMessage` until a root message is
|
|
438
|
+
* reached; that root's record_id is the thread_id
|
|
439
|
+
*
|
|
440
|
+
* `lookupMessage` is a sync function `(messageId) => chatMessage | null` that
|
|
441
|
+
* returns a prior inbound chat message (or any object with record_id and
|
|
442
|
+
* parent_message_id). If a parent cannot be resolved, throws thread_unresolved.
|
|
443
|
+
*
|
|
444
|
+
* @param {object} chatMessage - a normalized inbound chat message
|
|
445
|
+
* @param {object} [context]
|
|
446
|
+
* @param {(messageId: string) => object|null} [context.lookupMessage]
|
|
447
|
+
* @param {number} [context.maxDepth=256]
|
|
448
|
+
* @returns {string}
|
|
449
|
+
*/
|
|
450
|
+
export function normalizeThreadId(chatMessage, context = {}) {
|
|
451
|
+
if (!chatMessage || typeof chatMessage !== 'object') {
|
|
452
|
+
throw new BotHelperError('thread_unresolved', 'normalizeThreadId requires a chat message.');
|
|
453
|
+
}
|
|
454
|
+
const explicit = typeof chatMessage.thread_id === 'string' ? chatMessage.thread_id.trim() : '';
|
|
455
|
+
if (explicit) return explicit;
|
|
456
|
+
|
|
457
|
+
const selfId = typeof chatMessage.record_id === 'string' ? chatMessage.record_id.trim() : '';
|
|
458
|
+
if (!selfId) {
|
|
459
|
+
throw new BotHelperError(
|
|
460
|
+
'thread_unresolved',
|
|
461
|
+
'Chat message is missing record_id; cannot normalize thread_id.',
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const parentId = typeof chatMessage.parent_message_id === 'string'
|
|
466
|
+
? chatMessage.parent_message_id.trim()
|
|
467
|
+
: '';
|
|
468
|
+
if (!parentId) return selfId;
|
|
469
|
+
|
|
470
|
+
const lookupMessage = context.lookupMessage;
|
|
471
|
+
if (typeof lookupMessage !== 'function') {
|
|
472
|
+
throw new BotHelperError(
|
|
473
|
+
'thread_unresolved',
|
|
474
|
+
`Chat message ${selfId} has parent_message_id ${parentId} but no lookupMessage was provided.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const maxDepth = Number.isFinite(context.maxDepth) ? context.maxDepth : 256;
|
|
479
|
+
const seen = new Set([selfId]);
|
|
480
|
+
let currentId = parentId;
|
|
481
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
482
|
+
if (seen.has(currentId)) {
|
|
483
|
+
throw new BotHelperError(
|
|
484
|
+
'thread_unresolved',
|
|
485
|
+
`Thread walk for ${selfId} hit a cycle at ${currentId}.`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
seen.add(currentId);
|
|
489
|
+
const parent = lookupMessage(currentId);
|
|
490
|
+
if (!parent || typeof parent.record_id !== 'string') {
|
|
491
|
+
throw new BotHelperError(
|
|
492
|
+
'thread_unresolved',
|
|
493
|
+
`Thread walk for ${selfId} could not resolve parent ${currentId}.`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const parentRecordId = parent.record_id.trim();
|
|
497
|
+
if (!parentRecordId) {
|
|
498
|
+
throw new BotHelperError(
|
|
499
|
+
'thread_unresolved',
|
|
500
|
+
`Thread walk for ${selfId} resolved parent ${currentId} without a usable record_id.`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
const nextParent = typeof parent.parent_message_id === 'string'
|
|
504
|
+
? parent.parent_message_id.trim()
|
|
505
|
+
: '';
|
|
506
|
+
if (!nextParent) return parentRecordId;
|
|
507
|
+
currentId = nextParent;
|
|
508
|
+
}
|
|
509
|
+
throw new BotHelperError(
|
|
510
|
+
'thread_unresolved',
|
|
511
|
+
`Thread walk for ${selfId} exceeded maxDepth ${maxDepth}.`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Normalize the participant npub list for a channel. Accepts either a raw
|
|
517
|
+
* record+payload pair (the shape Tower returns) or an already-normalized
|
|
518
|
+
* inbound channel object.
|
|
519
|
+
*
|
|
520
|
+
* Returns a deduped, sorted array of bech32 npubs. The channel owner is
|
|
521
|
+
* always included.
|
|
522
|
+
*
|
|
523
|
+
* @param {object} input - { record, payload } OR an inboundChannel result
|
|
524
|
+
* @returns {string[]}
|
|
525
|
+
*/
|
|
526
|
+
export function normalizeChannelParticipants(input) {
|
|
527
|
+
if (!input || typeof input !== 'object') {
|
|
528
|
+
throw new BotHelperError(
|
|
529
|
+
'thread_unresolved',
|
|
530
|
+
'normalizeChannelParticipants requires a channel input.',
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
let channel;
|
|
534
|
+
if (Array.isArray(input.participant_npubs) || typeof input.owner_npub === 'string') {
|
|
535
|
+
channel = input;
|
|
536
|
+
} else if (input.record && input.payload) {
|
|
537
|
+
channel = inboundChannel(input.record, input.payload);
|
|
538
|
+
} else {
|
|
539
|
+
throw new BotHelperError(
|
|
540
|
+
'thread_unresolved',
|
|
541
|
+
'normalizeChannelParticipants requires { record, payload } or a normalized channel.',
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const set = new Set();
|
|
546
|
+
if (typeof channel.owner_npub === 'string' && channel.owner_npub.startsWith('npub1')) {
|
|
547
|
+
set.add(channel.owner_npub);
|
|
548
|
+
}
|
|
549
|
+
for (const candidate of (channel.participant_npubs || [])) {
|
|
550
|
+
if (typeof candidate !== 'string') continue;
|
|
551
|
+
const trimmed = candidate.trim();
|
|
552
|
+
if (trimmed.startsWith('npub1')) set.add(trimmed);
|
|
553
|
+
}
|
|
554
|
+
return [...set].sort();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Build the stable routing context Wingmen uses for Agent Chat evaluation.
|
|
559
|
+
*
|
|
560
|
+
* This composes the canonical shared helpers rather than reimplementing their
|
|
561
|
+
* logic elsewhere:
|
|
562
|
+
* - channel_id comes from the decrypted chat message
|
|
563
|
+
* - thread_id comes from normalizeThreadId()
|
|
564
|
+
* - participant_npubs comes from normalizeChannelParticipants()
|
|
565
|
+
*
|
|
566
|
+
* @param {object} input
|
|
567
|
+
* @param {object} input.chatMessage - normalized inbound chat message
|
|
568
|
+
* @param {object} input.channel - normalized inbound channel OR { record, payload }
|
|
569
|
+
* @param {object} [context]
|
|
570
|
+
* @param {(messageId: string) => object|null} [context.lookupMessage]
|
|
571
|
+
* @param {number} [context.maxDepth=256]
|
|
572
|
+
* @returns {{ record_id: string, channel_id: string, parent_message_id: string|null, thread_id: string, participant_npubs: string[] }}
|
|
573
|
+
*/
|
|
574
|
+
export function normalizeChatRoutingContext(input, context = {}) {
|
|
575
|
+
if (!input || typeof input !== 'object') {
|
|
576
|
+
throw new BotHelperError(
|
|
577
|
+
'thread_unresolved',
|
|
578
|
+
'normalizeChatRoutingContext requires { chatMessage, channel }.',
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const chatMessage = input.chatMessage;
|
|
582
|
+
if (!chatMessage || typeof chatMessage !== 'object') {
|
|
583
|
+
throw new BotHelperError(
|
|
584
|
+
'thread_unresolved',
|
|
585
|
+
'normalizeChatRoutingContext requires a normalized chatMessage.',
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const recordId = typeof chatMessage.record_id === 'string' ? chatMessage.record_id.trim() : '';
|
|
590
|
+
if (!recordId) {
|
|
591
|
+
throw new BotHelperError(
|
|
592
|
+
'thread_unresolved',
|
|
593
|
+
'normalizeChatRoutingContext requires chatMessage.record_id.',
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const channelId = typeof chatMessage.channel_id === 'string' ? chatMessage.channel_id.trim() : '';
|
|
598
|
+
if (!channelId) {
|
|
599
|
+
throw new BotHelperError(
|
|
600
|
+
'thread_unresolved',
|
|
601
|
+
`Chat message ${recordId} is missing channel_id; cannot build routing context.`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const channelInput = input.channel;
|
|
606
|
+
if (!channelInput || typeof channelInput !== 'object') {
|
|
607
|
+
throw new BotHelperError(
|
|
608
|
+
'thread_unresolved',
|
|
609
|
+
`Chat message ${recordId} requires channel context for participant normalization.`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const normalizedChannel = (Array.isArray(channelInput.participant_npubs) || typeof channelInput.owner_npub === 'string')
|
|
614
|
+
? channelInput
|
|
615
|
+
: channelInput.record && channelInput.payload
|
|
616
|
+
? inboundChannel(channelInput.record, channelInput.payload)
|
|
617
|
+
: null;
|
|
618
|
+
if (!normalizedChannel) {
|
|
619
|
+
throw new BotHelperError(
|
|
620
|
+
'thread_unresolved',
|
|
621
|
+
'normalizeChatRoutingContext requires channel as a normalized channel or { record, payload }.',
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const normalizedChannelId = typeof normalizedChannel.record_id === 'string'
|
|
626
|
+
? normalizedChannel.record_id.trim()
|
|
627
|
+
: '';
|
|
628
|
+
if (normalizedChannelId && normalizedChannelId !== channelId) {
|
|
629
|
+
throw new BotHelperError(
|
|
630
|
+
'thread_unresolved',
|
|
631
|
+
`Chat message ${recordId} references channel ${channelId} but channel context resolved to ${normalizedChannelId}.`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
record_id: recordId,
|
|
637
|
+
channel_id: channelId,
|
|
638
|
+
parent_message_id: typeof chatMessage.parent_message_id === 'string'
|
|
639
|
+
? chatMessage.parent_message_id.trim() || null
|
|
640
|
+
: null,
|
|
641
|
+
thread_id: normalizeThreadId(chatMessage, context),
|
|
642
|
+
participant_npubs: normalizeChannelParticipants(normalizedChannel),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Build the stable agent-first chat intercept context Wingmen needs for local
|
|
648
|
+
* candidate selection and per-agent intercept routing.
|
|
649
|
+
*
|
|
650
|
+
* This composes:
|
|
651
|
+
* - workspace_owner_npub from the outer record owner
|
|
652
|
+
* - source_app_npub from the record family hash (or explicit override)
|
|
653
|
+
* - channel/thread routing from normalizeChatRoutingContext()
|
|
654
|
+
* - message/readable group identities from extractChatReadableGroups()
|
|
655
|
+
*
|
|
656
|
+
* @param {object} input
|
|
657
|
+
* @param {object} input.record
|
|
658
|
+
* @param {object} input.chatMessage
|
|
659
|
+
* @param {object} input.channel
|
|
660
|
+
* @param {object} [input.groupKeys]
|
|
661
|
+
* @param {object} [context]
|
|
662
|
+
* @param {(messageId: string) => object|null} [context.lookupMessage]
|
|
663
|
+
* @param {number} [context.maxDepth=256]
|
|
664
|
+
* @returns {{
|
|
665
|
+
* record_id: string,
|
|
666
|
+
* workspace_owner_npub: string,
|
|
667
|
+
* source_app_npub: string|null,
|
|
668
|
+
* channel_id: string,
|
|
669
|
+
* parent_message_id: string|null,
|
|
670
|
+
* thread_id: string,
|
|
671
|
+
* sender_npub: string|null,
|
|
672
|
+
* participant_npubs: string[],
|
|
673
|
+
* message_group_ids: string[],
|
|
674
|
+
* message_group_npubs: string[],
|
|
675
|
+
* readable_group_ids: string[],
|
|
676
|
+
* readable_group_npubs: string[],
|
|
677
|
+
* }}
|
|
678
|
+
*/
|
|
679
|
+
export function normalizeChatInterceptContext(input, context = {}) {
|
|
680
|
+
if (!input || typeof input !== 'object') {
|
|
681
|
+
throw new BotHelperError(
|
|
682
|
+
'intercept_context_invalid',
|
|
683
|
+
'normalizeChatInterceptContext requires { record, chatMessage, channel }.',
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const record = input.record;
|
|
687
|
+
if (!record || typeof record !== 'object') {
|
|
688
|
+
throw new BotHelperError(
|
|
689
|
+
'intercept_context_invalid',
|
|
690
|
+
'normalizeChatInterceptContext requires a record.',
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const workspaceOwnerNpub = typeof record.owner_npub === 'string' ? record.owner_npub.trim() : '';
|
|
695
|
+
if (!workspaceOwnerNpub) {
|
|
696
|
+
throw new BotHelperError(
|
|
697
|
+
'intercept_context_invalid',
|
|
698
|
+
'normalizeChatInterceptContext requires record.owner_npub.',
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const routing = normalizeChatRoutingContext({
|
|
703
|
+
chatMessage: input.chatMessage,
|
|
704
|
+
channel: input.channel,
|
|
705
|
+
}, context);
|
|
706
|
+
const groups = extractChatReadableGroups({
|
|
707
|
+
record,
|
|
708
|
+
groupKeys: input.groupKeys ?? null,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
record_id: routing.record_id,
|
|
713
|
+
workspace_owner_npub: workspaceOwnerNpub,
|
|
714
|
+
source_app_npub: resolveSourceAppNpub(record, input.chatMessage),
|
|
715
|
+
channel_id: routing.channel_id,
|
|
716
|
+
parent_message_id: routing.parent_message_id,
|
|
717
|
+
thread_id: routing.thread_id,
|
|
718
|
+
sender_npub: typeof input.chatMessage?.sender_npub === 'string'
|
|
719
|
+
? input.chatMessage.sender_npub.trim() || null
|
|
720
|
+
: null,
|
|
721
|
+
participant_npubs: routing.participant_npubs,
|
|
722
|
+
message_group_ids: groups.message_group_ids,
|
|
723
|
+
message_group_npubs: groups.message_group_npubs,
|
|
724
|
+
readable_group_ids: groups.readable_group_ids,
|
|
725
|
+
readable_group_npubs: groups.readable_group_npubs,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Build the canonical agent-first intercept key:
|
|
731
|
+
*
|
|
732
|
+
* workspace_owner_npub + source_app_npub + channel_id + thread_id + agent_id
|
|
733
|
+
*
|
|
734
|
+
* @param {object} input
|
|
735
|
+
* @param {string} input.workspace_owner_npub
|
|
736
|
+
* @param {string|null} input.source_app_npub
|
|
737
|
+
* @param {string} input.channel_id
|
|
738
|
+
* @param {string} input.thread_id
|
|
739
|
+
* @param {string} input.agent_id
|
|
740
|
+
* @returns {string}
|
|
741
|
+
*/
|
|
742
|
+
export function buildAgentInterceptKey(input) {
|
|
743
|
+
if (!input || typeof input !== 'object') {
|
|
744
|
+
throw new BotHelperError(
|
|
745
|
+
'intercept_context_invalid',
|
|
746
|
+
'buildAgentInterceptKey requires an input object.',
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const workspaceOwnerNpub = typeof input.workspace_owner_npub === 'string'
|
|
751
|
+
? input.workspace_owner_npub.trim()
|
|
752
|
+
: '';
|
|
753
|
+
const sourceAppNpub = typeof input.source_app_npub === 'string'
|
|
754
|
+
? input.source_app_npub.trim()
|
|
755
|
+
: '';
|
|
756
|
+
const channelId = typeof input.channel_id === 'string' ? input.channel_id.trim() : '';
|
|
757
|
+
const threadId = typeof input.thread_id === 'string' ? input.thread_id.trim() : '';
|
|
758
|
+
const agentId = typeof input.agent_id === 'string' ? input.agent_id.trim() : '';
|
|
759
|
+
|
|
760
|
+
if (!workspaceOwnerNpub || !sourceAppNpub || !channelId || !threadId || !agentId) {
|
|
761
|
+
throw new BotHelperError(
|
|
762
|
+
'intercept_context_invalid',
|
|
763
|
+
'buildAgentInterceptKey requires workspace_owner_npub, source_app_npub, channel_id, thread_id, and agent_id.',
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return [
|
|
768
|
+
workspaceOwnerNpub,
|
|
769
|
+
sourceAppNpub,
|
|
770
|
+
channelId,
|
|
771
|
+
threadId,
|
|
772
|
+
agentId,
|
|
773
|
+
].join('+');
|
|
774
|
+
}
|