@ghostlytools/cli 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +16 -6
  2. package/dist/assets/api-BiaX6l6f.js +3 -0
  3. package/dist/assets/api-CrxVxtY1.js +4071 -0
  4. package/dist/assets/api-CrxVxtY1.js.map +1 -0
  5. package/dist/assets/apiKit-CajiDxdK.js +196 -0
  6. package/dist/assets/apiKit-CajiDxdK.js.map +1 -0
  7. package/dist/assets/apps-CbfiCSIw.js +787 -0
  8. package/dist/assets/apps-CbfiCSIw.js.map +1 -0
  9. package/dist/assets/apps-t23gkc28.js +2 -0
  10. package/dist/assets/{arkade-CdnKGDbg.js → arkade-D4BVJFEQ.js} +2 -2
  11. package/dist/assets/{arkade-CdnKGDbg.js.map → arkade-D4BVJFEQ.js.map} +1 -1
  12. package/dist/assets/backup-BAMi2lAq.js +731 -0
  13. package/dist/assets/backup-BAMi2lAq.js.map +1 -0
  14. package/dist/assets/backup-Cg52WeQi.js +2 -0
  15. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  16. package/dist/assets/buttons-CWl2yNJu.js +2 -0
  17. package/dist/assets/buttons-CX1pKB0j.js +497 -0
  18. package/dist/assets/buttons-CX1pKB0j.js.map +1 -0
  19. package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-Dn7H8qE1.js} +14 -8
  20. package/dist/assets/endpoint-Dn7H8qE1.js.map +1 -0
  21. package/dist/assets/envelope-BvU0fLEt.js +90 -0
  22. package/dist/assets/envelope-BvU0fLEt.js.map +1 -0
  23. package/dist/assets/errors-my6VJR_0.js +77 -0
  24. package/dist/assets/errors-my6VJR_0.js.map +1 -0
  25. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  26. package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
  27. package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
  28. package/dist/assets/fileBytes-CgbibpbV.js +2 -0
  29. package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
  30. package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
  31. package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
  32. package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
  33. package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
  34. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
  35. package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
  36. package/dist/assets/groupFiles-BNTSDaoq.js +7685 -0
  37. package/dist/assets/groupFiles-BNTSDaoq.js.map +1 -0
  38. package/dist/assets/groupFiles-DBbjOLuD.js +2088 -0
  39. package/dist/assets/groupFiles-DBbjOLuD.js.map +1 -0
  40. package/dist/assets/host-Cmd205Dc.js +4648 -0
  41. package/dist/assets/host-Cmd205Dc.js.map +1 -0
  42. package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
  43. package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
  44. package/dist/assets/{hyperdhtRelay-C1p0nkle.js → hyperdhtRelay-CEmBpT0Q.js} +26 -10
  45. package/dist/assets/hyperdhtRelay-CEmBpT0Q.js.map +1 -0
  46. package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
  47. package/dist/assets/idb-B5L8wuOm.js.map +1 -0
  48. package/dist/assets/irohWeb--YyEA-hX.js +545 -0
  49. package/dist/assets/irohWeb--YyEA-hX.js.map +1 -0
  50. package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
  51. package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
  52. package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
  53. package/dist/assets/main-BzNN6bkx.js +4302 -0
  54. package/dist/assets/main-BzNN6bkx.js.map +1 -0
  55. package/dist/assets/{mainline-BH6s8HVi.js → mainline-D5D5C-jT.js} +11 -6
  56. package/dist/assets/mainline-D5D5C-jT.js.map +1 -0
  57. package/dist/assets/mints-BrlY773j.js +74 -0
  58. package/dist/assets/mints-BrlY773j.js.map +1 -0
  59. package/dist/assets/mints-CqTdA4Gk.js +2 -0
  60. package/dist/assets/networkReason-DxZWHw6v.js +154 -0
  61. package/dist/assets/networkReason-DxZWHw6v.js.map +1 -0
  62. package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
  63. package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
  64. package/dist/assets/profiles-B6F4oa9K.js +139 -0
  65. package/dist/assets/profiles-B6F4oa9K.js.map +1 -0
  66. package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
  67. package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
  68. package/dist/assets/{server-0zfNpABQ.js → server-CBCEYKMN.js} +1103 -235
  69. package/dist/assets/server-CBCEYKMN.js.map +1 -0
  70. package/dist/assets/{src-CbLkHfz8.js → src-DhfGgAUu.js} +836 -2265
  71. package/dist/assets/src-DhfGgAUu.js.map +1 -0
  72. package/dist/assets/turnRefs-ChEfgbSX.js +323 -0
  73. package/dist/assets/turnRefs-ChEfgbSX.js.map +1 -0
  74. package/dist/assets/{usdt-Qf4UuygS.js → usdt-C1Xao2xH.js} +3 -3
  75. package/dist/assets/{usdt-Qf4UuygS.js.map → usdt-C1Xao2xH.js.map} +1 -1
  76. package/dist/ghostly.mjs +4 -22077
  77. package/dist/ghostly.mjs.map +1 -1
  78. package/package.json +1 -1
  79. package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
  80. package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
  81. package/dist/assets/hyperdhtRelay-C1p0nkle.js.map +0 -1
  82. package/dist/assets/idb-CTF6LF5B.js.map +0 -1
  83. package/dist/assets/mainline-BH6s8HVi.js.map +0 -1
  84. package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
  85. package/dist/assets/server-0zfNpABQ.js.map +0 -1
  86. package/dist/assets/src-CbLkHfz8.js.map +0 -1
@@ -0,0 +1,4071 @@
1
+ import { $ as appRef, E as voicePeaksOf, Ft as toBase64Url, L as cardLine, N as STATUS_CARD_LIMITS, Nt as randomBytes$1, P as appCardId, S as baseMime, T as parseVoiceMeta, _t as LIMITS, b as PLAYABLE_AUDIO, it as isAppRef, m as readImageMeta, t as GROUP_FILE_LIMITS, v as sanitizeFileName, x as VOICE_LIMITS, y as sanitizeMime, z as checkStatusCard } from "./groupFiles-DBbjOLuD.js";
2
+ import { Da as AWAITING_PEER_MS, Dr as TYPING_REFRESH_MS, Er as TYPING_KINDS, Fi as identityStatement, Ma as GhostlyHttpError, ac as MAX_AVATAR_LENGTH, at as groupName, kr as sanitizeTypingStatus, ls as inviteLink, m as APP_PERMISSIONS, oc as sanitizeAvatar, qr as APP_DATA_MAX_BYTES, y as isAppUrl } from "./src-DhfGgAUu.js";
3
+ import { c as fileStore } from "./idb-B5L8wuOm.js";
4
+ import { O as waitingGroupFile, T as removeStored, ct as iceServerProblem, i as groupOfFile, k as IDENTITY_PROVIDERS, l as replyRef, lt as iceServerSetting, n as groupFileId, v as paymentNetwork } from "./groupFiles-BNTSDaoq.js";
5
+ import { f as FILE_BYTES_STEP, o as fileBytes, s as fileBytesOf } from "./fileBytes-Di7VZtmU.js";
6
+ import { a as waitForEdit, c as findSecret, i as pressable, n as buttonsCard, o as waitForGroupFrame, s as waitForMessage, t as BUTTON_METHODS } from "./buttons-CX1pKB0j.js";
7
+ import { i as ENGINE_READS, n as withStoredIds, r as ENGINE_METHODS } from "./turnRefs-ChEfgbSX.js";
8
+ import { t as CliError } from "./errors-my6VJR_0.js";
9
+ import { _ as str, a as findChat, c as groupOf, d as messageOf, f as node, g as state, h as sentAt, i as chatOrGroup, l as inviteTaken, m as oneOf, n as bool, o as findChatOrGroup, p as num, r as chatOf, s as findGroup, u as list, v as waitForState } from "./apiKit-CajiDxdK.js";
10
+ import { t as BACKUP_METHODS } from "./backup-BAMi2lAq.js";
11
+ import { o as checkedBundle } from "./apps-CbfiCSIw.js";
12
+ import { generateMnemonic } from "@scure/bip39";
13
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
14
+ import { openAsBlob, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { access, constants as constants$1, open, readFile, rm, stat } from "node:fs/promises";
16
+ import { basename, extname, join, resolve } from "node:path";
17
+ import { spawn } from "node:child_process";
18
+ import { randomBytes, timingSafeEqual } from "node:crypto";
19
+ import { createServer } from "node:http";
20
+ import { Readable } from "node:stream";
21
+ import { pipeline } from "node:stream/promises";
22
+ //#region src/typing.ts
23
+ /**
24
+ * `typing --for` and `group typing --for`: a `start` holds 6 s on the other side (WISP 401 § Typing, WISP 902 · Group
25
+ * Mesh § Typing), so the daemon says it again until the time is up, a message goes to that chat or group, or a
26
+ * `--stop`. The engine sends a `start` at most once per refresh, counted from when the last one went: asking every half
27
+ * refresh missed the tick at 3 s by a millisecond about half the time, so a start went every 4.5 s, 1.5 s inside the
28
+ * contact's 6 s, and a busy machine flickered the contact's indicator off and on. Asking every half second sends one
29
+ * at most 3.5 s after the last, and soon again when the send limit held one back.
30
+ */
31
+ var KEEP_EVERY_MS = TYPING_REFRESH_MS / 6;
32
+ var keepers = /* @__PURE__ */ new WeakMap();
33
+ function keepersOf(ctx) {
34
+ let map = keepers.get(ctx);
35
+ if (!map) keepers.set(ctx, map = /* @__PURE__ */ new Map());
36
+ return map;
37
+ }
38
+ var keyOf = (target) => "groupId" in target ? `group:${target.groupId}` : target.linkId;
39
+ /** Says it once: this side is typing (with its word), or stopped. */
40
+ function sayTyping(ctx, target, typing, word = {}) {
41
+ const said = typing ? word : {};
42
+ if ("groupId" in target) node(ctx).setGroupTyping({
43
+ groupId: target.groupId,
44
+ typing,
45
+ ...said
46
+ });
47
+ else node(ctx).setTyping({
48
+ linkId: target.linkId,
49
+ typing,
50
+ ...said
51
+ });
52
+ }
53
+ /**
54
+ * Says this side is typing in the chat or group for `ms`, with the kind and status it gave (`--kind`, `--status`) each
55
+ * time; resolves when that ends, whichever way.
56
+ */
57
+ function keepTyping(ctx, target, ms, word = {}) {
58
+ endTyping(ctx, target, false);
59
+ const map = keepersOf(ctx), key = keyOf(target);
60
+ return new Promise((resolve) => {
61
+ const end = () => {
62
+ clearInterval(again);
63
+ clearTimeout(until);
64
+ off();
65
+ if (map.get(key) === end) map.delete(key);
66
+ resolve();
67
+ };
68
+ const again = setInterval(() => sayTyping(ctx, target, true, word), KEEP_EVERY_MS);
69
+ const until = setTimeout(() => {
70
+ end();
71
+ sayTyping(ctx, target, false);
72
+ }, ms);
73
+ const off = ctx.hub.onEvent((event) => {
74
+ if ("groupId" in target ? event.type === "group.sent" && event.group === target.groupId : event.type === "message.sent" && event.chat === target.linkId) end();
75
+ });
76
+ map.set(key, end);
77
+ });
78
+ }
79
+ /** Ends a kept `--for` in the chat or group; `tell`: and says this side stopped. Whether one was kept. */
80
+ function endTyping(ctx, target, tell) {
81
+ const end = keepersOf(ctx).get(keyOf(target));
82
+ if (!end) return false;
83
+ end();
84
+ if (tell) sayTyping(ctx, target, false);
85
+ return true;
86
+ }
87
+ //#endregion
88
+ //#region src/voicePeaks.ts
89
+ /** Longer than any voice note (15 minutes) with room: a file past it is not decoded into memory. */
90
+ var MAX_BYTES = 268435456;
91
+ var FFMPEG_TIMEOUT_MS = 12e4;
92
+ /** The waveform and length of the sound in `path`, or why they could not be read. */
93
+ async function measureVoice(path) {
94
+ let sound;
95
+ try {
96
+ sound = await decodeSound(path);
97
+ } catch (error) {
98
+ return { problem: error instanceof Error ? error.message : String(error) };
99
+ }
100
+ if (sound.mono.length === 0) return { problem: "no sound in it" };
101
+ return { voice: {
102
+ duration: Math.round(sound.mono.length / sound.sampleRate * 1e3),
103
+ peaks: voicePeaksOf(sound.mono, sound.sampleRate)
104
+ } };
105
+ }
106
+ async function decodeSound(path) {
107
+ const bytes = await readFile(path);
108
+ if (bytes.length > MAX_BYTES) throw new Error("too large to decode");
109
+ let own;
110
+ try {
111
+ const sound = await decodeBytes(bytes);
112
+ if (sound) return sound;
113
+ } catch (error) {
114
+ own = error instanceof Error ? error : new Error(String(error));
115
+ }
116
+ try {
117
+ return await decodeWithFfmpeg(path);
118
+ } catch (error) {
119
+ const reason = error instanceof Error ? error.message : String(error);
120
+ throw new Error(own ? `${own.message}; ${reason}` : reason, { cause: error });
121
+ }
122
+ }
123
+ /** The sound in a file this module decodes itself, or null for a kind it leaves to ffmpeg. */
124
+ async function decodeBytes(bytes) {
125
+ if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WAVE") return decodeWav(bytes);
126
+ if (bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163) {
127
+ const opus = demuxWebm(bytes);
128
+ return opus ? decodeOpus(opus) : null;
129
+ }
130
+ if (ascii(bytes, 0, 4) === "OggS") {
131
+ const opus = demuxOgg(bytes);
132
+ return opus ? decodeOpus(opus) : null;
133
+ }
134
+ if (ascii(bytes, 0, 3) === "ID3" || bytes[0] === 255 && (bytes[1] & 224) === 224) return decodeMp3(bytes);
135
+ return null;
136
+ }
137
+ function ascii(bytes, at, length) {
138
+ return String.fromCharCode(...bytes.subarray(at, at + length));
139
+ }
140
+ function mix(channels, length) {
141
+ if (channels.length === 1) return channels[0].subarray(0, length);
142
+ const mono = new Float32Array(length);
143
+ for (const channel of channels) for (let i = 0; i < length; i++) mono[i] += channel[i] / channels.length;
144
+ return mono;
145
+ }
146
+ /** PCM (8, 16, 24 or 32 bit) or float (32 or 64 bit) WAV. A data chunk whose size is unknown (a pipe) runs to the end. */
147
+ function decodeWav(bytes) {
148
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
149
+ let format = 0, channels = 0, sampleRate = 0, bits = 0;
150
+ let at = 12;
151
+ while (at + 8 <= bytes.length) {
152
+ const id = ascii(bytes, at, 4);
153
+ const size = view.getUint32(at + 4, true);
154
+ const body = at + 8;
155
+ if (id === "fmt ") {
156
+ format = view.getUint16(body, true);
157
+ channels = view.getUint16(body + 2, true);
158
+ sampleRate = view.getUint32(body + 4, true);
159
+ bits = view.getUint16(body + 14, true);
160
+ if (format === 65534 && size >= 26) format = view.getUint16(body + 24, true);
161
+ } else if (id === "data") {
162
+ if (!channels || !sampleRate || !bits) throw new Error("a WAV without its format");
163
+ return {
164
+ mono: wavSamples(view, body, size === 0 || size === 4294967295 || body + size > bytes.length ? bytes.length : body + size, format, channels, bits),
165
+ sampleRate
166
+ };
167
+ }
168
+ at = body + size + (size & 1);
169
+ }
170
+ throw new Error("a WAV without sound");
171
+ }
172
+ function wavSamples(view, from, to, format, channels, bits) {
173
+ const width = bits / 8;
174
+ const read = format === 1 && bits === 8 ? (at) => (view.getUint8(at) - 128) / 128 : format === 1 && bits === 16 ? (at) => view.getInt16(at, true) / 32768 : format === 1 && bits === 24 ? (at) => (view.getInt8(at + 2) << 16 | view.getUint8(at + 1) << 8 | view.getUint8(at)) / 8388608 : format === 1 && bits === 32 ? (at) => view.getInt32(at, true) / 2147483648 : format === 3 && bits === 32 ? (at) => view.getFloat32(at, true) : format === 3 && bits === 64 ? (at) => view.getFloat64(at, true) : void 0;
175
+ if (!read) throw new Error(`a WAV in a format not read here (${format}, ${bits} bit)`);
176
+ const frames = Math.floor((to - from) / (width * channels));
177
+ const mono = new Float32Array(frames);
178
+ for (let frame = 0; frame < frames; frame++) {
179
+ let sum = 0;
180
+ for (let c = 0; c < channels; c++) sum += read(from + (frame * channels + c) * width);
181
+ mono[frame] = sum / channels;
182
+ }
183
+ return mono;
184
+ }
185
+ /** A variable-length number in EBML: its length in bytes and value (the marker bit kept for ids, dropped for sizes). */
186
+ function vint(bytes, at, keepMarker) {
187
+ const first = bytes[at];
188
+ if (first === void 0 || first === 0) return null;
189
+ const length = Math.clz32(first) - 23;
190
+ if (at + length > bytes.length) return null;
191
+ let value = keepMarker ? first : first & 255 >> length;
192
+ let allOnes = value === 255 >> length;
193
+ for (let i = 1; i < length; i++) {
194
+ value = value * 256 + bytes[at + i];
195
+ if (bytes[at + i] !== 255) allOnes = false;
196
+ }
197
+ return {
198
+ length,
199
+ value,
200
+ unknown: !keepMarker && allOnes
201
+ };
202
+ }
203
+ /** Elements whose children are read: EBML Segment, Tracks, TrackEntry, Audio, Cluster, BlockGroup. */
204
+ var WEBM_PARENTS = /* @__PURE__ */ new Set([
205
+ 408125543,
206
+ 374648427,
207
+ 174,
208
+ 225,
209
+ 524531317,
210
+ 160
211
+ ]);
212
+ /**
213
+ * The Opus track of a WebM (Matroska) file. It reads the file in order, stepping into the elements that hold what it
214
+ * needs rather than jumping by their sizes: MediaRecorder writes the Segment and every Cluster with an unknown size.
215
+ */
216
+ function demuxWebm(bytes) {
217
+ const tracks = [];
218
+ const blocks = [];
219
+ let at = 0;
220
+ while (at < bytes.length) {
221
+ const id = vint(bytes, at, true);
222
+ if (!id) break;
223
+ const size = vint(bytes, at + id.length, false);
224
+ if (!size) break;
225
+ const body = at + id.length + size.length;
226
+ if (WEBM_PARENTS.has(id.value)) {
227
+ if (id.value === 174) tracks.push({
228
+ number: 0,
229
+ codec: "",
230
+ head: null,
231
+ channels: 0
232
+ });
233
+ at = body;
234
+ continue;
235
+ }
236
+ if (size.unknown) break;
237
+ const end = Math.min(bytes.length, body + size.value);
238
+ const data = bytes.subarray(body, end);
239
+ const track = tracks.at(-1);
240
+ if (id.value === 215 && track) track.number = uint(data);
241
+ else if (id.value === 134 && track) track.codec = ascii(data, 0, data.length);
242
+ else if (id.value === 25506 && track) track.head = data;
243
+ else if (id.value === 159 && track) track.channels = uint(data);
244
+ else if (id.value === 163 || id.value === 161) {
245
+ const number = vint(data, 0, false);
246
+ if (number) for (const frame of laced(data, number.length)) blocks.push({
247
+ track: number.value,
248
+ data: frame
249
+ });
250
+ }
251
+ at = end;
252
+ }
253
+ const opus = tracks.find((t) => t.codec === "A_OPUS");
254
+ if (!opus) return null;
255
+ return {
256
+ head: opus.head,
257
+ channels: opus.channels || 1,
258
+ frames: blocks.filter((b) => b.track === opus.number).map((b) => b.data)
259
+ };
260
+ }
261
+ function uint(data) {
262
+ let value = 0;
263
+ for (const byte of data) value = value * 256 + byte;
264
+ return value;
265
+ }
266
+ /** The frames in a (Simple)Block after its track number: one, or several laced (Xiph, fixed-size or EBML). */
267
+ function laced(data, at) {
268
+ const flags = data[at + 2];
269
+ let pos = at + 3;
270
+ const lacing = flags >> 1 & 3;
271
+ if (lacing === 0) return [data.subarray(pos)];
272
+ const count = data[pos++] + 1;
273
+ const sizes = [];
274
+ if (lacing === 1) for (let i = 0; i < count - 1; i++) {
275
+ let size = 0;
276
+ for (let byte = 255; byte === 255; size += byte) byte = data[pos++];
277
+ sizes.push(size);
278
+ }
279
+ else if (lacing === 3) {
280
+ const first = vint(data, pos, false);
281
+ pos += first.length;
282
+ sizes.push(first.value);
283
+ for (let i = 1; i < count - 1; i++) {
284
+ const delta = vint(data, pos, false);
285
+ pos += delta.length;
286
+ sizes.push(sizes[i - 1] + delta.value - (2 ** (7 * delta.length - 1) - 1));
287
+ }
288
+ } else {
289
+ const each = Math.floor((data.length - pos) / count);
290
+ for (let i = 0; i < count - 1; i++) sizes.push(each);
291
+ }
292
+ sizes.push(data.length - pos - sizes.reduce((a, b) => a + b, 0));
293
+ const frames = [];
294
+ for (const size of sizes) {
295
+ frames.push(data.subarray(pos, pos + size));
296
+ pos += size;
297
+ }
298
+ return frames;
299
+ }
300
+ /** The first Opus stream of an Ogg file: its packets put back together from the pages' segments. */
301
+ function demuxOgg(bytes) {
302
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
303
+ let serial;
304
+ let head = null;
305
+ const frames = [];
306
+ let packet = [];
307
+ let endGranule;
308
+ let at = 0;
309
+ while (at + 27 <= bytes.length && ascii(bytes, at, 4) === "OggS") {
310
+ const segments = bytes[at + 26];
311
+ const table = bytes.subarray(at + 27, at + 27 + segments);
312
+ let pos = at + 27 + segments;
313
+ const pageSerial = view.getUint32(at + 14, true);
314
+ const granule = Number(view.getBigInt64(at + 6, true));
315
+ if (serial === void 0 ? ascii(bytes, pos, 8) === "OpusHead" : pageSerial === serial) {
316
+ serial = pageSerial;
317
+ if (granule >= 0) endGranule = granule;
318
+ for (const size of table) {
319
+ packet.push(bytes.subarray(pos, pos + size));
320
+ pos += size;
321
+ if (size < 255) {
322
+ const whole = packet.length === 1 ? packet[0] : Buffer.concat(packet);
323
+ packet = [];
324
+ if (!head) head = whole;
325
+ else if (ascii(whole, 0, 8) !== "OpusTags") frames.push(whole);
326
+ }
327
+ }
328
+ } else pos += table.reduce((a, b) => a + b, 0);
329
+ at = pos;
330
+ }
331
+ if (!head) return null;
332
+ return {
333
+ head,
334
+ channels: head[9] ?? 1,
335
+ frames,
336
+ endGranule
337
+ };
338
+ }
339
+ async function decodeOpus(stream) {
340
+ const { OpusDecoder } = await import("opus-decoder");
341
+ const head = stream.head && ascii(stream.head, 0, 8) === "OpusHead" ? stream.head : null;
342
+ const channels = head ? head[9] : Math.max(1, stream.channels);
343
+ const preSkip = head ? head[10] | head[11] << 8 : 0;
344
+ const decoder = new OpusDecoder({
345
+ channels,
346
+ preSkip,
347
+ ...(head ? head[18] : 0) !== 0 && head ? {
348
+ streamCount: head[19],
349
+ coupledStreamCount: head[20],
350
+ channelMappingTable: [...head.subarray(21, 21 + channels)]
351
+ } : {
352
+ streamCount: 1,
353
+ coupledStreamCount: channels - 1,
354
+ channelMappingTable: channels === 1 ? [0] : [0, 1]
355
+ }
356
+ });
357
+ await decoder.ready;
358
+ try {
359
+ const decoded = decoder.decodeFrames(stream.frames);
360
+ let length = decoded.samplesDecoded;
361
+ if (stream.endGranule !== void 0 && stream.endGranule > preSkip) length = Math.min(length, stream.endGranule - preSkip);
362
+ return {
363
+ mono: mix(decoded.channelData, length),
364
+ sampleRate: decoded.sampleRate
365
+ };
366
+ } finally {
367
+ decoder.free();
368
+ }
369
+ }
370
+ async function decodeMp3(bytes) {
371
+ const { MPEGDecoder } = await import("mpg123-decoder");
372
+ const decoder = new MPEGDecoder();
373
+ await decoder.ready;
374
+ try {
375
+ const decoded = decoder.decode(bytes);
376
+ if (!decoded.samplesDecoded) throw new Error("no MP3 frames in it");
377
+ return {
378
+ mono: mix(decoded.channelData, decoded.samplesDecoded),
379
+ sampleRate: decoded.sampleRate
380
+ };
381
+ } finally {
382
+ decoder.free();
383
+ }
384
+ }
385
+ /** Anything ffmpeg reads, as a float WAV on its output (the channels kept, so they are averaged as above). */
386
+ function decodeWithFfmpeg(path, command = process.env.GHOSTLY_FFMPEG || "ffmpeg") {
387
+ return new Promise((resolve, reject) => {
388
+ const child = spawn(command, [
389
+ "-v",
390
+ "error",
391
+ "-nostdin",
392
+ "-i",
393
+ path,
394
+ "-vn",
395
+ "-f",
396
+ "wav",
397
+ "-c:a",
398
+ "pcm_f32le",
399
+ "-"
400
+ ], { stdio: [
401
+ "ignore",
402
+ "pipe",
403
+ "pipe"
404
+ ] });
405
+ const chunks = [];
406
+ let size = 0, errors = "";
407
+ const timer = setTimeout(() => child.kill("SIGKILL"), FFMPEG_TIMEOUT_MS);
408
+ child.stdout.on("data", (chunk) => {
409
+ size += chunk.length;
410
+ if (size > MAX_BYTES * 2) child.kill("SIGKILL");
411
+ else chunks.push(chunk);
412
+ });
413
+ child.stderr.on("data", (chunk) => {
414
+ if (errors.length < 2e3) errors += chunk.toString();
415
+ });
416
+ child.on("error", (error) => {
417
+ clearTimeout(timer);
418
+ reject(/* @__PURE__ */ new Error(error.code === "ENOENT" ? "no decoder here for this kind of audio (install ffmpeg to read it)" : `ffmpeg: ${error.message}`));
419
+ });
420
+ child.on("close", (code) => {
421
+ clearTimeout(timer);
422
+ if (code !== 0) {
423
+ reject(/* @__PURE__ */ new Error(`ffmpeg could not read it${errors.trim() ? `: ${errors.trim().split("\n")[0]}` : ""}`));
424
+ return;
425
+ }
426
+ try {
427
+ resolve(decodeWav(Buffer.concat(chunks)));
428
+ } catch (error) {
429
+ reject(error);
430
+ }
431
+ });
432
+ });
433
+ }
434
+ //#endregion
435
+ //#region src/files.ts
436
+ /**
437
+ * Files and voice notes in a chat (WISP 1100, phase 3), as the app's composer sends them (packages/browser
438
+ * platform/services.ts `sendFile`): the bytes go into the profile's file store first, then the engine offers them.
439
+ * Large files follow files/3: the receiving side's person (here, a command) accepts or declines each offer.
440
+ *
441
+ * In a group (WISP 503 · Group Files) the file is announced as a group message, and each member's app fetches the
442
+ * bytes from whoever holds them: by itself up to the automatic limits, on `file accept` past them.
443
+ */
444
+ var TYPES$1 = {
445
+ ".png": "image/png",
446
+ ".jpg": "image/jpeg",
447
+ ".jpeg": "image/jpeg",
448
+ ".gif": "image/gif",
449
+ ".webp": "image/webp",
450
+ ".pdf": "application/pdf",
451
+ ".txt": "text/plain",
452
+ ".md": "text/markdown",
453
+ ".json": "application/json",
454
+ ".csv": "text/csv",
455
+ ".zip": "application/zip",
456
+ ".mp3": "audio/mpeg",
457
+ ".m4a": "audio/mp4",
458
+ ".ogg": "audio/ogg",
459
+ ".opus": "audio/ogg",
460
+ ".wav": "audio/wav",
461
+ ".webm": "audio/webm",
462
+ ".mp4": "video/mp4",
463
+ ".mov": "video/quicktime"
464
+ };
465
+ function mimeOf(path, given) {
466
+ return sanitizeMime(given ?? TYPES$1[extname(path).toLowerCase()] ?? "application/octet-stream");
467
+ }
468
+ /** A chat's files as the timeline shows them, with where each transfer stands (`transferOf`, by file id). */
469
+ function filesOf(messages, transferOf) {
470
+ return messages.filter((m) => m.file).map((m) => ({
471
+ messageId: m.id,
472
+ from: m.sender,
473
+ timestamp: m.timestamp,
474
+ file: {
475
+ id: m.file.id,
476
+ name: m.file.name,
477
+ size: m.file.size,
478
+ mime: m.file.mime,
479
+ ...m.file.voice ? { voice: m.file.voice } : {},
480
+ ...m.file.image ? { image: m.file.image } : {}
481
+ },
482
+ transfer: transferOf(m.file.id) ?? null
483
+ }));
484
+ }
485
+ /** The chat or the group a file command names (`findChatOrGroup`). */
486
+ var chatOrGroupOf = (ctx, params) => findChatOrGroup(state(ctx).links, state(ctx).groups, str(params, "chat", true));
487
+ /**
488
+ * The chat or group a file belongs to: a file id starts with its chat's id (a group's file with `group-<id>-`), so it
489
+ * may be left out; given, it must be the file's own. `where` is what the answer names it by.
490
+ */
491
+ function fileChat(ctx, params, fileId) {
492
+ const groupId = groupOfFile(fileId, state(ctx).groups.map((g) => g.id));
493
+ if (groupId) {
494
+ const named = params.chat === void 0 ? void 0 : chatOrGroupOf(ctx, params);
495
+ if (named && !("group" in named && named.group.id === groupId)) throw new CliError("not_found", `No file ${fileId} in this ${"group" in named ? "group" : "chat"}`);
496
+ return {
497
+ linkId: `group:${groupId}`,
498
+ where: { group: groupId }
499
+ };
500
+ }
501
+ const link = params.chat === void 0 ? state(ctx).links.find((l) => fileId.startsWith(`${l.id}-in-`) || fileId.startsWith(`${l.id}-out-`)) : chatOf(ctx, params);
502
+ if (!link) throw new CliError("not_found", `No file ${fileId}`);
503
+ if (!fileId.startsWith(`${link.id}-`)) throw new CliError("not_found", `No file ${fileId} in this chat`);
504
+ return {
505
+ linkId: link.id,
506
+ where: { chat: link.id }
507
+ };
508
+ }
509
+ var VOICE_ONLY = "A voice note is audio (webm, ogg, mp4, mpeg, aac, m4a or wav) of at most 15 minutes, with peaks from 0 to 255";
510
+ /** `--voice [ms] [--peaks …]`: the voice description, its length and waveform measured from the sound when not given. */
511
+ async function voiceOf(params, path, mime) {
512
+ if (params.voice === void 0) return {};
513
+ if (!PLAYABLE_AUDIO.test(baseMime(mime))) throw new CliError("bad_request", VOICE_ONLY);
514
+ let duration = params.voice === true ? void 0 : num(params, "voice", 0, { min: 1 });
515
+ let peaks = list(params, "peaks").map(Number);
516
+ let warning;
517
+ if (duration === void 0 || !peaks.length) {
518
+ const measured = await measureVoice(path);
519
+ if ("voice" in measured) {
520
+ duration ??= measured.voice.duration;
521
+ if (!peaks.length) peaks = measured.voice.peaks;
522
+ } else if (duration === void 0) throw new CliError("bad_request", `Could not measure the voice note (${measured.problem}): give its length as --voice <ms>`);
523
+ else warning = `Sent without a waveform: could not read the sound (${measured.problem})`;
524
+ }
525
+ const voice = parseVoiceMeta({
526
+ duration,
527
+ peaks: peaks.length ? peaks : new Array(VOICE_LIMITS.bars).fill(0)
528
+ }, mime);
529
+ if (!voice) throw new CliError("bad_request", VOICE_ONLY);
530
+ return {
531
+ voice,
532
+ ...warning ? { warning } : {}
533
+ };
534
+ }
535
+ /**
536
+ * A file announced in a group (WISP 503): its bytes staged under a group file id with their digest, as a chat's are, then
537
+ * the announcement. This device is its first holder and serves the members who ask.
538
+ */
539
+ async function sendToGroup(ctx, params, group, path, size) {
540
+ if (size > GROUP_FILE_LIMITS.maxBytes) throw new CliError("refused", `Too large for a group (at most ${GROUP_FILE_LIMITS.maxBytes} bytes)`);
541
+ if (!size) throw new CliError("refused", "An empty file cannot go to a group");
542
+ const mime = mimeOf(path, str(params, "mime"));
543
+ const replyTo = str(params, "reply");
544
+ if (replyTo) {
545
+ if (!(await node(ctx).groupMessages({ groupId: group.id })).some((m) => m.id === replyTo || replyRef(m) === replyTo)) throw new CliError("not_found", "That message is not in this group, or cannot be replied to");
546
+ }
547
+ const check = await node(ctx).groupFileCheck({ groupId: group.id });
548
+ if (check.error) throw check.refused ? new CliError("refused", check.error) : groupSendRefused(check.error);
549
+ const { voice, warning } = await voiceOf(params, path, mime);
550
+ const source = await openAsBlob(path, { type: mime });
551
+ const image = voice ? void 0 : await readImageMeta(source, mime);
552
+ const fileId = groupFileId(group.id, "out");
553
+ const file = {
554
+ id: fileId,
555
+ name: sanitizeFileName(str(params, "name") ?? basename(path)),
556
+ size,
557
+ mime,
558
+ ...voice ? { voice } : {},
559
+ ...image ? { image } : {}
560
+ };
561
+ const timestamp = Date.now();
562
+ const bytes = await fileBytes();
563
+ const digest = await bytes.stage(fileId, source);
564
+ await fileStore.put({
565
+ id: fileId,
566
+ linkId: `group:${group.id}`,
567
+ bytes: bytes.kind,
568
+ digest,
569
+ createdAt: timestamp,
570
+ direction: "out",
571
+ metadata: {
572
+ name: file.name,
573
+ size,
574
+ mime,
575
+ timestamp,
576
+ ...voice ? { voice } : {},
577
+ ...image ? { image } : {}
578
+ },
579
+ transfer: {
580
+ state: "transferring",
581
+ transferred: 0,
582
+ size
583
+ }
584
+ });
585
+ endTyping(ctx, { groupId: group.id }, false);
586
+ const result = await node(ctx).sendGroupFile({
587
+ groupId: group.id,
588
+ file,
589
+ ...replyTo ? { replyTo } : {}
590
+ });
591
+ if (result.error) {
592
+ await removeStored(fileId).catch(() => {});
593
+ throw result.refused ? new CliError("refused", result.error) : groupSendRefused(result.error);
594
+ }
595
+ return {
596
+ group: group.id,
597
+ messageId: result.messageId ?? null,
598
+ file: {
599
+ id: fileId,
600
+ name: file.name,
601
+ size,
602
+ mime,
603
+ ...voice ? { voice: true } : {},
604
+ ...image ? { image } : {}
605
+ },
606
+ ...warning ? { warning } : {}
607
+ };
608
+ }
609
+ /** Bytes a file system takes in one name: 255 on ext4, btrfs, APFS (NTFS counts 255 UTF-16 units, within this too). */
610
+ var NAME_BYTES = 255;
611
+ /**
612
+ * `name` as an entry of a folder: an app's name may have 200 characters, which in UTF-8 can be 800 bytes. The end of
613
+ * its stem goes until it fits, its extension stays (as `sanitizeFileName` shortens it), so "文…文.txt" is saved as a text.
614
+ */
615
+ function fitName(name) {
616
+ const fits = (candidate) => Buffer.byteLength(candidate) <= NAME_BYTES;
617
+ if (fits(name)) return name;
618
+ const extension = /\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? "";
619
+ const stem = [...name.slice(0, name.length - extension.length)];
620
+ while (stem.length && !fits(stem.join("").trimEnd() + extension)) stem.pop();
621
+ return (stem.join("").trimEnd() || "file") + extension;
622
+ }
623
+ /**
624
+ * The name `file save --dir` writes a file under. Its sender chose it, and the folder is one scripts and people run
625
+ * tools in: a name that starts with a dash is an option to the tool a `*` hands it to ("-rf", "--checkpoint-action=…"),
626
+ * so it gets a `_` first, as a Windows device name does in `sanitizeFileName`.
627
+ */
628
+ function saveName(name) {
629
+ const clean = sanitizeFileName(name);
630
+ return fitName(clean.startsWith("-") ? `_${clean}` : clean);
631
+ }
632
+ var ACTIONS = [
633
+ "accept",
634
+ "decline",
635
+ "pause",
636
+ "resume",
637
+ "cancel",
638
+ "resend",
639
+ "request"
640
+ ];
641
+ var FILE_METHODS = {
642
+ async "file.send"(ctx, params) {
643
+ const target = chatOrGroupOf(ctx, params);
644
+ const path = resolve(str(params, "path", true));
645
+ let info;
646
+ try {
647
+ info = await stat(path);
648
+ } catch {
649
+ throw new CliError("not_found", `No file ${path}`);
650
+ }
651
+ if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
652
+ await access(path, constants$1.R_OK);
653
+ if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
654
+ const { link } = target;
655
+ if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
656
+ if (!link.capabilities?.largeFiles && info.size > LIMITS.maxFileBytes) throw new CliError("refused", `Too large for the contact's app (at most ${LIMITS.maxFileBytes} bytes)`);
657
+ if (typeof link.peerFileRoom === "number" && info.size > link.peerFileRoom) throw new CliError("refused", `Not enough space on the contact's device (${link.peerFileRoom} bytes free)`);
658
+ const mime = mimeOf(path, str(params, "mime"));
659
+ const replyTo = str(params, "reply");
660
+ if (replyTo) {
661
+ if (!(await node(ctx).getMessages(link.id)).some((m) => m.id === replyTo || replyRef(m) === replyTo)) throw new CliError("not_found", "That message is not in this chat, or cannot be replied to");
662
+ }
663
+ const { voice, warning } = await voiceOf(params, path, mime);
664
+ const source = await openAsBlob(path, { type: mime });
665
+ const image = voice ? void 0 : await readImageMeta(source, mime);
666
+ const wireId = toBase64Url(randomBytes$1(12));
667
+ const file = {
668
+ id: `${link.id}-out-${wireId}`,
669
+ name: sanitizeFileName(str(params, "name") ?? basename(path)),
670
+ size: info.size,
671
+ mime,
672
+ ...voice ? { voice } : {},
673
+ ...image ? { image } : {}
674
+ };
675
+ const timestamp = sentAt();
676
+ const bytes = await fileBytes();
677
+ const digest = await bytes.stage(file.id, source);
678
+ await fileStore.put({
679
+ id: file.id,
680
+ linkId: link.id,
681
+ bytes: bytes.kind,
682
+ digest,
683
+ createdAt: timestamp,
684
+ direction: "out",
685
+ wireId,
686
+ metadata: {
687
+ name: file.name,
688
+ size: file.size,
689
+ mime: file.mime,
690
+ timestamp,
691
+ ...voice ? { voice } : {},
692
+ ...image ? { image } : {}
693
+ },
694
+ transfer: {
695
+ state: "transferring",
696
+ transferred: 0,
697
+ size: file.size
698
+ }
699
+ });
700
+ endTyping(ctx, { linkId: link.id }, true);
701
+ try {
702
+ await node(ctx).sendFile({
703
+ linkId: link.id,
704
+ file,
705
+ timestamp,
706
+ ...replyTo ? { replyTo } : {}
707
+ });
708
+ } catch (error) {
709
+ await removeStored(file.id).catch(() => {});
710
+ throw new CliError("refused", error instanceof Error ? error.message : String(error));
711
+ }
712
+ return {
713
+ chat: link.id,
714
+ file: {
715
+ id: file.id,
716
+ name: file.name,
717
+ size: file.size,
718
+ mime: file.mime,
719
+ ...voice ? { voice: true } : {},
720
+ ...image ? { image } : {}
721
+ },
722
+ ...warning ? { warning } : {}
723
+ };
724
+ },
725
+ async "file.list"(ctx, params) {
726
+ const target = chatOrGroupOf(ctx, params);
727
+ const views = state(ctx).transfers;
728
+ const keptOf = async (linkId) => new Map((await fileStore.listForLink(linkId).catch(() => [])).flatMap((f) => {
729
+ const kept = f.transfer?.state === "done" ? {
730
+ ...f.transfer,
731
+ direction: f.direction
732
+ } : waitingGroupFile(f);
733
+ return kept ? [[f.id, kept]] : [];
734
+ }));
735
+ if ("group" in target) {
736
+ const kept = await keptOf(`group:${target.group.id}`);
737
+ return {
738
+ group: target.group.id,
739
+ files: filesOf(await node(ctx).groupMessages({ groupId: target.group.id }), (id) => views[id] ?? kept.get(id))
740
+ };
741
+ }
742
+ const kept = await keptOf(target.link.id);
743
+ return {
744
+ chat: target.link.id,
745
+ files: filesOf(await node(ctx).getMessages(target.link.id), (id) => views[id] ?? kept.get(id))
746
+ };
747
+ },
748
+ /**
749
+ * files/3: answer an offer, or pause, resume or cancel a transfer, either way; `resend` a file sent from here that
750
+ * stopped moving (or failed), `request` again one that stopped arriving. Without a chat, the file's id names it.
751
+ * A group's file (WISP 503) takes `accept` (download one this device did not fetch by itself), `request`, and `cancel`,
752
+ * `decline`, `pause` and `resume` for one that is coming.
753
+ */
754
+ async "file.action"(ctx, params) {
755
+ const fileId = str(params, "file", true);
756
+ const { linkId, where } = fileChat(ctx, params, fileId);
757
+ const action = oneOf(params, "action", ACTIONS, "accept");
758
+ if ("group" in where) {
759
+ if (action === "resend") throw new CliError("bad_request", "A group's file is downloaded (file accept) or asked for again (file request)");
760
+ if ((state(ctx).transfers[fileId] ?? (await fileStore.get(fileId).catch(() => void 0))?.transfer)?.state === "done") throw new CliError("refused", action === "accept" || action === "request" || action === "resume" ? "Nothing to ask for: the file is all here" : "Nothing to stop: the file is all here");
761
+ try {
762
+ await node(ctx).fileAction({
763
+ linkId,
764
+ fileId,
765
+ action
766
+ });
767
+ } catch (error) {
768
+ throw new CliError("refused", error instanceof Error ? error.message : String(error));
769
+ }
770
+ return {
771
+ ...where,
772
+ file: fileId,
773
+ action,
774
+ transfer: state(ctx).transfers[fileId] ?? null
775
+ };
776
+ }
777
+ if (action === "resend" || action === "request") {
778
+ const outgoing = fileId.startsWith(`${linkId}-out-`);
779
+ if (outgoing !== (action === "resend")) throw new CliError("bad_request", outgoing ? "A file sent from here is sent again: file resend" : "A received file is asked for again: file request");
780
+ const transfer = state(ctx).transfers[fileId];
781
+ if (!transfer || transfer.state === "done") throw new CliError("refused", outgoing ? "Nothing to send again: it arrived whole" : "Nothing to ask for: the file is all here");
782
+ if (transfer.state === "failed" && (!outgoing || !transfer.retry)) throw new CliError("refused", `It ended: ${transfer.error ?? "failed"}`);
783
+ }
784
+ await node(ctx).fileAction({
785
+ linkId,
786
+ fileId,
787
+ action
788
+ });
789
+ return {
790
+ ...where,
791
+ file: fileId,
792
+ action,
793
+ transfer: state(ctx).transfers[fileId] ?? null
794
+ };
795
+ },
796
+ /**
797
+ * Until a transfer ends: done answers, failed is an `engine` error with the transfer's words (and whether `file
798
+ * resend` can go on). A file whose transfer this run has not seen answers from the file store.
799
+ */
800
+ async "file.wait"(ctx, params) {
801
+ const fileId = str(params, "file", true);
802
+ const { where } = fileChat(ctx, params, fileId);
803
+ const stored = await fileStore.get(fileId);
804
+ if (!stored && !state(ctx).transfers[fileId]) throw new CliError("not_found", `No file ${fileId}`);
805
+ const ms = num(params, "timeout", 300, {
806
+ min: 1,
807
+ max: 86400
808
+ }) * 1e3;
809
+ const transfer = await waitForState(ctx, (s) => {
810
+ const now = s.transfers[fileId] ?? stored?.transfer;
811
+ return now && (now.state === "done" || now.state === "failed") ? now : void 0;
812
+ }, ms, `file ${fileId} to arrive`).catch((error) => {
813
+ const now = state(ctx).transfers[fileId];
814
+ if (error instanceof CliError && error.code === "timeout" && now) throw new CliError("timeout", `${error.message} (${now.transferred} of ${now.size} bytes${now.stage ? `, ${now.stage}` : ""})`, {
815
+ file: fileId,
816
+ transferred: now.transferred,
817
+ size: now.size,
818
+ stage: now.stage ?? null
819
+ });
820
+ throw error;
821
+ });
822
+ if (transfer.state === "failed") throw new CliError("engine", transfer.error ?? "The transfer failed", {
823
+ file: fileId,
824
+ ...where,
825
+ retry: !!transfer.retry
826
+ });
827
+ return {
828
+ ...where,
829
+ file: fileId,
830
+ state: "done",
831
+ size: transfer.size
832
+ };
833
+ },
834
+ /** Writes a received (or sent) file to disk: into `dir` under its own name (`saveName`), or to `path`. Never over a file unless `force`. */
835
+ async "file.save"(ctx, params) {
836
+ const fileId = str(params, "file", true);
837
+ if (params.chat !== void 0) fileChat(ctx, params, fileId);
838
+ if (bool(params, "wait")) await FILE_METHODS["file.wait"](ctx, {
839
+ file: fileId,
840
+ timeout: params.timeout
841
+ });
842
+ const stored = await fileStore.get(fileId);
843
+ if (!stored?.metadata) throw new CliError("not_found", `No file ${fileId}`);
844
+ if (stored.leftOut) throw new CliError("not_found", "Not in this backup: the light backup this profile was restored from left the file out");
845
+ const transfer = state(ctx).transfers[fileId] ?? stored.transfer;
846
+ if (transfer && transfer.state !== "done") throw new CliError("unavailable", `The file is not all here yet (${transfer.transferred} of ${transfer.size} bytes)`);
847
+ const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), saveName(stored.metadata.name));
848
+ const flags = bool(params, "force") ? "w" : "wx";
849
+ let out;
850
+ try {
851
+ out = await open(target, flags, 384);
852
+ } catch (error) {
853
+ if (error.code === "EEXIST") throw new CliError("confirm", `${target} exists: pass force (--force) to replace it`);
854
+ throw error;
855
+ }
856
+ let written = 0, complete = false;
857
+ try {
858
+ const backend = stored.bytes ? await fileBytesOf(stored.bytes) : null;
859
+ if (backend) {
860
+ const size = await backend.size(fileId);
861
+ if (size === null) throw new CliError("not_found", "The file's bytes are not on this device");
862
+ for (let offset = 0; offset < size; offset += FILE_BYTES_STEP) {
863
+ const chunk = await backend.read(fileId, offset, FILE_BYTES_STEP);
864
+ await out.write(chunk);
865
+ written += chunk.length;
866
+ }
867
+ } else if (stored.blob) {
868
+ const chunk = new Uint8Array(await stored.blob.arrayBuffer());
869
+ await out.write(chunk);
870
+ written = chunk.length;
871
+ } else throw new CliError("not_found", "The file's bytes are not on this device");
872
+ complete = true;
873
+ } finally {
874
+ await out.close();
875
+ if (!complete) await rm(target, { force: true });
876
+ }
877
+ return {
878
+ file: fileId,
879
+ path: target,
880
+ size: written,
881
+ name: stored.metadata.name,
882
+ mime: stored.metadata.mime
883
+ };
884
+ }
885
+ };
886
+ //#endregion
887
+ //#region src/statusCards.ts
888
+ /** A card's updates go at most this often: the rest merge into the next, so a chatty bot spends few edits. */
889
+ var CARD_UPDATE_GAP_MS = 2500;
890
+ var paces = /* @__PURE__ */ new WeakMap();
891
+ function paceOf(ctx, key) {
892
+ let map = paces.get(ctx);
893
+ if (!map) paces.set(ctx, map = /* @__PURE__ */ new Map());
894
+ let pace = map.get(key);
895
+ if (!pace) map.set(key, pace = { last: 0 });
896
+ return pace;
897
+ }
898
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
899
+ /** The fields a command gave: an object, or a usage error that says so. */
900
+ function fieldsOf(params) {
901
+ const fields = params.card ?? {};
902
+ if (!isObject(fields)) throw new CliError("bad_request", "card must be an object of the card's fields");
903
+ return fields;
904
+ }
905
+ /**
906
+ * An update merged over a card: fields given replace those there, `pr` field by field, and a field given as null goes
907
+ * (`--json '{"step":null}'`). The kind and the id never change.
908
+ */
909
+ function mergeCard(base, patch) {
910
+ const out = { ...base };
911
+ for (const [key, value] of Object.entries(patch)) {
912
+ if (key === "kind" || key === "id") continue;
913
+ if (value === null) {
914
+ delete out[key];
915
+ continue;
916
+ }
917
+ if (key === "pr" && isObject(value) && isObject(base.pr)) {
918
+ const pr = { ...base.pr };
919
+ for (const [k, v] of Object.entries(value)) if (v === null) delete pr[k];
920
+ else pr[k] = v;
921
+ out.pr = pr;
922
+ continue;
923
+ }
924
+ out[key] = value;
925
+ }
926
+ return out;
927
+ }
928
+ /**
929
+ * A routine's run from the command (`run: {result, summary?}`), as its last run and the first of its recent runs,
930
+ * `STATUS_CARD_LIMITS.runs` kept. Undefined when the command names none.
931
+ */
932
+ function withRun(fields, params, now = Date.now()) {
933
+ const run = params.run;
934
+ if (run === void 0) return fields;
935
+ if (!isObject(run) || typeof run.result !== "string") throw new CliError("bad_request", "run is {result: ok|failed|skipped, summary?}");
936
+ const lastRun = {
937
+ at: now,
938
+ result: run.result,
939
+ ...typeof run.summary === "string" && run.summary ? { summary: run.summary } : {}
940
+ };
941
+ const before = Array.isArray(fields.runs) ? fields.runs : [];
942
+ return {
943
+ ...fields,
944
+ lastRun,
945
+ runs: [lastRun, ...before].slice(0, STATUS_CARD_LIMITS.runs)
946
+ };
947
+ }
948
+ /** A card checked by the sender's rule, or a `bad_request` that says what is wrong. */
949
+ function checked(card) {
950
+ const result = checkStatusCard(card);
951
+ if ("error" in result) throw new CliError("bad_request", `Status card: ${result.error}`, { card });
952
+ return result.card;
953
+ }
954
+ /** Every text in a card's fields (title, step, items, runs, links…), as the contact's app may show it. */
955
+ function cardTexts(value, out = []) {
956
+ if (typeof value === "string") out.push(value);
957
+ else if (Array.isArray(value)) for (const item of value) cardTexts(item, out);
958
+ else if (isObject(value)) for (const item of Object.values(value)) cardTexts(item, out);
959
+ return out;
960
+ }
961
+ /**
962
+ * The secret guard `send` has (docs/CLI.md § Safety): a card whose text looks like a seed, a private key or a Cashu
963
+ * token goes only with `--force`. `fields` are what this command gives (an update's own, not the card it merges over).
964
+ * A card's text is read as given and as the card carries it (`cardLine`): a line loses its invisible characters on the
965
+ * way, so a key one of them broke in two is whole again on the card. The fallback text goes as it is, as `send`'s.
966
+ */
967
+ function guardSecrets(fields, params, verb) {
968
+ if (bool(params, "force")) return;
969
+ const check = (text) => {
970
+ const secret = findSecret(text);
971
+ if (secret) throw new CliError("confirm", `The card's text looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; ${verb} it with --force if you mean to`, { kind: secret.kind });
972
+ };
973
+ for (const text of cardTexts([fields, params.run])) {
974
+ check(text);
975
+ check(cardLine(text, Infinity) ?? "");
976
+ }
977
+ if (typeof params.text === "string") check(params.text);
978
+ }
979
+ /** Per card (its chat or group, its kind and its id): the message of mine that carries it, as this process last saw it. */
980
+ var carriers = /* @__PURE__ */ new WeakMap();
981
+ var carrierKey = (linkId, kind, id) => `${linkId}\n${kind}\n${id}`;
982
+ function carriersOf(ctx) {
983
+ let map = carriers.get(ctx);
984
+ if (map) return map;
985
+ carriers.set(ctx, map = /* @__PURE__ */ new Map());
986
+ const cards = map;
987
+ ctx.hub.onEvent((event) => {
988
+ if (event.type !== "message.sent" && event.type !== "group.sent") return;
989
+ const message = event.message;
990
+ const card = message?.card;
991
+ if (typeof message?.id !== "string" || typeof card?.id !== "string" || card.kind !== "task" && card.kind !== "routine" && card.kind !== "usage") return;
992
+ carried(cards, carrierKey(event.type === "group.sent" ? `group:${String(event.group)}` : String(event.chat), card.kind, card.id), message.id);
993
+ });
994
+ return map;
995
+ }
996
+ function carried(cards, key, messageId) {
997
+ cards.delete(key);
998
+ cards.set(key, messageId);
999
+ if (cards.size > 1e3) cards.delete(cards.keys().next().value);
1000
+ }
1001
+ /**
1002
+ * The latest message of mine in this chat or group with a card of this kind and id: the one an update edits. A card
1003
+ * this process sent or found before is read by its message's key, so its updates cost the same in a long chat as in a
1004
+ * short one; only a card not seen yet (the first update after a start, a one-shot) is looked for in the whole history.
1005
+ */
1006
+ async function cardMessage(ctx, target, kind, id) {
1007
+ const cards = carriersOf(ctx), key = carrierKey(target.linkId, kind, id);
1008
+ const carries = (message) => message?.sender === "me" && message.card?.kind === kind && message.card.id === id;
1009
+ const known = cards.get(key);
1010
+ if (known !== void 0) {
1011
+ const message = await node(ctx).getMessage(target.linkId, known);
1012
+ if (carries(message)) return message;
1013
+ cards.delete(key);
1014
+ }
1015
+ const messages = target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId);
1016
+ if (cards.has(key)) return cardMessage(ctx, target, kind, id);
1017
+ for (let i = messages.length - 1; i >= 0; i--) {
1018
+ const message = messages[i];
1019
+ if (!carries(message)) continue;
1020
+ carried(cards, key, message.id);
1021
+ return message;
1022
+ }
1023
+ throw new CliError("not_found", `No ${kind} ${JSON.stringify(id)} of yours in this ${target.group ? "group" : "chat"}: send it first with ghostly ${kind} send`);
1024
+ }
1025
+ function answer(target, kind, id, messageId, extra) {
1026
+ return {
1027
+ [target.group ? "group" : "chat"]: target.id,
1028
+ [kind]: id,
1029
+ messageId,
1030
+ ...extra
1031
+ };
1032
+ }
1033
+ /**
1034
+ * A task's parent is another task of mine in the same chat or group (WISP 405 · Status Cards § A task): one that is
1035
+ * not there is refused here, with its name, rather than sent to stand alone on every board (a reader keeps the field
1036
+ * and shows the task ungrouped). Send the parent first.
1037
+ */
1038
+ async function parentThere(ctx, target, card) {
1039
+ if (card.kind !== "task" || !card.parent) return;
1040
+ const parent = card.parent;
1041
+ await cardMessage(ctx, target, "task", parent).catch(() => {
1042
+ throw new CliError("not_found", `--parent: no task ${JSON.stringify(parent)} of yours in this ${target.group ? "group" : "chat"}: send the parent first`);
1043
+ });
1044
+ }
1045
+ /** A new card: a message whose text is written from it, in a chat or a group. */
1046
+ async function sendCard(ctx, params, kind) {
1047
+ const target = chatOrGroup(ctx, str(params, "chat", true));
1048
+ const fields = fieldsOf(params);
1049
+ guardSecrets(fields, params, "send");
1050
+ const now = Date.now();
1051
+ const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url(randomBytes$1(6))}`;
1052
+ const card = checked(withRun({
1053
+ ...kind === "task" ? {
1054
+ status: "running",
1055
+ startedAt: now,
1056
+ updatedAt: now
1057
+ } : kind === "routine" ? { state: "active" } : {},
1058
+ ...fields,
1059
+ kind,
1060
+ id
1061
+ }, params, now));
1062
+ await parentThere(ctx, target, card);
1063
+ const text = str(params, "text") ?? "";
1064
+ const wait = oneOf(params, "wait", target.group ? ["none", "sent"] : [
1065
+ "none",
1066
+ "sent",
1067
+ "delivered"
1068
+ ], "none");
1069
+ const ms = num(params, "timeout", 30, {
1070
+ min: 1,
1071
+ max: 3600
1072
+ }) * 1e3;
1073
+ if (target.group) {
1074
+ const result = await node(ctx).sendGroupMessage({
1075
+ groupId: target.id,
1076
+ text,
1077
+ card
1078
+ });
1079
+ if (result.error || !result.messageId) throw new CliError(result.refused ? "refused" : "unavailable", result.error ?? "Nothing to send");
1080
+ carried(carriersOf(ctx), carrierKey(target.linkId, kind, id), result.messageId);
1081
+ const edges = wait === "sent" ? await waitForGroupFrame(ctx, target.id, result.messageId, void 0, ms) : void 0;
1082
+ return answer(target, kind, id, result.messageId, {
1083
+ card,
1084
+ ...edges !== void 0 && { edges }
1085
+ });
1086
+ }
1087
+ const result = await node(ctx).sendMessage({
1088
+ linkId: target.linkId,
1089
+ text,
1090
+ card
1091
+ });
1092
+ if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
1093
+ if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
1094
+ carried(carriersOf(ctx), carrierKey(target.linkId, kind, id), result.messageId);
1095
+ let message = await node(ctx).getMessage(target.linkId, result.messageId);
1096
+ if (wait !== "none") message = await waitForMessage(ctx, target.linkId, result.messageId, wait, ms);
1097
+ return answer(target, kind, id, result.messageId, {
1098
+ card,
1099
+ delivery: message?.delivery ?? null
1100
+ });
1101
+ }
1102
+ /** Edits the card's message with its merged card, now. */
1103
+ async function editCard(ctx, target, messageId, card, text) {
1104
+ const result = await node(ctx).editMessage({
1105
+ linkId: target.linkId,
1106
+ messageId,
1107
+ text,
1108
+ card
1109
+ });
1110
+ if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
1111
+ }
1112
+ /**
1113
+ * An update of a card of mine: the fields given, merged over its latest state (what waits to go included). It goes at
1114
+ * once when the card's last update is `CARD_UPDATE_GAP_MS` old; otherwise it waits for that, merged with any update
1115
+ * that comes meanwhile (`queued: true`). A one-shot command waits the time out itself: it has no daemon to wait in.
1116
+ */
1117
+ async function updateCard(ctx, params, kind) {
1118
+ const target = chatOrGroup(ctx, str(params, "chat", true));
1119
+ const id = str(params, kind, true);
1120
+ const patch = fieldsOf(params);
1121
+ guardSecrets(patch, params, "update");
1122
+ const message = await cardMessage(ctx, target, kind, id);
1123
+ if ((message.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) throw new CliError("refused", `This ${kind}'s message took ${STATUS_CARD_LIMITS.edits} updates, the most one takes: start a new card with ghostly ${kind} send (a new --id, or the same one: the newest card of an id stands for it)`, {
1124
+ messageId: message.id,
1125
+ edits: message.edit.seq
1126
+ });
1127
+ const pace = paceOf(ctx, `${target.linkId}\n${message.id}`);
1128
+ const card = checked(withRun(mergeCard(pace.next ?? message.card, {
1129
+ ...patch,
1130
+ ...kind === "task" && { updatedAt: Date.now() }
1131
+ }), params));
1132
+ if (isObject(patch) && patch.parent !== void 0) await parentThere(ctx, target, card);
1133
+ const text = str(params, "text") ?? "";
1134
+ const wait = oneOf(params, "wait", target.group ? ["none", "sent"] : ["none", "confirmed"], "none");
1135
+ const ms = num(params, "timeout", 30, {
1136
+ min: 1,
1137
+ max: 3600
1138
+ }) * 1e3;
1139
+ const due = Math.max(pace.last, message.edit?.at ?? message.timestamp) + CARD_UPDATE_GAP_MS - Date.now();
1140
+ let queued = false;
1141
+ if (due > 0 && ctx.mode === "daemon") {
1142
+ pace.next = card;
1143
+ queued = true;
1144
+ if (!pace.timer) pace.flushed = new Promise((resolve) => {
1145
+ const send = () => {
1146
+ const next = pace.next;
1147
+ clearTimeout(pace.timer);
1148
+ pace.timer = void 0;
1149
+ pace.next = void 0;
1150
+ pace.flush = void 0;
1151
+ pace.last = Date.now();
1152
+ (next ? editCard(ctx, target, message.id, next, text) : Promise.resolve()).catch(() => {}).finally(resolve);
1153
+ };
1154
+ pace.flush = send;
1155
+ pace.timer = setTimeout(send, due);
1156
+ });
1157
+ if (wait !== "none") await pace.flushed;
1158
+ } else {
1159
+ if (due > 0) await new Promise((resolve) => setTimeout(resolve, due));
1160
+ pace.last = Date.now();
1161
+ await editCard(ctx, target, message.id, card, text);
1162
+ }
1163
+ let edited = await node(ctx).getMessage(target.linkId, message.id);
1164
+ if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
1165
+ const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
1166
+ return answer(target, kind, id, message.id, {
1167
+ card,
1168
+ queued,
1169
+ edits: edited?.edit?.seq ?? 0,
1170
+ ...target.group ? { ...edges !== void 0 && { edges } } : { confirmed: (!queued || wait !== "none") && !!edited && !edited.edit?.pending }
1171
+ });
1172
+ }
1173
+ /**
1174
+ * Sends now every update still waiting for its time: the daemon is stopping, and the update it answered `queued` for
1175
+ * (often a task's last one, done or failed) would otherwise go with it, leaving the card running for good. Called
1176
+ * while the engine can still store the edit; it goes to the contact as any edit does, now or once they are back.
1177
+ */
1178
+ async function flushCardUpdates(ctx) {
1179
+ const waiting = [...paces.get(ctx)?.values() ?? []].filter((pace) => pace.flush);
1180
+ for (const pace of waiting) pace.flush();
1181
+ await Promise.all(waiting.map((pace) => pace.flushed));
1182
+ }
1183
+ /** A usage card's fields: all of them are said again by each report, so one not given goes from the card. */
1184
+ var USAGE_FIELDS = [
1185
+ "left",
1186
+ "used",
1187
+ "limit",
1188
+ "label",
1189
+ "account",
1190
+ "window",
1191
+ "resetsAt",
1192
+ "updatedAt",
1193
+ "windows"
1194
+ ];
1195
+ /** The id a bot's usage card goes by when it names none: one live card per chat. */
1196
+ var USAGE_CARD_ID = "usage";
1197
+ /**
1198
+ * One chat's usage report (WISP 405 § Usage): the bot's usage card there, kept as one message. The first report sends
1199
+ * it; every later one edits it in place (quiet: no sound, no unread, no move in the list), paced as a task's updates.
1200
+ * A report is whole: what it does not say goes from the card. A card at its edits' cap gives way to a new message.
1201
+ */
1202
+ async function reportUsage(ctx, ref, fields, params) {
1203
+ const target = chatOrGroup(ctx, ref);
1204
+ const id = typeof fields.id === "string" && fields.id ? fields.id : USAGE_CARD_ID;
1205
+ const report = {
1206
+ updatedAt: Date.now(),
1207
+ ...fields,
1208
+ kind: "usage",
1209
+ id
1210
+ };
1211
+ checked(report);
1212
+ const sent = await cardMessage(ctx, target, "usage", id).catch(() => void 0);
1213
+ const wait = oneOf(params, "wait", ["none", "sent"], "none");
1214
+ if (!sent || (sent.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) return {
1215
+ ...await sendCard(ctx, {
1216
+ ...params,
1217
+ chat: ref,
1218
+ card: report,
1219
+ wait
1220
+ }, "usage"),
1221
+ updated: false
1222
+ };
1223
+ const patch = {};
1224
+ for (const key of USAGE_FIELDS) patch[key] = report[key] ?? null;
1225
+ return {
1226
+ ...await updateCard(ctx, {
1227
+ ...params,
1228
+ chat: ref,
1229
+ usage: id,
1230
+ card: patch,
1231
+ wait: wait === "none" ? "none" : target.group ? "sent" : "confirmed"
1232
+ }, "usage"),
1233
+ updated: true
1234
+ };
1235
+ }
1236
+ /** `usage send`: one chat or group, or (`all`) every 1:1 chat with a contact; a chat that refuses is said, not fatal. */
1237
+ async function sendUsage(ctx, params) {
1238
+ const fields = fieldsOf(params);
1239
+ if (!bool(params, "all")) return reportUsage(ctx, str(params, "chat", true), fields, params);
1240
+ if (str(params, "chat")) throw new CliError("bad_request", "Name a chat or --all, not both");
1241
+ checked({
1242
+ updatedAt: Date.now(),
1243
+ ...fields,
1244
+ kind: "usage",
1245
+ id: typeof fields.id === "string" && fields.id ? fields.id : USAGE_CARD_ID
1246
+ });
1247
+ const chats = state(ctx).links.filter((link) => link.profile && link.peerPubKeyZ32);
1248
+ const results = [];
1249
+ for (const link of chats) try {
1250
+ results.push(await reportUsage(ctx, link.id, fields, params));
1251
+ } catch (error) {
1252
+ results.push({
1253
+ chat: link.id,
1254
+ error: error instanceof Error ? error.message : String(error)
1255
+ });
1256
+ }
1257
+ return { chats: results };
1258
+ }
1259
+ var STATUS_CARD_METHODS = {
1260
+ /** WISP 405 · Status Cards: a task card in a chat or a group; the answer names the task's id and its message. */
1261
+ "task.send": (ctx, params) => sendCard(ctx, params, "task"),
1262
+ /** A task's update: the fields given, merged over its latest state, paced one per `CARD_UPDATE_GAP_MS`. */
1263
+ "task.update": (ctx, params) => updateCard(ctx, params, "task"),
1264
+ /** A routine card (its schedule, state, runs); `run` records one. */
1265
+ "routine.send": (ctx, params) => sendCard(ctx, params, "routine"),
1266
+ /** A routine's update, as a task's; `run` records a run as its last and the newest of its recent ones. */
1267
+ "routine.update": (ctx, params) => updateCard(ctx, params, "routine"),
1268
+ /** WISP 405 § Usage: how much of a quota the bot has left, kept as one card per chat; `all` for every 1:1 chat. */
1269
+ "usage.send": sendUsage
1270
+ };
1271
+ //#endregion
1272
+ //#region src/holds.ts
1273
+ /**
1274
+ * `chat disconnect --hold <minutes>`: a chat kept off its direct link for a while. It is the chat's own "DHT only"
1275
+ * choice (WISP 400: either side choosing it keeps both off the live link, so the contact's app does not redial),
1276
+ * taken back when the time is up. Text still goes over the DHT (short texts), files wait. The holds are kept in
1277
+ * the profile folder: a daemon that restarts takes back the ones whose time is up, and never a DHT only the chat had
1278
+ * chosen before (that one is not a hold).
1279
+ */
1280
+ var HOLD_MAX_MINUTES = 10080;
1281
+ var timers = /* @__PURE__ */ new WeakMap();
1282
+ function holdsFile(ctx) {
1283
+ return join(ctx.runtime.paths.dir, "holds.json");
1284
+ }
1285
+ function readHolds(ctx) {
1286
+ try {
1287
+ return JSON.parse(readFileSync(holdsFile(ctx), "utf8"));
1288
+ } catch {
1289
+ return {};
1290
+ }
1291
+ }
1292
+ function writeHolds(ctx, holds) {
1293
+ const file = holdsFile(ctx);
1294
+ writeFileSync(file + ".tmp", JSON.stringify(holds) + "\n", { mode: 384 });
1295
+ renameSync(file + ".tmp", file);
1296
+ }
1297
+ function timersOf(ctx) {
1298
+ let map = timers.get(ctx);
1299
+ if (!map) timers.set(ctx, map = /* @__PURE__ */ new Map());
1300
+ return map;
1301
+ }
1302
+ function schedule(ctx, linkId, until) {
1303
+ const map = timersOf(ctx);
1304
+ clearTimeout(map.get(linkId));
1305
+ const timer = setTimeout(() => {
1306
+ map.delete(linkId);
1307
+ releaseHold(ctx, linkId, "expired").catch((error) => process.stderr.write(`ghostly: the hold on ${linkId} could not end: ${error instanceof Error ? error.message : String(error)}\n`));
1308
+ }, Math.max(0, until - Date.now()));
1309
+ timer.unref?.();
1310
+ map.set(linkId, timer);
1311
+ }
1312
+ /** When the chat's hold ends, or null. */
1313
+ function holdOf(ctx, linkId) {
1314
+ return readHolds(ctx)[linkId]?.until ?? null;
1315
+ }
1316
+ /** Keeps the chat off its direct link for `minutes` (again: from now). */
1317
+ async function holdChat(ctx, link, minutes) {
1318
+ const holds = readHolds(ctx);
1319
+ if (link.deliveryMode === "dht" && !holds[link.id]) throw new CliError("refused", "This chat is on DHT only already, by its own choice: chat transport auto leaves it");
1320
+ if (!link.profile) throw new CliError("unavailable", "A chat of the older protocol has no DHT only");
1321
+ const until = Date.now() + minutes * 6e4;
1322
+ if (link.deliveryMode !== "dht") try {
1323
+ await node(ctx).setChatTransport({
1324
+ linkId: link.id,
1325
+ transport: "dht"
1326
+ });
1327
+ } catch (error) {
1328
+ throw new CliError("unavailable", error instanceof Error ? error.message : String(error));
1329
+ }
1330
+ holds[link.id] = {
1331
+ until,
1332
+ since: holds[link.id]?.since ?? Date.now()
1333
+ };
1334
+ writeHolds(ctx, holds);
1335
+ schedule(ctx, link.id, until);
1336
+ ctx.hub.emit("chat.held", `chat.held:${link.id}:${until}`, {
1337
+ chat: link.id,
1338
+ until
1339
+ });
1340
+ return until;
1341
+ }
1342
+ /**
1343
+ * Ends a hold: the chat leaves DHT only, unless someone chose otherwise meanwhile. Whether there was one. `reason`:
1344
+ * expired, connect (`chat connect`) or lifted (`--hold 0`).
1345
+ */
1346
+ async function releaseHold(ctx, linkId, reason) {
1347
+ const holds = readHolds(ctx);
1348
+ if (!holds[linkId]) return false;
1349
+ clearTimeout(timersOf(ctx).get(linkId));
1350
+ timersOf(ctx).delete(linkId);
1351
+ if (state(ctx).links.find((l) => l.id === linkId)?.deliveryMode === "dht") await node(ctx).setDeliveryMode({
1352
+ linkId,
1353
+ mode: "stream"
1354
+ });
1355
+ delete holds[linkId];
1356
+ writeHolds(ctx, holds);
1357
+ ctx.hub.emit("chat.released", `chat.released:${linkId}:${Date.now()}`, {
1358
+ chat: linkId,
1359
+ reason
1360
+ });
1361
+ return true;
1362
+ }
1363
+ /** At start: holds whose time is up end now, the others are timed again. */
1364
+ function resumeHolds(ctx) {
1365
+ for (const [linkId, hold] of Object.entries(readHolds(ctx))) schedule(ctx, linkId, hold.until);
1366
+ }
1367
+ //#endregion
1368
+ //#region src/views.ts
1369
+ /**
1370
+ * A live session carries the chat now, in this run: its link is open and, on a paired chat, the session authenticated
1371
+ * on a transport. The engine's own rule for a chat's connection line (`observeTransport`). `textDelivery` alone says
1372
+ * how a text would go, and nothing the engine remembers from an earlier run (a chat that was live when the app last
1373
+ * ran, WISP 100) counts: until the contact answers again the link is not open and the pairing is `connecting`.
1374
+ */
1375
+ function isLive(link) {
1376
+ if (link.textDelivery !== "stream" || link.dataLink !== "open") return false;
1377
+ return !link.profile || link.pairing?.status === "ready" && !!link.pairing.transport;
1378
+ }
1379
+ function chatJson(link, showSecret) {
1380
+ const label = link.label?.trim() || null;
1381
+ const peerName = link.peerNick?.trim() || null;
1382
+ const live = isLive(link);
1383
+ return {
1384
+ id: link.id,
1385
+ name: label ?? peerName,
1386
+ label,
1387
+ peerName,
1388
+ peer: link.peerPubKeyZ32,
1389
+ createdAt: link.createdAt,
1390
+ lastMessageAt: link.lastMessageAt,
1391
+ live,
1392
+ transport: live ? link.pairing?.transport ?? link.pairingProgress?.transport ?? null : null,
1393
+ text: link.textDelivery ?? (link.profile ? "unavailable" : "dht"),
1394
+ pairing: link.pairing?.status ?? null,
1395
+ stage: link.pairingProgress?.stage ?? null,
1396
+ verified: !!link.peerVerified,
1397
+ invite: link.pairing?.status === "ready" || !link.inviteCode ? null : showSecret === true ? link.inviteCode : "<hidden>",
1398
+ compatibility: !link.profile,
1399
+ peerPicture: !!link.peerAvatar
1400
+ };
1401
+ }
1402
+ /** A chat's details: the summary plus what its connection panel shows. */
1403
+ function chatDetailsJson(link, showSecret) {
1404
+ return {
1405
+ ...chatJson(link, showSecret),
1406
+ progress: link.pairingProgress ?? null,
1407
+ transports: {
1408
+ mine: link.availableTransports ?? [],
1409
+ peer: link.peerTransports ?? [],
1410
+ preferred: link.preferredTransport ?? null,
1411
+ fallback: link.transportFallback ?? null,
1412
+ automatic: link.transportAutomatic ?? null,
1413
+ relayed: link.relayedTransports ?? []
1414
+ },
1415
+ wait: link.transportWait ?? null,
1416
+ lastAttempt: link.liveAttempt ?? null,
1417
+ liveSince: link.transportLive?.since ?? null,
1418
+ rttMs: link.transportRttMs ?? null,
1419
+ relays: link.transportRelayed?.relays ?? null,
1420
+ deliveryMode: link.deliveryMode ?? null,
1421
+ capabilities: link.capabilities ?? null,
1422
+ hold: link.hold ?? null,
1423
+ peerOnline: link.peerOnline,
1424
+ peerLastSeenAt: link.peerLastSeenAt || null,
1425
+ history: link.transportHistory ?? null,
1426
+ errors: link.transportErrors ?? null,
1427
+ comparisonCode: link.pairing?.code ?? null
1428
+ };
1429
+ }
1430
+ function messageJson(message) {
1431
+ return {
1432
+ id: message.id,
1433
+ chat: message.linkId,
1434
+ from: message.sender,
1435
+ text: message.text,
1436
+ timestamp: message.timestamp,
1437
+ ...message.sentAt !== void 0 ? { sentAt: message.sentAt } : {},
1438
+ delivery: message.delivery ?? (message.sender === "peer" ? null : "sent"),
1439
+ deliveryError: message.deliveryError ?? null,
1440
+ via: message.via,
1441
+ nick: message.nick ?? null,
1442
+ ...message.member ? { member: message.member } : {},
1443
+ ...message.mentions?.length ? { mentions: message.mentions.map((m) => ({
1444
+ key: m.k,
1445
+ offset: m.o,
1446
+ length: m.l
1447
+ })) } : {},
1448
+ ...message.mentioned ? { mentioned: true } : {},
1449
+ ...message.file ? { file: fileJson(message.file) } : {},
1450
+ ...message.paymentId ? { paymentId: message.paymentId } : {},
1451
+ ...message.event ? { event: message.event } : {},
1452
+ ...message.replyTo ? { replyTo: {
1453
+ id: message.replyTo.messageId ?? message.replyTo.id,
1454
+ snippet: message.replyTo.snippet,
1455
+ from: message.replyTo.from ?? null,
1456
+ ...message.replyTo.member ? { member: message.replyTo.member } : {},
1457
+ found: !!message.replyTo.messageId,
1458
+ ...message.replyTo.button ? { button: message.replyTo.button } : {}
1459
+ } } : {},
1460
+ ...reactionsJson(message).length ? { reactions: reactionsJson(message) } : {},
1461
+ ...message.edit ? {
1462
+ edits: message.edit.seq,
1463
+ editedAt: message.edit.at,
1464
+ ...message.edit.pending ? { editPending: true } : {}
1465
+ } : {},
1466
+ ...message.forwarded ? { forwarded: message.forwarded } : {},
1467
+ ...message.card ? { card: message.card } : {},
1468
+ ...message.press ? { press: {
1469
+ messageId: message.press.messageId,
1470
+ button: message.press.button,
1471
+ label: message.press.label,
1472
+ ...message.press.inferred ? { inferred: true } : {}
1473
+ } } : {}
1474
+ };
1475
+ }
1476
+ /**
1477
+ * A message's file; a voice note's length (ms) and loudness bars ride along (older releases said only `voice: true`),
1478
+ * and a picture's size as it is shown when its sender sent it.
1479
+ */
1480
+ function fileJson(file) {
1481
+ return {
1482
+ id: file.id,
1483
+ name: file.name,
1484
+ size: file.size,
1485
+ mime: file.mime,
1486
+ ...file.voice ? { voice: {
1487
+ duration: file.voice.duration,
1488
+ ...file.voice.peaks?.length ? { peaks: [...file.voice.peaks] } : {}
1489
+ } } : {},
1490
+ ...file.image ? { image: {
1491
+ width: file.image.width,
1492
+ height: file.image.height
1493
+ } } : {}
1494
+ };
1495
+ }
1496
+ /** A message's reactions shown now (taken-back ones left out), oldest first. */
1497
+ function reactionsJson(message) {
1498
+ return Object.entries(message.reactions ?? {}).filter(([, r]) => r.e).sort(([, a], [, b]) => a.at - b.at).map(([by, r]) => ({
1499
+ by,
1500
+ emoji: r.e,
1501
+ at: r.at
1502
+ }));
1503
+ }
1504
+ /**
1505
+ * A 1:1 chat's message with its author named: the engine keeps a contact's message without a name (the name rides on
1506
+ * the chat), so the name the contact gave fills `nick`, as a group's roster does. Mine keep null. Before, `nick` was
1507
+ * always null in a 1:1 chat, and so was an agent turn's `untrusted.name`.
1508
+ */
1509
+ function chatMessageJson(message, link) {
1510
+ const json = messageJson(message);
1511
+ if (json.nick || message.sender !== "peer") return json;
1512
+ const nick = link?.peerNick?.trim();
1513
+ return nick ? {
1514
+ ...json,
1515
+ nick
1516
+ } : json;
1517
+ }
1518
+ /**
1519
+ * A group's message with its author named: mesh and community messages are kept with the member's key only (the
1520
+ * name rides on the edge or the roster), so the roster fills `nick` when the message has none, and for an author no
1521
+ * longer in it (removed, or back with a new member key) the name the group knew them by.
1522
+ */
1523
+ function groupMessageJson(message, group) {
1524
+ const json = messageJson(message);
1525
+ if (json.nick || !message.member) return json;
1526
+ const nick = group?.members.find((m) => m.key === message.member)?.nick ?? group?.formerNames?.[message.member];
1527
+ return nick ? {
1528
+ ...json,
1529
+ nick
1530
+ } : json;
1531
+ }
1532
+ /**
1533
+ * A group as the CLI prints it. The entry link lets anyone who reads it join, so it is `<hidden>` unless asked for
1534
+ * (`--show-secret`, or `group link`); null when the group has none.
1535
+ */
1536
+ function groupJson(group, showSecret = false) {
1537
+ return {
1538
+ id: group.id,
1539
+ name: group.name,
1540
+ profile: group.profile,
1541
+ status: group.status ?? (group.invitation ? "invited" : null),
1542
+ statusReason: group.statusReason ?? null,
1543
+ admin: group.isAdmin,
1544
+ me: group.myKey ?? null,
1545
+ canSend: group.canSend,
1546
+ link: group.entryLink ? showSecret ? group.entryLink : "<hidden>" : null,
1547
+ lastMessageAt: group.lastMessageAt,
1548
+ members: group.members.map((m) => ({
1549
+ key: m.key,
1550
+ name: m.nick ?? null,
1551
+ role: m.role,
1552
+ me: m.me,
1553
+ online: m.online,
1554
+ ...m.hub ? { hub: true } : {},
1555
+ ...m.viaHub ? { viaHub: true } : {},
1556
+ ...m.hubRole ? { hubRole: m.hubRole } : {}
1557
+ })),
1558
+ ...group.hubs ? { hubs: group.hubs } : {},
1559
+ invitation: group.invitation ? {
1560
+ chat: group.invitation.linkId,
1561
+ accepted: group.invitation.accepted,
1562
+ viaLink: !!group.invitation.viaLink,
1563
+ stage: group.invitation.stage ?? null
1564
+ } : null,
1565
+ community: group.community ?? null,
1566
+ picture: !!group.picture
1567
+ };
1568
+ }
1569
+ //#endregion
1570
+ //#region src/groupAdmin.ts
1571
+ /**
1572
+ * Group administration and pictures (WISP 1100, phase 3): what the app's group menu and profile screen do. The
1573
+ * engine enforces who may do what (the admin); these check the arguments and name members.
1574
+ */
1575
+ /**
1576
+ * A member by key, unique key prefix or name; never this profile itself. A whole key is that member, whatever another
1577
+ * member calls itself; a prefix and a name stand side by side (two members is an error, not a guess).
1578
+ */
1579
+ function findMember(group, ref) {
1580
+ const lower = ref.toLowerCase();
1581
+ const others = group.members.filter((m) => !m.me);
1582
+ const byKey = others.filter((m) => m.key === ref);
1583
+ const matches = byKey.length ? byKey : others.filter((m) => m.key.startsWith(ref) || m.nick?.trim().toLowerCase() === lower);
1584
+ if (matches.length === 1) return matches[0];
1585
+ throw new CliError(matches.length ? "bad_request" : "not_found", matches.length ? `${JSON.stringify(ref)} names more than one member` : `No member ${JSON.stringify(ref)} in ${group.name}`);
1586
+ }
1587
+ /**
1588
+ * A picture as Ghostly sends it: a square JPEG data URL, checked as a contact would check it. The app scales and
1589
+ * crops any image to 128×128; here the file must already be a JPEG within the bounds (no image library on Node).
1590
+ */
1591
+ async function pictureFrom(path) {
1592
+ let bytes;
1593
+ try {
1594
+ bytes = await readFile(resolve(path));
1595
+ } catch {
1596
+ throw new CliError("not_found", `No file ${path}`);
1597
+ }
1598
+ const url = `data:image/jpeg;base64,${bytes.toString("base64")}`;
1599
+ const checked = sanitizeAvatar(url);
1600
+ if (!checked) throw new CliError("bad_request", `A picture is a JPEG of at most 512×512 px and about ${Math.floor(MAX_AVATAR_LENGTH * .74 / 1e3)} KB (128×128 is what the app sends): resize it first`);
1601
+ return checked;
1602
+ }
1603
+ async function picture(params) {
1604
+ if (bool(params, "clear")) return null;
1605
+ return pictureFrom(str(params, "path", true));
1606
+ }
1607
+ var view = (ctx, id, params) => groupJson(state(ctx).groups.find((g) => g.id === id) ?? groupOf(ctx, { group: id }), bool(params, "showSecret"));
1608
+ var GROUP_ADMIN_METHODS = {
1609
+ async "group.invite"(ctx, params) {
1610
+ const group = groupOf(ctx, params);
1611
+ const link = chatOf(ctx, params);
1612
+ await node(ctx).inviteToGroup({
1613
+ groupId: group.id,
1614
+ linkId: link.id
1615
+ });
1616
+ return view(ctx, group.id, params);
1617
+ },
1618
+ async "group.remove"(ctx, params) {
1619
+ const group = groupOf(ctx, params);
1620
+ await node(ctx).removeGroupMember({
1621
+ groupId: group.id,
1622
+ key: findMember(group, str(params, "member", true)).key
1623
+ });
1624
+ return view(ctx, group.id, params);
1625
+ },
1626
+ async "group.admin"(ctx, params) {
1627
+ const group = groupOf(ctx, params);
1628
+ await node(ctx).makeGroupAdmin({
1629
+ groupId: group.id,
1630
+ key: findMember(group, str(params, "member", true)).key
1631
+ });
1632
+ return view(ctx, group.id, params);
1633
+ },
1634
+ async "group.hub"(ctx, params) {
1635
+ const group = groupOf(ctx, params);
1636
+ const role = str(params, "role") ?? "auto";
1637
+ if (![
1638
+ "pin",
1639
+ "exclude",
1640
+ "auto"
1641
+ ].includes(role)) throw new CliError("bad_request", "role is pin, exclude or auto");
1642
+ await node(ctx).setGroupHub({
1643
+ groupId: group.id,
1644
+ key: findMember(group, str(params, "member", true)).key,
1645
+ role: role === "auto" ? null : role
1646
+ });
1647
+ return view(ctx, group.id, params);
1648
+ },
1649
+ async "group.rename"(ctx, params) {
1650
+ const group = groupOf(ctx, params);
1651
+ const name = groupName(str(params, "name", true));
1652
+ if (!name) throw new CliError("bad_request", `A group's name is 1 to 64 characters on one line`);
1653
+ await node(ctx).renameGroup({
1654
+ groupId: group.id,
1655
+ name
1656
+ });
1657
+ return view(ctx, group.id, params);
1658
+ },
1659
+ async "group.rotate"(ctx, params) {
1660
+ const group = groupOf(ctx, params);
1661
+ await node(ctx).rotateGroup({ groupId: group.id });
1662
+ return view(ctx, group.id, params);
1663
+ },
1664
+ async "group.link"(ctx, params) {
1665
+ const group = groupOf(ctx, params);
1666
+ if (params.on === false) {
1667
+ await node(ctx).disableGroupLink({ groupId: group.id });
1668
+ return {
1669
+ group: group.id,
1670
+ link: null
1671
+ };
1672
+ }
1673
+ const { link } = await node(ctx).enableGroupLink({
1674
+ groupId: group.id,
1675
+ ...bool(params, "reset") ? { reset: true } : {}
1676
+ });
1677
+ return {
1678
+ group: group.id,
1679
+ link
1680
+ };
1681
+ },
1682
+ async "group.picture"(ctx, params) {
1683
+ const group = groupOf(ctx, params);
1684
+ await node(ctx).setGroupPicture({
1685
+ groupId: group.id,
1686
+ picture: await picture(params)
1687
+ });
1688
+ return {
1689
+ group: group.id,
1690
+ picture: !bool(params, "clear")
1691
+ };
1692
+ },
1693
+ async "profile.picture"(ctx, params) {
1694
+ const avatar = await picture(params);
1695
+ await node(ctx).updateSettings({ settings: { avatar: avatar ?? "" } });
1696
+ return { picture: avatar !== null };
1697
+ }
1698
+ };
1699
+ //#endregion
1700
+ //#region src/identities.ts
1701
+ /** Drafts per daemon: a proof made in two steps needs the same process for both. */
1702
+ var drafts = /* @__PURE__ */ new Map();
1703
+ var DRAFT_TTL_MS = 36e5;
1704
+ function provider(id) {
1705
+ const found = IDENTITY_PROVIDERS.find((p) => p.id === id);
1706
+ if (!found) throw new CliError("not_found", `No identity provider ${id} (ghostly identity providers)`);
1707
+ return found;
1708
+ }
1709
+ /**
1710
+ * A signer that runs in this process: not a browser sign-in, and not one that says it cannot run here (a NIP-07
1711
+ * extension, an AT Protocol sign-in window: neither exists outside the app's page).
1712
+ */
1713
+ function headless(s) {
1714
+ if (s.kind === "redirect") return false;
1715
+ try {
1716
+ return s.available?.() ?? true;
1717
+ } catch {
1718
+ return false;
1719
+ }
1720
+ }
1721
+ function signerOf(p, id) {
1722
+ const usable = p.signers.filter(headless);
1723
+ const found = id ? p.signers.find((s) => s.id === id) : usable[0];
1724
+ if (!found) throw new CliError(id ? "not_found" : "unavailable", id ? `${p.label} has no signer ${id}` : `${p.label} proofs need a browser window: make them in the app`);
1725
+ if (!headless(found)) {
1726
+ const instead = usable.length ? ` (here: --signer ${usable.map((s) => s.id).join(" or ")})` : "";
1727
+ throw new CliError("unavailable", `${found.label} needs a browser window: make this proof in the app${instead}`);
1728
+ }
1729
+ return found;
1730
+ }
1731
+ function instructionsJson(i) {
1732
+ return {
1733
+ steps: i.steps.map((s) => ({
1734
+ text: s.text,
1735
+ ...s.copy ? { copy: s.copy } : {}
1736
+ })),
1737
+ ...i.paste ? { paste: i.paste.label } : {}
1738
+ };
1739
+ }
1740
+ function proofJson(p) {
1741
+ return {
1742
+ id: p.id,
1743
+ provider: p.provider,
1744
+ subject: p.subject,
1745
+ verified: p.verified,
1746
+ issuedAt: p.issuedAt,
1747
+ expiresAt: p.expiresAt,
1748
+ sharedWith: p.sharedWith,
1749
+ ...p.publicUri ? { publicUri: p.publicUri } : {}
1750
+ };
1751
+ }
1752
+ function pruneDrafts(now = Date.now()) {
1753
+ for (const [id, draft] of drafts) if (now - draft.createdAt > DRAFT_TTL_MS) drafts.delete(id);
1754
+ }
1755
+ async function begin(ctx, p, subject, params) {
1756
+ let normalized;
1757
+ try {
1758
+ normalized = p.subject?.normalize ? p.subject.normalize(subject) : subject;
1759
+ } catch (error) {
1760
+ throw new CliError("bad_request", error instanceof Error ? error.message : String(error));
1761
+ }
1762
+ if (!normalized) throw new CliError("bad_request", `Not a ${p.subject?.label ?? "subject"} ${p.label} takes`);
1763
+ const days = params.days === void 0 ? void 0 : num(params, "days", 0, {
1764
+ min: 1,
1765
+ max: p.validity.maxDays
1766
+ });
1767
+ return node(ctx).beginIdentityProof({
1768
+ provider: p.id,
1769
+ subject: normalized,
1770
+ ...days ? { validityDays: days } : {}
1771
+ });
1772
+ }
1773
+ var IDENTITY_METHODS = {
1774
+ async "identity.providers"() {
1775
+ return { providers: IDENTITY_PROVIDERS.map((p) => ({
1776
+ id: p.id,
1777
+ label: p.label,
1778
+ category: p.category,
1779
+ subject: p.subject ? {
1780
+ label: p.subject.label,
1781
+ placeholder: p.subject.placeholder ?? null
1782
+ } : null,
1783
+ signers: p.signers.map((s) => ({
1784
+ id: s.id,
1785
+ kind: s.kind,
1786
+ label: s.label,
1787
+ headless: headless(s),
1788
+ ...s.kind === "in-app" && s.fields ? { fields: s.fields.map((f) => ({
1789
+ name: f.name,
1790
+ label: f.label,
1791
+ kind: f.kind,
1792
+ optional: !!f.optional
1793
+ })) } : {}
1794
+ }))
1795
+ })) };
1796
+ },
1797
+ async "identity.list"(ctx) {
1798
+ return { proofs: state(ctx).identityProofs.map(proofJson) };
1799
+ },
1800
+ /** Starts a proof. In-app signers finish it here; the others return the statement and what to do with it. */
1801
+ async "identity.add"(ctx, params) {
1802
+ pruneDrafts();
1803
+ const p = provider(str(params, "provider", true));
1804
+ const signer = signerOf(p, str(params, "signer"));
1805
+ if (signer.kind === "in-app") {
1806
+ const values = params.fields ?? {};
1807
+ if (typeof values !== "object" || Array.isArray(values) || Object.values(values).some((v) => typeof v !== "string")) throw new CliError("bad_request", "fields must be an object of strings");
1808
+ for (const field of signer.fields ?? []) if (!field.optional && !values[field.name]) throw new CliError("bad_request", `${signer.label} needs ${field.name} (${field.label})`);
1809
+ const signal = AbortSignal.timeout(num(params, "timeout", 300, {
1810
+ min: 5,
1811
+ max: 3600
1812
+ }) * 1e3);
1813
+ return {
1814
+ done: true,
1815
+ proof: proofJson(await signer.run({
1816
+ values,
1817
+ signal,
1818
+ onAuthUrl: (url) => {
1819
+ ctx.hub.emit("identity.approval", `identity.approval:${Date.now()}`, {
1820
+ provider: p.id,
1821
+ url
1822
+ });
1823
+ },
1824
+ onProgress: (message) => {
1825
+ ctx.hub.emit("identity.progress", `identity.progress:${Date.now()}`, {
1826
+ provider: p.id,
1827
+ message
1828
+ });
1829
+ },
1830
+ onApproval: (request) => {
1831
+ if (request) ctx.hub.emit("identity.approval", `identity.approval:${Date.now()}`, {
1832
+ provider: p.id,
1833
+ ...request.qr ? {
1834
+ qr: request.qr.value,
1835
+ label: request.qr.label
1836
+ } : {},
1837
+ notes: request.notes ?? []
1838
+ });
1839
+ }
1840
+ }, async (session) => {
1841
+ const { draftId, binding } = await begin(ctx, p, await session.subject(), params);
1842
+ try {
1843
+ return await node(ctx).completeIdentityProof({
1844
+ draftId,
1845
+ evidence: await session.sign(identityStatement(binding))
1846
+ });
1847
+ } catch (error) {
1848
+ try {
1849
+ await node(ctx).cancelIdentityProof({ draftId });
1850
+ } catch {}
1851
+ throw error;
1852
+ }
1853
+ }))
1854
+ };
1855
+ }
1856
+ if (signer.kind === "redirect") throw new CliError("unavailable", `${signer.label} needs a browser window: make this proof in the app`);
1857
+ const { draftId, binding } = await begin(ctx, p, str(params, "subject", true), params);
1858
+ const statement = identityStatement(binding);
1859
+ drafts.set(draftId, {
1860
+ draftId,
1861
+ provider: p.id,
1862
+ signer: signer.id,
1863
+ binding,
1864
+ createdAt: Date.now()
1865
+ });
1866
+ return {
1867
+ done: false,
1868
+ draft: draftId,
1869
+ provider: p.id,
1870
+ signer: signer.id,
1871
+ statement: statement.text,
1872
+ instructions: instructionsJson(signer.instructions(statement)),
1873
+ expiresInSeconds: DRAFT_TTL_MS / 1e3
1874
+ };
1875
+ },
1876
+ /** Finishes a two-step proof: what the tool printed (external-tool), or nothing (publish: the engine looks it up). */
1877
+ async "identity.complete"(ctx, params) {
1878
+ const draft = drafts.get(str(params, "draft", true));
1879
+ if (!draft) throw new CliError("not_found", "No such draft here: it is kept by the daemon that made it, for an hour");
1880
+ const signer = signerOf(provider(draft.provider), draft.signer);
1881
+ const statement = identityStatement(draft.binding);
1882
+ let evidence;
1883
+ try {
1884
+ if (signer.kind === "external-tool") evidence = await signer.parse(str(params, "evidence", true), statement);
1885
+ else if (signer.kind === "publish") evidence = await signer.evidence(statement);
1886
+ else throw new CliError("bad_request", "This draft needs no completion");
1887
+ } catch (error) {
1888
+ if (error instanceof CliError) throw error;
1889
+ throw new CliError("bad_request", error instanceof Error ? error.message : String(error));
1890
+ }
1891
+ const proof = await node(ctx).completeIdentityProof({
1892
+ draftId: draft.draftId,
1893
+ evidence
1894
+ });
1895
+ drafts.delete(draft.draftId);
1896
+ return {
1897
+ done: true,
1898
+ proof: proofJson(proof)
1899
+ };
1900
+ },
1901
+ async "identity.cancel"(ctx, params) {
1902
+ const draftId = str(params, "draft", true);
1903
+ if (!drafts.delete(draftId)) throw new CliError("not_found", "No such draft here: it is kept by the daemon that made it, for an hour");
1904
+ await node(ctx).cancelIdentityProof({ draftId });
1905
+ return { cancelled: draftId };
1906
+ },
1907
+ async "identity.remove"(ctx, params) {
1908
+ const id = str(params, "id", true);
1909
+ if (!state(ctx).identityProofs.some((p) => p.id === id)) throw new CliError("not_found", `No identity proof ${JSON.stringify(id)} (ghostly identity list)`);
1910
+ await node(ctx).removeIdentityProof({ id });
1911
+ return { removed: id };
1912
+ },
1913
+ async "identity.share"(ctx, params) {
1914
+ const link = chatOf(ctx, params);
1915
+ const id = str(params, "id", true);
1916
+ if (!state(ctx).identityProofs.some((p) => p.id === id)) throw new CliError("not_found", `No identity proof ${JSON.stringify(id)} (ghostly identity list)`);
1917
+ await node(ctx).shareIdentityProof({
1918
+ linkId: link.id,
1919
+ id
1920
+ });
1921
+ return {
1922
+ chat: link.id,
1923
+ shared: id
1924
+ };
1925
+ },
1926
+ async "identity.withdraw"(ctx, params) {
1927
+ const link = chatOf(ctx, params);
1928
+ const id = str(params, "id", true);
1929
+ if (!link.identities?.shared.some((s) => s.id === id)) throw new CliError("not_found", `No identity proof ${JSON.stringify(id)} shared in this chat (ghostly identity contact ${link.id})`);
1930
+ await node(ctx).withdrawIdentityProof({
1931
+ linkId: link.id,
1932
+ id
1933
+ });
1934
+ return {
1935
+ chat: link.id,
1936
+ withdrawn: id
1937
+ };
1938
+ },
1939
+ /** What a contact shared in a chat, as this device checked it, and what this side shares there. */
1940
+ async "identity.contact"(ctx, params) {
1941
+ const link = chatOf(ctx, params);
1942
+ const view = link.identities;
1943
+ return {
1944
+ chat: link.id,
1945
+ support: view?.support ?? false,
1946
+ received: (view?.received ?? []).map((r) => ({
1947
+ id: r.id,
1948
+ provider: r.provider,
1949
+ subject: r.subject,
1950
+ status: r.status,
1951
+ verified: r.verified,
1952
+ verifiedAt: r.verifiedAt,
1953
+ checkedAt: r.checkedAt,
1954
+ expiresAt: r.expiresAt,
1955
+ error: r.error ?? null,
1956
+ recheckDue: r.recheckDue,
1957
+ ...r.publicProfile ? { publicProfile: r.publicProfile } : {}
1958
+ })),
1959
+ shared: view?.shared ?? []
1960
+ };
1961
+ },
1962
+ async "identity.recheck"(ctx, params) {
1963
+ const link = chatOf(ctx, params);
1964
+ const id = str(params, "id", true);
1965
+ if (!link.identities?.received.some((r) => r.id === id)) throw new CliError("not_found", `No identity proof ${JSON.stringify(id)} from this contact (ghostly identity contact ${link.id})`);
1966
+ await node(ctx).recheckIdentityProof({
1967
+ linkId: link.id,
1968
+ id
1969
+ });
1970
+ return IDENTITY_METHODS["identity.contact"](ctx, { chat: link.id });
1971
+ }
1972
+ };
1973
+ //#endregion
1974
+ //#region src/services.ts
1975
+ /**
1976
+ * Shared services (WISP 700/701, WISP 1100 phase 3b): a web app on this machine a contact may open over the chat's live
1977
+ * link, and a contact's app opened here on a loopback port. As on the Desktop: only loopback is reached, redirects are
1978
+ * handed back rather than followed, and a service is shared with each contact by name, never by default.
1979
+ */
1980
+ var LOOPBACK = /* @__PURE__ */ new Set([
1981
+ "127.0.0.1",
1982
+ "localhost",
1983
+ "[::1]",
1984
+ "::1"
1985
+ ]);
1986
+ /** The engine's way to reach a shared app: loopback only, no redirect followed, no cookies (Node sends none). */
1987
+ var nodeLocalFetch = async (request) => {
1988
+ const url = new URL(request.url);
1989
+ if (!["http:", "https:"].includes(url.protocol) || !(LOOPBACK.has(url.hostname) || /^127\.\d+\.\d+\.\d+$/.test(url.hostname))) throw new Error("Shared services are reached on this machine only");
1990
+ const response = await fetch(url, {
1991
+ method: request.method,
1992
+ headers: request.headers,
1993
+ body: request.body,
1994
+ signal: request.signal,
1995
+ redirect: "manual",
1996
+ ...request.body ? { duplex: "half" } : {}
1997
+ });
1998
+ const headers = [];
1999
+ response.headers.forEach((value, name) => headers.push([name, value]));
2000
+ return {
2001
+ status: response.status,
2002
+ headers,
2003
+ body: response.body ? (async function* () {
2004
+ const reader = response.body.getReader();
2005
+ try {
2006
+ for (;;) {
2007
+ const { done, value } = await reader.read();
2008
+ if (done) return;
2009
+ if (value?.length) yield value;
2010
+ }
2011
+ } finally {
2012
+ reader.cancel().catch(() => {});
2013
+ }
2014
+ })() : null
2015
+ };
2016
+ };
2017
+ /** Contact services open on loopback ports, by `<peer>/<service>`, while this process runs. */
2018
+ var open$1 = /* @__PURE__ */ new Map();
2019
+ var MAX_RESPONSE_BYTES = 33554432;
2020
+ /** Cookie names an opened service may set and have passed back (browsers keep about 180 per host). */
2021
+ var MAX_COOKIE_NAMES = 256;
2022
+ /**
2023
+ * The cookie that shows a browser was given this service's link here. It is this machine's, never the contact's:
2024
+ * it is not passed on to the contact, and a `Set-Cookie` from the contact by that name is dropped.
2025
+ */
2026
+ var OPEN_COOKIE = "ghostly_open";
2027
+ /** The link `service open` prints: it sets `OPEN_COOKIE` and sends the browser on to `/`. */
2028
+ var OPEN_PATH = "/.ghostly-open/";
2029
+ /**
2030
+ * Only the cookies this service set itself (by name, from its own `Set-Cookie`) go on to the contact; any other a
2031
+ * browser sends (one set for localhost by another app here, this machine's `OPEN_COOKIE`) stays here.
2032
+ */
2033
+ function ownCookies(header, names) {
2034
+ const kept = header.split(";").map((pair) => pair.trim()).filter((pair) => {
2035
+ const at = pair.indexOf("=");
2036
+ const name = at > 0 ? pair.slice(0, at).trim() : "";
2037
+ return !!name && name !== "ghostly_open" && names.has(name);
2038
+ });
2039
+ return kept.length ? kept.join("; ") : null;
2040
+ }
2041
+ /** The cookie a `Set-Cookie` sets, by name. */
2042
+ function setCookieName(value) {
2043
+ const at = value.indexOf("="), end = value.indexOf(";");
2044
+ if (at <= 0 || end !== -1 && end < at) return null;
2045
+ return value.slice(0, at).trim() || null;
2046
+ }
2047
+ /**
2048
+ * A contact's `Set-Cookie` as this machine passes it to the browser: without `Domain`, so the cookie stays on the
2049
+ * service's own host name (not every *.localhost). Null for one this machine does not take (`OPEN_COOKIE`, no name).
2050
+ */
2051
+ function hostOnlyCookie(value) {
2052
+ const name = setCookieName(value);
2053
+ if (!name || name === "ghostly_open") return null;
2054
+ return value.split(";").filter((part, i) => i === 0 || !/^\s*domain\s*(=|$)/i.test(part)).join(";");
2055
+ }
2056
+ /** A cookie's values in a `Cookie` header, by name (a page may set another by the same name on a narrower path). */
2057
+ function cookieValues(header, name) {
2058
+ return (header ?? "").split(";").flatMap((pair) => {
2059
+ const at = pair.indexOf("=");
2060
+ return at > 0 && pair.slice(0, at).trim() === name ? [pair.slice(at + 1).trim()] : [];
2061
+ });
2062
+ }
2063
+ function sameSecret(a, b) {
2064
+ return a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
2065
+ }
2066
+ function plain(response, status, text, headers = {}) {
2067
+ response.writeHead(status, {
2068
+ "content-type": "text/plain; charset=utf-8",
2069
+ "cache-control": "no-store",
2070
+ ...headers
2071
+ });
2072
+ response.end(text);
2073
+ }
2074
+ /**
2075
+ * A contact's app on a loopback port of this machine, under a host name of its own (`<random>.localhost`): its
2076
+ * cookies and storage are its own, apart from any other app on 127.0.0.1 or localhost, and a name that merely
2077
+ * resolves here (DNS rebinding) is not it. Only a browser given the link (which sets `OPEN_COOKIE`) is served; anyone
2078
+ * else on this machine who finds the port gets 404. The body streams through, bounded by `MAX_RESPONSE_BYTES`.
2079
+ */
2080
+ async function openServiceServer(send, port = 0) {
2081
+ const name = `${randomBytes(16).toString("hex")}.localhost`;
2082
+ const token = randomBytes(32).toString("hex");
2083
+ const cookies = /* @__PURE__ */ new Set();
2084
+ let host = "";
2085
+ const server = createServer((request, response) => {
2086
+ if (request.headers.host?.toLowerCase() !== host) return plain(response, 404, "Not found");
2087
+ const path = request.url ?? "/";
2088
+ if (path.startsWith(OPEN_PATH)) {
2089
+ if (!sameSecret(path.slice(15), token)) return plain(response, 404, "Not found");
2090
+ return plain(response, 303, "", {
2091
+ location: "/",
2092
+ "referrer-policy": "no-referrer",
2093
+ "set-cookie": `${OPEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax`
2094
+ });
2095
+ }
2096
+ if (!cookieValues(request.headers.cookie, "ghostly_open").some((value) => sameSecret(value, token))) return plain(response, 404, "Not found");
2097
+ const chunks = [];
2098
+ let size = 0, refused = false;
2099
+ request.on("data", (chunk) => {
2100
+ if (refused) return;
2101
+ size += chunk.length;
2102
+ if (size > LIMITS.maxRequestBodyBytes) {
2103
+ refused = true;
2104
+ chunks.length = 0;
2105
+ plain(response, 413, "The request body is too large", { connection: "close" });
2106
+ return;
2107
+ }
2108
+ chunks.push(chunk);
2109
+ });
2110
+ request.on("end", () => {
2111
+ if (!refused) proxy(request, response, chunks, send, cookies);
2112
+ });
2113
+ });
2114
+ await new Promise((resolve, reject) => {
2115
+ server.once("error", reject);
2116
+ server.listen(port, "127.0.0.1", () => {
2117
+ server.off("error", reject);
2118
+ resolve();
2119
+ });
2120
+ });
2121
+ host = `${name}:${server.address().port}`;
2122
+ return {
2123
+ server,
2124
+ host,
2125
+ url: `http://${host}${OPEN_PATH}${token}`
2126
+ };
2127
+ }
2128
+ async function proxy(request, response, chunks, send, cookies) {
2129
+ const stop = new AbortController();
2130
+ response.on("close", () => {
2131
+ if (!response.writableFinished) stop.abort();
2132
+ });
2133
+ try {
2134
+ const headers = Object.entries(request.headers).flatMap(([name, value]) => Array.isArray(value) ? value.map((v) => [name, v]) : value === void 0 ? [] : [[name, value]]).flatMap(([name, value]) => {
2135
+ if (name.toLowerCase() !== "cookie") return [[name, value]];
2136
+ const own = ownCookies(value, cookies);
2137
+ return own ? [[name, own]] : [];
2138
+ });
2139
+ const answer = await send({
2140
+ method: request.method ?? "GET",
2141
+ path: request.url ?? "/",
2142
+ headers,
2143
+ body: chunks.length ? new Uint8Array(Buffer.concat(chunks)) : null,
2144
+ maxResponseBytes: MAX_RESPONSE_BYTES,
2145
+ signal: stop.signal
2146
+ });
2147
+ const out = {};
2148
+ for (const [name, value] of answer.headers) {
2149
+ const lower = name.toLowerCase();
2150
+ if (lower === "transfer-encoding" || lower === "connection") continue;
2151
+ if (lower === "set-cookie") {
2152
+ const cookie = hostOnlyCookie(value);
2153
+ if (!cookie) continue;
2154
+ if (cookies.size < MAX_COOKIE_NAMES) cookies.add(setCookieName(cookie));
2155
+ out[lower] = [...out[lower] ?? [], cookie];
2156
+ } else out[lower] = value;
2157
+ }
2158
+ response.writeHead(answer.status, out);
2159
+ await pipeline(Readable.from(answer.body, { objectMode: false }), response);
2160
+ } catch (error) {
2161
+ if (response.headersSent) {
2162
+ response.destroy();
2163
+ return;
2164
+ }
2165
+ const gone = error instanceof GhostlyHttpError && [
2166
+ "unreachable",
2167
+ "timeout",
2168
+ "closed",
2169
+ "offline"
2170
+ ].includes(error.code);
2171
+ plain(response, gone ? 503 : 502, gone ? "This service is not reachable. Services exist while their ghost is online." : `The request failed: ${error instanceof Error ? error.message : String(error)}`);
2172
+ }
2173
+ }
2174
+ /**
2175
+ * The service a command names, by its id: one of this profile's. The engine changes nothing for an id it does not
2176
+ * know and says nothing, so a mistyped id read as done ("shared": true, with a contact).
2177
+ */
2178
+ function serviceOf(ctx, params) {
2179
+ const serviceId = str(params, "service", true);
2180
+ if (!state(ctx).services.some((s) => s.id === serviceId)) throw new CliError("not_found", `No service ${JSON.stringify(serviceId)} (ghostly service list)`);
2181
+ return serviceId;
2182
+ }
2183
+ var SERVICE_METHODS = {
2184
+ async "service.list"(ctx) {
2185
+ return { services: state(ctx).services.map((s) => ({
2186
+ id: s.id,
2187
+ name: s.name,
2188
+ target: s.target,
2189
+ enabled: s.enabled,
2190
+ sharedWith: s.sharedWith ?? [],
2191
+ requests: s.requests
2192
+ })) };
2193
+ },
2194
+ async "service.add"(ctx, params) {
2195
+ const target = str(params, "target", true);
2196
+ let url;
2197
+ try {
2198
+ url = new URL(target);
2199
+ } catch {
2200
+ throw new CliError("bad_request", `Not a URL: ${target}`);
2201
+ }
2202
+ if (!(LOOPBACK.has(url.hostname) || /^127\.\d+\.\d+\.\d+$/.test(url.hostname))) throw new CliError("refused", "Only a web app on this machine (127.0.0.1, localhost) can be shared");
2203
+ return node(ctx).addService({
2204
+ name: str(params, "name", true),
2205
+ target
2206
+ });
2207
+ },
2208
+ async "service.remove"(ctx, params) {
2209
+ const serviceId = serviceOf(ctx, params);
2210
+ await node(ctx).removeService({ serviceId });
2211
+ return { removed: serviceId };
2212
+ },
2213
+ async "service.enable"(ctx, params) {
2214
+ const serviceId = serviceOf(ctx, params);
2215
+ await node(ctx).setServiceEnabled({
2216
+ serviceId,
2217
+ enabled: !bool(params, "off")
2218
+ });
2219
+ return {
2220
+ service: serviceId,
2221
+ enabled: !bool(params, "off")
2222
+ };
2223
+ },
2224
+ async "service.share"(ctx, params) {
2225
+ const link = chatOf(ctx, params);
2226
+ const shared = !bool(params, "off");
2227
+ const serviceId = serviceOf(ctx, params);
2228
+ await node(ctx).setServiceShared({
2229
+ serviceId,
2230
+ peerPubKeyZ32: link.peerPubKeyZ32,
2231
+ shared
2232
+ });
2233
+ return {
2234
+ service: serviceId,
2235
+ chat: link.id,
2236
+ shared
2237
+ };
2238
+ },
2239
+ /** What a contact shares with this profile (while its app is live). */
2240
+ async "service.peer"(ctx, params) {
2241
+ const link = chatOf(ctx, params);
2242
+ return {
2243
+ chat: link.id,
2244
+ live: isLive(link),
2245
+ services: link.peerServices ?? []
2246
+ };
2247
+ },
2248
+ /** A contact's shared app on a loopback port of this machine, as the Desktop opens it in a window. */
2249
+ async "service.open"(ctx, params) {
2250
+ if (ctx.mode !== "daemon") throw new CliError("unavailable", "An opened service lives in the daemon: start `ghostly daemon --detach` first");
2251
+ const link = chatOf(ctx, params);
2252
+ const service = str(params, "service", true);
2253
+ const key = `${link.peerPubKeyZ32}/${service}`;
2254
+ const already = open$1.get(key);
2255
+ if (already) return {
2256
+ chat: link.id,
2257
+ service,
2258
+ url: already.url
2259
+ };
2260
+ if (!link.peerServices?.some((s) => s.id === service)) throw new CliError("not_found", `The contact shares no service ${service} (or is not live)`);
2261
+ const peer = link.peerPubKeyZ32;
2262
+ const opened = await openServiceServer((request) => node(ctx).request(peer, service, request), num(params, "port", 0, {
2263
+ min: 0,
2264
+ max: 65535
2265
+ }));
2266
+ open$1.set(key, opened);
2267
+ return {
2268
+ chat: link.id,
2269
+ service,
2270
+ url: opened.url
2271
+ };
2272
+ },
2273
+ async "service.close"(ctx, params) {
2274
+ const key = `${chatOf(ctx, params).peerPubKeyZ32}/${str(params, "service", true)}`;
2275
+ const entry = open$1.get(key);
2276
+ if (!entry) throw new CliError("not_found", "That service is not open here");
2277
+ open$1.delete(key);
2278
+ await new Promise((resolve) => entry.server.close(() => resolve()));
2279
+ return { closed: key };
2280
+ }
2281
+ };
2282
+ //#endregion
2283
+ //#region src/calls/api.ts
2284
+ /** Voice calls (WISP 1100 § Calls): the API methods. The audio itself goes over each call's own socket. */
2285
+ var CALL_METHODS = {
2286
+ async "call.start"(ctx, params) {
2287
+ return ctx.calls.start(str(params, "chat", true), { rate: params.rate });
2288
+ },
2289
+ async "call.answer"(ctx, params) {
2290
+ return ctx.calls.answer(str(params, "call"), { rate: params.rate });
2291
+ },
2292
+ async "call.hangup"(ctx, params) {
2293
+ return ctx.calls.hangup(str(params, "call"));
2294
+ },
2295
+ async "call.list"(ctx) {
2296
+ return {
2297
+ calls: ctx.calls.list(),
2298
+ autoAnswer: ctx.calls.getAuto(),
2299
+ available: ctx.runtime.callsUnavailable === null,
2300
+ ...ctx.runtime.callsUnavailable ? { unavailable: ctx.runtime.callsUnavailable } : {}
2301
+ };
2302
+ },
2303
+ async "call.get"(ctx, params) {
2304
+ return ctx.calls.get(str(params, "call"));
2305
+ },
2306
+ async "call.flush"(ctx, params) {
2307
+ return ctx.calls.flush(str(params, "call"));
2308
+ },
2309
+ async "call.auto"(ctx, params) {
2310
+ if (params.on === void 0) return { autoAnswer: ctx.calls.getAuto() };
2311
+ return { autoAnswer: ctx.calls.setAuto({
2312
+ on: bool(params, "on"),
2313
+ from: list(params, "from"),
2314
+ rate: params.rate
2315
+ }) };
2316
+ }
2317
+ };
2318
+ //#endregion
2319
+ //#region src/appServe.ts
2320
+ /** The permissions a bot can be granted: `internet` is a page's, and no page runs here. */
2321
+ var GRANTABLE = ["chat", "name"];
2322
+ var servedFile = (dir) => join(dir, "apps.json");
2323
+ /** The apps a profile serves, by its folder: none when the file is missing or not a list of them. */
2324
+ function servedApps(dir) {
2325
+ try {
2326
+ const read = JSON.parse(readFileSync(servedFile(dir), "utf8"));
2327
+ return Array.isArray(read) ? read.filter((app) => !!app && typeof app === "object" && isAppRef(app.ref)) : [];
2328
+ } catch {
2329
+ return [];
2330
+ }
2331
+ }
2332
+ function writeServed(dir, apps) {
2333
+ const file = servedFile(dir);
2334
+ writeFileSync(file + ".tmp", JSON.stringify(apps, null, 2) + "\n", { mode: 384 });
2335
+ renameSync(file + ".tmp", file);
2336
+ }
2337
+ /** A served app by its reference or, when only one has it, its name. */
2338
+ function servedApp(apps, ref) {
2339
+ const matches = apps.filter((a) => a.ref === ref || a.ref.split("/")[1] === ref);
2340
+ if (matches.length !== 1) throw new CliError(matches.length ? "bad_request" : "not_found", matches.length ? `${JSON.stringify(ref)} names more than one served app: give its whole reference` : `No served app ${JSON.stringify(ref)}: app serve its bundle first`);
2341
+ return matches[0];
2342
+ }
2343
+ /** Whether this engine offers apps/1 now: it read the list when it started, so the first app served waits for a restart. */
2344
+ var offered = (ctx, apps) => ({
2345
+ offered: ctx.runtime.apps,
2346
+ ...apps.length > 0 && !ctx.runtime.apps && { restart: true }
2347
+ });
2348
+ var APP_SERVE_METHODS = {
2349
+ async "app.serve"(ctx, params) {
2350
+ const source = str(params, "bundle", true);
2351
+ const grant = list(params, "grant");
2352
+ const unknown = grant.filter((p) => !APP_PERMISSIONS.includes(p));
2353
+ if (unknown.length) throw new CliError("bad_request", `grant takes ${GRANTABLE.join(" or ")}, not ${unknown.map((p) => JSON.stringify(p)).join(", ")}`);
2354
+ if (grant.includes("internet")) throw new CliError("refused", "internet cannot be granted here: no app's page runs on the CLI, and a bot reaches the network as its own program");
2355
+ const given = str(params, "url");
2356
+ if (given !== void 0 && !isAppUrl(given)) throw new CliError("bad_request", "url is where a contact's app reads the bundle: https only, no user or password, jsDelivr only at a full commit");
2357
+ const { bundle, url } = await checkedBundle(source);
2358
+ const m = bundle.manifest;
2359
+ if (!m.permissions.includes("chat")) throw new CliError("refused", `${m.title} does not ask for chat: it talks to no contact, so a bot has nothing to speak for it`);
2360
+ const extra = grant.filter((p) => !m.permissions.includes(p));
2361
+ if (extra.length) throw new CliError("bad_request", `${m.title} does not ask for ${extra.join(" or ")}: grant only what its manifest asks (${m.permissions.join(", ")})`);
2362
+ const missing = m.permissions.filter((p) => GRANTABLE.includes(p) && !grant.includes(p));
2363
+ if (missing.length) throw new CliError("confirm", `${m.title} asks for ${missing.join(" and ")}: serve it with ${missing.map((p) => `--grant ${p}`).join(" ")} to allow that`, {
2364
+ asks: m.permissions,
2365
+ missing
2366
+ });
2367
+ const ref = appRef(m.publisher, m.name);
2368
+ const cardUrl = given ?? url ?? m.sources?.[0];
2369
+ const app = {
2370
+ ref,
2371
+ title: m.title,
2372
+ version: m.version,
2373
+ sequence: m.sequence,
2374
+ digest: bundle.digest,
2375
+ ...cardUrl !== void 0 && { url: cardUrl },
2376
+ permissions: m.permissions,
2377
+ granted: m.permissions.filter((p) => grant.includes(p)),
2378
+ servedAt: Date.now()
2379
+ };
2380
+ const apps = servedApps(ctx.runtime.paths.dir);
2381
+ const held = apps.find((a) => a.ref === ref);
2382
+ if (held && app.sequence < held.sequence) throw new CliError("refused", `${m.title} ${held.version} (sequence ${held.sequence}) is served: ${m.version} is older (sequence ${app.sequence}); app unserve it first to go back`);
2383
+ if (held && app.sequence === held.sequence && app.digest !== held.digest) throw new CliError("refused", `Another ${m.title} with sequence ${app.sequence} is served (two different versions ${app.sequence} exist); app unserve it first to take this one`);
2384
+ const next = [...apps.filter((a) => a.ref !== ref), app].sort((a, b) => a.ref < b.ref ? -1 : 1);
2385
+ writeServed(ctx.runtime.paths.dir, next);
2386
+ return {
2387
+ served: app,
2388
+ ...offered(ctx, next)
2389
+ };
2390
+ },
2391
+ async "app.served"(ctx) {
2392
+ const apps = servedApps(ctx.runtime.paths.dir);
2393
+ return {
2394
+ apps,
2395
+ ...offered(ctx, apps)
2396
+ };
2397
+ },
2398
+ async "app.unserve"(ctx, params) {
2399
+ const ref = str(params, "ref", true);
2400
+ const apps = servedApps(ctx.runtime.paths.dir);
2401
+ const app = servedApp(apps, ref);
2402
+ const next = apps.filter((a) => a !== app);
2403
+ writeServed(ctx.runtime.paths.dir, next);
2404
+ return {
2405
+ unserved: app.ref,
2406
+ apps: next.length,
2407
+ ...offered(ctx, next)
2408
+ };
2409
+ }
2410
+ };
2411
+ //#endregion
2412
+ //#region src/appEvents.ts
2413
+ /** Per hub: what the contacts have open now, `<chat> <reference>` to the version they said. */
2414
+ var peers = /* @__PURE__ */ new WeakMap();
2415
+ /** The version of a served app the contact has open in a chat on this session, or undefined. */
2416
+ function peerVersion(hub, chat, ref) {
2417
+ return peers.get(hub)?.get(`${chat} ${ref}`);
2418
+ }
2419
+ function reportAppFrames(hub, node, profileDir) {
2420
+ const open = /* @__PURE__ */ new Map();
2421
+ peers.set(hub, open);
2422
+ /** Per chat: chat app id to reference, of the apps served when the contact last opened one there. */
2423
+ const known = /* @__PURE__ */ new Map();
2424
+ let count = 0;
2425
+ const learn = (chat) => {
2426
+ const ids = /* @__PURE__ */ new Map();
2427
+ for (const { ref } of servedApps(profileDir)) try {
2428
+ ids.set(node.appId({
2429
+ linkId: chat,
2430
+ ref
2431
+ }).app, ref);
2432
+ } catch {}
2433
+ known.set(chat, ids);
2434
+ return ids;
2435
+ };
2436
+ return hub.onAppFrame((chat, frame) => {
2437
+ const app = ("o" in frame && frame.o === "open" ? learn(chat) : known.get(chat) ?? learn(chat)).get(frame.app);
2438
+ if (!app) return;
2439
+ const id = `${chat}:${app}:${Date.now()}-${++count}`;
2440
+ if ("o" in frame) {
2441
+ if (frame.o === "open") open.set(`${chat} ${app}`, frame.v);
2442
+ else open.delete(`${chat} ${app}`);
2443
+ }
2444
+ if (!("o" in frame)) hub.emit("app.message", `app.message:${id}`, {
2445
+ chat,
2446
+ app,
2447
+ data: frame.d
2448
+ });
2449
+ else if (frame.o === "open") hub.emit("app.opened", `app.opened:${id}`, {
2450
+ chat,
2451
+ app,
2452
+ version: frame.v
2453
+ });
2454
+ else hub.emit("app.closed", `app.closed:${id}`, {
2455
+ chat,
2456
+ app,
2457
+ ...frame.offline && { offline: true }
2458
+ });
2459
+ });
2460
+ }
2461
+ //#endregion
2462
+ //#region src/appChat.ts
2463
+ /** The chat and the served app a command names, on an engine that speaks apps/1. */
2464
+ function target(ctx, params) {
2465
+ const app = servedApp(servedApps(ctx.runtime.paths.dir), str(params, "ref", true));
2466
+ if (ctx.mode !== "daemon") throw new CliError("unavailable", "An app is open only while a daemon runs: start one (ghostly daemon start) and open it there");
2467
+ if (!ctx.runtime.apps) throw new CliError("unavailable", "This daemon started before an app was served, so it offers no apps/1 yet: ghostly daemon restart");
2468
+ const link = chatOf(ctx, params);
2469
+ return {
2470
+ chat: link.id,
2471
+ live: isLive(link),
2472
+ app
2473
+ };
2474
+ }
2475
+ /** The engine's own words for a call it refused (a chat not paired yet, a group), as `refused`. */
2476
+ async function engine(run) {
2477
+ try {
2478
+ return await run();
2479
+ } catch (error) {
2480
+ throw new CliError("refused", error instanceof Error ? error.message : String(error));
2481
+ }
2482
+ }
2483
+ var APP_CHAT_METHODS = {
2484
+ async "app.open"(ctx, params) {
2485
+ const { chat, live, app } = target(ctx, params);
2486
+ const peer = peerVersion(ctx.hub, chat, app.ref) ?? null;
2487
+ await engine(() => node(ctx).appOpen({
2488
+ linkId: chat,
2489
+ ref: app.ref,
2490
+ version: app.version
2491
+ }));
2492
+ let card = null;
2493
+ if (!bool(params, "noCard") && peer === null) {
2494
+ const sent = {
2495
+ kind: "app",
2496
+ id: appCardId(app.ref),
2497
+ ref: app.ref,
2498
+ digest: app.digest,
2499
+ sequence: app.sequence,
2500
+ title: app.title,
2501
+ version: app.version,
2502
+ ...app.url !== void 0 && { url: app.url },
2503
+ opened: true
2504
+ };
2505
+ const result = await node(ctx).sendMessage({
2506
+ linkId: chat,
2507
+ text: "",
2508
+ card: sent
2509
+ });
2510
+ if (result.error || !result.messageId) {
2511
+ await engine(() => node(ctx).appClose({
2512
+ linkId: chat,
2513
+ ref: app.ref
2514
+ }));
2515
+ throw new CliError(result.refused ? "refused" : "unavailable", result.error ?? "The app's card could not be sent");
2516
+ }
2517
+ card = result.messageId;
2518
+ }
2519
+ return {
2520
+ chat,
2521
+ app: app.ref,
2522
+ version: app.version,
2523
+ card,
2524
+ peer,
2525
+ live
2526
+ };
2527
+ },
2528
+ async "app.close"(ctx, params) {
2529
+ const { chat, app } = target(ctx, params);
2530
+ await engine(() => node(ctx).appClose({
2531
+ linkId: chat,
2532
+ ref: app.ref
2533
+ }));
2534
+ return {
2535
+ chat,
2536
+ app: app.ref,
2537
+ closed: true
2538
+ };
2539
+ },
2540
+ async "app.send"(ctx, params) {
2541
+ const { chat, app } = target(ctx, params);
2542
+ if (!("data" in params) || params.data === void 0) throw new CliError("bad_request", "data is required: the app's message, any JSON value");
2543
+ const { error } = await engine(() => node(ctx).appSend({
2544
+ linkId: chat,
2545
+ ref: app.ref,
2546
+ data: params.data
2547
+ }));
2548
+ if (error) throw new CliError(NOT_SENT[error].code, NOT_SENT[error].message, { reason: error });
2549
+ return {
2550
+ chat,
2551
+ app: app.ref,
2552
+ sent: true
2553
+ };
2554
+ }
2555
+ };
2556
+ /** Why a frame did not go (WISP 1200 § In a chat), in `details.reason`. Nothing is kept to send later. */
2557
+ var NOT_SENT = {
2558
+ "not-open": {
2559
+ code: "refused",
2560
+ message: "The app is not open in this chat on this side: app open it first"
2561
+ },
2562
+ "too-large": {
2563
+ code: "bad_request",
2564
+ message: `The message is over ${APP_DATA_MAX_BYTES} bytes of JSON, the most an app's frame carries`
2565
+ },
2566
+ offline: {
2567
+ code: "unavailable",
2568
+ message: "The chat is not live: an app's frames go on the live session only, and none is kept for later"
2569
+ },
2570
+ "peer-closed": {
2571
+ code: "unavailable",
2572
+ message: "The contact's app is not open in this chat (or their Ghostly runs no apps): wait for app.opened"
2573
+ },
2574
+ "too-fast": {
2575
+ code: "refused",
2576
+ message: `More than 48 frames in a second, or the connection is still sending earlier ones: slow down and send it again`
2577
+ }
2578
+ };
2579
+ //#endregion
2580
+ //#region src/wallets.ts
2581
+ /**
2582
+ * Wallets and payments (WISP 1100, phase 2): the app's wallet deck and a chat's payment sheet, as methods. Test
2583
+ * networks move nothing real. On Mainnet every spend passes `confirmedReal`, which the engine requires, only when the
2584
+ * call says `confirmReal: true` (the CLI's `--confirm-real`): no flag, no spend.
2585
+ */
2586
+ var NETWORKS = ["mainnet", "testnet"];
2587
+ var TYPES = [
2588
+ "cashu",
2589
+ "lightning",
2590
+ "arkade",
2591
+ "bark",
2592
+ "spark",
2593
+ "bitcoin",
2594
+ "fedimint",
2595
+ "usdt"
2596
+ ];
2597
+ var METHODS$1 = [
2598
+ "cashu",
2599
+ "arkade",
2600
+ "usdt",
2601
+ "bark",
2602
+ "bitcoin",
2603
+ "fedimint",
2604
+ "spark"
2605
+ ];
2606
+ var CHAT_METHODS = [
2607
+ "cashu",
2608
+ "lightning",
2609
+ ...METHODS$1.filter((m) => m !== "cashu")
2610
+ ];
2611
+ /**
2612
+ * What the app has and a headless Ghostly cannot run yet, and why (WISP 1100 § Wallet SDKs on Node). New offers
2613
+ * them as unavailable; creating one is refused before the engine is asked.
2614
+ */
2615
+ var NODE_GAPS = { bark: "Bark's SDK ships a browser build only: its WebAssembly needs a browser window. Use it from the app." };
2616
+ var network = (params, fallback = "testnet") => oneOf(params, "network", NETWORKS, fallback);
2617
+ /** `confirmedReal` for the engine: only when the caller confirmed real money, and only then. */
2618
+ var real = (params) => bool(params, "confirmReal") ? { confirmedReal: true } : {};
2619
+ function netView(s, n) {
2620
+ return s.wallet.networks?.[n];
2621
+ }
2622
+ /** A Lightning card named by `card` on the network asked: one that is not there is not_found, before the engine is asked. */
2623
+ function lightningCard(ctx, params) {
2624
+ const n = network(params), card = str(params, "card", true);
2625
+ if (!netView(state(ctx), n)?.lightnings?.some((c) => c.card === card)) throw new CliError("not_found", `No Lightning card ${JSON.stringify(card)} on ${n} (ghostly wallet list)`);
2626
+ }
2627
+ /**
2628
+ * The Lightning card `card` names, checked before the engine is asked: given with another type of wallet it is a usage
2629
+ * error, and one not on the network is not_found. Before, the engine dropped it for every type but Lightning, so a card
2630
+ * that named nothing removed the whole wallet of that type and the answer echoed the card as removed.
2631
+ */
2632
+ function cardOf(ctx, params, type) {
2633
+ const card = str(params, "card");
2634
+ if (card === void 0) return void 0;
2635
+ if (type !== "lightning") throw new CliError("usage", `--card names a Lightning card: not with ${type}`);
2636
+ lightningCard(ctx, params);
2637
+ return card;
2638
+ }
2639
+ /**
2640
+ * The engine opens the federations joined before in the background as it starts. A daemon has long done so when a call
2641
+ * comes; a one-shot command waits for them (at most 30 s) before its balances are read or money moves through one.
2642
+ */
2643
+ async function federationsOpen(ctx, ms = 3e4) {
2644
+ if (ctx.mode !== "one-shot") return;
2645
+ const until = Date.now() + ms;
2646
+ const connecting = () => NETWORKS.some((n) => (netView(state(ctx), n)?.fedimint?.federations ?? []).some((f) => f.status === "connecting"));
2647
+ while (connecting() && Date.now() < until) await new Promise((resolve) => setTimeout(resolve, 250));
2648
+ }
2649
+ /** One wallet of the deck with its balance, in its own unit. Never a key, a phrase or a token. */
2650
+ function walletJson(instance, s) {
2651
+ const view = netView(s, instance.network);
2652
+ let balance = null, unit = "sat", address = null, status = null, error = null;
2653
+ switch (instance.type) {
2654
+ case "cashu":
2655
+ balance = view?.balance ?? null;
2656
+ break;
2657
+ case "lightning": {
2658
+ const card = view?.lightnings?.find((c) => c.card === instance.card);
2659
+ balance = card?.balance ?? null;
2660
+ status = card?.status ?? null;
2661
+ error = card?.error ?? null;
2662
+ break;
2663
+ }
2664
+ case "arkade":
2665
+ balance = view?.ark?.balance ?? null;
2666
+ address = view?.ark?.address ?? null;
2667
+ error = view?.ark?.error ?? null;
2668
+ break;
2669
+ case "bark":
2670
+ balance = view?.bark?.balance ?? null;
2671
+ error = view?.bark?.error ?? null;
2672
+ break;
2673
+ case "spark":
2674
+ balance = view?.spark?.balance ?? null;
2675
+ address = view?.spark?.address ?? null;
2676
+ error = view?.spark?.error ?? null;
2677
+ break;
2678
+ case "bitcoin":
2679
+ balance = view?.bitcoin?.balance ?? null;
2680
+ address = view?.bitcoin?.address ?? null;
2681
+ status = view?.bitcoin?.status ?? null;
2682
+ error = view?.bitcoin?.error ?? null;
2683
+ break;
2684
+ case "fedimint":
2685
+ balance = view?.fedimint?.balance ?? null;
2686
+ error = view?.fedimint?.error ?? null;
2687
+ break;
2688
+ case "usdt":
2689
+ balance = view?.usdt?.balance ?? null;
2690
+ unit = "token-base";
2691
+ address = view?.usdt?.address ?? null;
2692
+ error = view?.usdt?.error ?? null;
2693
+ }
2694
+ return {
2695
+ id: instance.id,
2696
+ type: instance.type,
2697
+ network: instance.network,
2698
+ ...instance.card ? {
2699
+ card: instance.card,
2700
+ name: instance.name ?? null,
2701
+ receive: !!instance.receive
2702
+ } : {},
2703
+ balance,
2704
+ unit,
2705
+ address,
2706
+ status,
2707
+ error,
2708
+ config: instance.config
2709
+ };
2710
+ }
2711
+ function paymentJson(p) {
2712
+ return {
2713
+ id: p.id,
2714
+ chat: p.linkId,
2715
+ kind: p.kind,
2716
+ direction: p.direction,
2717
+ amount: p.amount,
2718
+ unit: p.unit,
2719
+ memo: p.memo ?? null,
2720
+ state: p.state,
2721
+ error: p.error ?? null,
2722
+ network: paymentNetwork(p),
2723
+ method: p.target?.method ?? (p.invoice ? "lightning" : p.mint || p.mints ? "cashu" : null),
2724
+ createdAt: p.createdAt,
2725
+ ...p.group ? { group: p.group } : {},
2726
+ ...p.closed ? { closed: true } : {},
2727
+ ...p.txid ? { txid: p.txid } : {}
2728
+ };
2729
+ }
2730
+ var isInvoice = (text) => /^(lightning:)?ln(bc|tb|tbs|sb|bcrt)[0-9a-z]+$/i.test(text.trim());
2731
+ var isLnurl = (text) => /^(lightning:)?lnurl[0-9a-z]+$/i.test(text.trim()) || /^[a-z0-9._+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(text.trim());
2732
+ /**
2733
+ * The engine refusing the mint URL the person typed (not a URL, or not https): their value, so `bad_request`, as
2734
+ * `service add` says; before, it was `engine` (exit 1), which a bot reads as the engine failing and may retry. Read by
2735
+ * its shape: the engine's `EngineError` carries `engineCode`.
2736
+ */
2737
+ function badMintUrl(error) {
2738
+ const code = error instanceof Error ? error.engineCode : void 0;
2739
+ return code === "invalidMintUrl" || code === "mintNotHttps";
2740
+ }
2741
+ /**
2742
+ * The engine's wallet refusals about what the person asked, by the words it writes (they carry no `engineCode`): a
2743
+ * wallet or card that is not there, a token or invoice that is not one, a wallet already made, no mint or source to do
2744
+ * it with. Before, each was `engine` (exit 1), which a bot reads as the engine failing and may retry.
2745
+ */
2746
+ var WALLET_REFUSALS = [
2747
+ [/^There is no .+ to remove$/, "not_found"],
2748
+ [/^That Lightning card is gone\b/, "not_found"],
2749
+ [/^That is not a (valid ecash token|Lightning invoice)$/, "bad_request"],
2750
+ [/^You already have a .+ wallet$/, "refused"],
2751
+ [/^No mint could create an invoice: No mint configured$/, "unavailable"],
2752
+ [/^No Bitcoin source is set up$/, "unavailable"],
2753
+ [/^The faucet did not pay: This wallet has no test mint\b/, "unavailable"],
2754
+ [/^Ghostly cannot ask this wallet's faucet by itself\b/, "unavailable"]
2755
+ ];
2756
+ function walletRefusal(error) {
2757
+ if (!(error instanceof Error) || error instanceof CliError) return error;
2758
+ const code = WALLET_REFUSALS.find(([pattern]) => pattern.test(error.message))?.[1];
2759
+ return code ? new CliError(code, error.message) : error;
2760
+ }
2761
+ var WALLET_METHODS = Object.fromEntries(Object.entries({
2762
+ async "wallet.list"(ctx, params) {
2763
+ await federationsOpen(ctx);
2764
+ const s = state(ctx);
2765
+ const only = params.network === void 0 ? null : network(params);
2766
+ return {
2767
+ wallets: (s.wallet.wallets ?? []).filter((w) => !only || w.network === only).map((w) => walletJson(w, s)),
2768
+ offers: (s.wallet.offers ?? []).filter((o) => !only || o.network === only).map((o) => {
2769
+ const gap = NODE_GAPS[o.type];
2770
+ return {
2771
+ type: o.type,
2772
+ network: o.network,
2773
+ available: o.available && !gap,
2774
+ reason: gap ?? o.reason ?? null,
2775
+ exists: !!o.exists,
2776
+ several: !!o.several,
2777
+ needs: o.needs ?? null,
2778
+ ...o.providers ? { providers: o.providers.map((p) => ({
2779
+ id: p.id,
2780
+ label: p.label,
2781
+ fields: p.fields.map((f) => ({
2782
+ name: f.name,
2783
+ label: f.label,
2784
+ kind: f.kind,
2785
+ optional: !!f.optional
2786
+ }))
2787
+ })) } : {}
2788
+ };
2789
+ })
2790
+ };
2791
+ },
2792
+ async "wallet.create"(ctx, params) {
2793
+ const type = oneOf(params, "type", TYPES, "cashu");
2794
+ const n = network(params);
2795
+ const gap = NODE_GAPS[type];
2796
+ if (gap) throw new CliError("unavailable", gap);
2797
+ await federationsOpen(ctx);
2798
+ const values = {};
2799
+ const raw = params.values ?? {};
2800
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.values(raw).some((v) => typeof v !== "string")) throw new CliError("bad_request", "values must be an object of strings");
2801
+ Object.assign(values, raw);
2802
+ const provider = str(params, "provider");
2803
+ const offered = state(ctx).wallet.offers?.find((o) => o.type === type && o.network === n)?.providers;
2804
+ if (provider && offered && !offered.some((p) => p.id === provider)) throw new CliError("bad_request", `No ${type} source ${JSON.stringify(provider)} on ${n} (ghostly wallet list)`);
2805
+ if (type === "bitcoin" && (provider ?? "bdk") === "bdk" && !values.mnemonic) values.mnemonic = generateMnemonic(wordlist, 128);
2806
+ return walletJson(await node(ctx).walletCreate({
2807
+ type,
2808
+ network: n,
2809
+ ...provider ? { providerId: provider } : type === "bitcoin" ? { providerId: "bdk" } : {},
2810
+ ...Object.keys(values).length ? { values } : {},
2811
+ ...str(params, "invite") ? { invite: str(params, "invite") } : {},
2812
+ ...str(params, "apiKey") ? { apiKey: str(params, "apiKey") } : {}
2813
+ }), state(ctx));
2814
+ },
2815
+ async "wallet.remove"(ctx, params) {
2816
+ const type = oneOf(params, "type", TYPES, "cashu");
2817
+ const n = network(params);
2818
+ const card = cardOf(ctx, params, type);
2819
+ try {
2820
+ await node(ctx).walletRemove({
2821
+ type,
2822
+ network: n,
2823
+ ...card ? { card } : {},
2824
+ ...bool(params, "acceptLoss") ? { acceptLoss: true } : {}
2825
+ });
2826
+ } catch (error) {
2827
+ const message = error instanceof Error ? error.message : String(error);
2828
+ if (!bool(params, "acceptLoss") && /hold|still|awaiting|lose|backup/i.test(message)) throw new CliError("confirm", `${message} (pass acceptLoss, --accept-loss, only if that is intended)`);
2829
+ throw error;
2830
+ }
2831
+ return { removed: {
2832
+ type,
2833
+ network: n,
2834
+ card: card ?? null
2835
+ } };
2836
+ },
2837
+ async "wallet.faucet"(ctx, params) {
2838
+ const type = oneOf(params, "type", TYPES, "cashu");
2839
+ if (network(params) !== "testnet") throw new CliError("refused", "Test coins are for Testnet wallets only");
2840
+ const card = cardOf(ctx, params, type);
2841
+ return node(ctx).walletTestCoins({
2842
+ type,
2843
+ network: "testnet",
2844
+ ...card ? { card } : {}
2845
+ });
2846
+ },
2847
+ async "wallet.history"(ctx, params) {
2848
+ const n = network(params);
2849
+ const limit = num(params, "limit", 50, {
2850
+ min: 1,
2851
+ max: 1e4,
2852
+ whole: true
2853
+ });
2854
+ await federationsOpen(ctx);
2855
+ return {
2856
+ network: n,
2857
+ history: (netView(state(ctx), n)?.history ?? []).slice(0, limit)
2858
+ };
2859
+ },
2860
+ async "wallet.receive"(ctx, params) {
2861
+ const amount = num(params, "amount", 0, { min: 1 });
2862
+ if (!amount) throw new CliError("bad_request", "amount is required");
2863
+ const n = network(params);
2864
+ await federationsOpen(ctx);
2865
+ const card = cardOf(ctx, params, "lightning");
2866
+ const invoice = await node(ctx).walletReceiveLightning({
2867
+ amount,
2868
+ network: n,
2869
+ ...card ? { card } : {}
2870
+ });
2871
+ return {
2872
+ network: n,
2873
+ amount,
2874
+ invoice: invoice.invoice,
2875
+ expiresAt: invoice.expiresAt,
2876
+ paymentHash: invoice.paymentHash ?? null,
2877
+ source: invoice.source
2878
+ };
2879
+ },
2880
+ async "wallet.address"(ctx, params) {
2881
+ const type = oneOf(params, "type", [
2882
+ "bitcoin",
2883
+ "arkade",
2884
+ "spark",
2885
+ "usdt"
2886
+ ], "bitcoin");
2887
+ const n = network(params);
2888
+ if (type === "bitcoin") return {
2889
+ type,
2890
+ network: n,
2891
+ address: await node(ctx).bitcoinReceiveAddress({ network: n })
2892
+ };
2893
+ const view = netView(state(ctx), n);
2894
+ const address = type === "arkade" ? view?.ark?.address : type === "spark" ? view?.spark?.address : view?.usdt?.address;
2895
+ if (!address) throw new CliError("unavailable", `No ${type} wallet with an address on ${n}`);
2896
+ return {
2897
+ type,
2898
+ network: n,
2899
+ address
2900
+ };
2901
+ },
2902
+ async "wallet.redeem"(ctx, params) {
2903
+ return node(ctx).walletReceiveToken({ token: str(params, "token", true).trim() });
2904
+ },
2905
+ async "wallet.mint.add"(ctx, params) {
2906
+ return node(ctx).walletAddMint({
2907
+ url: str(params, "url", true),
2908
+ ...bool(params, "primary") ? { primary: true } : {}
2909
+ }).catch((error) => {
2910
+ throw badMintUrl(error) ? new CliError("bad_request", error.message) : error;
2911
+ });
2912
+ },
2913
+ async "lightning.default"(ctx, params) {
2914
+ lightningCard(ctx, params);
2915
+ await node(ctx).lightningSetReceive({
2916
+ network: network(params),
2917
+ card: str(params, "card", true)
2918
+ });
2919
+ return {
2920
+ network: network(params),
2921
+ receive: str(params, "card", true)
2922
+ };
2923
+ },
2924
+ async "lightning.rename"(ctx, params) {
2925
+ lightningCard(ctx, params);
2926
+ await node(ctx).lightningRename({
2927
+ network: network(params),
2928
+ card: str(params, "card", true),
2929
+ name: str(params, "name", true)
2930
+ });
2931
+ return {
2932
+ network: network(params),
2933
+ card: str(params, "card", true),
2934
+ name: str(params, "name", true)
2935
+ };
2936
+ },
2937
+ /** An invoice, a Lightning address or an LNURL, from the network's Lightning (a card of it, or its default). */
2938
+ async "pay"(ctx, params) {
2939
+ const text = str(params, "target", true).trim();
2940
+ const card = str(params, "card");
2941
+ let invoice = text.replace(/^lightning:/i, "");
2942
+ let n;
2943
+ let note;
2944
+ if (isInvoice(text)) n = network(params);
2945
+ else if (isLnurl(text)) {
2946
+ n = network(params);
2947
+ const amount = num(params, "amount", 0, { min: 1 });
2948
+ if (!amount) throw new CliError("bad_request", "amount is required to pay a Lightning address or LNURL");
2949
+ const view = await node(ctx).lnurlResolve({
2950
+ text,
2951
+ network: n
2952
+ });
2953
+ if (amount < view.minSat || amount > view.maxSat) throw new CliError("refused", `${view.domain} takes from ${view.minSat} to ${view.maxSat} sats`);
2954
+ const got = await node(ctx).lnurlInvoice({
2955
+ id: view.id,
2956
+ amount,
2957
+ ...str(params, "comment") ? { comment: str(params, "comment") } : {},
2958
+ network: n
2959
+ });
2960
+ invoice = got.invoice;
2961
+ note = got.note;
2962
+ } else throw new CliError("bad_request", "Not a Lightning invoice, Lightning address or LNURL");
2963
+ if (n === "mainnet" && !bool(params, "confirmReal")) throw new CliError("confirm", "This pays real money (Mainnet): pass --confirm-real to confirm this payment");
2964
+ await federationsOpen(ctx);
2965
+ const quote = await node(ctx).walletQuoteInvoice({
2966
+ invoice,
2967
+ network: n,
2968
+ ...card ? { card } : {}
2969
+ });
2970
+ const maxFee = params.maxFee === void 0 ? null : num(params, "maxFee", 0);
2971
+ if (maxFee !== null && quote.feeReserve > maxFee) throw new CliError("refused", `The fee may reach ${quote.feeReserve} sats, more than the ${maxFee} allowed`);
2972
+ const paid = await node(ctx).walletPayQuote({
2973
+ quote: quote.quote,
2974
+ mint: quote.mint,
2975
+ ...note ? { note } : {},
2976
+ ...real(params)
2977
+ });
2978
+ return {
2979
+ network: n,
2980
+ amount: quote.amount,
2981
+ feeReserve: quote.feeReserve,
2982
+ source: quote.source ?? quote.mint,
2983
+ paid: paid.paid
2984
+ };
2985
+ },
2986
+ /** Ecash to a contact in a chat (the payment sheet's Pay). */
2987
+ async "chat.pay"(ctx, params) {
2988
+ const link = chatOf(ctx, params);
2989
+ inviteTaken(link);
2990
+ const amount = num(params, "amount", 0, { min: 1 });
2991
+ if (!amount) throw new CliError("bad_request", "amount is required");
2992
+ const n = network(params);
2993
+ if (n === "mainnet" && !bool(params, "confirmReal")) throw new CliError("confirm", "This pays real money (Mainnet): pass --confirm-real to confirm this payment");
2994
+ await federationsOpen(ctx);
2995
+ const { paymentId } = await node(ctx).sendPayment({
2996
+ linkId: link.id,
2997
+ amount,
2998
+ timestamp: sentAt(),
2999
+ network: n,
3000
+ ...str(params, "memo") ? { memo: str(params, "memo") } : {},
3001
+ ...real(params)
3002
+ });
3003
+ return {
3004
+ chat: link.id,
3005
+ paymentId
3006
+ };
3007
+ },
3008
+ /** A request in a chat (the payment sheet's Request): the contact's app pays it. */
3009
+ async "chat.request"(ctx, params) {
3010
+ const link = chatOf(ctx, params);
3011
+ const amount = num(params, "amount", 0, { min: 1 });
3012
+ if (!amount) throw new CliError("bad_request", "amount is required");
3013
+ const method = params.method === void 0 ? void 0 : oneOf(params, "method", METHODS$1, "cashu");
3014
+ const rail = params.rail === void 0 ? void 0 : oneOf(params, "rail", ["cashu", "lightning"], "cashu");
3015
+ await federationsOpen(ctx);
3016
+ const { paymentId } = await node(ctx).requestPayment({
3017
+ linkId: link.id,
3018
+ amount,
3019
+ timestamp: sentAt(),
3020
+ network: network(params),
3021
+ ...str(params, "memo") ? { memo: str(params, "memo") } : {},
3022
+ ...method ? { method } : {},
3023
+ ...rail ? { rail } : {},
3024
+ ...str(params, "card") ? { card: str(params, "card") } : {}
3025
+ });
3026
+ return {
3027
+ chat: link.id,
3028
+ paymentId
3029
+ };
3030
+ },
3031
+ /** Pays a request the contact made in a chat. */
3032
+ async "chat.payRequest"(ctx, params) {
3033
+ const link = chatOf(ctx, params);
3034
+ const paymentId = str(params, "payment", true);
3035
+ const request = state(ctx).payments[paymentId];
3036
+ if (!request || request.linkId !== link.id || request.kind !== "request") throw new CliError("not_found", `No request ${paymentId} in this chat`);
3037
+ const n = paymentNetwork(request);
3038
+ if (n === "mainnet" && !bool(params, "confirmReal")) throw new CliError("confirm", "This request is for real money (Mainnet): pass --confirm-real to confirm this payment", { network: n });
3039
+ const via = params.via === void 0 ? void 0 : oneOf(params, "via", ["lightning"], "lightning");
3040
+ await federationsOpen(ctx);
3041
+ await node(ctx).payRequest({
3042
+ linkId: link.id,
3043
+ paymentId,
3044
+ network: n,
3045
+ ...via ? { via } : {},
3046
+ ...params.maxFee !== void 0 ? { maxFee: num(params, "maxFee", 0) } : {},
3047
+ ...str(params, "card") ? { card: str(params, "card") } : {},
3048
+ ...real(params)
3049
+ });
3050
+ return paymentJson(state(ctx).payments[paymentId] ?? request);
3051
+ },
3052
+ /** Which ways of paying this chat takes, per network (the chat's Accept switches). */
3053
+ async "chat.accept"(ctx, params) {
3054
+ const link = chatOf(ctx, params);
3055
+ const method = oneOf(params, "method", CHAT_METHODS, "cashu");
3056
+ const on = bool(params, "on");
3057
+ const nets = params.networks === void 0 ? void 0 : (() => {
3058
+ const value = params.networks;
3059
+ if (!Array.isArray(value) || !value.every((v) => v === "mainnet" || v === "testnet")) throw new CliError("bad_request", "networks must list mainnet and/or testnet");
3060
+ return value;
3061
+ })();
3062
+ await node(ctx).setChatPaymentMethods({
3063
+ linkId: link.id,
3064
+ methods: { [method]: on },
3065
+ ...nets ? { networks: { [method]: nets } } : {}
3066
+ });
3067
+ const now = state(ctx).links.find((l) => l.id === link.id);
3068
+ return {
3069
+ chat: link.id,
3070
+ methods: now?.paymentMethods ?? null,
3071
+ networks: now?.paymentNetworks ?? null
3072
+ };
3073
+ },
3074
+ async "payment.list"(ctx, params) {
3075
+ const chat = params.chat === void 0 ? null : chatOf(ctx, params).id;
3076
+ return { payments: Object.values(state(ctx).payments).filter((p) => !chat || p.linkId === chat).sort((a, b) => b.createdAt - a.createdAt).map(paymentJson) };
3077
+ },
3078
+ async "payment.check"(ctx, params) {
3079
+ const link = chatOf(ctx, params);
3080
+ if (state(ctx).payments[str(params, "payment", true)]?.linkId !== link.id) throw new CliError("not_found", `No payment ${JSON.stringify(str(params, "payment", true))} in this chat (ghostly payment list --chat ${link.id})`);
3081
+ await node(ctx).checkPayment({
3082
+ linkId: link.id,
3083
+ paymentId: str(params, "payment", true)
3084
+ });
3085
+ return {
3086
+ chat: link.id,
3087
+ paymentId: str(params, "payment", true)
3088
+ };
3089
+ },
3090
+ async "payment.reclaim"(ctx, params) {
3091
+ if (!state(ctx).payments[str(params, "payment", true)]) throw new CliError("not_found", `No payment ${JSON.stringify(str(params, "payment", true))} (ghostly payment list)`);
3092
+ await node(ctx).reclaimPayment({ paymentId: str(params, "payment", true) });
3093
+ const p = state(ctx).payments[str(params, "payment", true)];
3094
+ return p ? paymentJson(p) : { paymentId: str(params, "payment", true) };
3095
+ }
3096
+ }).map(([name, method]) => [name, (ctx, params) => method(ctx, params).catch((error) => {
3097
+ throw walletRefusal(error);
3098
+ })]));
3099
+ //#endregion
3100
+ //#region src/api.ts
3101
+ /**
3102
+ * A text past what a chat or a group carries (16 KiB of UTF-8, as the engine counts it: trimmed) is refused before the
3103
+ * engine is asked, with a code that says it never goes as it is, not "unavailable", which reads as "try later".
3104
+ */
3105
+ function checkTextSize(text) {
3106
+ const bytes = new TextEncoder().encode(text.trim()).length;
3107
+ if (bytes > LIMITS.maxChatMessageBytes) throw new CliError("bad_request", `Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes (${bytes}): shorten it, or send it as a file`, {
3108
+ bytes,
3109
+ max: LIMITS.maxChatMessageBytes
3110
+ });
3111
+ }
3112
+ /**
3113
+ * The engine's state as `engine getState` prints it without `--show-secret`: the settings' credentials, a group's
3114
+ * entry link (anyone who reads it can join) and a pending chat's invite code (whoever reads it can take the chat)
3115
+ * are `<hidden>`.
3116
+ */
3117
+ function hideStateSecrets(state) {
3118
+ return {
3119
+ ...state,
3120
+ settings: redactSettings(state.settings),
3121
+ ...state.groups ? { groups: state.groups.map((g) => g.entryLink ? {
3122
+ ...g,
3123
+ entryLink: "<hidden>"
3124
+ } : g) } : {},
3125
+ ...state.links ? { links: state.links.map((l) => l.inviteCode ? {
3126
+ ...l,
3127
+ inviteCode: "<hidden>"
3128
+ } : l) } : {}
3129
+ };
3130
+ }
3131
+ /** Settings as a bot may read them: storage credentials and other secrets masked unless asked for. */
3132
+ function redactSettings(settings, showSecret = false) {
3133
+ if (showSecret) return settings;
3134
+ const mask = (value, key = "") => {
3135
+ if (value && typeof value === "object" && !Array.isArray(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, mask(v, k)]));
3136
+ if (Array.isArray(value)) return value.map((item) => mask(item, key));
3137
+ if (typeof value === "string" && (/secret|password|token|credential|privatekey|nsec|bunker/i.test(key) || key === "auth" || key === "space")) return "<hidden>";
3138
+ return value;
3139
+ };
3140
+ const { avatar, wake, ...rest } = settings;
3141
+ return {
3142
+ ...mask(rest),
3143
+ ...avatar ? { avatar: "<set>" } : {},
3144
+ ...wake ? { wake: "<set>" } : {}
3145
+ };
3146
+ }
3147
+ /** Settings a command may change; the rest are the app's to manage. */
3148
+ var SETTABLE = {
3149
+ relays: "strings",
3150
+ irohRelays: "strings",
3151
+ hyperdhtRelay: "string",
3152
+ readRelays: "boolean",
3153
+ iceServers: "ice",
3154
+ publicProfiles: "boolean",
3155
+ online: "boolean",
3156
+ shareProfile: "boolean",
3157
+ sendTyping: "boolean",
3158
+ nick: "string",
3159
+ autoDownloads: "boolean",
3160
+ serveFiles: "boolean"
3161
+ };
3162
+ var TRANSPORT_NAMES = {
3163
+ auto: "auto",
3164
+ dht: "dht",
3165
+ webrtc: "webrtc/1",
3166
+ iroh: "iroh/1",
3167
+ hyperdht: "hyperdht/1",
3168
+ "webrtc/1": "webrtc/1",
3169
+ "iroh/1": "iroh/1",
3170
+ "hyperdht/1": "hyperdht/1"
3171
+ };
3172
+ /** Code points before `index` (UTF-16) in `text`: mentions count code points (WISP 902 § Mentions). */
3173
+ var codePoints = (text, index) => [...text.slice(0, index)].length;
3174
+ /**
3175
+ * Mentions for a group message: each ref (a member key, a unique prefix of one, a member's name, or `everyone`) must
3176
+ * be written in the text as `@<name>` (or `@<key prefix>`, `@everyone`); the first such place is the mention.
3177
+ */
3178
+ function mentionsFor(text, refs, group) {
3179
+ const out = [];
3180
+ for (const ref of refs) {
3181
+ let key, spelled;
3182
+ if (ref === "everyone" || ref === "*") {
3183
+ key = "*";
3184
+ spelled = ["everyone", "all"];
3185
+ } else {
3186
+ const lower = ref.toLowerCase();
3187
+ const byKey = group.members.filter((m) => !m.me && m.key === ref);
3188
+ const matches = byKey.length ? byKey : group.members.filter((m) => !m.me && (m.key.startsWith(ref) || m.nick?.trim().toLowerCase() === lower));
3189
+ if (matches.length !== 1) throw new CliError(matches.length ? "bad_request" : "not_found", matches.length ? `${JSON.stringify(ref)} names more than one member` : `No member ${JSON.stringify(ref)} in ${group.name}`);
3190
+ key = matches[0].key;
3191
+ spelled = [
3192
+ matches[0].nick?.trim(),
3193
+ ref,
3194
+ key.slice(0, 8)
3195
+ ].filter((s) => !!s);
3196
+ }
3197
+ const found = spelled.map((name) => ({
3198
+ name,
3199
+ index: text.toLowerCase().indexOf("@" + name.toLowerCase())
3200
+ })).find((f) => f.index >= 0);
3201
+ if (!found) throw new CliError("bad_request", `Write @${spelled[0]} in the text to mention ${ref}`);
3202
+ out.push({
3203
+ k: key,
3204
+ o: codePoints(text, found.index),
3205
+ l: [..."@" + found.name].length
3206
+ });
3207
+ }
3208
+ return out;
3209
+ }
3210
+ /** Per host: stops watching the invite it marked as the chat on screen. */
3211
+ var inviteShown = /* @__PURE__ */ new WeakMap();
3212
+ /**
3213
+ * Marks a new invite as the chat on screen (native listeners, the quick pace) only until it is paired: a paired chat
3214
+ * keeps its listeners anyway, and a daemon nobody looks at reads an away contact at the background pace, as its
3215
+ * other chats and the same chat after a restart do. Nor past its wait for a peer (`AWAITING_PEER_MS`): an invite
3216
+ * nobody joined is then read at the background pace like the daemon's other ones, which it no longer holds back.
3217
+ */
3218
+ function showUntilPaired(ctx, linkId) {
3219
+ inviteShown.get(ctx)?.();
3220
+ node(ctx).setActiveLink({ linkId });
3221
+ let seen = false;
3222
+ const done = (s) => {
3223
+ const link = s.links.find((l) => l.id === linkId);
3224
+ if (link) seen = true;
3225
+ return link ? !!link.peerParticipationKey : seen;
3226
+ };
3227
+ const stop = () => {
3228
+ off();
3229
+ clearTimeout(timer);
3230
+ if (inviteShown.get(ctx) === stop) inviteShown.delete(ctx);
3231
+ };
3232
+ const hide = () => {
3233
+ stop();
3234
+ node(ctx).setActiveLink({ linkId: null });
3235
+ };
3236
+ const timer = setTimeout(hide, AWAITING_PEER_MS);
3237
+ timer.unref?.();
3238
+ const off = ctx.hub.onState((s) => {
3239
+ if (done(s)) hide();
3240
+ });
3241
+ inviteShown.set(ctx, stop);
3242
+ }
3243
+ var METHODS = {
3244
+ ...WALLET_METHODS,
3245
+ ...FILE_METHODS,
3246
+ ...STATUS_CARD_METHODS,
3247
+ ...BUTTON_METHODS,
3248
+ ...GROUP_ADMIN_METHODS,
3249
+ ...IDENTITY_METHODS,
3250
+ ...SERVICE_METHODS,
3251
+ ...BACKUP_METHODS,
3252
+ ...CALL_METHODS,
3253
+ ...APP_SERVE_METHODS,
3254
+ ...APP_CHAT_METHODS,
3255
+ async status(ctx) {
3256
+ const s = state(ctx);
3257
+ return {
3258
+ version: ctx.version,
3259
+ profile: ctx.runtime.paths.name,
3260
+ mode: ctx.mode,
3261
+ pid: process.pid,
3262
+ online: s.settings.online,
3263
+ name: s.settings.nick || null,
3264
+ webrtc: ctx.runtime.webrtc,
3265
+ calls: ctx.runtime.callsUnavailable === null,
3266
+ chats: s.links.length,
3267
+ live: s.links.filter(isLive).length,
3268
+ groups: s.groups.length,
3269
+ discovery: {
3270
+ protocol: s.transport.protocol,
3271
+ relays: s.transport.relays
3272
+ },
3273
+ events: { lastSeq: ctx.hub.lastSeq }
3274
+ };
3275
+ },
3276
+ async "profile.get"(ctx) {
3277
+ const { settings } = state(ctx);
3278
+ return {
3279
+ profile: ctx.runtime.paths.name,
3280
+ name: settings.nick || null,
3281
+ shareProfile: settings.shareProfile !== false,
3282
+ picture: !!settings.avatar,
3283
+ online: settings.online
3284
+ };
3285
+ },
3286
+ async "profile.set"(ctx, params) {
3287
+ const patch = {};
3288
+ const name = params.name;
3289
+ if (name !== void 0) {
3290
+ if (typeof name !== "string" || [...name].length > 64) throw new CliError("bad_request", "name must be a string of up to 64 characters");
3291
+ patch.nick = name.trim();
3292
+ }
3293
+ if (params.shareProfile !== void 0) patch.shareProfile = bool(params, "shareProfile");
3294
+ await node(ctx).updateSettings({ settings: patch });
3295
+ return METHODS["profile.get"](ctx, {});
3296
+ },
3297
+ async "settings.get"(ctx, params) {
3298
+ return redactSettings(state(ctx).settings, bool(params, "showSecret"));
3299
+ },
3300
+ async "settings.set"(ctx, params) {
3301
+ const patch = {};
3302
+ let warning;
3303
+ for (const [key, value] of Object.entries(params)) {
3304
+ const kind = Object.hasOwn(SETTABLE, key) ? SETTABLE[key] : void 0;
3305
+ if (!kind) throw new CliError("bad_request", `${key} is not a setting the CLI changes (${Object.keys(SETTABLE).join(", ")})`);
3306
+ if (kind === "strings" && !(Array.isArray(value) && value.every((v) => typeof v === "string"))) throw new CliError("bad_request", `${key} must be a list of strings`);
3307
+ if (kind === "string" && typeof value !== "string") throw new CliError("bad_request", `${key} must be a string`);
3308
+ if (kind === "boolean" && typeof value !== "boolean") throw new CliError("bad_request", `${key} must be true or false`);
3309
+ if (kind === "ice") {
3310
+ if (!(Array.isArray(value) && value.every((v) => v && typeof v === "object"))) throw new CliError("bad_request", `${key} must be a list of ICE servers`);
3311
+ const servers = value.map(iceServerSetting);
3312
+ for (const server of servers) {
3313
+ const problem = iceServerProblem(server);
3314
+ if (problem) throw new CliError("bad_request", `${key}: ${problem}`);
3315
+ }
3316
+ patch[key] = servers;
3317
+ const unused = servers.flatMap((server) => server.urls.split(/[\s,]+/)).filter((url) => /^turns:|\?transport=tcp$/i.test(url));
3318
+ if (unused.length) warning = `The CLI reaches TURN over UDP only: it does not use ${unused.join(", ")} (the apps do). Add a turn: server over UDP for the CLI`;
3319
+ continue;
3320
+ }
3321
+ patch[key] = value;
3322
+ }
3323
+ await node(ctx).updateSettings({ settings: patch });
3324
+ return {
3325
+ ...redactSettings(state(ctx).settings),
3326
+ ...warning ? { warning } : {}
3327
+ };
3328
+ },
3329
+ async "invite.create"(ctx, params) {
3330
+ const label = str(params, "label");
3331
+ const { linkId, inviteCode } = await node(ctx).createLink();
3332
+ if (label) await node(ctx).renameLink({
3333
+ linkId,
3334
+ label
3335
+ });
3336
+ showUntilPaired(ctx, linkId);
3337
+ const progressOf = (s) => s.links.find((l) => l.id === linkId)?.pairingProgress;
3338
+ const progress = await waitForState(ctx, (s) => {
3339
+ const p = progressOf(s);
3340
+ return p && (p.stage !== "publishing" || ctx.mode === "daemon" && p.relayWaitUntil) ? p : void 0;
3341
+ }, 2e4, "the invite to be published").catch(() => progressOf(state(ctx)));
3342
+ const published = !!progress && progress.stage !== "publishing" && progress.reason !== "publish";
3343
+ const waitUntil = published ? void 0 : progress?.relayWaitUntil;
3344
+ return {
3345
+ chat: linkId,
3346
+ invite: inviteCode,
3347
+ link: inviteLink(inviteCode),
3348
+ published,
3349
+ ...waitUntil !== void 0 && { retryInMs: Math.max(0, waitUntil - Date.now()) }
3350
+ };
3351
+ },
3352
+ async "invite.join"(ctx, params) {
3353
+ let invite = str(params, "invite", true).trim();
3354
+ const hash = invite.indexOf("#");
3355
+ if (/^https?:\/\//i.test(invite) && hash !== -1) invite = invite.slice(hash + 1);
3356
+ let linkId;
3357
+ try {
3358
+ ({linkId} = await node(ctx).joinLink({ inviteCode: invite }));
3359
+ } catch (error) {
3360
+ const message = error instanceof Error ? error.message : String(error);
3361
+ if (/own invite/i.test(message)) throw new CliError("refused", message);
3362
+ if (/does not look like/i.test(message)) throw new CliError("bad_request", message);
3363
+ throw error;
3364
+ }
3365
+ const label = str(params, "label");
3366
+ if (label) await node(ctx).renameLink({
3367
+ linkId,
3368
+ label
3369
+ });
3370
+ showUntilPaired(ctx, linkId);
3371
+ return { chat: linkId };
3372
+ },
3373
+ async "chat.list"(ctx, params) {
3374
+ const showSecret = bool(params, "showSecret");
3375
+ return { chats: state(ctx).links.map((link) => chatJson(link, showSecret)).sort((a, b) => b.lastMessageAt - a.lastMessageAt || b.createdAt - a.createdAt) };
3376
+ },
3377
+ async "chat.get"(ctx, params) {
3378
+ const link = chatOf(ctx, params);
3379
+ return {
3380
+ ...chatDetailsJson(link, bool(params, "showSecret")),
3381
+ heldUntil: holdOf(ctx, link.id)
3382
+ };
3383
+ },
3384
+ async "chat.history"(ctx, params) {
3385
+ const link = chatOf(ctx, params);
3386
+ return historyOf(ctx, link.id, params, (m) => chatMessageJson(m, link));
3387
+ },
3388
+ async "chat.send"(ctx, params) {
3389
+ const link = chatOf(ctx, params);
3390
+ const text = str(params, "text", true);
3391
+ checkTextSize(text);
3392
+ if (!bool(params, "force")) {
3393
+ const secret = findSecret(text);
3394
+ if (secret) throw new CliError("confirm", `The text looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; send it with --force if you mean to`, { kind: secret.kind });
3395
+ }
3396
+ const wait = oneOf(params, "wait", [
3397
+ "none",
3398
+ "sent",
3399
+ "delivered"
3400
+ ], "none");
3401
+ const replyTo = str(params, "reply");
3402
+ const card = buttonsCard(params);
3403
+ endTyping(ctx, { linkId: link.id }, false);
3404
+ const result = await node(ctx).sendMessage({
3405
+ linkId: link.id,
3406
+ text,
3407
+ ...replyTo ? { replyTo } : {},
3408
+ ...card ? { card } : {}
3409
+ });
3410
+ if (result.error) throw /not in this chat|cannot be replied to/.test(result.error) ? new CliError("not_found", result.error) : new CliError(result.refused ? "refused" : "unavailable", result.error);
3411
+ if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
3412
+ let message = await node(ctx).getMessage(link.id, result.messageId);
3413
+ if (wait !== "none") message = await waitForMessage(ctx, link.id, result.messageId, wait, num(params, "timeout", 30, {
3414
+ min: 1,
3415
+ max: 3600
3416
+ }) * 1e3);
3417
+ return {
3418
+ chat: link.id,
3419
+ messageId: result.messageId,
3420
+ delivery: message?.delivery ?? null,
3421
+ ...card ? {
3422
+ buttons: card.id,
3423
+ card,
3424
+ pressable: pressable(link)
3425
+ } : {}
3426
+ };
3427
+ },
3428
+ /**
3429
+ * WISP 400 § Edits: the whole new text of one of my texts in a 1:1 chat. It shows here at once and reaches the contact
3430
+ * once the chat is live and both apps offer edit/1; `--wait confirmed` waits for the contact to confirm it.
3431
+ */
3432
+ async "chat.edit"(ctx, params) {
3433
+ const link = chatOf(ctx, params);
3434
+ const messageId = str(params, "message", true);
3435
+ const text = str(params, "text", true);
3436
+ if (!bool(params, "force")) {
3437
+ const secret = findSecret(text);
3438
+ if (secret) throw new CliError("confirm", `The text looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; edit with --force if you mean to`, { kind: secret.kind });
3439
+ }
3440
+ const wait = oneOf(params, "wait", ["none", "confirmed"], "none");
3441
+ const result = await node(ctx).editMessage({
3442
+ linkId: link.id,
3443
+ messageId,
3444
+ text
3445
+ });
3446
+ if (result.error) throw editRefused(await messageOf(ctx, link.id, messageId), messageId, "chat", result.error, result.refused);
3447
+ const id = result.messageId ?? messageId;
3448
+ let message = await node(ctx).getMessage(link.id, id);
3449
+ if (wait === "confirmed" && message?.edit?.pending) message = await waitForEdit(ctx, link.id, id, num(params, "timeout", 30, {
3450
+ min: 1,
3451
+ max: 3600
3452
+ }) * 1e3);
3453
+ return {
3454
+ chat: link.id,
3455
+ messageId: id,
3456
+ edits: message?.edit?.seq ?? 0,
3457
+ confirmed: !!message && !message.edit?.pending
3458
+ };
3459
+ },
3460
+ /**
3461
+ * WISP 401 § Typing: said on the live session only, and a start only while `sendTyping` is on; it holds 6 s there.
3462
+ * `kind` (typing, recording, thinking) and `status` (a short line, e.g. "Transcribing your audio…") say what this
3463
+ * side is doing; a new one reaches the contact at once.
3464
+ * `for`: kept that many seconds (a one-shot stays that long), until a message to the chat or a stop.
3465
+ */
3466
+ async "chat.typing"(ctx, params) {
3467
+ const link = chatOf(ctx, params);
3468
+ const typing = !bool(params, "stop");
3469
+ const { kind, status } = typingWord(params, typing);
3470
+ const word = {
3471
+ ...kind !== "typing" ? { kind } : {},
3472
+ ...status ? { status } : {}
3473
+ };
3474
+ const seconds = params.for === void 0 ? 0 : num(params, "for", 0, {
3475
+ min: 1,
3476
+ max: 600
3477
+ });
3478
+ if (!typing && seconds) throw new CliError("bad_request", "for keeps typing on: not with stop");
3479
+ endTyping(ctx, { linkId: link.id }, false);
3480
+ node(ctx).setTyping({
3481
+ linkId: link.id,
3482
+ typing,
3483
+ ...word
3484
+ });
3485
+ const until = seconds ? Date.now() + seconds * 1e3 : null;
3486
+ const kept = seconds ? keepTyping(ctx, { linkId: link.id }, seconds * 1e3, word) : null;
3487
+ if (kept && ctx.mode === "one-shot") await kept;
3488
+ return {
3489
+ chat: link.id,
3490
+ typing,
3491
+ ...typing ? { kind } : {},
3492
+ ...status ? { status } : {},
3493
+ live: link.dataLink === "open",
3494
+ sendTyping: state(ctx).settings.sendTyping !== false,
3495
+ ...until ? { until } : {}
3496
+ };
3497
+ },
3498
+ /** WISP 400 § Reactions: one emoji per person per message, a new one replaces mine; `remove` takes mine back. */
3499
+ async "chat.react"(ctx, params) {
3500
+ const link = notAGroup(ctx, params, () => chatOf(ctx, params), "group react", "<message> <emoji>");
3501
+ return {
3502
+ chat: link.id,
3503
+ ...await react(ctx, link.id, params)
3504
+ };
3505
+ },
3506
+ /**
3507
+ * WISP 400 § Pinned message: one per chat or group, a new pin replaces it; `remove` unpins. In a community only the
3508
+ * admin pins. Answers the chat (or group) and the message now pinned (null once unpinned).
3509
+ */
3510
+ async "chat.pin"(ctx, params) {
3511
+ const target = chatOrGroup(ctx, str(params, "chat", true));
3512
+ const remove = bool(params, "remove"), messageId = remove ? str(params, "message") : str(params, "message", true);
3513
+ const result = await node(ctx).pinMessage({
3514
+ linkId: target.linkId,
3515
+ ...messageId && { messageId },
3516
+ remove
3517
+ });
3518
+ if (result.error) throw new CliError(/not in this chat|Nothing is pinned/.test(result.error) ? "not_found" : /Only the admin/.test(result.error) ? "refused" : "bad_request", result.error);
3519
+ return {
3520
+ [target.group ? "group" : "chat"]: target.id,
3521
+ messageId: remove ? null : messageId,
3522
+ removed: remove
3523
+ };
3524
+ },
3525
+ /**
3526
+ * WISP 400 § Forwards: messages of a chat or a group sent on to up to 5 chats and groups, each as a new message of mine
3527
+ * that says it was forwarded (one hop more) and nothing of who wrote it. A file goes from the bytes here, never
3528
+ * fetched again; a group takes texts only. `wait: sent`: each text until it is on its way (a group's until an edge
3529
+ * took it), each file until its transfer is done. What a chat refused comes back per chat; any refusal fails the call.
3530
+ */
3531
+ async "chat.forward"(ctx, params) {
3532
+ const from = chatOrGroup(ctx, str(params, "chat", true));
3533
+ const messageIds = list(params, "messages");
3534
+ if (!messageIds.length) throw new CliError("bad_request", "Name the messages to forward (their ids, from history or an event)");
3535
+ const to = list(params, "to").map((ref) => chatOrGroup(ctx, ref));
3536
+ if (!to.length) throw new CliError("bad_request", "Forward to which chat? Name one or more with --to");
3537
+ const chosen = [];
3538
+ for (const id of messageIds) {
3539
+ const message = await node(ctx).getMessage(from.linkId, id);
3540
+ if (!message) throw new CliError("not_found", `No message ${id} in this ${from.group ? "group" : "chat"}`);
3541
+ chosen.push(message);
3542
+ }
3543
+ if (!bool(params, "force")) for (const message of chosen) {
3544
+ const secret = message.file ? null : findSecret(message.text);
3545
+ if (secret) throw new CliError("confirm", `A message looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; forward it with --force if you mean to`, {
3546
+ kind: secret.kind,
3547
+ messageId: message.id
3548
+ });
3549
+ }
3550
+ const wait = oneOf(params, "wait", ["none", "sent"], "none");
3551
+ let results;
3552
+ try {
3553
+ ({results} = await node(ctx).forwardMessages({
3554
+ linkId: from.linkId,
3555
+ messageIds,
3556
+ to: to.map((t) => t.linkId)
3557
+ }));
3558
+ } catch (error) {
3559
+ throw new CliError("refused", error instanceof Error ? error.message : String(error));
3560
+ }
3561
+ const ms = num(params, "timeout", 30, {
3562
+ min: 1,
3563
+ max: 3600
3564
+ }) * 1e3;
3565
+ const out = results.map((result, i) => ({
3566
+ to: to[i].id,
3567
+ kind: to[i].group ? "group" : "chat",
3568
+ messageIds: result.messageIds,
3569
+ error: result.error
3570
+ }));
3571
+ if (wait === "sent") for (const [i, result] of out.entries()) for (const messageId of result.messageIds) {
3572
+ if (to[i].group) {
3573
+ await waitForGroupFrame(ctx, to[i].id, messageId, void 0, ms);
3574
+ continue;
3575
+ }
3576
+ const sent = await node(ctx).getMessage(to[i].id, messageId);
3577
+ if (sent?.file) await FILE_METHODS["file.wait"](ctx, {
3578
+ chat: to[i].id,
3579
+ file: sent.file.id,
3580
+ timeout: ms / 1e3
3581
+ });
3582
+ else await waitForMessage(ctx, to[i].id, messageId, "sent", ms);
3583
+ }
3584
+ const failed = out.filter((r) => r.error);
3585
+ if (failed.length) throw new CliError(failed.length === out.length ? "refused" : "engine", failed.map((r) => `${r.to}: ${r.error}`).join("; "), {
3586
+ from: from.id,
3587
+ results: out
3588
+ });
3589
+ return {
3590
+ from: from.id,
3591
+ results: out
3592
+ };
3593
+ },
3594
+ async "chat.retry"(ctx, params) {
3595
+ const link = chatOf(ctx, params);
3596
+ const messageId = str(params, "message", true);
3597
+ if (!await node(ctx).getMessage(link.id, messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
3598
+ await node(ctx).retryMessage({
3599
+ linkId: link.id,
3600
+ messageId
3601
+ });
3602
+ return {
3603
+ chat: link.id,
3604
+ messageId
3605
+ };
3606
+ },
3607
+ async "chat.delete"(ctx, params) {
3608
+ const link = chatOf(ctx, params);
3609
+ const messageId = str(params, "message", true);
3610
+ if (!await node(ctx).getMessage(link.id, messageId)) throw new CliError("not_found", `No message ${messageId} in this chat`);
3611
+ await node(ctx).deleteMessage({
3612
+ linkId: link.id,
3613
+ messageId
3614
+ });
3615
+ return {
3616
+ chat: link.id,
3617
+ deleted: messageId
3618
+ };
3619
+ },
3620
+ async "chat.details"(ctx, params) {
3621
+ const link = chatOf(ctx, params);
3622
+ const details = await node(ctx).messageDetails({
3623
+ linkId: link.id,
3624
+ messageId: str(params, "message", true)
3625
+ });
3626
+ if (!details) throw new CliError("not_found", `No message ${String(params.message)} in this chat`);
3627
+ return details;
3628
+ },
3629
+ async "chat.rename"(ctx, params) {
3630
+ const link = chatOf(ctx, params);
3631
+ await node(ctx).renameLink({
3632
+ linkId: link.id,
3633
+ label: str(params, "name") ?? ""
3634
+ });
3635
+ return chatJson(findChat(state(ctx).links, link.id));
3636
+ },
3637
+ async "chat.remove"(ctx, params) {
3638
+ const link = chatOf(ctx, params);
3639
+ if (!bool(params, "yes")) throw new CliError("confirm", "Removing a chat deletes its keys and history on this device; pass yes (--yes) to go on");
3640
+ await node(ctx).removeLink({ linkId: link.id });
3641
+ return { removed: link.id };
3642
+ },
3643
+ async "chat.transport"(ctx, params) {
3644
+ const link = chatOf(ctx, params);
3645
+ const name = str(params, "transport", true);
3646
+ const transport = Object.hasOwn(TRANSPORT_NAMES, name) ? TRANSPORT_NAMES[name] : void 0;
3647
+ if (!transport) throw new CliError("bad_request", `transport must be one of auto, dht, webrtc, iroh, hyperdht`);
3648
+ await node(ctx).setChatTransport({
3649
+ linkId: link.id,
3650
+ transport
3651
+ });
3652
+ return chatDetailsJson(findChat(state(ctx).links, link.id));
3653
+ },
3654
+ /** Reconnects now; a chat held off its direct link (`chat disconnect --hold`) leaves the hold first. */
3655
+ async "chat.connect"(ctx, params) {
3656
+ const link = chatOf(ctx, params);
3657
+ await releaseHold(ctx, link.id, "connect");
3658
+ inviteTaken(link);
3659
+ await node(ctx).connect({ linkId: link.id });
3660
+ return chatJson(findChat(state(ctx).links, link.id));
3661
+ },
3662
+ /**
3663
+ * Closes the live session; the contact's app may dial again at once. `hold` (minutes): the chat stays off its
3664
+ * direct link that long, on the DHT (its "DHT only" choice, taken back after); 0 ends a hold now.
3665
+ */
3666
+ async "chat.disconnect"(ctx, params) {
3667
+ const link = chatOf(ctx, params);
3668
+ if (params.hold === void 0) {
3669
+ node(ctx).disconnect({ linkId: link.id });
3670
+ return chatJson(findChat(state(ctx).links, link.id));
3671
+ }
3672
+ const minutes = num(params, "hold", 0, {
3673
+ min: 0,
3674
+ max: HOLD_MAX_MINUTES
3675
+ });
3676
+ if (minutes === 0) await releaseHold(ctx, link.id, "lifted");
3677
+ else await holdChat(ctx, link, minutes);
3678
+ return {
3679
+ ...chatJson(findChat(state(ctx).links, link.id)),
3680
+ heldUntil: holdOf(ctx, link.id)
3681
+ };
3682
+ },
3683
+ async "chat.verify"(ctx, params) {
3684
+ const link = chatOf(ctx, params);
3685
+ const code = str(params, "code", true).replace(/\s+/g, "").toLowerCase();
3686
+ const mine = link.pairing?.code?.replace(/\s+/g, "").toLowerCase();
3687
+ if (!mine) throw new CliError("unavailable", "This chat shows no comparison code yet (it needs a live session)");
3688
+ if (code !== mine) throw new CliError("refused", "The code does not match the one this side shows: do not trust this chat until you know why");
3689
+ await node(ctx).confirmPair({
3690
+ linkId: link.id,
3691
+ code: link.pairing.code
3692
+ });
3693
+ return chatJson(findChat(state(ctx).links, link.id));
3694
+ },
3695
+ async "chat.wait"(ctx, params) {
3696
+ const link = chatOf(ctx, params);
3697
+ const until = oneOf(params, "until", [
3698
+ "live",
3699
+ "text",
3700
+ "paired"
3701
+ ], "live");
3702
+ const ms = num(params, "timeout", 60, {
3703
+ min: 1,
3704
+ max: 86400
3705
+ }) * 1e3;
3706
+ return chatJson(await waitForState(ctx, (s) => {
3707
+ const now = s.links.find((l) => l.id === link.id);
3708
+ if (!now) throw new CliError("not_found", `Chat ${link.id} was removed`);
3709
+ return (until === "live" ? isLive(now) : until === "text" ? now.textDelivery === "stream" || now.textDelivery === "dht" || now.textDelivery === "hold" : !!now.pairingProgress?.peerSeen || now.pairing?.status === "ready" || isLive(now)) ? now : void 0;
3710
+ }, ms, `chat ${link.id} to be ${until}`));
3711
+ },
3712
+ async "group.create"(ctx, params) {
3713
+ const name = groupName(str(params, "name", true));
3714
+ if (!name) throw new CliError("bad_request", `A group's name is 1 to 64 characters on one line`);
3715
+ const profile = oneOf(params, "profile", ["community", "mesh"], "community");
3716
+ const { groupId } = await node(ctx).createGroup({
3717
+ name,
3718
+ profile
3719
+ });
3720
+ let link = null;
3721
+ if (profile === "community") ({link} = await node(ctx).enableGroupLink({ groupId }));
3722
+ return {
3723
+ group: groupId,
3724
+ link
3725
+ };
3726
+ },
3727
+ async "group.join"(ctx, params) {
3728
+ let groupId;
3729
+ try {
3730
+ ({groupId} = await node(ctx).joinGroupByLink({ link: str(params, "link", true) }));
3731
+ } catch (error) {
3732
+ if (error instanceof Error && error.message === "This is not a link to a group") throw new CliError("bad_request", error.message);
3733
+ throw error;
3734
+ }
3735
+ return { group: groupId };
3736
+ },
3737
+ async "group.list"(ctx, params) {
3738
+ const showSecret = bool(params, "showSecret");
3739
+ return { groups: state(ctx).groups.map((g) => groupJson(g, showSecret)).sort((a, b) => b.lastMessageAt - a.lastMessageAt) };
3740
+ },
3741
+ async "group.get"(ctx, params) {
3742
+ return groupJson(groupOf(ctx, params), bool(params, "showSecret"));
3743
+ },
3744
+ async "group.history"(ctx, params) {
3745
+ const group = groupOf(ctx, params);
3746
+ return historyOf(ctx, `group:${group.id}`, params, (m) => groupMessageJson(m, group));
3747
+ },
3748
+ async "group.send"(ctx, params) {
3749
+ const group = groupOf(ctx, params);
3750
+ const text = str(params, "text", true);
3751
+ checkTextSize(text);
3752
+ if (!bool(params, "force")) {
3753
+ const secret = findSecret(text);
3754
+ if (secret) throw new CliError("confirm", "The text looks like a secret or a Cashu token; send it with --force if you mean to", { kind: secret.kind });
3755
+ }
3756
+ const mentions = mentionsFor(text, list(params, "mentions"), group);
3757
+ const replyTo = str(params, "reply");
3758
+ const wait = oneOf(params, "wait", ["none", "sent"], "none");
3759
+ const card = buttonsCard(params);
3760
+ endTyping(ctx, { groupId: group.id }, false);
3761
+ const result = await node(ctx).sendGroupMessage({
3762
+ groupId: group.id,
3763
+ text,
3764
+ ...mentions.length ? { mentions } : {},
3765
+ ...replyTo ? { replyTo } : {},
3766
+ ...card ? { card } : {}
3767
+ });
3768
+ if (result.error) throw result.refused ? new CliError("refused", result.error) : groupSendRefused(result.error);
3769
+ const messageId = result.messageId ?? null;
3770
+ const edges = messageId ? wait === "sent" ? await waitForGroupFrame(ctx, group.id, messageId, void 0, num(params, "timeout", 30, {
3771
+ min: 1,
3772
+ max: 3600
3773
+ }) * 1e3) : node(ctx).groupTaken({
3774
+ groupId: group.id,
3775
+ messageId
3776
+ }) : 0;
3777
+ return {
3778
+ group: group.id,
3779
+ messageId,
3780
+ sent: edges > 0,
3781
+ edges,
3782
+ ...card ? {
3783
+ buttons: card.id,
3784
+ card
3785
+ } : {}
3786
+ };
3787
+ },
3788
+ /**
3789
+ * WISP 902 § Edits: the whole new text of one of my messages in a group. It shows here at once and goes to the members
3790
+ * (a private group's over the edges that are up, the others when theirs open; a community's through the group).
3791
+ * `mentions`: members the new text names beyond those the message named. `sent`: no longer waiting for the pace.
3792
+ */
3793
+ async "group.edit"(ctx, params) {
3794
+ const group = groupOf(ctx, params);
3795
+ const messageId = str(params, "message", true);
3796
+ const text = str(params, "text", true);
3797
+ if (!bool(params, "force")) {
3798
+ const secret = findSecret(text);
3799
+ if (secret) throw new CliError("confirm", `The text looks like ${secret.kind === "cashu" ? "a Cashu token (money anyone who reads it can take)" : "a secret (a seed or a private key)"}; edit with --force if you mean to`, { kind: secret.kind });
3800
+ }
3801
+ const mentions = mentionsFor(text, list(params, "mentions"), group);
3802
+ const wait = oneOf(params, "wait", ["none", "sent"], "none");
3803
+ const linkId = `group:${group.id}`;
3804
+ const before = (await node(ctx).getMessage(linkId, messageId))?.edit?.seq ?? 0;
3805
+ const result = await node(ctx).editMessage({
3806
+ linkId,
3807
+ messageId,
3808
+ text,
3809
+ ...mentions.length ? { mentions } : {}
3810
+ });
3811
+ if (result.error) throw editRefused(await messageOf(ctx, linkId, messageId), messageId, "group", result.error, result.refused);
3812
+ const message = await node(ctx).getMessage(linkId, messageId);
3813
+ const edits = message?.edit?.seq ?? 0;
3814
+ if (edits === before) return {
3815
+ group: group.id,
3816
+ messageId,
3817
+ edits,
3818
+ sent: false,
3819
+ edges: 0,
3820
+ unchanged: true
3821
+ };
3822
+ const edges = !edits ? 0 : wait === "sent" ? await waitForGroupFrame(ctx, group.id, messageId, edits, num(params, "timeout", 30, {
3823
+ min: 1,
3824
+ max: 3600
3825
+ }) * 1e3) : node(ctx).groupTaken({
3826
+ groupId: group.id,
3827
+ messageId,
3828
+ edit: edits
3829
+ });
3830
+ return {
3831
+ group: group.id,
3832
+ messageId,
3833
+ edits,
3834
+ sent: edges > 0 || !!message && !message.edit?.pending,
3835
+ edges
3836
+ };
3837
+ },
3838
+ async "group.react"(ctx, params) {
3839
+ const group = groupOf(ctx, params);
3840
+ return {
3841
+ group: group.id,
3842
+ ...await react(ctx, `group:${group.id}`, params)
3843
+ };
3844
+ },
3845
+ /**
3846
+ * WISP 902 · Group Mesh § Typing: `chat.typing` in a private group, said on the edges that are open (`reached`: to
3847
+ * how many members), with the same kinds, status rules, 6 s hold and `for`. A community does not carry typing yet.
3848
+ */
3849
+ async "group.typing"(ctx, params) {
3850
+ const group = groupOf(ctx, params);
3851
+ if (group.profile !== "mesh") throw new CliError("unavailable", "A community does not carry typing yet: only a private group does");
3852
+ const typing = !bool(params, "stop");
3853
+ const { kind, status } = typingWord(params, typing);
3854
+ const word = {
3855
+ ...kind !== "typing" ? { kind } : {},
3856
+ ...status ? { status } : {}
3857
+ };
3858
+ const seconds = params.for === void 0 ? 0 : num(params, "for", 0, {
3859
+ min: 1,
3860
+ max: 600
3861
+ });
3862
+ if (!typing && seconds) throw new CliError("bad_request", "for keeps typing on: not with stop");
3863
+ if (typing && group.status !== "active") throw new CliError("bad_request", "You are not in this group");
3864
+ const target = { groupId: group.id };
3865
+ endTyping(ctx, target, false);
3866
+ sayTyping(ctx, target, typing, word);
3867
+ const until = seconds ? Date.now() + seconds * 1e3 : null;
3868
+ const kept = seconds ? keepTyping(ctx, target, seconds * 1e3, word) : null;
3869
+ if (kept && ctx.mode === "one-shot") await kept;
3870
+ return {
3871
+ group: group.id,
3872
+ typing,
3873
+ ...typing ? { kind } : {},
3874
+ ...status ? { status } : {},
3875
+ reached: group.members.filter((m) => !m.me && m.online && !m.viaHub).length,
3876
+ sendTyping: state(ctx).settings.sendTyping !== false,
3877
+ ...until ? { until } : {}
3878
+ };
3879
+ },
3880
+ async "group.leave"(ctx, params) {
3881
+ const group = groupOf(ctx, params);
3882
+ await node(ctx).leaveGroup({ groupId: group.id });
3883
+ return { left: group.id };
3884
+ },
3885
+ async "group.forget"(ctx, params) {
3886
+ const group = groupOf(ctx, params);
3887
+ if (!bool(params, "yes")) throw new CliError("confirm", "Forgetting a group deletes its history on this device; pass yes (--yes) to go on");
3888
+ await node(ctx).forgetGroup({ groupId: group.id });
3889
+ return { forgotten: group.id };
3890
+ },
3891
+ async "group.accept"(ctx, params) {
3892
+ const group = groupOf(ctx, params);
3893
+ await node(ctx).acceptGroupInvitation({ groupId: group.id });
3894
+ return { group: group.id };
3895
+ },
3896
+ async "group.decline"(ctx, params) {
3897
+ const group = groupOf(ctx, params);
3898
+ await node(ctx).declineGroupInvitation({ groupId: group.id });
3899
+ return { group: group.id };
3900
+ },
3901
+ async "events.replay"(ctx, params) {
3902
+ return {
3903
+ events: ctx.hub.replay(num(params, "since", 0)),
3904
+ lastSeq: ctx.hub.lastSeq
3905
+ };
3906
+ },
3907
+ async "daemon.stop"(ctx) {
3908
+ if (ctx.mode !== "daemon" || !ctx.stop) throw new CliError("unavailable", "No daemon runs this profile");
3909
+ setTimeout(() => ctx.stop(), 10);
3910
+ return {
3911
+ stopping: true,
3912
+ pid: process.pid
3913
+ };
3914
+ },
3915
+ async "engine.call"(ctx, params) {
3916
+ const method = str(params, "method", true);
3917
+ if (!ENGINE_METHODS.includes(method) && !ENGINE_READS.includes(method)) throw new CliError("not_found", `The engine has no call ${method}`);
3918
+ const raw = params.params;
3919
+ if (raw !== void 0 && (raw === null || typeof raw !== "object" || Array.isArray(raw))) throw new CliError("bad_request", "params must be an object");
3920
+ let args = raw;
3921
+ if (args && "confirmedReal" in args && !bool(params, "confirmReal")) throw new CliError("confirm", `${method} spends real money: pass --confirm-real (confirmReal: true over the socket) to confirm it`);
3922
+ if (method === "getMessages") return node(ctx).getMessages(str(args ?? {}, "linkId", true));
3923
+ if (method === "getState") return state(ctx);
3924
+ if (method === "setActiveLink") inviteShown.get(ctx)?.();
3925
+ const target = node(ctx)[method];
3926
+ if (typeof target !== "function") throw new CliError("not_found", `The engine has no call ${method}`);
3927
+ if (args === void 0) args = void 0;
3928
+ return await target.call(node(ctx), args) ?? null;
3929
+ }
3930
+ };
3931
+ /** Reacts to a message of a chat or a group (`group:<id>`): `emoji`, or `remove` for "" (WISP 400 § Reactions). */
3932
+ async function react(ctx, linkId, params) {
3933
+ const messageId = str(params, "message", true);
3934
+ const removed = bool(params, "remove");
3935
+ const emoji = removed ? "" : str(params, "emoji");
3936
+ if (!removed && !emoji) throw new CliError("usage", "Give one emoji, or --remove to take yours back");
3937
+ const result = await node(ctx).react({
3938
+ linkId,
3939
+ messageId,
3940
+ emoji: emoji ?? ""
3941
+ });
3942
+ if (result.error) throw new CliError(result.refused ? "refused" : /not in this chat|No reaction of yours/.test(result.error) ? "not_found" : "bad_request", result.error);
3943
+ return {
3944
+ messageId,
3945
+ emoji: emoji || null,
3946
+ removed
3947
+ };
3948
+ }
3949
+ /**
3950
+ * What `typing` says: a kind, and a status a bot writes. The contact's app would cut a long status and drop one with
3951
+ * a link; here that is an error instead, so the bot learns what the contact will (not) see.
3952
+ */
3953
+ function typingWord(params, typing) {
3954
+ const kind = oneOf(params, "kind", TYPING_KINDS, "typing");
3955
+ const raw = str(params, "status");
3956
+ if (!typing && (params.kind !== void 0 || raw !== void 0)) throw new CliError("bad_request", "kind and status go with a start, not with stop");
3957
+ if (raw === void 0) return { kind };
3958
+ const oneLine = raw.replace(/\s+/g, " ").trim();
3959
+ if ([...oneLine].length > 40) throw new CliError("bad_request", `status: at most 40 characters`);
3960
+ const status = sanitizeTypingStatus(oneLine);
3961
+ if (!status) throw new CliError("bad_request", "status: plain text, with no link or markup");
3962
+ return {
3963
+ kind,
3964
+ status
3965
+ };
3966
+ }
3967
+ /**
3968
+ * A group message the engine did not send, by what a bot can do about it: a `--reply` to a message this group does not
3969
+ * have is `not_found` (exit 3), as `group react`, `group edit` and `pin` answer for one; out of the group (removed,
3970
+ * left, a forked history) is `refused`: it will not go later either. Anything else may go on a later try
3971
+ * (`unavailable`): the epoch's key is still on its way. All three were `unavailable` (exit 1) before.
3972
+ */
3973
+ function groupSendRefused(error) {
3974
+ if (/not in this chat|cannot be replied to/.test(error)) return new CliError("not_found", error);
3975
+ if (/^You (were removed from|left|are no longer in) this group$|Membership changes are halted/.test(error)) return new CliError("refused", error);
3976
+ return new CliError("unavailable", error);
3977
+ }
3978
+ /**
3979
+ * A chat command given a group's id, prefix or name: `not_found` that says it is a group, with the group command that
3980
+ * takes it (`react` on a group, where `group react` is meant). A name no chat and no group has stays the chat's error.
3981
+ */
3982
+ function notAGroup(ctx, params, chat, command, rest) {
3983
+ try {
3984
+ return chat();
3985
+ } catch (error) {
3986
+ if (!(error instanceof CliError) || error.code !== "not_found") throw error;
3987
+ const ref = str(params, "chat", true);
3988
+ let group;
3989
+ try {
3990
+ group = findGroup(state(ctx).groups, ref);
3991
+ } catch {
3992
+ throw error;
3993
+ }
3994
+ const shown = /^[\w.:-]+$/.test(ref) ? ref : JSON.stringify(ref);
3995
+ throw new CliError("not_found", `${JSON.stringify(ref)} is a group, not a chat: ghostly ${command} ${shown} ${rest}`, { group: group.id });
3996
+ }
3997
+ }
3998
+ /**
3999
+ * An edit the engine refused. A message that is not in the chat or group at all is `not_found` (exit 3), naming the id
4000
+ * to give, as `button update` and `task update` do; before, a mistyped or lost id read "Only your own text messages
4001
+ * can be edited" (`refused`, exit 1), as if the message were someone else's.
4002
+ */
4003
+ function editRefused(message, ref, where, error, refused) {
4004
+ if (refused && !message) return new CliError("not_found", `No message ${JSON.stringify(ref)} in this ${where}: give the messageId ${where === "group" ? "group send" : "send"} printed`);
4005
+ return new CliError(refused ? "refused" : "unavailable", error);
4006
+ }
4007
+ /**
4008
+ * A page of a chat's or a group's history. Without `after` only that page is read from the store (the newest, or those
4009
+ * before `before`), so a long chat's is as quick as a short one's; with `after` the whole history is read and cut.
4010
+ */
4011
+ async function historyOf(ctx, chat, params, json = messageJson) {
4012
+ if (params.after !== void 0 && params.after !== null) return history(await node(ctx).getMessages(chat), params, json);
4013
+ const limit = num(params, "limit", 50, {
4014
+ min: 1,
4015
+ max: 1e4,
4016
+ whole: true
4017
+ });
4018
+ const before = params.before ?? void 0;
4019
+ if (before !== void 0 && typeof before !== "number" && typeof before !== "string") throw new CliError("bad_request", "before must be a timestamp or a message id");
4020
+ const page = await node(ctx).messagePage({
4021
+ linkId: chat,
4022
+ limit,
4023
+ before
4024
+ }).catch((error) => {
4025
+ throw error instanceof Error && error.message === `No message ${before}` ? new CliError("not_found", error.message) : error;
4026
+ });
4027
+ return {
4028
+ messages: page.messages.map((m) => json(m)),
4029
+ more: page.more
4030
+ };
4031
+ }
4032
+ function history(messages, params, json = messageJson) {
4033
+ const limit = num(params, "limit", 50, {
4034
+ min: 1,
4035
+ max: 1e4,
4036
+ whole: true
4037
+ });
4038
+ const before = params.before;
4039
+ const after = params.after;
4040
+ let list = messages.slice().sort((a, b) => a.timestamp - b.timestamp);
4041
+ const cut = (value, side) => {
4042
+ if (value === void 0 || value === null) return;
4043
+ if (typeof value === "number") {
4044
+ list = list.filter((m) => side === "before" ? m.timestamp < value : m.timestamp > value);
4045
+ return;
4046
+ }
4047
+ if (typeof value !== "string") throw new CliError("bad_request", `${side} must be a timestamp or a message id`);
4048
+ const index = list.findIndex((m) => m.id === value);
4049
+ if (index === -1) throw new CliError("not_found", `No message ${value}`);
4050
+ list = side === "before" ? list.slice(0, index) : list.slice(index + 1);
4051
+ };
4052
+ cut(before, "before");
4053
+ cut(after, "after");
4054
+ const more = after !== void 0 && after !== null ? list.length > limit : list.length > limit;
4055
+ return {
4056
+ messages: (after !== void 0 && after !== null ? list.slice(0, limit) : list.slice(-limit)).map((m) => json(m)),
4057
+ more
4058
+ };
4059
+ }
4060
+ var API_METHODS = Object.keys(METHODS);
4061
+ /** Runs one method of the API: one the table lists itself (`constructor`, which every object has, is none). */
4062
+ async function callApi(ctx, method, params = {}) {
4063
+ const run = Object.hasOwn(METHODS, method) ? METHODS[method] : void 0;
4064
+ if (!run) throw new CliError("not_found", `No method ${method}`);
4065
+ if (params === null || typeof params !== "object" || Array.isArray(params)) throw new CliError("bad_request", "params must be an object");
4066
+ return run(ctx, await withStoredIds(ctx, params));
4067
+ }
4068
+ //#endregion
4069
+ export { flushCardUpdates as _, hideStateSecrets as a, paymentJson as c, nodeLocalFetch as d, chatJson as f, resumeHolds as g, groupMessageJson as h, groupSendRefused as i, reportAppFrames as l, groupJson as m, SETTABLE as n, mentionsFor as o, chatMessageJson as p, callApi as r, redactSettings as s, API_METHODS as t, servedApps as u };
4070
+
4071
+ //# sourceMappingURL=api-CrxVxtY1.js.map