@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.
- package/README.md +25 -9
- package/dist/assets/api-Dq2QXQDk.js +4172 -0
- package/dist/assets/api-Dq2QXQDk.js.map +1 -0
- package/dist/assets/api-TiB5QSG9.js +3 -0
- package/dist/assets/apiKit-CA2x5GJF.js +200 -0
- package/dist/assets/apiKit-CA2x5GJF.js.map +1 -0
- package/dist/assets/apps-BxV9i_Y4.js +786 -0
- package/dist/assets/apps-BxV9i_Y4.js.map +1 -0
- package/dist/assets/apps-C-T8jeji.js +2 -0
- package/dist/assets/{arkade-CdnKGDbg.js → arkade-C_qDmHnm.js} +2 -2
- package/dist/assets/{arkade-CdnKGDbg.js.map → arkade-C_qDmHnm.js.map} +1 -1
- package/dist/assets/backup-Bugnn3Ue.js +2 -0
- package/dist/assets/backup-CnMN2oUK.js +731 -0
- package/dist/assets/backup-CnMN2oUK.js.map +1 -0
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoind-CZchoh43.js +349 -0
- package/dist/assets/bitcoind-CZchoh43.js.map +1 -0
- package/dist/assets/bitcoindProvider-DGw3xBSa.js +97 -0
- package/dist/assets/bitcoindProvider-DGw3xBSa.js.map +1 -0
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/buttons-Bj7Vf7Pn.js +2 -0
- package/dist/assets/buttons-CvNBzh2S.js +496 -0
- package/dist/assets/buttons-CvNBzh2S.js.map +1 -0
- package/dist/assets/{endpoint-pgFkR2AS.js → endpoint-BOTFMpfS.js} +56 -18
- package/dist/assets/endpoint-BOTFMpfS.js.map +1 -0
- package/dist/assets/envelope-WjingYSc.js +90 -0
- package/dist/assets/envelope-WjingYSc.js.map +1 -0
- package/dist/assets/errors-DO5z6h_l.js +105 -0
- package/dist/assets/errors-DO5z6h_l.js.map +1 -0
- package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
- package/dist/assets/{fileBytes-Dbifu5I5.js → fileBytes-CfSXjTFa.js} +2 -2
- package/dist/assets/{fileBytes-Dbifu5I5.js.map → fileBytes-CfSXjTFa.js.map} +1 -1
- package/dist/assets/fileBytes-CgbibpbV.js +2 -0
- package/dist/assets/{fileBytes-BcYZhNgF.js → fileBytes-Di7VZtmU.js} +6 -19
- package/dist/assets/fileBytes-Di7VZtmU.js.map +1 -0
- package/dist/assets/fileBytesIdb-CFP-DSkZ.js +2 -0
- package/dist/assets/{fileBytesIdb-DzSaqI0A.js → fileBytesIdb-D5u6H8X_.js} +4 -4
- package/dist/assets/{fileBytesIdb-DzSaqI0A.js.map → fileBytesIdb-D5u6H8X_.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js → fileBytesOpfs-Dd1N4ERH.js} +3 -3
- package/dist/assets/{fileBytesOpfs-Dx8RZiV5.js.map → fileBytesOpfs-Dd1N4ERH.js.map} +1 -1
- package/dist/assets/groupFiles-IEUW3DQz.js +3202 -0
- package/dist/assets/groupFiles-IEUW3DQz.js.map +1 -0
- package/dist/assets/host-CWt-jtqz.js +4673 -0
- package/dist/assets/host-CWt-jtqz.js.map +1 -0
- package/dist/assets/hyperdhtRelay-BT_wRxab.js +21 -0
- package/dist/assets/hyperdhtRelay-BT_wRxab.js.map +1 -0
- package/dist/assets/{hyperdhtRelay-C1p0nkle.js → hyperdhtRelay-CPYwSLLS.js} +25 -10
- package/dist/assets/hyperdhtRelay-CPYwSLLS.js.map +1 -0
- package/dist/assets/{idb-CTF6LF5B.js → idb-B5L8wuOm.js} +7 -5
- package/dist/assets/idb-B5L8wuOm.js.map +1 -0
- package/dist/assets/irohWeb-4oChIOIp.js +544 -0
- package/dist/assets/irohWeb-4oChIOIp.js.map +1 -0
- package/dist/assets/{joinTrace-CO8mlhNO.js → joinTrace-CjRDFy85.js} +2 -7
- package/dist/assets/{joinTrace-CO8mlhNO.js.map → joinTrace-CjRDFy85.js.map} +1 -1
- package/dist/assets/joinTrace-D-fnt2p3.js +2 -0
- package/dist/assets/main-DbqcdShN.js +4409 -0
- package/dist/assets/main-DbqcdShN.js.map +1 -0
- package/dist/assets/{mainline-BH6s8HVi.js → mainline-ZkiOnevs.js} +11 -6
- package/dist/assets/mainline-ZkiOnevs.js.map +1 -0
- package/dist/assets/mints-BrlY773j.js +74 -0
- package/dist/assets/mints-BrlY773j.js.map +1 -0
- package/dist/assets/mints-CqTdA4Gk.js +2 -0
- package/dist/assets/networkReason-CetKkTPo.js +154 -0
- package/dist/assets/networkReason-CetKkTPo.js.map +1 -0
- package/dist/assets/nwc-CgkqyvsE.js +612 -0
- package/dist/assets/nwc-CgkqyvsE.js.map +1 -0
- package/dist/assets/nwcProvider-4eVz_8HP.js +77 -0
- package/dist/assets/nwcProvider-4eVz_8HP.js.map +1 -0
- package/dist/assets/privateFolder-Bk9C9oFv.js +38 -0
- package/dist/assets/privateFolder-Bk9C9oFv.js.map +1 -0
- package/dist/assets/profiles-BHc9pEYg.js +139 -0
- package/dist/assets/profiles-BHc9pEYg.js.map +1 -0
- package/dist/assets/registry-DlAKO0Fy.js +3793 -0
- package/dist/assets/registry-DlAKO0Fy.js.map +1 -0
- package/dist/assets/registry-OBJBnHVQ.js +2 -0
- package/dist/assets/resolve-CouHhCpJ.js +591 -0
- package/dist/assets/resolve-CouHhCpJ.js.map +1 -0
- package/dist/assets/{restoredRows-C_84q0Pd.js → restoredRows-Db3oUTko.js} +2 -2
- package/dist/assets/{restoredRows-C_84q0Pd.js.map → restoredRows-Db3oUTko.js.map} +1 -1
- package/dist/assets/{server-0zfNpABQ.js → server-Ss0gJ-nw.js} +1275 -1404
- package/dist/assets/server-Ss0gJ-nw.js.map +1 -0
- package/dist/assets/{src-CbLkHfz8.js → src-DDzSERyc.js} +882 -134
- package/dist/assets/src-DDzSERyc.js.map +1 -0
- package/dist/assets/turnRefs-B87dtrHM.js +323 -0
- package/dist/assets/turnRefs-B87dtrHM.js.map +1 -0
- package/dist/assets/types-C4_KafL_.js +122 -0
- package/dist/assets/types-C4_KafL_.js.map +1 -0
- package/dist/assets/{usdt-Qf4UuygS.js → usdt-Fj8CQO2z.js} +3 -3
- package/dist/assets/{usdt-Qf4UuygS.js.map → usdt-Fj8CQO2z.js.map} +1 -1
- package/dist/ghostly.mjs +4 -22077
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/assets/endpoint-pgFkR2AS.js.map +0 -1
- package/dist/assets/fileBytes-BcYZhNgF.js.map +0 -1
- package/dist/assets/hyperdhtRelay-C1p0nkle.js.map +0 -1
- package/dist/assets/idb-CTF6LF5B.js.map +0 -1
- package/dist/assets/mainline-BH6s8HVi.js.map +0 -1
- package/dist/assets/rolldown-runtime-8H4AJuhK.js +0 -14
- package/dist/assets/server-0zfNpABQ.js.map +0 -1
- 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
|