@ghostlytools/cli 1.2.4 → 1.3.1

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