@ghostlytools/cli 1.1.7 → 1.2.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 +27 -19
- package/SKILL.md +15 -7
- package/dist/assets/arkade-CtCpFaF7.js +362 -0
- package/dist/assets/arkade-CtCpFaF7.js.map +1 -0
- package/dist/assets/{endpoint-D_3bYafG.js → endpoint-pgFkR2AS.js} +47 -10
- package/dist/assets/endpoint-pgFkR2AS.js.map +1 -0
- package/dist/assets/{fileBytes-3AQ2tJzW.js → fileBytes-BcYZhNgF.js} +3 -3
- package/dist/assets/{fileBytes-3AQ2tJzW.js.map → fileBytes-BcYZhNgF.js.map} +1 -1
- package/dist/assets/{fileBytes-CAZDydIe.js → fileBytes-Dbifu5I5.js} +2 -2
- package/dist/assets/{fileBytes-CAZDydIe.js.map → fileBytes-Dbifu5I5.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-CmG00qE-.js → fileBytesIdb-DzSaqI0A.js} +3 -3
- package/dist/assets/{fileBytesIdb-CmG00qE-.js.map → fileBytesIdb-DzSaqI0A.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-CTgqt-p3.js → fileBytesOpfs-Dx8RZiV5.js} +3 -3
- package/dist/assets/{fileBytesOpfs-CTgqt-p3.js.map → fileBytesOpfs-Dx8RZiV5.js.map} +1 -1
- package/dist/assets/{hyperdhtRelay-DvXgebV7.js → hyperdhtRelay-C0XRWySP.js} +3 -3
- package/dist/assets/{hyperdhtRelay-DvXgebV7.js.map → hyperdhtRelay-C0XRWySP.js.map} +1 -1
- package/dist/assets/{idb-BIdus0WX.js → idb-CTF6LF5B.js} +62 -6
- package/dist/assets/idb-CTF6LF5B.js.map +1 -0
- package/dist/assets/{mainline-B7wHcLaW.js → mainline-DVYdrus6.js} +2 -2
- package/dist/assets/{mainline-B7wHcLaW.js.map → mainline-DVYdrus6.js.map} +1 -1
- package/dist/assets/{restoredRows-lhTKGmCO.js → restoredRows-C_84q0Pd.js} +2 -2
- package/dist/assets/{restoredRows-lhTKGmCO.js.map → restoredRows-C_84q0Pd.js.map} +1 -1
- package/dist/assets/{server-GJW_5wGI.js → server-D5FBZYW8.js} +1514 -1088
- package/dist/assets/server-D5FBZYW8.js.map +1 -0
- package/dist/assets/{src-Cp3o_yHN.js → src-aKGjb_yz.js} +855 -262
- package/dist/assets/src-aKGjb_yz.js.map +1 -0
- package/dist/assets/usdt-CUfucwZf.js +410 -0
- package/dist/assets/usdt-CUfucwZf.js.map +1 -0
- package/dist/ghostly.mjs +1326 -262
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +4 -4
- package/dist/assets/endpoint-D_3bYafG.js.map +0 -1
- package/dist/assets/idb-BIdus0WX.js.map +0 -1
- package/dist/assets/server-GJW_5wGI.js.map +0 -1
- package/dist/assets/src-Cp3o_yHN.js.map +0 -1
package/dist/ghostly.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
|
|
3
|
-
import { $ as
|
|
4
|
-
import { c as fileStore,
|
|
5
|
-
import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-
|
|
3
|
+
import { $ as SSHSIG_NAMESPACE, $a as appRef, $c as randomBytes$1, $n as engineError, An as encodeDnsQuery, Ao as RTC_CONFIG, At as didJwsAlg, Ba as cardLine, Bn as resolveLightningDestination, Bt as parseDidSignaturePaste, Cn as homeserverWebEndpoint, Ct as DID_DOCUMENT_MAX_BYTES$1, Dn as dnsProofName, Dt as checkDidJws, En as decodeDnsResponse, Eo as LIMITS, Et as DidError, Fa as PR_CHECKS, Fn as recordsFromWellKnown, Fo as extractParamsFromSdp, Ft as didWebFileUrl, Ha as checkStatusCard, Hc as openRelayPayload, Ht as staticDidDocument, Ia as PR_STATES, Ii as identityStatement, In as recordsName, Io as parseCallSignal, It as didWebHost, Jt as resolveDidDht, Ki as irohDescriptor, Kr as sanitizeTypingStatus, La as RUN_RESULTS, Ln as txtValue, Lo as signalHasVideo, Lt as normalizeDid, Ma as replySnippet, Mn as nip05RootKey, Mo as buildSdpFromSignal, Mt as didWebDocumentUrl, Nc as setLinkTraceSink, Ni as boundVerifiedIdentity, Nn as normalizeDomain, No as callRtcConfig, Nt as didWebFile, On as domainTxtRecord, Ot as checkDidSignature, Pa as ITEM_STATES, Pn as recordsFromTxt, Po as callSignalHeardAt, Pt as didWebFileNames, Qa as appFingerprint, Qc as fromBase64Url$1, Ra as STATUS_CARD_LIMITS, Rn as wellKnownUrl, Rt as parseDid, Sn as pubkyProofPath, So as GhostlyHttpError, St as verifyAtprotoRecordProof, Tn as DOMAIN_FILE_MAX_BYTES, Ur as TYPING_KINDS, Ut as strictBase64Url, Va as cardUrl, Vn as decodeBolt11, Vr as transferEnded, Vt as signingKeys, Wr as TYPING_REFRESH_MS, Xa as APP_PREFIXES, Xc as sanitizeAvatar, Yc as MAX_AVATAR_LENGTH, Z as groupName, Za as APP_STATEMENT_LIMITS, _ as readAppBundle, _n as isPubkyProofFolder, _t as isAtprotoDid, aa as PLAYABLE_AUDIO, ao as signAppObject, ar as fetchesByItself, at as verifySshSignature, bn as pubkyProofCapability, bt as shortAtprotoDid, c as signAppStore, ca as fileMessageText, ci as checkPushEndpoint, co as canonicalJsonBytes, ct as decodeAnyBitcoinAddress, dr as readGroupWantNo, dt as ATPROTO_PROOF_COLLECTION, ea as readImageMeta, el as toBase64Url, eo as checkAppRevokeStatement, er as engineText, et as SshSigError, ft as ATPROTO_PROOF_SCOPE, g as isAppUrl, gn as isPubkyKey, gt as checkAtprotoProofRecord, h as checkAppManifest, hn as PUBKY_PROOF_MAX_BYTES, ht as atprotoProofRkey, ia as sanitizeMime, in as WALLET_NETWORKS, io as readAppRevocations, ir as GROUP_WANT_NO_FRAME, it as sshSignCommand, ji as IdentityCheckUnavailable, jn as isPublicAddress, jo as answersOffer, jt as didSignedMessages, kn as domainWellKnownFile, ko as CALL_SIGNAL_MAX_AGE_MS, kt as didGhostlyServiceEntry, l as APP_BUNDLE_LIMITS, la as parseVoiceMeta, ln as walletNetworkOf, lr as readGroupHave, lt as ATPROTO_DECLARED_SCOPE, m as assembleAppBundle, ma as RateWindow, mt as atprotoProofRecord, n as appStoreDecision, nc as inviteLink, nl as utf8Decode, nr as GROUP_HAVE_FRAME, nt as parseSshSignature, o as checkAppStoreIndex, oa as VOICE_LIMITS, oo as signAppRevocation, or as groupFileFallback, ot as verifyBitcoinMessage, pc as seedSigner, pi as BUTTONS_CAPABILITY, pt as atprotoDidMethod, qi as irohRelayUrl, ra as sanitizeFileName, rl as utf8Encode, rt as sameSshKey, s as readAppStore, sa as baseMime, so as canonicalJson, sr as isGroupMessageId, st as BitcoinAddressError, tl as toZ32, tr as GROUP_FILE_LIMITS, tt as parseSshPublicKey, ua as voicePeaksOf, ur as readGroupWant, ut as ATPROTO_FULL_SCOPE, vn as newPubkyProofFolder, vt as normalizeAtprotoHandle, wn as pubkyHomeserverOf, wt as DID_FILE_MAX_BYTES, xn as pubkyProofFile, xt as AtprotoProofError, yn as normalizePubkyKey, yt as parseAtprotoDidDocument, za as buttonLabelClash, zc as RELAY_PAYLOAD_MAX_BYTES, zn as requestLnurlInvoice, zr as ChatFiles, zt as parseDidDocument } from "./assets/src-aKGjb_yz.js";
|
|
4
|
+
import { c as fileStore, f as store$1, i as STORES, m as wrap, o as databaseName, p as transact, u as openDb } from "./assets/idb-CTF6LF5B.js";
|
|
5
|
+
import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-BcYZhNgF.js";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { connect, createServer, isIP } from "node:net";
|
|
@@ -12,13 +12,13 @@ import { sha256 } from "@noble/hashes/sha2.js";
|
|
|
12
12
|
import { createBase58check } from "@scure/base";
|
|
13
13
|
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
14
14
|
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
|
15
|
-
import { link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
16
|
-
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { access, constants, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
16
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
17
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
17
18
|
import { decode, npubEncode } from "nostr-tools/nip19";
|
|
18
19
|
import { BunkerSigner } from "nostr-tools/nip46";
|
|
19
20
|
import { SimplePool } from "nostr-tools/pool";
|
|
20
21
|
import { generateSecretKey } from "nostr-tools/pure";
|
|
21
|
-
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
22
22
|
import { createServer as createServer$1 } from "node:http";
|
|
23
23
|
import { Readable } from "node:stream";
|
|
24
24
|
import { pipeline } from "node:stream/promises";
|
|
@@ -35,7 +35,7 @@ import { IDBFactory, IDBKeyRange as IDBKeyRange$1, IDBTransaction } from "fake-i
|
|
|
35
35
|
import { format } from "node:util";
|
|
36
36
|
var package_default = {
|
|
37
37
|
name: "@ghostlytools/cli",
|
|
38
|
-
version: "1.1
|
|
38
|
+
version: "1.2.1",
|
|
39
39
|
type: "module",
|
|
40
40
|
description: "Ghostly for bots and terminals: the app's own engine, headless on Node, behind a daemon and a `ghostly` command with JSON output.",
|
|
41
41
|
license: "MIT",
|
|
@@ -63,7 +63,7 @@ var package_default = {
|
|
|
63
63
|
dependencies: {
|
|
64
64
|
"@arkade-os/sdk": "0.4.78",
|
|
65
65
|
"@atproto/jwk-webcrypto": "^0.3.4",
|
|
66
|
-
"@atproto/oauth-client": "^0.8.
|
|
66
|
+
"@atproto/oauth-client": "^0.8.9",
|
|
67
67
|
"@bitcoindevkit/bdk-wallet-web": "0.3.0",
|
|
68
68
|
"@bitcoinerlab/descriptors-scure": "^3.2.0",
|
|
69
69
|
"@cashu/cashu-ts": "^4.11.0",
|
|
@@ -87,7 +87,7 @@ var package_default = {
|
|
|
87
87
|
"ethers": "6.17.0",
|
|
88
88
|
"eventsource": "^5.1.2",
|
|
89
89
|
"fake-indexeddb": "6.2.5",
|
|
90
|
-
"hyperdht": "6.34.
|
|
90
|
+
"hyperdht": "6.34.1",
|
|
91
91
|
"keet-identity-key": "3.2.0",
|
|
92
92
|
"mpg123-decoder": "1.0.3",
|
|
93
93
|
"node-datachannel": "^0.33.4",
|
|
@@ -101,7 +101,7 @@ var package_default = {
|
|
|
101
101
|
"@ghostly/iroh-web": "*",
|
|
102
102
|
"@types/node": "^26.6.4",
|
|
103
103
|
"typescript": "^5.9.3",
|
|
104
|
-
"vite": "^8.3.
|
|
104
|
+
"vite": "^8.3.3",
|
|
105
105
|
"vitest": "^5.0.3"
|
|
106
106
|
}
|
|
107
107
|
};
|
|
@@ -139,7 +139,7 @@ var CliError = class extends Error {
|
|
|
139
139
|
/** Anything thrown, as the error a bot sees: the engine's own refusals keep their words. */
|
|
140
140
|
function asCliError(error) {
|
|
141
141
|
if (error instanceof CliError) return error;
|
|
142
|
-
if (error && typeof error === "object" && "code" in error && "message" in error && typeof error.code === "string" && error.code
|
|
142
|
+
if (error && typeof error === "object" && "code" in error && "message" in error && typeof error.code === "string" && Object.hasOwn(EXIT, error.code)) return new CliError(error.code, String(error.message));
|
|
143
143
|
const local = localFileError(error);
|
|
144
144
|
if (local) return local;
|
|
145
145
|
const unopened = profileOpenError(error);
|
|
@@ -176,6 +176,7 @@ function localFileError(error) {
|
|
|
176
176
|
if (code === "ENOENT" || code === "ENOTDIR") return new CliError("not_found", `No such file or folder: ${where}`);
|
|
177
177
|
if (code === "EACCES" || code === "EPERM" || code === "EROFS") return new CliError("refused", `Not allowed to write or read ${where}`);
|
|
178
178
|
if (code === "EISDIR") return new CliError("bad_request", `${where} is a folder: name a file`);
|
|
179
|
+
if (code === "ENAMETOOLONG") return new CliError("bad_request", `A name in ${where} is too long for this file system (at most 255 bytes)`);
|
|
179
180
|
}
|
|
180
181
|
//#endregion
|
|
181
182
|
//#region src/args.ts
|
|
@@ -282,9 +283,17 @@ function parseArgs(argv, spec, idSlot = () => false) {
|
|
|
282
283
|
options
|
|
283
284
|
};
|
|
284
285
|
}
|
|
286
|
+
/** The global option a word names (`--profile`, `--profile=bot`, `-p`), else undefined. */
|
|
287
|
+
function globalOption(arg) {
|
|
288
|
+
const eq = arg.indexOf("=");
|
|
289
|
+
const name = arg.startsWith("--") ? arg.slice(2, eq === -1 ? void 0 : eq) : arg.length === 2 && arg.startsWith("-") ? Object.entries(GLOBAL_OPTIONS).find(([, o]) => o.short === arg.slice(1))?.[0] : void 0;
|
|
290
|
+
return name && Object.hasOwn(GLOBAL_OPTIONS, name) ? GLOBAL_OPTIONS[name] : void 0;
|
|
291
|
+
}
|
|
285
292
|
/**
|
|
286
293
|
* Global options given before the command (`ghostly --profile bot send …`) move after it, where every command's
|
|
287
|
-
* parser reads them. Stops at `--` and at the first word that is not a global option.
|
|
294
|
+
* parser reads them. Stops at `--` and at the first word that is not a global option. They go before the first word
|
|
295
|
+
* after the command's that starts with a dash (its first option, or the `--` that ends them), else at the end: never
|
|
296
|
+
* between an option and its value, which may itself be `--` (`--pretty profile set --name --`).
|
|
288
297
|
*/
|
|
289
298
|
function liftGlobals(argv) {
|
|
290
299
|
const lifted = [];
|
|
@@ -292,19 +301,18 @@ function liftGlobals(argv) {
|
|
|
292
301
|
while (i < argv.length) {
|
|
293
302
|
const arg = argv[i];
|
|
294
303
|
const eq = arg.indexOf("=");
|
|
295
|
-
const
|
|
296
|
-
const option = name ? GLOBAL_OPTIONS[name] : void 0;
|
|
304
|
+
const option = globalOption(arg);
|
|
297
305
|
if (!option || arg === "--") break;
|
|
298
306
|
const takesValue = option.type !== "boolean" && eq === -1;
|
|
299
307
|
lifted.push(...argv.slice(i, i + (takesValue ? 2 : 1)));
|
|
300
308
|
i += takesValue ? 2 : 1;
|
|
301
309
|
}
|
|
302
310
|
const rest = argv.slice(i);
|
|
303
|
-
const
|
|
304
|
-
return
|
|
305
|
-
...rest.slice(0,
|
|
311
|
+
const dash = rest[0] === "--" ? 0 : rest.findIndex((word, i) => i > 0 && word.startsWith("-"));
|
|
312
|
+
return dash === -1 ? [...rest, ...lifted] : [
|
|
313
|
+
...rest.slice(0, dash),
|
|
306
314
|
...lifted,
|
|
307
|
-
...rest.slice(
|
|
315
|
+
...rest.slice(dash)
|
|
308
316
|
];
|
|
309
317
|
}
|
|
310
318
|
//#endregion
|
|
@@ -675,6 +683,7 @@ var ENGINE_METHODS = [
|
|
|
675
683
|
"joinGroupByLink",
|
|
676
684
|
"sendGroupMessage",
|
|
677
685
|
"sendGroupFile",
|
|
686
|
+
"groupFileCheck",
|
|
678
687
|
"pressButton",
|
|
679
688
|
"groupMessages",
|
|
680
689
|
"messagePage",
|
|
@@ -807,64 +816,104 @@ var CONTACT_KEY = /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/;
|
|
|
807
816
|
* itself (which the contact controls, so it never wins over a label).
|
|
808
817
|
*/
|
|
809
818
|
function findChat(links, ref) {
|
|
819
|
+
return chatBy(links, ref).link;
|
|
820
|
+
}
|
|
821
|
+
/** `findChat`, and whether only the name the contact gave itself (`theirName`) found the chat. */
|
|
822
|
+
function chatBy(links, ref) {
|
|
810
823
|
const exact = links.find((link) => link.id === ref);
|
|
811
|
-
if (exact) return
|
|
824
|
+
if (exact) return {
|
|
825
|
+
link: exact,
|
|
826
|
+
theirName: false
|
|
827
|
+
};
|
|
812
828
|
if (CONTACT_KEY.test(ref)) {
|
|
813
829
|
const byKey = links.filter((link) => link.peerPubKeyZ32 === ref);
|
|
814
|
-
if (byKey.length === 1) return
|
|
830
|
+
if (byKey.length === 1) return {
|
|
831
|
+
link: byKey[0],
|
|
832
|
+
theirName: false
|
|
833
|
+
};
|
|
815
834
|
if (byKey.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one chat: use its id`, { matches: byKey.map((link) => link.id) });
|
|
816
835
|
throw new CliError("not_found", `No chat with ${JSON.stringify(ref)}`);
|
|
817
836
|
}
|
|
818
837
|
const byPrefix = links.filter((link) => link.id.startsWith(ref));
|
|
819
|
-
if (byPrefix.length === 1) return
|
|
838
|
+
if (byPrefix.length === 1) return {
|
|
839
|
+
link: byPrefix[0],
|
|
840
|
+
theirName: false
|
|
841
|
+
};
|
|
820
842
|
const lower = ref.toLowerCase();
|
|
821
843
|
const named = (name) => name?.trim().toLowerCase() === lower;
|
|
822
844
|
const byLabel = links.filter((link) => named(link.label));
|
|
823
845
|
const byName = byLabel.length ? byLabel : links.filter((link) => named(link.peerNick));
|
|
824
|
-
if (byName.length === 1) return
|
|
846
|
+
if (byName.length === 1) return {
|
|
847
|
+
link: byName[0],
|
|
848
|
+
theirName: !byLabel.length
|
|
849
|
+
};
|
|
825
850
|
if (byPrefix.length > 1 || byName.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one chat: use its id`, { matches: [...byPrefix, ...byName].map((link) => link.id) });
|
|
826
851
|
throw new CliError("not_found", `No chat ${JSON.stringify(ref)}`);
|
|
827
852
|
}
|
|
853
|
+
/** Only an invitation so far: this profile is not in the group (yet), and whoever invited chose its name. */
|
|
854
|
+
var invitedOnly = (group) => group.status === void 0 && !!group.invitation;
|
|
855
|
+
/**
|
|
856
|
+
* A group by its id, by a unique prefix of its id, or by its name. A group this profile is in wins over an invitation
|
|
857
|
+
* with the same prefix or name (the one who invites chooses those); an invitation is still found by its id, and by
|
|
858
|
+
* prefix or name where no group of this profile has it.
|
|
859
|
+
*/
|
|
828
860
|
function findGroup(groups, ref) {
|
|
829
861
|
const exact = groups.find((group) => group.id === ref);
|
|
830
862
|
if (exact) return exact;
|
|
831
|
-
const
|
|
863
|
+
const mineFirst = (found) => {
|
|
864
|
+
const mine = found.filter((group) => !invitedOnly(group));
|
|
865
|
+
return mine.length ? mine : found;
|
|
866
|
+
};
|
|
867
|
+
const byPrefix = mineFirst(groups.filter((group) => group.id.startsWith(ref)));
|
|
832
868
|
if (byPrefix.length === 1) return byPrefix[0];
|
|
833
|
-
const byName = groups.filter((group) => group.name.trim().toLowerCase() === ref.toLowerCase());
|
|
869
|
+
const byName = mineFirst(groups.filter((group) => group.name.trim().toLowerCase() === ref.toLowerCase()));
|
|
834
870
|
if (byName.length === 1) return byName[0];
|
|
835
871
|
if (byPrefix.length > 1 || byName.length > 1) throw new CliError("bad_request", `${JSON.stringify(ref)} names more than one group: use its id`, { matches: [...byPrefix, ...byName].map((group) => group.id) });
|
|
836
872
|
throw new CliError("not_found", `No group ${JSON.stringify(ref)}`);
|
|
837
873
|
}
|
|
838
|
-
var chatOf = (ctx, params) => findChat(state(ctx).links, str(params, "chat", true));
|
|
839
874
|
/**
|
|
840
|
-
* A chat or a group,
|
|
841
|
-
* then a group.
|
|
875
|
+
* A chat or a group, where a command takes either: `group:<id>` is a group; anything else a chat first (`findChat`),
|
|
876
|
+
* then a group. A chat found only by the name its contact gave itself never takes what also names a group: a group's
|
|
877
|
+
* own id is that group, and a prefix or the name of a group this profile is in is refused, with both ids. With neither,
|
|
878
|
+
* the error says what `findChat` would.
|
|
842
879
|
*/
|
|
843
|
-
function
|
|
844
|
-
if (ref.startsWith("group:")) {
|
|
845
|
-
|
|
846
|
-
return {
|
|
847
|
-
id: group.id,
|
|
848
|
-
linkId: `group:${group.id}`,
|
|
849
|
-
group: true
|
|
850
|
-
};
|
|
851
|
-
}
|
|
880
|
+
function findChatOrGroup(links, groups, ref) {
|
|
881
|
+
if (ref.startsWith("group:")) return { group: findGroup(groups, ref.slice(6)) };
|
|
882
|
+
let chat;
|
|
852
883
|
try {
|
|
853
|
-
|
|
854
|
-
return {
|
|
855
|
-
id: link.id,
|
|
856
|
-
linkId: link.id,
|
|
857
|
-
group: false
|
|
858
|
-
};
|
|
884
|
+
chat = chatBy(links, ref);
|
|
859
885
|
} catch (error) {
|
|
860
886
|
if (!(error instanceof CliError) || error.code !== "not_found") throw error;
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
};
|
|
887
|
+
try {
|
|
888
|
+
return { group: findGroup(groups, ref) };
|
|
889
|
+
} catch (inner) {
|
|
890
|
+
throw inner instanceof CliError && inner.code !== "not_found" ? inner : error;
|
|
891
|
+
}
|
|
867
892
|
}
|
|
893
|
+
if (!chat.theirName) return { link: chat.link };
|
|
894
|
+
const exact = groups.find((group) => group.id === ref);
|
|
895
|
+
if (exact) return { group: exact };
|
|
896
|
+
const lower = ref.toLowerCase();
|
|
897
|
+
const also = groups.filter((group) => !invitedOnly(group) && (group.id.startsWith(ref) || group.name.trim().toLowerCase() === lower));
|
|
898
|
+
if (also.length) throw new CliError("bad_request", `${JSON.stringify(ref)} names a chat (the name its contact chose) and a group: use the chat's id or group:<id>`, {
|
|
899
|
+
chat: chat.link.id,
|
|
900
|
+
groups: also.map((group) => group.id)
|
|
901
|
+
});
|
|
902
|
+
return { link: chat.link };
|
|
903
|
+
}
|
|
904
|
+
var chatOf = (ctx, params) => findChat(state(ctx).links, str(params, "chat", true));
|
|
905
|
+
/** A chat or a group as `findChatOrGroup` reads it. `linkId` is what the engine calls it. */
|
|
906
|
+
function chatOrGroup(ctx, ref) {
|
|
907
|
+
const found = findChatOrGroup(state(ctx).links, state(ctx).groups, ref);
|
|
908
|
+
return "group" in found ? {
|
|
909
|
+
id: found.group.id,
|
|
910
|
+
linkId: `group:${found.group.id}`,
|
|
911
|
+
group: true
|
|
912
|
+
} : {
|
|
913
|
+
id: found.link.id,
|
|
914
|
+
linkId: found.link.id,
|
|
915
|
+
group: false
|
|
916
|
+
};
|
|
868
917
|
}
|
|
869
918
|
var groupOf = (ctx, params) => findGroup(state(ctx).groups, str(params, "group", true));
|
|
870
919
|
/** Resolves with `check`'s first defined answer: now, or on a later state; `timeout` error after `ms`. */
|
|
@@ -1043,7 +1092,7 @@ function groupReply(reply, myKey) {
|
|
|
1043
1092
|
//#endregion
|
|
1044
1093
|
//#region ../browser/src/engine/groupFiles.ts
|
|
1045
1094
|
/** A group file's local id: its group's and a random part, never anything a member sends. */
|
|
1046
|
-
var groupFileId = (groupId, direction, random = toBase64Url
|
|
1095
|
+
var groupFileId = (groupId, direction, random = toBase64Url(randomBytes$1(12))) => `group-${groupId}-${direction}-${random}`;
|
|
1047
1096
|
/** The group a local file id names, among `groupIds`. */
|
|
1048
1097
|
var groupOfFile = (fileId, groupIds) => [...groupIds].find((g) => fileId.startsWith(`group-${g}-in-`) || fileId.startsWith(`group-${g}-out-`));
|
|
1049
1098
|
/** What a file's message says: its caption, or (when the author wrote none) the file itself, not the line older apps show. */
|
|
@@ -1051,23 +1100,28 @@ var groupFileText = (text, meta) => text === groupFileFallback(meta) ? fileMessa
|
|
|
1051
1100
|
/** The profile's files store and file storage, as a 1:1 chat's files use them. */
|
|
1052
1101
|
var storedGroupFiles = {
|
|
1053
1102
|
list: (groupId) => fileStore.listForLink(`group:${groupId}`),
|
|
1103
|
+
groups: () => fileStore.groupIds(),
|
|
1054
1104
|
get: (id) => fileStore.get(id),
|
|
1055
1105
|
put: (file) => fileStore.put(file),
|
|
1056
1106
|
patch: (id, fields) => fileStore.patch(id, fields),
|
|
1057
1107
|
remove: (id) => removeStored(id),
|
|
1058
|
-
async write(id) {
|
|
1108
|
+
async write(id, resume = false) {
|
|
1059
1109
|
const stored = await fileStore.get(id);
|
|
1060
1110
|
const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
|
|
1061
|
-
|
|
1111
|
+
const size = await bytes.size(id);
|
|
1112
|
+
const offset = resume && size !== null ? size : 0;
|
|
1113
|
+
if (size !== null && size !== offset) await bytes.truncate(id, offset);
|
|
1062
1114
|
if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
|
|
1063
|
-
const appender = new FileAppender(bytes, id,
|
|
1115
|
+
const appender = new FileAppender(bytes, id, offset);
|
|
1064
1116
|
return {
|
|
1117
|
+
offset,
|
|
1065
1118
|
append: (chunk) => appender.append(chunk),
|
|
1066
1119
|
flush: () => appender.flush(),
|
|
1067
1120
|
digest: async () => {
|
|
1068
1121
|
await appender.close();
|
|
1069
1122
|
return bytes.digest(id);
|
|
1070
1123
|
},
|
|
1124
|
+
close: () => appender.close(),
|
|
1071
1125
|
discard: async () => {
|
|
1072
1126
|
await appender.close().catch(() => {});
|
|
1073
1127
|
await bytes.remove(id);
|
|
@@ -1077,8 +1131,14 @@ var storedGroupFiles = {
|
|
|
1077
1131
|
read: (file, offset, length) => readStored(file, offset, length),
|
|
1078
1132
|
room: async () => (await fileBytes()).room()
|
|
1079
1133
|
};
|
|
1080
|
-
/** Why a want waits, as the person reads it. */
|
|
1081
|
-
var
|
|
1134
|
+
/** Why a want waits, as the person reads it (the app shows its own words for each `wait`). */
|
|
1135
|
+
var WAITING_NOTES = {
|
|
1136
|
+
nobody: "Nobody you are connected to has this file yet",
|
|
1137
|
+
busy: "Its holders are busy; asking again soon",
|
|
1138
|
+
damaged: "It arrived damaged; ask again"
|
|
1139
|
+
};
|
|
1140
|
+
/** Why a want stopped: every holder served bytes that are not the author's file. */
|
|
1141
|
+
var DAMAGED$1 = "Every copy of this file arrived damaged and was deleted. Ask for it again later.";
|
|
1082
1142
|
var GroupFileDesk = class {
|
|
1083
1143
|
deps;
|
|
1084
1144
|
kept = /* @__PURE__ */ new Map();
|
|
@@ -1090,6 +1150,7 @@ var GroupFileDesk = class {
|
|
|
1090
1150
|
own = /* @__PURE__ */ new Map();
|
|
1091
1151
|
/** Announcements sent per group, and taken per member of a group (WISP 503 § Rules for both, Pace). */
|
|
1092
1152
|
sentPace = /* @__PURE__ */ new Map();
|
|
1153
|
+
sentTaken = /* @__PURE__ */ new Map();
|
|
1093
1154
|
takenPace = /* @__PURE__ */ new Map();
|
|
1094
1155
|
/** `<group>\n<message id>` a `group-have` named before its message came → its holders, newest first. */
|
|
1095
1156
|
ahead = /* @__PURE__ */ new Map();
|
|
@@ -1126,7 +1187,8 @@ var GroupFileDesk = class {
|
|
|
1126
1187
|
auto: !!file.group.auto,
|
|
1127
1188
|
asked: !!file.group.asked,
|
|
1128
1189
|
held: file.transfer?.state === "done",
|
|
1129
|
-
holders: []
|
|
1190
|
+
holders: [],
|
|
1191
|
+
damaged: /* @__PURE__ */ new Set()
|
|
1130
1192
|
};
|
|
1131
1193
|
this.add(kept);
|
|
1132
1194
|
if (kept.held) this.show(kept, {
|
|
@@ -1135,6 +1197,14 @@ var GroupFileDesk = class {
|
|
|
1135
1197
|
size,
|
|
1136
1198
|
direction: kept.mine ? "out" : "in"
|
|
1137
1199
|
});
|
|
1200
|
+
else if (file.transfer?.state === "failed") this.show(kept, {
|
|
1201
|
+
state: "failed",
|
|
1202
|
+
transferred: 0,
|
|
1203
|
+
size,
|
|
1204
|
+
direction: "in",
|
|
1205
|
+
retry: true,
|
|
1206
|
+
error: file.transfer.error ?? DAMAGED$1
|
|
1207
|
+
});
|
|
1138
1208
|
else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
|
|
1139
1209
|
else this.show(kept, {
|
|
1140
1210
|
state: "transferring",
|
|
@@ -1147,9 +1217,27 @@ var GroupFileDesk = class {
|
|
|
1147
1217
|
for (const [id] of this.wants) this.ask(this.kept.get(id));
|
|
1148
1218
|
this.deps.changed();
|
|
1149
1219
|
}
|
|
1150
|
-
/**
|
|
1220
|
+
/**
|
|
1221
|
+
* At start, once every group was read: the files of a group this device no longer has (`groupIds` are the groups it
|
|
1222
|
+
* has, a tombstone or a join under way among them) go, with their bytes. A group left before Leave took its files
|
|
1223
|
+
* left them behind, where nothing lists them.
|
|
1224
|
+
*/
|
|
1225
|
+
async sweep(groupIds) {
|
|
1226
|
+
const here = new Set(groupIds);
|
|
1227
|
+
for (const groupId of await this.deps.store.groups().catch(() => [])) {
|
|
1228
|
+
if (here.has(groupId)) continue;
|
|
1229
|
+
for (const file of await this.deps.store.list(groupId).catch(() => [])) await this.deps.store.remove(file.id).catch(() => {});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* An error when this group took as many announcements of mine as it takes in a minute, or as its members take from
|
|
1234
|
+
* one member in `announceWindowMs` (past that they would get the text, not the file); null when one may go now.
|
|
1235
|
+
*/
|
|
1151
1236
|
mayAnnounce(groupId) {
|
|
1152
|
-
|
|
1237
|
+
const wait = Math.max(...this.sentPaces(groupId).map((pace) => pace.wait()));
|
|
1238
|
+
if (!wait) return null;
|
|
1239
|
+
const minutes = Math.ceil(wait / 6e4);
|
|
1240
|
+
return `You sent many files to this group just now. Wait ${minutes > 1 ? `${minutes} minutes` : "a minute"}.`;
|
|
1153
1241
|
}
|
|
1154
1242
|
/** My file, stored under `fileId` with the author's digest, is about to be announced in the group: its message takes it. */
|
|
1155
1243
|
outgoing(groupId, fileId, meta) {
|
|
@@ -1170,7 +1258,7 @@ var GroupFileDesk = class {
|
|
|
1170
1258
|
const ownId = m.sender === me ? this.own.get(`${groupId}\n${m.file.d}`) : void 0;
|
|
1171
1259
|
if (ownId) {
|
|
1172
1260
|
this.own.delete(`${groupId}\n${m.file.d}`);
|
|
1173
|
-
this.
|
|
1261
|
+
for (const pace of this.sentPaces(groupId)) pace.take();
|
|
1174
1262
|
const kept = {
|
|
1175
1263
|
id: ownId,
|
|
1176
1264
|
groupId,
|
|
@@ -1181,7 +1269,8 @@ var GroupFileDesk = class {
|
|
|
1181
1269
|
auto: false,
|
|
1182
1270
|
asked: false,
|
|
1183
1271
|
held: true,
|
|
1184
|
-
holders: []
|
|
1272
|
+
holders: [],
|
|
1273
|
+
damaged: /* @__PURE__ */ new Set()
|
|
1185
1274
|
};
|
|
1186
1275
|
this.add(kept);
|
|
1187
1276
|
const transfer = {
|
|
@@ -1218,7 +1307,8 @@ var GroupFileDesk = class {
|
|
|
1218
1307
|
auto,
|
|
1219
1308
|
asked: false,
|
|
1220
1309
|
held: false,
|
|
1221
|
-
holders
|
|
1310
|
+
holders,
|
|
1311
|
+
damaged: /* @__PURE__ */ new Set()
|
|
1222
1312
|
};
|
|
1223
1313
|
const { name, size, mime, voice, video, image } = m.file;
|
|
1224
1314
|
this.add(kept);
|
|
@@ -1271,9 +1361,18 @@ var GroupFileDesk = class {
|
|
|
1271
1361
|
kept.asked = true;
|
|
1272
1362
|
await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
|
|
1273
1363
|
}
|
|
1364
|
+
kept.damaged.clear();
|
|
1365
|
+
if (this.deps.transfers.get(kept.id)?.state === "failed") await this.deps.store.patch(kept.id, { transfer: {
|
|
1366
|
+
state: "transferring",
|
|
1367
|
+
transferred: 0,
|
|
1368
|
+
size: kept.meta.size
|
|
1369
|
+
} }).catch(() => {});
|
|
1274
1370
|
const want = this.wants.get(kept.id);
|
|
1275
1371
|
if (!want) this.wants.set(kept.id, this.newWant());
|
|
1276
|
-
else if (!want.wire)
|
|
1372
|
+
else if (!want.wire) {
|
|
1373
|
+
want.tried.clear();
|
|
1374
|
+
want.why = void 0;
|
|
1375
|
+
}
|
|
1277
1376
|
this.ask(kept);
|
|
1278
1377
|
}
|
|
1279
1378
|
/** The group is gone from this device: its files go too. */
|
|
@@ -1348,6 +1447,7 @@ var GroupFileDesk = class {
|
|
|
1348
1447
|
}
|
|
1349
1448
|
edge.taking.set(frame.id, kept.id);
|
|
1350
1449
|
want.wire = frame.id;
|
|
1450
|
+
want.why = void 0;
|
|
1351
1451
|
want.movedAt = this.deps.now();
|
|
1352
1452
|
}
|
|
1353
1453
|
return edge.files.handle(frame);
|
|
@@ -1371,9 +1471,13 @@ var GroupFileDesk = class {
|
|
|
1371
1471
|
want.wire = void 0;
|
|
1372
1472
|
}
|
|
1373
1473
|
if (want.holder) want.tried.add(want.holder);
|
|
1374
|
-
else
|
|
1474
|
+
else {
|
|
1475
|
+
want.tried.clear();
|
|
1476
|
+
want.why = void 0;
|
|
1477
|
+
}
|
|
1375
1478
|
this.ask(kept);
|
|
1376
1479
|
}
|
|
1480
|
+
this.letGo();
|
|
1377
1481
|
for (const edge of this.edgesById.values()) {
|
|
1378
1482
|
if (!edge.have.size || now - edge.haveAt < GROUP_FILE_LIMITS.haveEveryMs || !this.deps.ready(edge.linkId)) continue;
|
|
1379
1483
|
this.sendHave(edge, [...edge.have]);
|
|
@@ -1415,10 +1519,20 @@ var GroupFileDesk = class {
|
|
|
1415
1519
|
if (!want || kept.held || want.wire) return;
|
|
1416
1520
|
const membership = this.deps.membership(kept.groupId);
|
|
1417
1521
|
const edges = this.deps.edges(kept.groupId);
|
|
1418
|
-
const
|
|
1522
|
+
const known = [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key));
|
|
1523
|
+
if (known.length && known.every((key) => kept.damaged.has(key))) {
|
|
1524
|
+
this.stopDamaged(kept);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const order = known.filter((key) => !want.tried.has(key) && !kept.damaged.has(key));
|
|
1528
|
+
let behind = false;
|
|
1419
1529
|
for (const key of order) {
|
|
1420
1530
|
const linkId = edges.get(key);
|
|
1421
1531
|
if (!linkId || !this.deps.ready(linkId) || !this.edgesById.get(linkId)?.files.live) continue;
|
|
1532
|
+
if (this.askedOf(linkId, want)) {
|
|
1533
|
+
behind = true;
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1422
1536
|
if (!this.deps.send(linkId, {
|
|
1423
1537
|
t: "group-want",
|
|
1424
1538
|
g: kept.groupId,
|
|
@@ -1435,7 +1549,7 @@ var GroupFileDesk = class {
|
|
|
1435
1549
|
this.show(kept, {
|
|
1436
1550
|
state: "transferring",
|
|
1437
1551
|
stage: "queued",
|
|
1438
|
-
transferred: 0,
|
|
1552
|
+
transferred: kept.stored ?? 0,
|
|
1439
1553
|
size: kept.meta.size,
|
|
1440
1554
|
direction: "in"
|
|
1441
1555
|
});
|
|
@@ -1446,13 +1560,50 @@ var GroupFileDesk = class {
|
|
|
1446
1560
|
linkId: void 0,
|
|
1447
1561
|
at: this.deps.now()
|
|
1448
1562
|
});
|
|
1563
|
+
if (behind) {
|
|
1564
|
+
this.show(kept, {
|
|
1565
|
+
state: "transferring",
|
|
1566
|
+
stage: "queued",
|
|
1567
|
+
transferred: kept.stored ?? 0,
|
|
1568
|
+
size: kept.meta.size,
|
|
1569
|
+
direction: "in"
|
|
1570
|
+
});
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const wait = want.why ?? "nobody";
|
|
1449
1574
|
this.show(kept, {
|
|
1450
1575
|
state: "transferring",
|
|
1451
1576
|
stage: "waiting",
|
|
1452
|
-
transferred: 0,
|
|
1577
|
+
transferred: kept.stored ?? 0,
|
|
1453
1578
|
size: kept.meta.size,
|
|
1454
1579
|
direction: "in",
|
|
1455
|
-
|
|
1580
|
+
wait,
|
|
1581
|
+
note: WAITING_NOTES[wait],
|
|
1582
|
+
...wait === "damaged" && { stalled: true }
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
/** Another file is asked of, or coming from, the member at the other end of this edge. */
|
|
1586
|
+
askedOf(linkId, except) {
|
|
1587
|
+
for (const want of this.wants.values()) if (want !== except && want.linkId === linkId) return true;
|
|
1588
|
+
return false;
|
|
1589
|
+
}
|
|
1590
|
+
/** No holder serves the author's bytes: the want stops, and stays stopped after a restart, until Download. */
|
|
1591
|
+
stopDamaged(kept) {
|
|
1592
|
+
this.wants.delete(kept.id);
|
|
1593
|
+
const { size } = kept.meta;
|
|
1594
|
+
this.deps.store.patch(kept.id, { transfer: {
|
|
1595
|
+
state: "failed",
|
|
1596
|
+
transferred: 0,
|
|
1597
|
+
size,
|
|
1598
|
+
error: DAMAGED$1
|
|
1599
|
+
} }).catch(() => {});
|
|
1600
|
+
this.show(kept, {
|
|
1601
|
+
state: "failed",
|
|
1602
|
+
transferred: 0,
|
|
1603
|
+
size,
|
|
1604
|
+
direction: "in",
|
|
1605
|
+
retry: true,
|
|
1606
|
+
error: DAMAGED$1
|
|
1456
1607
|
});
|
|
1457
1608
|
}
|
|
1458
1609
|
/** A holder's no: the next one is asked. One that deleted the file is no holder any more. */
|
|
@@ -1461,9 +1612,11 @@ var GroupFileDesk = class {
|
|
|
1461
1612
|
const want = kept && this.wants.get(kept.id);
|
|
1462
1613
|
if (!kept || !want || want.linkId !== linkId || want.wire) return;
|
|
1463
1614
|
if (why === "gone") kept.holders = kept.holders.filter((key) => key !== peer);
|
|
1615
|
+
if (why === "busy") want.why = "busy";
|
|
1464
1616
|
want.tried.add(peer);
|
|
1465
1617
|
want.holder = want.linkId = void 0;
|
|
1466
1618
|
this.ask(kept);
|
|
1619
|
+
this.retry(groupId);
|
|
1467
1620
|
}
|
|
1468
1621
|
/** A member says it holds these files: remembered as a holder of each (newest first), and asked when a want waits. */
|
|
1469
1622
|
heard(groupId, from, ids) {
|
|
@@ -1499,8 +1652,12 @@ var GroupFileDesk = class {
|
|
|
1499
1652
|
});
|
|
1500
1653
|
return;
|
|
1501
1654
|
}
|
|
1502
|
-
const wire = toBase64Url
|
|
1655
|
+
const wire = toBase64Url(randomBytes$1(12));
|
|
1503
1656
|
edge.serving.set(wire, kept.id);
|
|
1657
|
+
edge.servedMoved.set(wire, {
|
|
1658
|
+
confirmed: 0,
|
|
1659
|
+
at: this.deps.now()
|
|
1660
|
+
});
|
|
1504
1661
|
const { name, mime, size, voice, video, image } = kept.meta;
|
|
1505
1662
|
const file = {
|
|
1506
1663
|
id: wire,
|
|
@@ -1524,8 +1681,13 @@ var GroupFileDesk = class {
|
|
|
1524
1681
|
if (!membership || !membership.couldRead(edge.peer, messageId)) return "refused";
|
|
1525
1682
|
if (!kept) return "gone";
|
|
1526
1683
|
if (!this.deps.settings().serveFiles || !edge.files.live) return "busy";
|
|
1684
|
+
this.letGo();
|
|
1527
1685
|
const active = this.servingNow();
|
|
1528
|
-
if (active.length >= GROUP_FILE_LIMITS.serveAtOnce
|
|
1686
|
+
if (active.length >= GROUP_FILE_LIMITS.serveAtOnce) {
|
|
1687
|
+
edge.busy.add(messageId);
|
|
1688
|
+
return "busy";
|
|
1689
|
+
}
|
|
1690
|
+
if (active.some((a) => a.peer === edge.peer && a.sending)) return "busy";
|
|
1529
1691
|
const today = Math.floor(this.deps.now() / 864e5);
|
|
1530
1692
|
if (this.served.day !== today) this.served = {
|
|
1531
1693
|
day: today,
|
|
@@ -1534,18 +1696,37 @@ var GroupFileDesk = class {
|
|
|
1534
1696
|
const reserved = active.reduce((sum, a) => sum + a.size, 0);
|
|
1535
1697
|
return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
|
|
1536
1698
|
}
|
|
1537
|
-
/** Transfers served now, all groups together: to whom, and
|
|
1699
|
+
/** Transfers served now, all groups together: to whom, their size, and whether bytes are still to go. */
|
|
1538
1700
|
servingNow() {
|
|
1539
1701
|
const active = [];
|
|
1540
1702
|
for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
|
|
1541
1703
|
const record = edge.files.get("out", wire);
|
|
1542
1704
|
if (record && !transferEnded(record)) active.push({
|
|
1543
1705
|
peer: edge.peer,
|
|
1544
|
-
size: record.file.size
|
|
1706
|
+
size: record.file.size,
|
|
1707
|
+
sending: record.confirmed < record.file.size
|
|
1545
1708
|
});
|
|
1546
1709
|
}
|
|
1547
1710
|
return active;
|
|
1548
1711
|
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Served transfers that no longer count end, their places free for others (WISP 503 § Serving limits): the asker's
|
|
1714
|
+
* session closed, it is not in the roster any more, or its bytes have not moved for `wantWaitMs` (the asker has moved
|
|
1715
|
+
* on by then).
|
|
1716
|
+
*/
|
|
1717
|
+
letGo() {
|
|
1718
|
+
const now = this.deps.now();
|
|
1719
|
+
for (const edge of this.edgesById.values()) for (const wire of [...edge.serving.keys()]) {
|
|
1720
|
+
const record = edge.files.get("out", wire);
|
|
1721
|
+
if (!record || transferEnded(record)) continue;
|
|
1722
|
+
let moved = edge.servedMoved.get(wire);
|
|
1723
|
+
if (!moved || moved.confirmed !== record.confirmed) edge.servedMoved.set(wire, moved = {
|
|
1724
|
+
confirmed: record.confirmed,
|
|
1725
|
+
at: now
|
|
1726
|
+
});
|
|
1727
|
+
if (!edge.files.live || !this.deps.membership(edge.groupId)?.inRoster(edge.peer) || now - moved.at >= GROUP_FILE_LIMITS.wantWaitMs) edge.files.cancel("out", wire);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1549
1730
|
/** Says on an edge which files this device holds. */
|
|
1550
1731
|
sendHave(edge, ids) {
|
|
1551
1732
|
for (let i = 0; i < ids.length; i += GROUP_FILE_LIMITS.haveIds) if (!this.deps.send(edge.linkId, {
|
|
@@ -1579,9 +1760,11 @@ var GroupFileDesk = class {
|
|
|
1579
1760
|
peer,
|
|
1580
1761
|
files: void 0,
|
|
1581
1762
|
serving: /* @__PURE__ */ new Map(),
|
|
1763
|
+
servedMoved: /* @__PURE__ */ new Map(),
|
|
1582
1764
|
taking: /* @__PURE__ */ new Map(),
|
|
1583
1765
|
have: /* @__PURE__ */ new Set(),
|
|
1584
|
-
haveAt: 0
|
|
1766
|
+
haveAt: 0,
|
|
1767
|
+
busy: /* @__PURE__ */ new Set()
|
|
1585
1768
|
};
|
|
1586
1769
|
edge.files = new ChatFiles({
|
|
1587
1770
|
send: (frame) => {
|
|
@@ -1615,13 +1798,28 @@ var GroupFileDesk = class {
|
|
|
1615
1798
|
const kept = this.kept.get(edge.taking.get(record.id) ?? "");
|
|
1616
1799
|
if (!kept) throw new Error("No such file");
|
|
1617
1800
|
await kept.idle;
|
|
1618
|
-
const writer = await this.deps.store.write(kept.id);
|
|
1801
|
+
const writer = await this.deps.store.write(kept.id, !kept.fresh);
|
|
1802
|
+
kept.fresh = false;
|
|
1803
|
+
const resumed = writer.offset > 0;
|
|
1804
|
+
let damaged = false;
|
|
1619
1805
|
return {
|
|
1620
|
-
offset:
|
|
1806
|
+
offset: writer.offset,
|
|
1621
1807
|
append: (bytes) => writer.append(bytes),
|
|
1622
1808
|
flush: () => writer.flush(),
|
|
1623
|
-
verify: async () =>
|
|
1624
|
-
|
|
1809
|
+
verify: async () => {
|
|
1810
|
+
damaged = await writer.digest() !== kept.meta.d;
|
|
1811
|
+
const want = this.wants.get(kept.id);
|
|
1812
|
+
if (damaged && want) want.why = "damaged";
|
|
1813
|
+
return !damaged;
|
|
1814
|
+
},
|
|
1815
|
+
discard: () => {
|
|
1816
|
+
if (damaged) {
|
|
1817
|
+
kept.stored = 0;
|
|
1818
|
+
if (resumed) kept.fresh = kept.unclear = true;
|
|
1819
|
+
else kept.damaged.add(edge.peer);
|
|
1820
|
+
}
|
|
1821
|
+
return kept.idle = (damaged ? writer.discard() : writer.close()).catch(() => {});
|
|
1822
|
+
}
|
|
1625
1823
|
};
|
|
1626
1824
|
}
|
|
1627
1825
|
async openSource(edge, record) {
|
|
@@ -1643,6 +1841,12 @@ var GroupFileDesk = class {
|
|
|
1643
1841
|
};
|
|
1644
1842
|
this.served.bytes += record.confirmed;
|
|
1645
1843
|
edge.serving.delete(record.id);
|
|
1844
|
+
edge.servedMoved.delete(record.id);
|
|
1845
|
+
const serving = this.deps.settings().serveFiles;
|
|
1846
|
+
for (const other of this.edgesById.values()) {
|
|
1847
|
+
if (serving) for (const id of other.busy) other.have.add(id);
|
|
1848
|
+
other.busy.clear();
|
|
1849
|
+
}
|
|
1646
1850
|
}
|
|
1647
1851
|
return;
|
|
1648
1852
|
}
|
|
@@ -1652,6 +1856,7 @@ var GroupFileDesk = class {
|
|
|
1652
1856
|
want.moved = transferred;
|
|
1653
1857
|
want.movedAt = this.deps.now();
|
|
1654
1858
|
}
|
|
1859
|
+
if (record.state !== "failed" && record.state !== "declined" && record.state !== "cancelled") kept.stored = transferred;
|
|
1655
1860
|
const { size } = kept.meta;
|
|
1656
1861
|
switch (record.state) {
|
|
1657
1862
|
case "done":
|
|
@@ -1670,12 +1875,14 @@ var GroupFileDesk = class {
|
|
|
1670
1875
|
direction: "in"
|
|
1671
1876
|
});
|
|
1672
1877
|
this.queueHave(kept);
|
|
1878
|
+
this.retry(kept.groupId);
|
|
1673
1879
|
return;
|
|
1674
1880
|
case "failed":
|
|
1675
1881
|
case "declined":
|
|
1676
1882
|
case "cancelled":
|
|
1677
1883
|
edge.taking.delete(record.id);
|
|
1678
|
-
if (want.holder) want.tried.add(want.holder);
|
|
1884
|
+
if (want.holder && !kept.unclear) want.tried.add(want.holder);
|
|
1885
|
+
kept.unclear = false;
|
|
1679
1886
|
Object.assign(want, {
|
|
1680
1887
|
wire: void 0,
|
|
1681
1888
|
holder: void 0,
|
|
@@ -1683,6 +1890,7 @@ var GroupFileDesk = class {
|
|
|
1683
1890
|
moved: 0
|
|
1684
1891
|
});
|
|
1685
1892
|
this.ask(kept);
|
|
1893
|
+
this.retry(kept.groupId);
|
|
1686
1894
|
return;
|
|
1687
1895
|
case "verifying":
|
|
1688
1896
|
this.show(kept, {
|
|
@@ -1724,6 +1932,10 @@ var GroupFileDesk = class {
|
|
|
1724
1932
|
if (!window) windows.set(key, window = new RateWindow(limit, ms, () => this.deps.now()));
|
|
1725
1933
|
return window;
|
|
1726
1934
|
}
|
|
1935
|
+
/** My announcements in a group, in a minute and in the window the members count them over. */
|
|
1936
|
+
sentPaces(groupId) {
|
|
1937
|
+
return [this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4), this.pace(this.sentTaken, groupId, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs)];
|
|
1938
|
+
}
|
|
1727
1939
|
show(kept, view) {
|
|
1728
1940
|
this.deps.transfers.set(kept.id, view);
|
|
1729
1941
|
this.deps.changed();
|
|
@@ -2205,24 +2417,8 @@ function filesOf(messages, transfers) {
|
|
|
2205
2417
|
transfer: transfers[m.file.id] ?? null
|
|
2206
2418
|
}));
|
|
2207
2419
|
}
|
|
2208
|
-
/**
|
|
2209
|
-
|
|
2210
|
-
* as `forward` reads them. A name that is neither says what a chat would.
|
|
2211
|
-
*/
|
|
2212
|
-
function chatOrGroupOf(ctx, params) {
|
|
2213
|
-
const ref = str(params, "chat", true);
|
|
2214
|
-
if (ref.startsWith("group:")) return { group: findGroup(state(ctx).groups, ref.slice(6)) };
|
|
2215
|
-
try {
|
|
2216
|
-
return { link: chatOf(ctx, params) };
|
|
2217
|
-
} catch (error) {
|
|
2218
|
-
if (!(error instanceof CliError) || error.code !== "not_found") throw error;
|
|
2219
|
-
try {
|
|
2220
|
-
return { group: findGroup(state(ctx).groups, ref) };
|
|
2221
|
-
} catch {
|
|
2222
|
-
throw error;
|
|
2223
|
-
}
|
|
2224
|
-
}
|
|
2225
|
-
}
|
|
2420
|
+
/** The chat or the group a file command names (`findChatOrGroup`). */
|
|
2421
|
+
var chatOrGroupOf = (ctx, params) => findChatOrGroup(state(ctx).links, state(ctx).groups, str(params, "chat", true));
|
|
2226
2422
|
/**
|
|
2227
2423
|
* 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
|
|
2228
2424
|
* may be left out; given, it must be the file's own. `where` is what the answer names it by.
|
|
@@ -2283,6 +2479,8 @@ async function sendToGroup(ctx, params, group, path, size) {
|
|
|
2283
2479
|
if (replyTo) {
|
|
2284
2480
|
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");
|
|
2285
2481
|
}
|
|
2482
|
+
const check = await node(ctx).groupFileCheck({ groupId: group.id });
|
|
2483
|
+
if (check.error) throw check.refused ? new CliError("refused", check.error) : groupSendRefused(check.error);
|
|
2286
2484
|
const { voice, warning } = await voiceOf(params, path, mime);
|
|
2287
2485
|
const source = await openAsBlob(path, { type: mime });
|
|
2288
2486
|
const image = voice ? void 0 : await readImageMeta(source, mime);
|
|
@@ -2343,6 +2541,29 @@ async function sendToGroup(ctx, params, group, path, size) {
|
|
|
2343
2541
|
...warning ? { warning } : {}
|
|
2344
2542
|
};
|
|
2345
2543
|
}
|
|
2544
|
+
/** Bytes a file system takes in one name: 255 on ext4, btrfs, APFS (NTFS counts 255 UTF-16 units, within this too). */
|
|
2545
|
+
var NAME_BYTES = 255;
|
|
2546
|
+
/**
|
|
2547
|
+
* `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
|
|
2548
|
+
* its stem goes until it fits, its extension stays (as `sanitizeFileName` shortens it), so "文…文.txt" is saved as a text.
|
|
2549
|
+
*/
|
|
2550
|
+
function fitName(name) {
|
|
2551
|
+
const fits = (candidate) => Buffer.byteLength(candidate) <= NAME_BYTES;
|
|
2552
|
+
if (fits(name)) return name;
|
|
2553
|
+
const extension = /\.[^.\s]{1,15}$/u.exec(name)?.[0] ?? "";
|
|
2554
|
+
const stem = [...name.slice(0, name.length - extension.length)];
|
|
2555
|
+
while (stem.length && !fits(stem.join("").trimEnd() + extension)) stem.pop();
|
|
2556
|
+
return (stem.join("").trimEnd() || "file") + extension;
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* The name `file save --dir` writes a file under. Its sender chose it, and the folder is one scripts and people run
|
|
2560
|
+
* tools in: a name that starts with a dash is an option to the tool a `*` hands it to ("-rf", "--checkpoint-action=…"),
|
|
2561
|
+
* so it gets a `_` first, as a Windows device name does in `sanitizeFileName`.
|
|
2562
|
+
*/
|
|
2563
|
+
function saveName(name) {
|
|
2564
|
+
const clean = sanitizeFileName(name);
|
|
2565
|
+
return fitName(clean.startsWith("-") ? `_${clean}` : clean);
|
|
2566
|
+
}
|
|
2346
2567
|
var ACTIONS = [
|
|
2347
2568
|
"accept",
|
|
2348
2569
|
"decline",
|
|
@@ -2363,6 +2584,7 @@ var FILE_METHODS = {
|
|
|
2363
2584
|
throw new CliError("not_found", `No file ${path}`);
|
|
2364
2585
|
}
|
|
2365
2586
|
if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
|
|
2587
|
+
await access(path, constants.R_OK);
|
|
2366
2588
|
if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
|
|
2367
2589
|
const { link } = target;
|
|
2368
2590
|
if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
|
|
@@ -2376,7 +2598,7 @@ var FILE_METHODS = {
|
|
|
2376
2598
|
const { voice, warning } = await voiceOf(params, path, mime);
|
|
2377
2599
|
const source = await openAsBlob(path, { type: mime });
|
|
2378
2600
|
const image = voice ? void 0 : await readImageMeta(source, mime);
|
|
2379
|
-
const wireId = toBase64Url
|
|
2601
|
+
const wireId = toBase64Url(randomBytes$1(12));
|
|
2380
2602
|
const file = {
|
|
2381
2603
|
id: `${link.id}-out-${wireId}`,
|
|
2382
2604
|
name: sanitizeFileName(str(params, "name") ?? basename(path)),
|
|
@@ -2530,7 +2752,7 @@ var FILE_METHODS = {
|
|
|
2530
2752
|
size: transfer.size
|
|
2531
2753
|
};
|
|
2532
2754
|
},
|
|
2533
|
-
/** Writes a received (or sent) file to disk: into `dir` under its own name, or to `path`. Never over a file unless `force`. */
|
|
2755
|
+
/** Writes a received (or sent) file to disk: into `dir` under its own name (`saveName`), or to `path`. Never over a file unless `force`. */
|
|
2534
2756
|
async "file.save"(ctx, params) {
|
|
2535
2757
|
const fileId = str(params, "file", true);
|
|
2536
2758
|
if (params.chat !== void 0) fileChat(ctx, params, fileId);
|
|
@@ -2543,7 +2765,7 @@ var FILE_METHODS = {
|
|
|
2543
2765
|
if (stored.leftOut) throw new CliError("not_found", "Not in this backup: the light backup this profile was restored from left the file out");
|
|
2544
2766
|
const transfer = state(ctx).transfers[fileId] ?? stored.transfer;
|
|
2545
2767
|
if (transfer && transfer.state !== "done") throw new CliError("unavailable", `The file is not all here yet (${transfer.transferred} of ${transfer.size} bytes)`);
|
|
2546
|
-
const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."),
|
|
2768
|
+
const target = str(params, "path") ? resolve(str(params, "path", true)) : join(resolve(str(params, "dir") ?? "."), saveName(stored.metadata.name));
|
|
2547
2769
|
const flags = bool(params, "force") ? "w" : "wx";
|
|
2548
2770
|
let out;
|
|
2549
2771
|
try {
|
|
@@ -2596,7 +2818,7 @@ var DELIVERY_RANK = {
|
|
|
2596
2818
|
async function waitForEdit(ctx, chat, messageId, ms) {
|
|
2597
2819
|
const until = Date.now() + ms;
|
|
2598
2820
|
for (;;) {
|
|
2599
|
-
const message =
|
|
2821
|
+
const message = await node(ctx).getMessage(chat, messageId);
|
|
2600
2822
|
if (!message) throw new CliError("not_found", `No message ${messageId} in this chat`);
|
|
2601
2823
|
if (!message.edit?.pending) return message;
|
|
2602
2824
|
if (Date.now() >= until) throw new CliError("timeout", `Timed out after ${Math.round(ms / 1e3)} s: the contact has not confirmed the edit yet. It goes by itself once the chat is live and the contact's app shows edits, while this profile is online`, {
|
|
@@ -2648,8 +2870,7 @@ async function waitForMessage(ctx, chat, messageId, target, ms) {
|
|
|
2648
2870
|
if (error) reject(error);
|
|
2649
2871
|
else resolve(message);
|
|
2650
2872
|
};
|
|
2651
|
-
const check = (last = false) => void node(ctx).
|
|
2652
|
-
const message = messages.find((m) => m.id === messageId);
|
|
2873
|
+
const check = (last = false) => void node(ctx).getMessage(chat, messageId).then((message) => {
|
|
2653
2874
|
try {
|
|
2654
2875
|
const done = look(message);
|
|
2655
2876
|
if (done) finish(null, done);
|
|
@@ -2736,12 +2957,74 @@ function checked$1(card) {
|
|
|
2736
2957
|
if ("error" in result) throw new CliError("bad_request", `Status card: ${result.error}`, { card });
|
|
2737
2958
|
return result.card;
|
|
2738
2959
|
}
|
|
2739
|
-
/**
|
|
2960
|
+
/** Every text in a card's fields (title, step, items, runs, links…), as the contact's app may show it. */
|
|
2961
|
+
function cardTexts(value, out = []) {
|
|
2962
|
+
if (typeof value === "string") out.push(value);
|
|
2963
|
+
else if (Array.isArray(value)) for (const item of value) cardTexts(item, out);
|
|
2964
|
+
else if (isObject(value)) for (const item of Object.values(value)) cardTexts(item, out);
|
|
2965
|
+
return out;
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* The secret guard `send` has (docs/CLI.md § Safety): a card whose text looks like a seed, a private key or a Cashu
|
|
2969
|
+
* token goes only with `--force`. `fields` are what this command gives (an update's own, not the card it merges over).
|
|
2970
|
+
* A card's text is read as given and as the card carries it (`cardLine`): a line loses its invisible characters on the
|
|
2971
|
+
* 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.
|
|
2972
|
+
*/
|
|
2973
|
+
function guardSecrets(fields, params, verb) {
|
|
2974
|
+
if (bool(params, "force")) return;
|
|
2975
|
+
const check = (text) => {
|
|
2976
|
+
const secret = findSecret(text);
|
|
2977
|
+
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 });
|
|
2978
|
+
};
|
|
2979
|
+
for (const text of cardTexts([fields, params.run])) {
|
|
2980
|
+
check(text);
|
|
2981
|
+
check(cardLine(text, Infinity) ?? "");
|
|
2982
|
+
}
|
|
2983
|
+
if (typeof params.text === "string") check(params.text);
|
|
2984
|
+
}
|
|
2985
|
+
/** Per card (its chat or group, its kind and its id): the message of mine that carries it, as this process last saw it. */
|
|
2986
|
+
var carriers = /* @__PURE__ */ new WeakMap();
|
|
2987
|
+
var carrierKey = (linkId, kind, id) => `${linkId}\n${kind}\n${id}`;
|
|
2988
|
+
function carriersOf(ctx) {
|
|
2989
|
+
let map = carriers.get(ctx);
|
|
2990
|
+
if (map) return map;
|
|
2991
|
+
carriers.set(ctx, map = /* @__PURE__ */ new Map());
|
|
2992
|
+
const cards = map;
|
|
2993
|
+
ctx.hub.onEvent((event) => {
|
|
2994
|
+
if (event.type !== "message.sent" && event.type !== "group.sent") return;
|
|
2995
|
+
const message = event.message;
|
|
2996
|
+
const card = message?.card;
|
|
2997
|
+
if (typeof message?.id !== "string" || typeof card?.id !== "string" || card.kind !== "task" && card.kind !== "routine" && card.kind !== "usage") return;
|
|
2998
|
+
carried(cards, carrierKey(event.type === "group.sent" ? `group:${String(event.group)}` : String(event.chat), card.kind, card.id), message.id);
|
|
2999
|
+
});
|
|
3000
|
+
return map;
|
|
3001
|
+
}
|
|
3002
|
+
function carried(cards, key, messageId) {
|
|
3003
|
+
cards.delete(key);
|
|
3004
|
+
cards.set(key, messageId);
|
|
3005
|
+
if (cards.size > 1e3) cards.delete(cards.keys().next().value);
|
|
3006
|
+
}
|
|
3007
|
+
/**
|
|
3008
|
+
* 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
|
|
3009
|
+
* 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
|
|
3010
|
+
* short one; only a card not seen yet (the first update after a start, a one-shot) is looked for in the whole history.
|
|
3011
|
+
*/
|
|
2740
3012
|
async function cardMessage(ctx, target, kind, id) {
|
|
3013
|
+
const cards = carriersOf(ctx), key = carrierKey(target.linkId, kind, id);
|
|
3014
|
+
const carries = (message) => message?.sender === "me" && message.card?.kind === kind && message.card.id === id;
|
|
3015
|
+
const known = cards.get(key);
|
|
3016
|
+
if (known !== void 0) {
|
|
3017
|
+
const message = await node(ctx).getMessage(target.linkId, known);
|
|
3018
|
+
if (carries(message)) return message;
|
|
3019
|
+
cards.delete(key);
|
|
3020
|
+
}
|
|
2741
3021
|
const messages = target.group ? await node(ctx).groupMessages({ groupId: target.id }) : await node(ctx).getMessages(target.linkId);
|
|
3022
|
+
if (cards.has(key)) return cardMessage(ctx, target, kind, id);
|
|
2742
3023
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2743
3024
|
const message = messages[i];
|
|
2744
|
-
if (message
|
|
3025
|
+
if (!carries(message)) continue;
|
|
3026
|
+
carried(cards, key, message.id);
|
|
3027
|
+
return message;
|
|
2745
3028
|
}
|
|
2746
3029
|
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`);
|
|
2747
3030
|
}
|
|
@@ -2769,8 +3052,9 @@ async function parentThere(ctx, target, card) {
|
|
|
2769
3052
|
async function sendCard(ctx, params, kind) {
|
|
2770
3053
|
const target = chatOrGroup(ctx, str(params, "chat", true));
|
|
2771
3054
|
const fields = fieldsOf(params);
|
|
3055
|
+
guardSecrets(fields, params, "send");
|
|
2772
3056
|
const now = Date.now();
|
|
2773
|
-
const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url
|
|
3057
|
+
const id = typeof fields.id === "string" && fields.id ? fields.id : `${kind}-${toBase64Url(randomBytes$1(6))}`;
|
|
2774
3058
|
const card = checked$1(withRun({
|
|
2775
3059
|
...kind === "task" ? {
|
|
2776
3060
|
status: "running",
|
|
@@ -2799,6 +3083,7 @@ async function sendCard(ctx, params, kind) {
|
|
|
2799
3083
|
card
|
|
2800
3084
|
});
|
|
2801
3085
|
if (result.error || !result.messageId) throw new CliError(result.refused ? "refused" : "unavailable", result.error ?? "Nothing to send");
|
|
3086
|
+
carried(carriersOf(ctx), carrierKey(target.linkId, kind, id), result.messageId);
|
|
2802
3087
|
const edges = wait === "sent" ? await waitForGroupFrame(ctx, target.id, result.messageId, void 0, ms) : void 0;
|
|
2803
3088
|
return answer(target, kind, id, result.messageId, {
|
|
2804
3089
|
card,
|
|
@@ -2812,7 +3097,8 @@ async function sendCard(ctx, params, kind) {
|
|
|
2812
3097
|
});
|
|
2813
3098
|
if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
|
|
2814
3099
|
if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
|
|
2815
|
-
|
|
3100
|
+
carried(carriersOf(ctx), carrierKey(target.linkId, kind, id), result.messageId);
|
|
3101
|
+
let message = await node(ctx).getMessage(target.linkId, result.messageId);
|
|
2816
3102
|
if (wait !== "none") message = await waitForMessage(ctx, target.linkId, result.messageId, wait, ms);
|
|
2817
3103
|
return answer(target, kind, id, result.messageId, {
|
|
2818
3104
|
card,
|
|
@@ -2838,6 +3124,7 @@ async function updateCard(ctx, params, kind) {
|
|
|
2838
3124
|
const target = chatOrGroup(ctx, str(params, "chat", true));
|
|
2839
3125
|
const id = str(params, kind, true);
|
|
2840
3126
|
const patch = fieldsOf(params);
|
|
3127
|
+
guardSecrets(patch, params, "update");
|
|
2841
3128
|
const message = await cardMessage(ctx, target, kind, id);
|
|
2842
3129
|
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)`, {
|
|
2843
3130
|
messageId: message.id,
|
|
@@ -2879,7 +3166,7 @@ async function updateCard(ctx, params, kind) {
|
|
|
2879
3166
|
pace.last = Date.now();
|
|
2880
3167
|
await editCard(ctx, target, message.id, card, text);
|
|
2881
3168
|
}
|
|
2882
|
-
let edited =
|
|
3169
|
+
let edited = await node(ctx).getMessage(target.linkId, message.id);
|
|
2883
3170
|
if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
|
|
2884
3171
|
const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
|
|
2885
3172
|
return answer(target, kind, id, message.id, {
|
|
@@ -3049,7 +3336,7 @@ function buttonsCard(params) {
|
|
|
3049
3336
|
if (!Array.isArray(buttons)) throw new CliError("bad_request", "buttons must be a list of {id, label, style?, once?}");
|
|
3050
3337
|
return checked({
|
|
3051
3338
|
kind: "buttons",
|
|
3052
|
-
id: str(params, "buttonsId") ?? `ask-${toBase64Url
|
|
3339
|
+
id: str(params, "buttonsId") ?? `ask-${toBase64Url(randomBytes$1(6))}`,
|
|
3053
3340
|
buttons
|
|
3054
3341
|
});
|
|
3055
3342
|
}
|
|
@@ -3086,6 +3373,7 @@ async function updateButtons(ctx, params) {
|
|
|
3086
3373
|
edits: message.edit.seq
|
|
3087
3374
|
});
|
|
3088
3375
|
const before = message.card;
|
|
3376
|
+
if (chosen !== void 0 && !before.buttons.some((b) => b.id === chosen)) throw new CliError("bad_request", `That message has no button ${JSON.stringify(chosen)}; its buttons: ${before.buttons.map((b) => b.id).join(", ")}`, { card: before });
|
|
3089
3377
|
const card = checked({
|
|
3090
3378
|
...before,
|
|
3091
3379
|
...chosen !== void 0 && { chosen },
|
|
@@ -3104,7 +3392,7 @@ async function updateButtons(ctx, params) {
|
|
|
3104
3392
|
card
|
|
3105
3393
|
});
|
|
3106
3394
|
if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
|
|
3107
|
-
let edited =
|
|
3395
|
+
let edited = target.group ? (await node(ctx).groupMessages({ groupId: target.id })).find((m) => m.id === message.id) : await node(ctx).getMessage(target.linkId, message.id);
|
|
3108
3396
|
if (wait === "confirmed" && edited?.edit?.pending) edited = await waitForEdit(ctx, target.linkId, message.id, ms);
|
|
3109
3397
|
const edges = wait === "sent" && edited?.edit ? await waitForGroupFrame(ctx, target.id, message.id, edited.edit.seq, ms) : void 0;
|
|
3110
3398
|
return {
|
|
@@ -3167,7 +3455,7 @@ async function pressButton(ctx, params) {
|
|
|
3167
3455
|
edges
|
|
3168
3456
|
};
|
|
3169
3457
|
}
|
|
3170
|
-
const reply = wait === "none" ?
|
|
3458
|
+
const reply = wait === "none" ? await node(ctx).getMessage(target.linkId, replyId) : await waitForMessage(ctx, target.linkId, replyId, wait, ms);
|
|
3171
3459
|
return {
|
|
3172
3460
|
...answer,
|
|
3173
3461
|
delivery: reply?.delivery ?? null
|
|
@@ -3483,10 +3771,15 @@ function groupJson(group, showSecret = false) {
|
|
|
3483
3771
|
* Group administration and pictures (WISP 1100, phase 3): what the app's group menu and profile screen do. The
|
|
3484
3772
|
* engine enforces who may do what (the admin); these check the arguments and name members.
|
|
3485
3773
|
*/
|
|
3486
|
-
/**
|
|
3774
|
+
/**
|
|
3775
|
+
* A member by key, unique key prefix or name; never this profile itself. A whole key is that member, whatever another
|
|
3776
|
+
* member calls itself; a prefix and a name stand side by side (two members is an error, not a guess).
|
|
3777
|
+
*/
|
|
3487
3778
|
function findMember(group, ref) {
|
|
3488
3779
|
const lower = ref.toLowerCase();
|
|
3489
|
-
const
|
|
3780
|
+
const others = group.members.filter((m) => !m.me);
|
|
3781
|
+
const byKey = others.filter((m) => m.key === ref);
|
|
3782
|
+
const matches = byKey.length ? byKey : others.filter((m) => m.key.startsWith(ref) || m.nick?.trim().toLowerCase() === lower);
|
|
3490
3783
|
if (matches.length === 1) return matches[0];
|
|
3491
3784
|
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}`);
|
|
3492
3785
|
}
|
|
@@ -3603,6 +3896,63 @@ var GROUP_ADMIN_METHODS = {
|
|
|
3603
3896
|
}
|
|
3604
3897
|
};
|
|
3605
3898
|
//#endregion
|
|
3899
|
+
//#region src/turnRefs.ts
|
|
3900
|
+
/**
|
|
3901
|
+
* The id an agent turn names its message by (docs/CLI.md § Agent turns). A message is stored under an id its sender
|
|
3902
|
+
* chose most of (`peer_<the id they sent>`), so a turn carries this one instead, made here from the chat (or group) and
|
|
3903
|
+
* that id: `t_` and 32 hex digits, in which nobody can write a word. Every command that takes a message's id takes it.
|
|
3904
|
+
*/
|
|
3905
|
+
function turnRef(where, messageId) {
|
|
3906
|
+
return "t_" + createHash("sha256").update(`${where}\n${messageId}`).digest("hex").slice(0, 32);
|
|
3907
|
+
}
|
|
3908
|
+
var TURN_REF = /^t_[0-9a-f]{32}$/;
|
|
3909
|
+
var isRef = (value) => typeof value === "string" && TURN_REF.test(value);
|
|
3910
|
+
/**
|
|
3911
|
+
* `params` with each turn's id in `message`, `reply` and `messages` as the id its message is stored under: looked for
|
|
3912
|
+
* among the chat's (or group's) newest messages first, where a turn's almost always is, then in all of them. One that
|
|
3913
|
+
* names no message stays as given, and its command says so as for any unknown id.
|
|
3914
|
+
*/
|
|
3915
|
+
async function withStoredIds(ctx, params) {
|
|
3916
|
+
const wanted = new Set([
|
|
3917
|
+
params.message,
|
|
3918
|
+
params.reply,
|
|
3919
|
+
...Array.isArray(params.messages) ? params.messages : []
|
|
3920
|
+
].filter(isRef));
|
|
3921
|
+
if (!wanted.size) return params;
|
|
3922
|
+
let target = null;
|
|
3923
|
+
try {
|
|
3924
|
+
if (typeof params.group === "string") {
|
|
3925
|
+
const group = findGroup(state(ctx).groups, params.group);
|
|
3926
|
+
target = {
|
|
3927
|
+
id: group.id,
|
|
3928
|
+
linkId: `group:${group.id}`,
|
|
3929
|
+
group: true
|
|
3930
|
+
};
|
|
3931
|
+
} else if (typeof params.chat === "string") target = chatOrGroup(ctx, params.chat);
|
|
3932
|
+
} catch {}
|
|
3933
|
+
if (!target) return params;
|
|
3934
|
+
const { id, linkId, group } = target;
|
|
3935
|
+
const found = /* @__PURE__ */ new Map();
|
|
3936
|
+
const look = (messages) => {
|
|
3937
|
+
for (const message of messages) {
|
|
3938
|
+
const ref = turnRef(id, message.id);
|
|
3939
|
+
if (wanted.has(ref)) found.set(ref, message.id);
|
|
3940
|
+
}
|
|
3941
|
+
return found.size === wanted.size;
|
|
3942
|
+
};
|
|
3943
|
+
if (!look((await node(ctx).messagePage({
|
|
3944
|
+
linkId,
|
|
3945
|
+
limit: 200
|
|
3946
|
+
})).messages)) look(group ? await node(ctx).groupMessages({ groupId: id }) : await node(ctx).getMessages(linkId));
|
|
3947
|
+
const stored = (value) => isRef(value) ? found.get(value) ?? value : value;
|
|
3948
|
+
return {
|
|
3949
|
+
...params,
|
|
3950
|
+
...params.message !== void 0 && { message: stored(params.message) },
|
|
3951
|
+
...params.reply !== void 0 && { reply: stored(params.reply) },
|
|
3952
|
+
...Array.isArray(params.messages) && { messages: params.messages.map(stored) }
|
|
3953
|
+
};
|
|
3954
|
+
}
|
|
3955
|
+
//#endregion
|
|
3606
3956
|
//#region ../browser/src/proofs/bitcoinWallets.ts
|
|
3607
3957
|
/** What a proof proves and what it does not; shown next to every Bitcoin proof, given or received. */
|
|
3608
3958
|
var BITCOIN_PROOF_LIMITS = [
|
|
@@ -4135,7 +4485,7 @@ var ResolverUnavailable = class extends DomainCheckError {};
|
|
|
4135
4485
|
async function askResolver(resolver, name, type, options) {
|
|
4136
4486
|
let response;
|
|
4137
4487
|
try {
|
|
4138
|
-
response = await options.fetch(`${resolver.url}?dns=${toBase64Url
|
|
4488
|
+
response = await options.fetch(`${resolver.url}?dns=${toBase64Url(encodeDnsQuery(name, type))}`, {
|
|
4139
4489
|
headers: { accept: "application/dns-message" },
|
|
4140
4490
|
maxBytes: DNS_MAX_BYTES,
|
|
4141
4491
|
signal: options.signal,
|
|
@@ -4845,8 +5195,8 @@ async function preparePgpEvidence(input, options = {}) {
|
|
|
4845
5195
|
return {
|
|
4846
5196
|
evidence: {
|
|
4847
5197
|
scheme: PGP_SCHEME,
|
|
4848
|
-
key: toBase64Url
|
|
4849
|
-
signature: toBase64Url
|
|
5198
|
+
key: toBase64Url(await minimalKey(pgp, key, signer, at, config(pgp))),
|
|
5199
|
+
signature: toBase64Url(signature)
|
|
4850
5200
|
},
|
|
4851
5201
|
verified: info
|
|
4852
5202
|
};
|
|
@@ -4858,7 +5208,7 @@ function parsePgpEvidence(raw) {
|
|
|
4858
5208
|
if (!e || typeof e !== "object" || Array.isArray(e) || e.scheme !== "openpgp/1" || Object.keys(e).sort().join(",") !== "key,scheme,signature" || typeof e.key !== "string" || typeof e.signature !== "string" || !b64.test(e.key) || !b64.test(e.signature) || e.key.length > Math.ceil(PGP_LIMITS.sharedKey * 4 / 3) || e.signature.length > Math.ceil(PGP_LIMITS.sharedSignature * 4 / 3)) throw new PgpProofError("Invalid OpenPGP proof");
|
|
4859
5209
|
let canonical;
|
|
4860
5210
|
try {
|
|
4861
|
-
canonical = toBase64Url
|
|
5211
|
+
canonical = toBase64Url(fromBase64Url$1(e.key)) === e.key && toBase64Url(fromBase64Url$1(e.signature)) === e.signature;
|
|
4862
5212
|
} catch {
|
|
4863
5213
|
canonical = false;
|
|
4864
5214
|
}
|
|
@@ -5580,7 +5930,7 @@ function segment(value) {
|
|
|
5580
5930
|
} catch {
|
|
5581
5931
|
return refuse("Malformed ID token");
|
|
5582
5932
|
}
|
|
5583
|
-
if (toBase64Url
|
|
5933
|
+
if (toBase64Url(bytes) !== value) refuse("Malformed ID token");
|
|
5584
5934
|
return bytes;
|
|
5585
5935
|
}
|
|
5586
5936
|
function json(value) {
|
|
@@ -5728,7 +6078,7 @@ async function authorizationRequest(options) {
|
|
|
5728
6078
|
if (!clientId) throw new Error(`${provider.name} sign-in is not set up in this Ghostly build yet.`);
|
|
5729
6079
|
const { nonce } = options;
|
|
5730
6080
|
if (!/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) throw new Error("Invalid sign-in nonce");
|
|
5731
|
-
const state = `${options.statePrefix ?? ""}${toBase64Url
|
|
6081
|
+
const state = `${options.statePrefix ?? ""}${toBase64Url(randomBytes$1(32))}`;
|
|
5732
6082
|
const url = new URL(provider.authorizationEndpoint);
|
|
5733
6083
|
const { scope, claims } = scopeFor$1(provider, options.reveal);
|
|
5734
6084
|
const params = {
|
|
@@ -5743,8 +6093,8 @@ async function authorizationRequest(options) {
|
|
|
5743
6093
|
};
|
|
5744
6094
|
let verifier;
|
|
5745
6095
|
if (provider.responseType === "code") {
|
|
5746
|
-
verifier = toBase64Url
|
|
5747
|
-
params.code_challenge = toBase64Url
|
|
6096
|
+
verifier = toBase64Url(randomBytes$1(32));
|
|
6097
|
+
params.code_challenge = toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(verifier))));
|
|
5748
6098
|
params.code_challenge_method = "S256";
|
|
5749
6099
|
}
|
|
5750
6100
|
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
@@ -6319,7 +6669,7 @@ var signer = {
|
|
|
6319
6669
|
return {
|
|
6320
6670
|
method: "sig",
|
|
6321
6671
|
vm: check.key.id,
|
|
6322
|
-
sig: toBase64Url
|
|
6672
|
+
sig: toBase64Url(check.signature)
|
|
6323
6673
|
};
|
|
6324
6674
|
}
|
|
6325
6675
|
};
|
|
@@ -7571,7 +7921,7 @@ var exactKeys = (raw, keys) => !!raw && typeof raw === "object" && !Array.isArra
|
|
|
7571
7921
|
/** The key behind the fake in-app signer. Tests may replace it. */
|
|
7572
7922
|
var fakeKeyring = { seed: ed25519.utils.randomSecretKey() };
|
|
7573
7923
|
var fakeKeySubject = (seed = fakeKeyring.seed) => hex$3(ed25519.getPublicKey(seed));
|
|
7574
|
-
var fakeKeySign = (statement, seed = fakeKeyring.seed) => ({ sig: toBase64Url
|
|
7924
|
+
var fakeKeySign = (statement, seed = fakeKeyring.seed) => ({ sig: toBase64Url(ed25519.sign(statement.bytes, seed)) });
|
|
7575
7925
|
var fakeKey = {
|
|
7576
7926
|
id: "fake-key",
|
|
7577
7927
|
label: "Test key",
|
|
@@ -7654,13 +8004,13 @@ var ISSUER_SEED = unhex("7a".repeat(32));
|
|
|
7654
8004
|
var FAKE_ISSUER = "https://issuer.ghostly.test";
|
|
7655
8005
|
/** What the fake login page signs: iss, sub, nonce (= statement id), iat. */
|
|
7656
8006
|
function fakeAccountToken(statement, account = "alice", seed = ISSUER_SEED, iat = statement.binding.issuedAt) {
|
|
7657
|
-
const payload = toBase64Url
|
|
8007
|
+
const payload = toBase64Url(utf8Encode(JSON.stringify({
|
|
7658
8008
|
iss: FAKE_ISSUER,
|
|
7659
8009
|
sub: account,
|
|
7660
8010
|
nonce: statement.id,
|
|
7661
8011
|
iat
|
|
7662
8012
|
})));
|
|
7663
|
-
return { token: `${payload}.${toBase64Url
|
|
8013
|
+
return { token: `${payload}.${toBase64Url(ed25519.sign(utf8Encode(payload), seed))}` };
|
|
7664
8014
|
}
|
|
7665
8015
|
var fakeAccount = {
|
|
7666
8016
|
id: "fake-account",
|
|
@@ -8623,12 +8973,6 @@ var SERVICE_METHODS = {
|
|
|
8623
8973
|
};
|
|
8624
8974
|
//#endregion
|
|
8625
8975
|
//#region ../browser/src/backup/codec.ts
|
|
8626
|
-
/** Base64url without padding, in chunks: bundles can be many megabytes. */
|
|
8627
|
-
function toBase64Url(bytes) {
|
|
8628
|
-
let binary = "";
|
|
8629
|
-
for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
8630
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8631
|
-
}
|
|
8632
8976
|
function fromBase64Url(text) {
|
|
8633
8977
|
if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error("Invalid base64url");
|
|
8634
8978
|
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - text.length % 4) % 4));
|
|
@@ -12324,7 +12668,7 @@ var METHODS = {
|
|
|
12324
12668
|
async "settings.set"(ctx, params) {
|
|
12325
12669
|
const patch = {};
|
|
12326
12670
|
for (const [key, value] of Object.entries(params)) {
|
|
12327
|
-
const kind = SETTABLE[key];
|
|
12671
|
+
const kind = Object.hasOwn(SETTABLE, key) ? SETTABLE[key] : void 0;
|
|
12328
12672
|
if (!kind) throw new CliError("bad_request", `${key} is not a setting the CLI changes (${Object.keys(SETTABLE).join(", ")})`);
|
|
12329
12673
|
if (kind === "strings" && !(Array.isArray(value) && value.every((v) => typeof v === "string"))) throw new CliError("bad_request", `${key} must be a list of strings`);
|
|
12330
12674
|
if (kind === "string" && typeof value !== "string") throw new CliError("bad_request", `${key} must be a string`);
|
|
@@ -12343,14 +12687,19 @@ var METHODS = {
|
|
|
12343
12687
|
label
|
|
12344
12688
|
});
|
|
12345
12689
|
node(ctx).setActiveLink({ linkId });
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
12349
|
-
|
|
12690
|
+
const progressOf = (s) => s.links.find((l) => l.id === linkId)?.pairingProgress;
|
|
12691
|
+
const progress = await waitForState(ctx, (s) => {
|
|
12692
|
+
const p = progressOf(s);
|
|
12693
|
+
return p && (p.stage !== "publishing" || ctx.mode === "daemon" && p.relayWaitUntil) ? p : void 0;
|
|
12694
|
+
}, 2e4, "the invite to be published").catch(() => progressOf(state(ctx)));
|
|
12695
|
+
const published = !!progress && progress.stage !== "publishing" && progress.reason !== "publish";
|
|
12696
|
+
const waitUntil = published ? void 0 : progress?.relayWaitUntil;
|
|
12350
12697
|
return {
|
|
12351
12698
|
chat: linkId,
|
|
12352
12699
|
invite: inviteCode,
|
|
12353
|
-
link: inviteLink(inviteCode)
|
|
12700
|
+
link: inviteLink(inviteCode),
|
|
12701
|
+
published,
|
|
12702
|
+
...waitUntil !== void 0 && { retryInMs: Math.max(0, waitUntil - Date.now()) }
|
|
12354
12703
|
};
|
|
12355
12704
|
},
|
|
12356
12705
|
async "invite.join"(ctx, params) {
|
|
@@ -12412,7 +12761,7 @@ var METHODS = {
|
|
|
12412
12761
|
});
|
|
12413
12762
|
if (result.error) throw new CliError(result.refused ? "refused" : "unavailable", result.error);
|
|
12414
12763
|
if (!result.messageId) throw new CliError("bad_request", "Nothing to send");
|
|
12415
|
-
let message =
|
|
12764
|
+
let message = await node(ctx).getMessage(link.id, result.messageId);
|
|
12416
12765
|
if (wait !== "none") message = await waitForMessage(ctx, link.id, result.messageId, wait, num(params, "timeout", 30, {
|
|
12417
12766
|
min: 1,
|
|
12418
12767
|
max: 3600
|
|
@@ -12448,7 +12797,7 @@ var METHODS = {
|
|
|
12448
12797
|
});
|
|
12449
12798
|
if (result.error) throw editRefused(await node(ctx).getMessages(link.id), messageId, "chat", result.error, result.refused);
|
|
12450
12799
|
const id = result.messageId ?? messageId;
|
|
12451
|
-
let message =
|
|
12800
|
+
let message = await node(ctx).getMessage(link.id, id);
|
|
12452
12801
|
if (wait === "confirmed" && message?.edit?.pending) message = await waitForEdit(ctx, link.id, id, num(params, "timeout", 30, {
|
|
12453
12802
|
min: 1,
|
|
12454
12803
|
max: 3600
|
|
@@ -12641,7 +12990,8 @@ var METHODS = {
|
|
|
12641
12990
|
},
|
|
12642
12991
|
async "chat.transport"(ctx, params) {
|
|
12643
12992
|
const link = chatOf(ctx, params);
|
|
12644
|
-
const
|
|
12993
|
+
const name = str(params, "transport", true);
|
|
12994
|
+
const transport = Object.hasOwn(TRANSPORT_NAMES, name) ? TRANSPORT_NAMES[name] : void 0;
|
|
12645
12995
|
if (!transport) throw new CliError("bad_request", `transport must be one of auto, dht, webrtc, iroh, hyperdht`);
|
|
12646
12996
|
await node(ctx).setChatTransport({
|
|
12647
12997
|
linkId: link.id,
|
|
@@ -13024,12 +13374,12 @@ function history(messages, params, json = messageJson) {
|
|
|
13024
13374
|
};
|
|
13025
13375
|
}
|
|
13026
13376
|
Object.keys(METHODS);
|
|
13027
|
-
/** Runs one method of the API. */
|
|
13377
|
+
/** Runs one method of the API: one the table lists itself (`constructor`, which every object has, is none). */
|
|
13028
13378
|
async function callApi(ctx, method, params = {}) {
|
|
13029
|
-
const run = METHODS[method];
|
|
13379
|
+
const run = Object.hasOwn(METHODS, method) ? METHODS[method] : void 0;
|
|
13030
13380
|
if (!run) throw new CliError("not_found", `No method ${method}`);
|
|
13031
13381
|
if (params === null || typeof params !== "object" || Array.isArray(params)) throw new CliError("bad_request", "params must be an object");
|
|
13032
|
-
return run(ctx, params);
|
|
13382
|
+
return run(ctx, await withStoredIds(ctx, params));
|
|
13033
13383
|
}
|
|
13034
13384
|
//#endregion
|
|
13035
13385
|
//#region src/privateFolder.ts
|
|
@@ -13149,6 +13499,229 @@ function connectDaemon(path, timeoutMs = 2e3) {
|
|
|
13149
13499
|
});
|
|
13150
13500
|
}
|
|
13151
13501
|
//#endregion
|
|
13502
|
+
//#region src/appTemplate.ts
|
|
13503
|
+
var html = (text) => text.replace(/[&<>"]/g, (c) => ({
|
|
13504
|
+
"&": "&",
|
|
13505
|
+
"<": "<",
|
|
13506
|
+
">": ">",
|
|
13507
|
+
"\"": """
|
|
13508
|
+
})[c]);
|
|
13509
|
+
/** `ghostly-app.json`: the manifest without `publisher`, `sequence` and `files`, which `app publish` writes. */
|
|
13510
|
+
function templateManifest({ name, title }) {
|
|
13511
|
+
return {
|
|
13512
|
+
name,
|
|
13513
|
+
version: "0.1.0",
|
|
13514
|
+
kind: "mini-app",
|
|
13515
|
+
title,
|
|
13516
|
+
tagline: "Say hello to a contact",
|
|
13517
|
+
description: "Open it in a chat and your contact gets a card to join. Each side can then say hello to the other.",
|
|
13518
|
+
entry: "index.html",
|
|
13519
|
+
permissions: ["chat"],
|
|
13520
|
+
view: "chat",
|
|
13521
|
+
runtime: {
|
|
13522
|
+
host: ">=1.2",
|
|
13523
|
+
clients: ["web", "desktop"]
|
|
13524
|
+
},
|
|
13525
|
+
license: "MIT"
|
|
13526
|
+
};
|
|
13527
|
+
}
|
|
13528
|
+
/**
|
|
13529
|
+
* `index.html`: the whole app in one file, as the sandbox needs it (no network, so nothing is loaded from elsewhere).
|
|
13530
|
+
* Its script is plain JavaScript typed with JSDoc against `@ghostlytools/sdk/app`, so it runs as written and still type
|
|
13531
|
+
* checks (test/appInit.test.ts does). The title is the only thing put into it, as text. Its type imports are in single
|
|
13532
|
+
* quotes: they are text of the page, not imports of the CLI's own build (test/packageDeps.test.ts reads those).
|
|
13533
|
+
*/
|
|
13534
|
+
function templatePage({ title }) {
|
|
13535
|
+
return `<!doctype html>
|
|
13536
|
+
<html lang="en">
|
|
13537
|
+
<head>
|
|
13538
|
+
<meta charset="utf-8">
|
|
13539
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
13540
|
+
<title>${html(title)}</title>
|
|
13541
|
+
<style>
|
|
13542
|
+
:root { color-scheme: light dark; --bg: #ffffff; --fg: #16181d; --muted: #5b6270; --line: #d9dde3; --accent: #2f6fed; }
|
|
13543
|
+
:root[data-theme="dark"] { --bg: #16181d; --fg: #f2f4f7; --muted: #a3abb8; --line: #343a45; --accent: #7aa5ff; }
|
|
13544
|
+
body { margin: 0; padding: 20px; background: var(--bg); color: var(--fg); font: 16px/1.4 system-ui, sans-serif; }
|
|
13545
|
+
h1 { margin: 0 0 4px; font-size: 20px; }
|
|
13546
|
+
#status { margin: 0 0 16px; color: var(--muted); }
|
|
13547
|
+
button { font: inherit; padding: 8px 16px; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent); color: var(--bg); }
|
|
13548
|
+
button:disabled { opacity: 0.5; }
|
|
13549
|
+
ul { margin: 16px 0 0; padding: 0; list-style: none; }
|
|
13550
|
+
li { padding: 8px 0; border-top: 1px solid var(--line); }
|
|
13551
|
+
</style>
|
|
13552
|
+
</head>
|
|
13553
|
+
<body>
|
|
13554
|
+
<h1>${html(title)}</h1>
|
|
13555
|
+
<p id="status" role="status">Starting</p>
|
|
13556
|
+
<button id="hello" type="button" disabled>Say hello</button>
|
|
13557
|
+
<ul id="log" aria-label="Messages"></ul>
|
|
13558
|
+
<script type="module">
|
|
13559
|
+
// The types of the API come from the SDK (npm install --save-dev @ghostlytools/sdk); nothing of it runs here.
|
|
13560
|
+
/** @typedef {import('@ghostlytools/sdk/app').MiniAppApi} MiniAppApi */
|
|
13561
|
+
/** @typedef {import('@ghostlytools/sdk/app').MiniAppJson} MiniAppJson */
|
|
13562
|
+
|
|
13563
|
+
/** The longest text shown from a contact's frame. */
|
|
13564
|
+
const MAX_TEXT = 200;
|
|
13565
|
+
|
|
13566
|
+
const status = /** @type {HTMLElement} */ (document.getElementById("status"));
|
|
13567
|
+
const button = /** @type {HTMLButtonElement} */ (document.getElementById("hello"));
|
|
13568
|
+
const log = /** @type {HTMLElement} */ (document.getElementById("log"));
|
|
13569
|
+
|
|
13570
|
+
/** @param {string} text */
|
|
13571
|
+
function show(text) {
|
|
13572
|
+
const line = document.createElement("li");
|
|
13573
|
+
line.textContent = text;
|
|
13574
|
+
log.append(line);
|
|
13575
|
+
}
|
|
13576
|
+
|
|
13577
|
+
/**
|
|
13578
|
+
* What the contact's app sent, when it is a hello. A frame comes from another device: read only what this app sends,
|
|
13579
|
+
* and never put it in the page as HTML.
|
|
13580
|
+
* @param {MiniAppJson} data
|
|
13581
|
+
* @returns {string | null}
|
|
13582
|
+
*/
|
|
13583
|
+
function helloOf(data) {
|
|
13584
|
+
if (!data || typeof data !== "object" || Array.isArray(data) || data.type !== "hello") return null;
|
|
13585
|
+
return typeof data.text === "string" && data.text.length <= MAX_TEXT ? data.text : null;
|
|
13586
|
+
}
|
|
13587
|
+
|
|
13588
|
+
/**
|
|
13589
|
+
* Why a send was refused, in words. The code is the rejection's message (MINI_APP_ERROR_CODES in the SDK); a later
|
|
13590
|
+
* client may add one.
|
|
13591
|
+
* @param {unknown} error
|
|
13592
|
+
*/
|
|
13593
|
+
function refusal(error) {
|
|
13594
|
+
switch (error instanceof Error ? error.message : "") {
|
|
13595
|
+
case "offline": return "Not sent: you are not both online";
|
|
13596
|
+
case "peer-closed": case "not-open": return "Not sent: your contact has not opened this app";
|
|
13597
|
+
case "not-allowed": return "Open this app in a chat to say hello";
|
|
13598
|
+
default: return "Not sent";
|
|
13599
|
+
}
|
|
13600
|
+
}
|
|
13601
|
+
|
|
13602
|
+
/** @param {MiniAppApi} ghostly */
|
|
13603
|
+
async function start(ghostly) {
|
|
13604
|
+
// Listen before anything is awaited, so no frame is missed.
|
|
13605
|
+
ghostly.chat.on("message", (data) => {
|
|
13606
|
+
const text = helloOf(data);
|
|
13607
|
+
if (text !== null) show("Your contact: " + text);
|
|
13608
|
+
});
|
|
13609
|
+
ghostly.chat.on("peer", (peer) => {
|
|
13610
|
+
status.textContent = peer.open ? "Your contact has this app open" : "Waiting for your contact to open this app";
|
|
13611
|
+
});
|
|
13612
|
+
|
|
13613
|
+
const context = await ghostly.context();
|
|
13614
|
+
const dark = context.theme ? context.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
|
|
13615
|
+
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
13616
|
+
status.textContent = !context.inChat ? "Open this app in a chat to say hello"
|
|
13617
|
+
: context.peer ? "Your contact has this app open" : "Waiting for your contact to open this app";
|
|
13618
|
+
|
|
13619
|
+
button.disabled = false;
|
|
13620
|
+
button.addEventListener("click", async () => {
|
|
13621
|
+
try {
|
|
13622
|
+
await ghostly.chat.send({ type: "hello", text: "Hello!" });
|
|
13623
|
+
show("You: Hello!");
|
|
13624
|
+
} catch (error) {
|
|
13625
|
+
show(refusal(error));
|
|
13626
|
+
}
|
|
13627
|
+
});
|
|
13628
|
+
}
|
|
13629
|
+
|
|
13630
|
+
/** Ghostly's runner gives the app \`window.ghostly\`; a browser tab has none. */
|
|
13631
|
+
const ghostly = /** @type {{ ghostly?: MiniAppApi }} */ (window).ghostly;
|
|
13632
|
+
if (ghostly) void start(ghostly);
|
|
13633
|
+
else status.textContent = "This page is a Ghostly app: publish it and open it in Ghostly";
|
|
13634
|
+
<\/script>
|
|
13635
|
+
</body>
|
|
13636
|
+
</html>
|
|
13637
|
+
`;
|
|
13638
|
+
}
|
|
13639
|
+
/** `README.md`: what the folder holds and the way from it to a store. */
|
|
13640
|
+
function templateReadme({ name, title }) {
|
|
13641
|
+
return `# ${title}
|
|
13642
|
+
|
|
13643
|
+
A [Ghostly](https://github.com/MiguelMedeiros/ghostly) app, started with \`ghostly app init\`. Opened in a 1:1 chat, it
|
|
13644
|
+
gives your contact a card to join, and each side can say hello to the other.
|
|
13645
|
+
|
|
13646
|
+
A Ghostly app ([WISP 1200](https://github.com/MiguelMedeiros/ghostly/blob/dev/docs/wisps/1200-marketplace.md)) is a
|
|
13647
|
+
page that runs in the client's sandbox. It has no network, and talks to Ghostly only through \`window.ghostly\`:
|
|
13648
|
+
frames of at most 32 KiB to the same app on the contact's side (\`chat.send\`, \`chat.on\`), storage per app and per
|
|
13649
|
+
chat, and the files of its own bundle. Apps run in Ghostly from release 1.2.
|
|
13650
|
+
|
|
13651
|
+
| File | What it is |
|
|
13652
|
+
|---|---|
|
|
13653
|
+
| \`ghostly-app.json\` | The manifest, without \`publisher\`, \`sequence\` and \`files\`: \`ghostly app publish\` writes those |
|
|
13654
|
+
| \`index.html\` | The app, whole: styles and script inline |
|
|
13655
|
+
| \`README.md\` | This file |
|
|
13656
|
+
|
|
13657
|
+
## Write it
|
|
13658
|
+
|
|
13659
|
+
\`index.html\` is plain JavaScript, typed with JSDoc against \`@ghostlytools/sdk/app\`: \`MiniAppApi\` is
|
|
13660
|
+
\`window.ghostly\`, and the same module has the limits (\`MINI_APP_LIMITS\`) and the codes a refused call rejects with
|
|
13661
|
+
(\`MINI_APP_ERROR_CODES\`). For the types in your editor:
|
|
13662
|
+
|
|
13663
|
+
\`\`\`sh
|
|
13664
|
+
npm install --save-dev @ghostlytools/sdk
|
|
13665
|
+
\`\`\`
|
|
13666
|
+
|
|
13667
|
+
- What a contact's app sends is untrusted: check its shape, bound its size, and show it as text, never as HTML.
|
|
13668
|
+
- \`"view": "chat"\` runs the app inside a chat with one contact. \`"full"\` is a full-screen app opened from the Apps page.
|
|
13669
|
+
- \`permissions\` lists what the person is asked for: \`chat\` (frames to the contact's app), \`name\` (the person's
|
|
13670
|
+
display name) and \`internet\`. Adding one later makes every install ask before it updates.
|
|
13671
|
+
|
|
13672
|
+
## Build
|
|
13673
|
+
|
|
13674
|
+
There is nothing to build yet: the page is the app. Once it grows into several source files, or TypeScript, build it
|
|
13675
|
+
into one self-contained \`index.html\` (scripts, styles and images inline) and put that file and \`ghostly-app.json\`
|
|
13676
|
+
alone in a folder of their own, which is the folder you publish.
|
|
13677
|
+
[ghostly-chess](https://github.com/MiguelMedeiros/ghostly-chess) does it with Vite and a small
|
|
13678
|
+
\`scripts/stage.mjs\`. \`ghostly app publish\` takes every file of the folder it is given but dot files, this README
|
|
13679
|
+
included, so do not run it on a repository with sources, \`node_modules\` or anything private in it.
|
|
13680
|
+
|
|
13681
|
+
## Publish
|
|
13682
|
+
|
|
13683
|
+
\`\`\`sh
|
|
13684
|
+
ghostly app publish . --key ~/ghostly-keys/${name}-publisher.key
|
|
13685
|
+
ghostly app verify app.ghostlyapp
|
|
13686
|
+
\`\`\`
|
|
13687
|
+
|
|
13688
|
+
\`publish\` signs the folder into \`app.ghostlyapp\`. The first run makes the publisher key at the path you name:
|
|
13689
|
+
|
|
13690
|
+
- Keep it **outside this folder** and its repository (\`publish\` refuses a key inside it), and never commit it.
|
|
13691
|
+
- Back it up. An app is updated only with the key that signed it; a lost key means a new app.
|
|
13692
|
+
|
|
13693
|
+
\`verify\` checks the bundle as Ghostly will and prints its \`ref\` (\`<publisher key>/${name}\`), \`sequence\` and
|
|
13694
|
+
\`digest\`. For the next version, raise \`version\` in \`ghostly-app.json\` and publish again: the sequence goes up
|
|
13695
|
+
by one by itself.
|
|
13696
|
+
|
|
13697
|
+
## Try it
|
|
13698
|
+
|
|
13699
|
+
Commit \`app.ghostlyapp\` to a public GitHub repository, then in Ghostly open Apps, choose Add, and paste its URL
|
|
13700
|
+
(\`https://raw.githubusercontent.com/<owner>/<repo>/HEAD/app.ghostlyapp\`). Set \`sources\` in \`ghostly-app.json\`
|
|
13701
|
+
to that URL, in a list, so installs made from it find your updates. Open the app from a chat's + menu; your contact
|
|
13702
|
+
gets a card to install and open it.
|
|
13703
|
+
|
|
13704
|
+
## List it in a store
|
|
13705
|
+
|
|
13706
|
+
A store is a signed list of apps. The official one is a Git repository,
|
|
13707
|
+
[ghostly-store](https://github.com/MiguelMedeiros/ghostly-store), and apps enter it by pull request:
|
|
13708
|
+
|
|
13709
|
+
1. Commit \`app.ghostlyapp\` to your public repository and note that commit.
|
|
13710
|
+
2. Add \`apps/${name}.<first 16 characters of your publisher key>/listing.json\` with the \`ref\`, \`sequence\`
|
|
13711
|
+
and \`digest\` that \`ghostly app verify\` printed. Its \`urls\` start with a jsDelivr URL pinned to the full commit
|
|
13712
|
+
(\`https://cdn.jsdelivr.net/gh/<owner>/<repo>@<40-character commit>/app.ghostlyapp\`).
|
|
13713
|
+
3. Open the pull request. Its check verifies the bundle; a maintainer reviews the app and the owner signs the index.
|
|
13714
|
+
|
|
13715
|
+
The store's [CONTRIBUTING](https://github.com/MiguelMedeiros/ghostly-store/blob/main/CONTRIBUTING.md) has the rules.
|
|
13716
|
+
An app installed from a store updates only to the version that store lists, so each new version is a pull request too.
|
|
13717
|
+
|
|
13718
|
+
## If something goes wrong
|
|
13719
|
+
|
|
13720
|
+
If a version is harmful, \`ghostly app revoke . --key <your key> --up-to <sequence>\` writes \`ghostly-revoke.json\`;
|
|
13721
|
+
publish it beside \`app.ghostlyapp\` and Ghostly stops those versions.
|
|
13722
|
+
`;
|
|
13723
|
+
}
|
|
13724
|
+
//#endregion
|
|
13152
13725
|
//#region src/apps.ts
|
|
13153
13726
|
var APP_SOURCE = "ghostly-app.json";
|
|
13154
13727
|
var APP_REVOKE_FILE = "ghostly-revoke.json";
|
|
@@ -13316,7 +13889,7 @@ function described(bundle, bytes) {
|
|
|
13316
13889
|
sequence: m.sequence,
|
|
13317
13890
|
digest: bundle.digest,
|
|
13318
13891
|
bytes: bytes.length,
|
|
13319
|
-
sha256: toBase64Url
|
|
13892
|
+
sha256: toBase64Url(createHash("sha256").update(bytes).digest())
|
|
13320
13893
|
};
|
|
13321
13894
|
}
|
|
13322
13895
|
/** `app verify <bundle|url>`: every check a client makes before it stores a bundle (WISP 1200 · Reading a bundle). */
|
|
@@ -13330,6 +13903,70 @@ async function verifyApp(source) {
|
|
|
13330
13903
|
manifest: read.bundle.manifest
|
|
13331
13904
|
};
|
|
13332
13905
|
}
|
|
13906
|
+
/** A folder's name as an app's: lower case, anything else a hyphen (`My App` is `my-app`). */
|
|
13907
|
+
var nameFrom = (folder) => folder.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
13908
|
+
/** An app's name as a title: `my-app` is `My app`. */
|
|
13909
|
+
var titleFrom = (name) => (name[0].toUpperCase() + name.slice(1)).replace(/-/g, " ");
|
|
13910
|
+
/**
|
|
13911
|
+
* `app init <dir> [--name <name>] [--title <title>] [--force]`: a small working app in `<dir>` (made when missing),
|
|
13912
|
+
* which `app publish <dir>` signs as it is: `ghostly-app.json`, an `index.html` that says hello to the contact, and a
|
|
13913
|
+
* README with the way to a store (./appTemplate.ts). The name is `--name`, else the folder's; the title `--title`, else
|
|
13914
|
+
* the name's. Nothing is written when one of the three files is there, unless `--force`, which replaces those three only.
|
|
13915
|
+
*/
|
|
13916
|
+
async function initApp(dirArg, what = {}) {
|
|
13917
|
+
const usage = "ghostly app init <dir> [--name <name>] [--title <title>] [--force]";
|
|
13918
|
+
const dir = resolve(dirArg);
|
|
13919
|
+
const name = what.name ?? nameFrom(basename(dir));
|
|
13920
|
+
const title = what.title ?? (name ? titleFrom(name) : "");
|
|
13921
|
+
const page = templatePage({
|
|
13922
|
+
name,
|
|
13923
|
+
title
|
|
13924
|
+
});
|
|
13925
|
+
const pageBytes = new TextEncoder().encode(page);
|
|
13926
|
+
const checked = checkAppManifest({
|
|
13927
|
+
...templateManifest({
|
|
13928
|
+
name,
|
|
13929
|
+
title
|
|
13930
|
+
}),
|
|
13931
|
+
ghostlyApp: 1,
|
|
13932
|
+
publisher: toZ32(/* @__PURE__ */ new Uint8Array(32)),
|
|
13933
|
+
sequence: 1,
|
|
13934
|
+
files: [{
|
|
13935
|
+
path: "index.html",
|
|
13936
|
+
size: pageBytes.length,
|
|
13937
|
+
sha256: toBase64Url(createHash("sha256").update(pageBytes).digest())
|
|
13938
|
+
}]
|
|
13939
|
+
}, { strict: true });
|
|
13940
|
+
if (!checked.ok) {
|
|
13941
|
+
if (checked.detail === "name is not valid") throw new CliError("usage", what.name === void 0 ? `The folder's name gives no app name (${JSON.stringify(basename(dir))}): give --name, a lowercase letter then up to 31 lowercase letters, digits or hyphens: ${usage}` : `--name is a lowercase letter then up to 31 lowercase letters, digits or hyphens, not ${JSON.stringify(what.name)}`);
|
|
13942
|
+
if (checked.detail === "title is not valid") throw new CliError("usage", `--title is one line of 1 to ${APP_BUNDLE_LIMITS.title} characters, not ${JSON.stringify(title)}`);
|
|
13943
|
+
throw refused("The template makes no valid manifest", checked);
|
|
13944
|
+
}
|
|
13945
|
+
if (existsSync(dir) && !statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is a file: name a folder for the app`);
|
|
13946
|
+
const files = [
|
|
13947
|
+
[APP_SOURCE, `${JSON.stringify(templateManifest({
|
|
13948
|
+
name,
|
|
13949
|
+
title
|
|
13950
|
+
}), null, 2)}\n`],
|
|
13951
|
+
["index.html", page],
|
|
13952
|
+
["README.md", templateReadme({
|
|
13953
|
+
name,
|
|
13954
|
+
title
|
|
13955
|
+
})]
|
|
13956
|
+
];
|
|
13957
|
+
const held = files.map(([path]) => path).filter((path) => existsSync(join(dir, path)));
|
|
13958
|
+
if (held.length && !what.force) throw refused(`${dir} already holds ${held.join(", ")}: nothing was written (--force replaces ${held.length > 1 ? "them" : "it"})`, { reason: "exists" }, { files: held });
|
|
13959
|
+
mkdirSync(dir, { recursive: true });
|
|
13960
|
+
for (const [path, text] of files) writeWhole(join(dir, path), new TextEncoder().encode(text));
|
|
13961
|
+
return {
|
|
13962
|
+
dir,
|
|
13963
|
+
name,
|
|
13964
|
+
title,
|
|
13965
|
+
files: files.map(([path]) => path),
|
|
13966
|
+
...held.length ? { replaced: held } : {},
|
|
13967
|
+
next: `ghostly app publish ${dirArg} --key <a key file outside ${dirArg}>`
|
|
13968
|
+
};
|
|
13969
|
+
}
|
|
13333
13970
|
/** Every file of the folder, as the bundle names it; dot files and folders are left out and listed. */
|
|
13334
13971
|
function collectFiles(dir, leaveOut) {
|
|
13335
13972
|
const files = [];
|
|
@@ -13368,11 +14005,22 @@ function collectFiles(dir, leaveOut) {
|
|
|
13368
14005
|
}
|
|
13369
14006
|
};
|
|
13370
14007
|
walk(dir);
|
|
14008
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
13371
14009
|
return {
|
|
13372
14010
|
files,
|
|
13373
14011
|
skipped
|
|
13374
14012
|
};
|
|
13375
14013
|
}
|
|
14014
|
+
/**
|
|
14015
|
+
* A private key in a file: a PEM private key block with its body (PKCS #8, OpenSSH, RSA, EC, encrypted), or a Ghostly
|
|
14016
|
+
* key file's first line. A library that only names the block's header in its code does not match.
|
|
14017
|
+
*/
|
|
14018
|
+
var PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----\r?\n[A-Za-z0-9+/=\r\n:,-]{40,}-----END [A-Z0-9 ]*PRIVATE KEY-----|^Ghostly (publisher|store) key \(WISP 1200\)/;
|
|
14019
|
+
/** Whether `path` is `dir` or anything under it. */
|
|
14020
|
+
var isInside = (dir, path) => {
|
|
14021
|
+
const rel = relative(dir, path);
|
|
14022
|
+
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
14023
|
+
};
|
|
13376
14024
|
/** Fields of `ghostly-app.json` the command writes itself. */
|
|
13377
14025
|
var WRITTEN = ["publisher", "files"];
|
|
13378
14026
|
function readSource(path) {
|
|
@@ -13412,6 +14060,7 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
|
|
|
13412
14060
|
const dir = resolve(dirArg);
|
|
13413
14061
|
if (!statSync(dir).isDirectory()) throw new CliError("bad_request", `${dir} is not a folder`);
|
|
13414
14062
|
const keyPath = resolve(keyArg);
|
|
14063
|
+
if (isInside(dir, keyPath)) throw refused(`${keyPath} is inside ${dir}, which is published: keep the publisher key outside the app's folder (and its repository)`, { reason: "private-key" });
|
|
13415
14064
|
const out = resolve(outArg ?? join(dir, "app.ghostlyapp"));
|
|
13416
14065
|
const sourcePath = join(dir, APP_SOURCE);
|
|
13417
14066
|
const draft = readSource(sourcePath);
|
|
@@ -13438,6 +14087,11 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
|
|
|
13438
14087
|
path: f.path,
|
|
13439
14088
|
bytes: new Uint8Array(readFileSync(f.full))
|
|
13440
14089
|
}));
|
|
14090
|
+
const keyFile = contents.find((f) => PRIVATE_KEY.test(Buffer.from(f.bytes).toString("latin1")));
|
|
14091
|
+
if (keyFile) throw refused(`${keyFile.path} in ${dir} holds a private key, and a bundle is public: move it out of the app's folder`, {
|
|
14092
|
+
reason: "private-key",
|
|
14093
|
+
detail: keyFile.path
|
|
14094
|
+
});
|
|
13441
14095
|
const manifest = {
|
|
13442
14096
|
...draft,
|
|
13443
14097
|
ghostlyApp: 1,
|
|
@@ -13446,10 +14100,10 @@ async function publishApp(dirArg, keyArg, outArg, sequenceArg) {
|
|
|
13446
14100
|
files: contents.map((f) => ({
|
|
13447
14101
|
path: f.path,
|
|
13448
14102
|
size: f.bytes.length,
|
|
13449
|
-
sha256: toBase64Url
|
|
14103
|
+
sha256: toBase64Url(createHash("sha256").update(f.bytes).digest())
|
|
13450
14104
|
}))
|
|
13451
14105
|
};
|
|
13452
|
-
const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)));
|
|
14106
|
+
const checked = checkAppManifest(JSON.parse(JSON.stringify(manifest)), { strict: true });
|
|
13453
14107
|
if (!checked.ok) throw refused(`${sourcePath} and the folder's files make no valid manifest`, checked);
|
|
13454
14108
|
const { bytes: manifestBytes, signature } = await signAppObject(APP_PREFIXES.app, checked.manifest, key.signer);
|
|
13455
14109
|
const bundle = assembleAppBundle(manifestBytes, canonicalJsonBytes(signature), contents.map((f) => f.bytes));
|
|
@@ -13564,7 +14218,7 @@ async function signStore(indexArg, keyArg, outArg, now = Math.floor(Date.now() /
|
|
|
13564
14218
|
else key = makeKey(keyPath, "store");
|
|
13565
14219
|
if (index.key !== void 0 && index.key !== key.key) throw refused(`${indexPath} names another store key than ${keyPath}'s (${appFingerprint(key.key)})`, { reason: "store-key" });
|
|
13566
14220
|
index.key = key.key;
|
|
13567
|
-
const checked = checkAppStoreIndex(index);
|
|
14221
|
+
const checked = checkAppStoreIndex(index, { strict: true });
|
|
13568
14222
|
if (!checked.ok) throw refused(`${indexPath} is not a valid store index`, checked);
|
|
13569
14223
|
const { indexBytes, sigBytes } = await signAppStore(index, key.signer);
|
|
13570
14224
|
const read = readAppStore(indexBytes, sigBytes, now, key.key);
|
|
@@ -13627,6 +14281,31 @@ var key = {
|
|
|
13627
14281
|
* per command, in alphabetical order (test/commands.test.ts checks).
|
|
13628
14282
|
*/
|
|
13629
14283
|
var commands$8 = {
|
|
14284
|
+
"app init": {
|
|
14285
|
+
method: "app.init",
|
|
14286
|
+
usage: "app init <dir> [--name <name>] [--title <title>] [--force]",
|
|
14287
|
+
summary: "Start an app (WISP 1200): a small working one in <dir>, its ghostly-app.json, an index.html that says hello to the contact and a README, ready for app publish",
|
|
14288
|
+
args: ["dir"],
|
|
14289
|
+
options: {
|
|
14290
|
+
name: {
|
|
14291
|
+
type: "string",
|
|
14292
|
+
description: "The app's name, lowercase letters, digits and hyphens (default: the folder's)"
|
|
14293
|
+
},
|
|
14294
|
+
title: {
|
|
14295
|
+
type: "string",
|
|
14296
|
+
description: "The title people see, up to 40 characters (default: from the name)"
|
|
14297
|
+
},
|
|
14298
|
+
force: {
|
|
14299
|
+
type: "boolean",
|
|
14300
|
+
description: "Replace the three files when they are there"
|
|
14301
|
+
}
|
|
14302
|
+
},
|
|
14303
|
+
run: ({ options }, a) => initApp(a.dir, {
|
|
14304
|
+
name: options.name,
|
|
14305
|
+
title: options.title,
|
|
14306
|
+
force: options.force === true
|
|
14307
|
+
})
|
|
14308
|
+
},
|
|
13630
14309
|
"app publish": {
|
|
13631
14310
|
method: "app.publish",
|
|
13632
14311
|
usage: "app publish <dir> --key <file> [--out <file>]",
|
|
@@ -13804,6 +14483,17 @@ var showSecret = { "show-secret": {
|
|
|
13804
14483
|
description: "Print the group's entry link (anyone who has it can join)"
|
|
13805
14484
|
} };
|
|
13806
14485
|
var secret = (options) => options["show-secret"] === true ? { showSecret: true } : {};
|
|
14486
|
+
/**
|
|
14487
|
+
* `--for` of the typing commands: the seconds it is kept on, 1 to 600, and never with `--stop`. A usage error (exit 2)
|
|
14488
|
+
* that names the flag: before, the API's own check answered ("for must be a number from 1 to 600", exit 1).
|
|
14489
|
+
*/
|
|
14490
|
+
function typingFor(options) {
|
|
14491
|
+
const seconds = options.for;
|
|
14492
|
+
if (seconds === void 0) return void 0;
|
|
14493
|
+
if (typeof seconds !== "number" || !(seconds >= 1 && seconds <= 600)) throw new CliError("usage", `--for takes a number of seconds from 1 to 600, not ${JSON.stringify(seconds)}`);
|
|
14494
|
+
if (options.stop === true) throw new CliError("usage", "--for keeps it on: not with --stop");
|
|
14495
|
+
return seconds;
|
|
14496
|
+
}
|
|
13807
14497
|
/** A history cursor: a timestamp when it is all digits, else a message id. */
|
|
13808
14498
|
function cursor(value) {
|
|
13809
14499
|
if (value === void 0) return void 0;
|
|
@@ -13845,6 +14535,10 @@ var cardOptions = {
|
|
|
13845
14535
|
text: {
|
|
13846
14536
|
type: "string",
|
|
13847
14537
|
description: "The text older apps show instead (default: written from the card)"
|
|
14538
|
+
},
|
|
14539
|
+
force: {
|
|
14540
|
+
type: "boolean",
|
|
14541
|
+
description: "Send even if the card's text looks like a seed, a key or ecash"
|
|
13848
14542
|
}
|
|
13849
14543
|
};
|
|
13850
14544
|
/** `--link label=https://…` or a bare link, as a card's links. */
|
|
@@ -13878,7 +14572,26 @@ function itemOf(value) {
|
|
|
13878
14572
|
text: value.slice(colon + 1).trim()
|
|
13879
14573
|
};
|
|
13880
14574
|
}
|
|
13881
|
-
/**
|
|
14575
|
+
/** `--progress`, `--left`: a percent, or a usage error that names the flag. */
|
|
14576
|
+
function percent(options, flag) {
|
|
14577
|
+
const value = options[flag];
|
|
14578
|
+
if (value !== void 0 && !(typeof value === "number" && value >= 0 && value <= 100)) throw new CliError("usage", `--${flag} takes a percent from 0 to 100, not ${JSON.stringify(value)}`);
|
|
14579
|
+
}
|
|
14580
|
+
/** The flags of a task's pull request, and the field of `pr` each one gives. */
|
|
14581
|
+
var PR_FLAGS = [
|
|
14582
|
+
["pr-url", "url"],
|
|
14583
|
+
["pr-number", "number"],
|
|
14584
|
+
["additions", "additions"],
|
|
14585
|
+
["deletions", "deletions"],
|
|
14586
|
+
["files", "files"],
|
|
14587
|
+
["pr-state", "state"],
|
|
14588
|
+
["pr-checks", "checks"]
|
|
14589
|
+
];
|
|
14590
|
+
/**
|
|
14591
|
+
* A task's fields from its flags; what was not given is left out, so an update changes only what it names. What a flag
|
|
14592
|
+
* got wrong is a usage error (exit 2) that names the flag: before, it reached the card's own check and failed as
|
|
14593
|
+
* `bad_request` (exit 1) in the card's words ("Status card: done is at most total").
|
|
14594
|
+
*/
|
|
13882
14595
|
function taskFields(options) {
|
|
13883
14596
|
const fields = { ...cardJson(options.json) };
|
|
13884
14597
|
const set = (key, value) => {
|
|
@@ -13887,6 +14600,7 @@ function taskFields(options) {
|
|
|
13887
14600
|
set("id", options.id);
|
|
13888
14601
|
set("title", options.title);
|
|
13889
14602
|
set("status", options.status);
|
|
14603
|
+
percent(options, "progress");
|
|
13890
14604
|
set("progress", options.progress);
|
|
13891
14605
|
set("step", options.step);
|
|
13892
14606
|
set("branch", options.branch);
|
|
@@ -13895,22 +14609,18 @@ function taskFields(options) {
|
|
|
13895
14609
|
if (!match) throw new CliError("usage", `--steps takes done/total, like 2/5, not ${JSON.stringify(options.steps)}`);
|
|
13896
14610
|
fields.done = Number(match[1]);
|
|
13897
14611
|
fields.total = Number(match[2]);
|
|
14612
|
+
if (Number(match[2]) < 1 || Number(match[1]) > Number(match[2])) throw new CliError("usage", `--steps takes done/total with done at most total, like 2/5, not ${JSON.stringify(options.steps)}`);
|
|
13898
14613
|
}
|
|
14614
|
+
if (options["pr-url"] !== void 0 && !cardUrl(options["pr-url"])) throw new CliError("usage", `--pr-url takes an https link of at most ${STATUS_CARD_LIMITS.url} characters, not ${JSON.stringify(options["pr-url"])}`);
|
|
14615
|
+
for (const [flag, words] of [["pr-state", PR_STATES], ["pr-checks", PR_CHECKS]]) if (options[flag] !== void 0 && !words.includes(options[flag])) throw new CliError("usage", `--${flag} takes ${listed(words)}, not ${JSON.stringify(options[flag])}`);
|
|
13899
14616
|
const pr = {};
|
|
13900
|
-
for (const [flag, key] of [
|
|
13901
|
-
["pr-url", "url"],
|
|
13902
|
-
["pr-number", "number"],
|
|
13903
|
-
["additions", "additions"],
|
|
13904
|
-
["deletions", "deletions"],
|
|
13905
|
-
["files", "files"],
|
|
13906
|
-
["pr-state", "state"],
|
|
13907
|
-
["pr-checks", "checks"]
|
|
13908
|
-
]) if (options[flag] !== void 0) pr[key] = options[flag];
|
|
14617
|
+
for (const [flag, key] of PR_FLAGS) if (options[flag] !== void 0) pr[key] = options[flag];
|
|
13909
14618
|
if (Object.keys(pr).length) fields.pr = {
|
|
13910
14619
|
...fields.pr && typeof fields.pr === "object" ? fields.pr : {},
|
|
13911
14620
|
...pr
|
|
13912
14621
|
};
|
|
13913
14622
|
if (Array.isArray(options.item) && options.item.length) fields.items = options.item.map(itemOf);
|
|
14623
|
+
if (Array.isArray(options.tag) && options.tag.length > STATUS_CARD_LIMITS.tags) throw new CliError("usage", `--tag is given at most ${STATUS_CARD_LIMITS.tags} times, not ${options.tag.length}`);
|
|
13914
14624
|
if (Array.isArray(options.tag) && options.tag.length) fields.tags = options.tag;
|
|
13915
14625
|
set("parent", options.parent);
|
|
13916
14626
|
const links = cardLinks(options.link);
|
|
@@ -13927,6 +14637,25 @@ function needs(card, fields, usage) {
|
|
|
13927
14637
|
if (missing.length) throw new CliError("usage", `${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} needed: ghostly ${usage}`);
|
|
13928
14638
|
return card;
|
|
13929
14639
|
}
|
|
14640
|
+
/**
|
|
14641
|
+
* A new task's pull request goes by its link: `--pr-state` and the like without `--pr-url` (or `pr.url` in `--json`)
|
|
14642
|
+
* are a usage error that names them. An update's card may have the link already, so only a send asks.
|
|
14643
|
+
*/
|
|
14644
|
+
function prLinked(card, options, usage) {
|
|
14645
|
+
const given = PR_FLAGS.filter(([flag]) => options[flag] !== void 0).map(([flag]) => `--${flag}`);
|
|
14646
|
+
if (!given.length || card.pr.url !== void 0) return card;
|
|
14647
|
+
throw new CliError("usage", `${given.join(" and ")} ${given.length > 1 ? "go" : "goes"} with --pr-url: ghostly ${usage}`);
|
|
14648
|
+
}
|
|
14649
|
+
/**
|
|
14650
|
+
* A usage card says how much is left: `--left`, or `--used` with `--limit`, as flags or in `--json`. The one that is
|
|
14651
|
+
* missing is a usage error that names the flag.
|
|
14652
|
+
*/
|
|
14653
|
+
function quota(card, usage) {
|
|
14654
|
+
const used = card.used !== void 0;
|
|
14655
|
+
if (used !== (card.limit !== void 0)) throw new CliError("usage", `--${used ? "used" : "limit"} goes with --${used ? "limit" : "used"}: ghostly ${usage}`);
|
|
14656
|
+
if (card.left === void 0 && !used) throw new CliError("usage", `--left is needed (or --used with --limit): ghostly ${usage}`);
|
|
14657
|
+
return card;
|
|
14658
|
+
}
|
|
13930
14659
|
/** A time a command names: milliseconds, or a date the JavaScript Date reads (`2026-09-30T01:00:00Z`). */
|
|
13931
14660
|
function cardTime(value, flag) {
|
|
13932
14661
|
if (value === void 0) return void 0;
|
|
@@ -14075,6 +14804,7 @@ function usageWindowOf(value) {
|
|
|
14075
14804
|
/** A usage card's fields from its flags (WISP 405 § Usage); `--json` gives any of them, the flags win over it. */
|
|
14076
14805
|
function usageFields(options) {
|
|
14077
14806
|
const fields = { ...cardJson(options.json) };
|
|
14807
|
+
percent(options, "left");
|
|
14078
14808
|
for (const key of [
|
|
14079
14809
|
"id",
|
|
14080
14810
|
"left",
|
|
@@ -14132,7 +14862,8 @@ var usageOptions = {
|
|
|
14132
14862
|
description: "Every 1:1 chat with a contact, instead of one chat"
|
|
14133
14863
|
},
|
|
14134
14864
|
json: cardOptions.json,
|
|
14135
|
-
text: cardOptions.text
|
|
14865
|
+
text: cardOptions.text,
|
|
14866
|
+
force: cardOptions.force
|
|
14136
14867
|
};
|
|
14137
14868
|
//#endregion
|
|
14138
14869
|
//#region src/commands/calls.ts
|
|
@@ -14142,7 +14873,7 @@ var commands$7 = {
|
|
|
14142
14873
|
method: "call.answer",
|
|
14143
14874
|
usage: "call answer [<chat|call>] [--rate 48000]",
|
|
14144
14875
|
summary: "Answer a call that rings (the only one, or the one named)",
|
|
14145
|
-
args: ["call
|
|
14876
|
+
args: ["call...?"],
|
|
14146
14877
|
options: { rate },
|
|
14147
14878
|
params: ({ options }, a) => ({
|
|
14148
14879
|
call: a.call,
|
|
@@ -14153,7 +14884,7 @@ var commands$7 = {
|
|
|
14153
14884
|
method: "call.auto",
|
|
14154
14885
|
usage: "call auto [on|off] [--from <chat>]... [--rate 48000]",
|
|
14155
14886
|
summary: "Answer calls by themselves: from anyone, or the chats named (kept in the profile)",
|
|
14156
|
-
args: ["mode
|
|
14887
|
+
args: ["mode...?"],
|
|
14157
14888
|
options: {
|
|
14158
14889
|
from: {
|
|
14159
14890
|
type: "list",
|
|
@@ -14175,14 +14906,14 @@ var commands$7 = {
|
|
|
14175
14906
|
method: "call.flush",
|
|
14176
14907
|
usage: "call flush [<chat|call>]",
|
|
14177
14908
|
summary: "Drop the audio queued and not played yet (barge-in)",
|
|
14178
|
-
args: ["call
|
|
14909
|
+
args: ["call...?"],
|
|
14179
14910
|
params: (_, a) => ({ call: a.call })
|
|
14180
14911
|
},
|
|
14181
14912
|
"call hangup": {
|
|
14182
14913
|
method: "call.hangup",
|
|
14183
14914
|
usage: "call hangup [<chat|call>]",
|
|
14184
14915
|
summary: "Hang up, or decline a call that rings",
|
|
14185
|
-
args: ["call
|
|
14916
|
+
args: ["call...?"],
|
|
14186
14917
|
params: (_, a) => ({ call: a.call })
|
|
14187
14918
|
},
|
|
14188
14919
|
"call list": {
|
|
@@ -14204,6 +14935,7 @@ var commands$7 = {
|
|
|
14204
14935
|
};
|
|
14205
14936
|
//#endregion
|
|
14206
14937
|
//#region src/commands/chats.ts
|
|
14938
|
+
var BUTTON_UPDATE = "button update <chat|group> <message> [--chosen <button>] [--close] [--text <text>] [--force] [--wait none|confirmed|sent] [--timeout s]";
|
|
14207
14939
|
/** Invites, chats and messages: one entry per command, in alphabetical order (test/commands.test.ts checks). */
|
|
14208
14940
|
var commands$6 = {
|
|
14209
14941
|
"button press": {
|
|
@@ -14232,7 +14964,7 @@ var commands$6 = {
|
|
|
14232
14964
|
},
|
|
14233
14965
|
"button update": {
|
|
14234
14966
|
method: "button.update",
|
|
14235
|
-
usage:
|
|
14967
|
+
usage: BUTTON_UPDATE,
|
|
14236
14968
|
summary: "Show the answer on a message's buttons (--chosen), close them, or change its text (the messageId send --button gave)",
|
|
14237
14969
|
args: ["chat", "message"],
|
|
14238
14970
|
options: {
|
|
@@ -14255,16 +14987,19 @@ var commands$6 = {
|
|
|
14255
14987
|
},
|
|
14256
14988
|
timeout: groupWait.timeout
|
|
14257
14989
|
},
|
|
14258
|
-
params: ({ options }, a) =>
|
|
14259
|
-
|
|
14260
|
-
|
|
14261
|
-
|
|
14262
|
-
|
|
14263
|
-
|
|
14264
|
-
|
|
14265
|
-
|
|
14266
|
-
|
|
14267
|
-
|
|
14990
|
+
params: ({ options }, a) => {
|
|
14991
|
+
if (!options.chosen && options.close !== true && !options.text) throw new CliError("usage", `Give --chosen, --close or --text: ghostly ${BUTTON_UPDATE}`);
|
|
14992
|
+
return {
|
|
14993
|
+
chat: a.chat,
|
|
14994
|
+
message: a.message,
|
|
14995
|
+
chosen: options.chosen,
|
|
14996
|
+
close: options.close === true,
|
|
14997
|
+
text: options.text,
|
|
14998
|
+
force: options.force === true,
|
|
14999
|
+
wait: options.wait,
|
|
15000
|
+
timeout: options.timeout
|
|
15001
|
+
};
|
|
15002
|
+
}
|
|
14268
15003
|
},
|
|
14269
15004
|
"chat connect": {
|
|
14270
15005
|
method: "chat.connect",
|
|
@@ -14336,7 +15071,7 @@ var commands$6 = {
|
|
|
14336
15071
|
method: "chat.rename",
|
|
14337
15072
|
usage: "chat rename <chat> <name...>",
|
|
14338
15073
|
summary: "Name a chat on this side (empty: the contact's name)",
|
|
14339
|
-
args: ["chat", "name
|
|
15074
|
+
args: ["chat", "name...?"],
|
|
14340
15075
|
params: (_, { chat, name }) => ({
|
|
14341
15076
|
chat,
|
|
14342
15077
|
name: name ?? ""
|
|
@@ -14368,10 +15103,13 @@ var commands$6 = {
|
|
|
14368
15103
|
type: "string",
|
|
14369
15104
|
description: "The code the contact reads out"
|
|
14370
15105
|
} },
|
|
14371
|
-
params: ({ options }, { chat }) =>
|
|
14372
|
-
chat
|
|
14373
|
-
|
|
14374
|
-
|
|
15106
|
+
params: ({ options }, { chat }) => {
|
|
15107
|
+
if (typeof options.code !== "string" || !options.code.trim()) throw new CliError("usage", "--code is needed: ghostly chat verify <chat> --code <code>");
|
|
15108
|
+
return {
|
|
15109
|
+
chat,
|
|
15110
|
+
code: options.code
|
|
15111
|
+
};
|
|
15112
|
+
}
|
|
14375
15113
|
},
|
|
14376
15114
|
"chat wait": {
|
|
14377
15115
|
method: "chat.wait",
|
|
@@ -14414,14 +15152,17 @@ var commands$6 = {
|
|
|
14414
15152
|
},
|
|
14415
15153
|
timeout: groupWait.timeout
|
|
14416
15154
|
},
|
|
14417
|
-
params: ({ options }, a) =>
|
|
14418
|
-
|
|
14419
|
-
|
|
14420
|
-
|
|
14421
|
-
|
|
14422
|
-
|
|
14423
|
-
|
|
14424
|
-
|
|
15155
|
+
params: ({ options }, a) => {
|
|
15156
|
+
if (!Array.isArray(options.to) || !options.to.length) throw new CliError("usage", "Missing --to: ghostly forward <chat|group> <message>... --to <chat|group>... [--force] [--wait none|sent] [--timeout s]");
|
|
15157
|
+
return {
|
|
15158
|
+
chat: a.chat,
|
|
15159
|
+
messages: (a.message ?? "").split(" ").filter(Boolean),
|
|
15160
|
+
to: options.to,
|
|
15161
|
+
force: options.force === true,
|
|
15162
|
+
wait: options.wait,
|
|
15163
|
+
timeout: options.timeout
|
|
15164
|
+
};
|
|
15165
|
+
}
|
|
14425
15166
|
},
|
|
14426
15167
|
"invite create": {
|
|
14427
15168
|
method: "invite.create",
|
|
@@ -14479,18 +15220,22 @@ var commands$6 = {
|
|
|
14479
15220
|
},
|
|
14480
15221
|
"pin": {
|
|
14481
15222
|
method: "chat.pin",
|
|
14482
|
-
usage: "pin <chat|group> <message>
|
|
15223
|
+
usage: "pin <chat|group> <message> | --remove",
|
|
14483
15224
|
summary: "Pin a message at the top of a chat or group (one per chat: it replaces the pinned one); --remove unpins",
|
|
14484
|
-
args: ["chat", "message"],
|
|
15225
|
+
args: ["chat", "message...?"],
|
|
14485
15226
|
options: { remove: {
|
|
14486
15227
|
type: "boolean",
|
|
14487
|
-
description: "Unpin the chat's pinned message"
|
|
15228
|
+
description: "Unpin the chat's pinned message (it names no message)"
|
|
14488
15229
|
} },
|
|
14489
|
-
params: ({ options }, a) =>
|
|
14490
|
-
|
|
14491
|
-
|
|
14492
|
-
|
|
14493
|
-
|
|
15230
|
+
params: ({ options }, a) => {
|
|
15231
|
+
if (a.message === void 0 && options.remove !== true) throw new CliError("usage", "Missing <message>: ghostly pin <chat|group> <message> | --remove");
|
|
15232
|
+
if (a.message?.includes(" ")) throw new CliError("usage", "Too many arguments: ghostly pin <chat|group> <message> | --remove");
|
|
15233
|
+
return {
|
|
15234
|
+
chat: a.chat,
|
|
15235
|
+
message: a.message,
|
|
15236
|
+
remove: options.remove === true
|
|
15237
|
+
};
|
|
15238
|
+
}
|
|
14494
15239
|
},
|
|
14495
15240
|
"react": {
|
|
14496
15241
|
method: "chat.react",
|
|
@@ -14499,7 +15244,7 @@ var commands$6 = {
|
|
|
14499
15244
|
args: [
|
|
14500
15245
|
"chat",
|
|
14501
15246
|
"message",
|
|
14502
|
-
"emoji
|
|
15247
|
+
"emoji...?"
|
|
14503
15248
|
],
|
|
14504
15249
|
options: { remove: {
|
|
14505
15250
|
type: "boolean",
|
|
@@ -14514,7 +15259,7 @@ var commands$6 = {
|
|
|
14514
15259
|
},
|
|
14515
15260
|
"routine send": {
|
|
14516
15261
|
method: "routine.send",
|
|
14517
|
-
usage: "routine send <chat|group> --name <name> --schedule \"every day 01:00\" [--cron \"0 1 * * *\"] [--state active|paused] [--next <time>] [--run ok|failed|skipped[:summary]] [--link url]... [--id id] [--json json|-|file] [--wait none|sent|delivered] [--timeout s]",
|
|
15262
|
+
usage: "routine send <chat|group> --name <name> --schedule \"every day 01:00\" [--cron \"0 1 * * *\"] [--state active|paused] [--next <time>] [--run ok|failed|skipped[:summary]] [--link url]... [--id id] [--json json|-|file] [--force] [--wait none|sent|delivered] [--timeout s]",
|
|
14518
15263
|
summary: "Send a routine card (something a bot runs on a schedule: its last run and the next) to a chat or group",
|
|
14519
15264
|
args: ["chat"],
|
|
14520
15265
|
options: {
|
|
@@ -14531,13 +15276,14 @@ var commands$6 = {
|
|
|
14531
15276
|
card: needs(routineFields(options), [["name", "name"], ["schedule", "schedule"]], "routine send <chat|group> --name <name> --schedule \"every day 01:00\""),
|
|
14532
15277
|
run: runOf(options.run),
|
|
14533
15278
|
text: options.text,
|
|
15279
|
+
force: options.force === true,
|
|
14534
15280
|
wait: options.wait,
|
|
14535
15281
|
timeout: options.timeout
|
|
14536
15282
|
})
|
|
14537
15283
|
},
|
|
14538
15284
|
"routine update": {
|
|
14539
15285
|
method: "routine.update",
|
|
14540
|
-
usage: "routine update <chat|group> <routine> [--run ok|failed|skipped[:summary]] [--next <time>] [--state active|paused] [--schedule ...] [--json json|-|file] [--wait none|confirmed|sent] [--timeout s]",
|
|
15286
|
+
usage: "routine update <chat|group> <routine> [--run ok|failed|skipped[:summary]] [--next <time>] [--state active|paused] [--schedule ...] [--json json|-|file] [--force] [--wait none|confirmed|sent] [--timeout s]",
|
|
14541
15287
|
summary: "Update a routine card you sent: record a run, the next one, a pause (at most one update per 2.5 s; sooner ones merge)",
|
|
14542
15288
|
args: ["chat", "routine"],
|
|
14543
15289
|
options: {
|
|
@@ -14554,13 +15300,14 @@ var commands$6 = {
|
|
|
14554
15300
|
card: routineFields(options),
|
|
14555
15301
|
run: runOf(options.run),
|
|
14556
15302
|
text: options.text,
|
|
15303
|
+
force: options.force === true,
|
|
14557
15304
|
wait: options.wait,
|
|
14558
15305
|
timeout: options.timeout
|
|
14559
15306
|
})
|
|
14560
15307
|
},
|
|
14561
15308
|
"task send": {
|
|
14562
15309
|
method: "task.send",
|
|
14563
|
-
usage: "task send <chat|group> --title <title> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url url --pr-number n --additions n --deletions n --files n --pr-state s --pr-checks c] [--branch b] [--tag label]... [--parent task] [--link url]... [--id id] [--json json|-|file] [--wait none|sent|delivered] [--timeout s]",
|
|
15310
|
+
usage: "task send <chat|group> --title <title> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url url --pr-number n --additions n --deletions n --files n --pr-state s --pr-checks c] [--branch b] [--tag label]... [--parent task] [--link url]... [--id id] [--json json|-|file] [--force] [--wait none|sent|delivered] [--timeout s]",
|
|
14564
15311
|
summary: "Send a task card (a bot's work: status, progress, its PR) to a chat or group; prints its id and message",
|
|
14565
15312
|
args: ["chat"],
|
|
14566
15313
|
options: {
|
|
@@ -14574,15 +15321,16 @@ var commands$6 = {
|
|
|
14574
15321
|
},
|
|
14575
15322
|
params: ({ options }, { chat }) => ({
|
|
14576
15323
|
chat,
|
|
14577
|
-
card: needs(taskFields(options), [["title", "title"]], "task send <chat|group> --title <title>"),
|
|
15324
|
+
card: prLinked(needs(taskFields(options), [["title", "title"]], "task send <chat|group> --title <title>"), options, "task send <chat|group> --title <title> --pr-url <url>"),
|
|
14578
15325
|
text: options.text,
|
|
15326
|
+
force: options.force === true,
|
|
14579
15327
|
wait: options.wait,
|
|
14580
15328
|
timeout: options.timeout
|
|
14581
15329
|
})
|
|
14582
15330
|
},
|
|
14583
15331
|
"task update": {
|
|
14584
15332
|
method: "task.update",
|
|
14585
|
-
usage: "task update <chat|group> <task> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url ...] [--pr-state s] [--pr-checks c] [--tag label]... [--parent task] [--json json|-|file] [--wait none|confirmed|sent] [--timeout s]",
|
|
15333
|
+
usage: "task update <chat|group> <task> [--status s] [--progress n | --steps a/b] [--step \"...\"] [--item state:text]... [--pr-url ...] [--pr-state s] [--pr-checks c] [--tag label]... [--parent task] [--json json|-|file] [--force] [--wait none|confirmed|sent] [--timeout s]",
|
|
14586
15334
|
summary: "Update a task card you sent: the fields given, merged over it (at most one update per 2.5 s; sooner ones merge)",
|
|
14587
15335
|
args: ["chat", "task"],
|
|
14588
15336
|
options: {
|
|
@@ -14598,6 +15346,7 @@ var commands$6 = {
|
|
|
14598
15346
|
task: a.task,
|
|
14599
15347
|
card: taskFields(options),
|
|
14600
15348
|
text: options.text,
|
|
15349
|
+
force: options.force === true,
|
|
14601
15350
|
wait: options.wait,
|
|
14602
15351
|
timeout: options.timeout
|
|
14603
15352
|
})
|
|
@@ -14630,12 +15379,12 @@ var commands$6 = {
|
|
|
14630
15379
|
stop: options.stop === true,
|
|
14631
15380
|
kind: options.kind,
|
|
14632
15381
|
status: options.status,
|
|
14633
|
-
for: options
|
|
15382
|
+
for: typingFor(options)
|
|
14634
15383
|
})
|
|
14635
15384
|
},
|
|
14636
15385
|
"usage send": {
|
|
14637
15386
|
method: "usage.send",
|
|
14638
|
-
usage: "usage send [<chat|group>] [--all] --left <percent> [--window \"5 h\"] [--resets <time>] [--label Claude] [--account <name>] [--also week=80@<time>]... [--used n --limit n] [--id id] [--json json|-|file] [--wait none|sent] [--timeout s]",
|
|
15387
|
+
usage: "usage send [<chat|group>] [--all] --left <percent> [--window \"5 h\"] [--resets <time>] [--label Claude] [--account <name>] [--also week=80@<time>]... [--used n --limit n] [--id id] [--json json|-|file] [--force] [--wait none|sent] [--timeout s]",
|
|
14639
15388
|
summary: "Say how much of your quota is left (a bot's usage card, one per chat, updated in place); --all for every 1:1 chat",
|
|
14640
15389
|
args: ["chat?"],
|
|
14641
15390
|
options: {
|
|
@@ -14647,8 +15396,9 @@ var commands$6 = {
|
|
|
14647
15396
|
return {
|
|
14648
15397
|
chat,
|
|
14649
15398
|
all: options.all === true,
|
|
14650
|
-
card: usageFields(options),
|
|
15399
|
+
card: quota(usageFields(options), "usage send <chat|--all> --left <percent>"),
|
|
14651
15400
|
text: options.text,
|
|
15401
|
+
force: options.force === true,
|
|
14652
15402
|
wait: options.wait,
|
|
14653
15403
|
timeout: options.timeout
|
|
14654
15404
|
};
|
|
@@ -14849,15 +15599,18 @@ var commands$4 = {
|
|
|
14849
15599
|
method: "profile.picture",
|
|
14850
15600
|
usage: "profile picture <jpeg> | --clear",
|
|
14851
15601
|
summary: "The picture contacts see (a square JPEG, 128 px is what the app sends)",
|
|
14852
|
-
args: ["path
|
|
15602
|
+
args: ["path...?"],
|
|
14853
15603
|
options: { clear: {
|
|
14854
15604
|
type: "boolean",
|
|
14855
15605
|
description: "Remove the picture"
|
|
14856
15606
|
} },
|
|
14857
|
-
params: ({ options }, a) =>
|
|
14858
|
-
|
|
14859
|
-
|
|
14860
|
-
|
|
15607
|
+
params: ({ options }, a) => {
|
|
15608
|
+
if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly profile picture <jpeg> | --clear");
|
|
15609
|
+
return {
|
|
15610
|
+
path: here(a.path),
|
|
15611
|
+
clear: options.clear === true
|
|
15612
|
+
};
|
|
15613
|
+
}
|
|
14861
15614
|
},
|
|
14862
15615
|
"status": {
|
|
14863
15616
|
method: "status",
|
|
@@ -15034,16 +15787,19 @@ var commands$3 = {
|
|
|
15034
15787
|
method: "group.picture",
|
|
15035
15788
|
usage: "group picture <group> <jpeg> | --clear",
|
|
15036
15789
|
summary: "Set or clear the group's picture (admin; a square JPEG)",
|
|
15037
|
-
args: ["group", "path
|
|
15790
|
+
args: ["group", "path...?"],
|
|
15038
15791
|
options: { clear: {
|
|
15039
15792
|
type: "boolean",
|
|
15040
15793
|
description: "Remove the picture"
|
|
15041
15794
|
} },
|
|
15042
|
-
params: ({ options }, a) =>
|
|
15043
|
-
|
|
15044
|
-
|
|
15045
|
-
|
|
15046
|
-
|
|
15795
|
+
params: ({ options }, a) => {
|
|
15796
|
+
if (a.path === void 0 && options.clear !== true) throw new CliError("usage", "Missing <jpeg>: ghostly group picture <group> <jpeg> | --clear");
|
|
15797
|
+
return {
|
|
15798
|
+
group: a.group,
|
|
15799
|
+
path: here(a.path),
|
|
15800
|
+
clear: options.clear === true
|
|
15801
|
+
};
|
|
15802
|
+
}
|
|
15047
15803
|
},
|
|
15048
15804
|
"group react": {
|
|
15049
15805
|
method: "group.react",
|
|
@@ -15052,7 +15808,7 @@ var commands$3 = {
|
|
|
15052
15808
|
args: [
|
|
15053
15809
|
"group",
|
|
15054
15810
|
"message",
|
|
15055
|
-
"emoji
|
|
15811
|
+
"emoji...?"
|
|
15056
15812
|
],
|
|
15057
15813
|
options: { remove: {
|
|
15058
15814
|
type: "boolean",
|
|
@@ -15139,7 +15895,7 @@ var commands$3 = {
|
|
|
15139
15895
|
stop: options.stop === true,
|
|
15140
15896
|
kind: options.kind,
|
|
15141
15897
|
status: options.status,
|
|
15142
|
-
for: options
|
|
15898
|
+
for: typingFor(options)
|
|
15143
15899
|
})
|
|
15144
15900
|
}
|
|
15145
15901
|
};
|
|
@@ -15662,7 +16418,7 @@ var TEXT_COMMANDS = {
|
|
|
15662
16418
|
"send": {
|
|
15663
16419
|
method: "chat.send",
|
|
15664
16420
|
target: "chat",
|
|
15665
|
-
args: ["chat", "text
|
|
16421
|
+
args: ["chat", "text...?"],
|
|
15666
16422
|
usage: "send <chat> [text...] [--reply <message>] [--button id:Label]... [--style id=primary|neutral|danger]... [--once] [--id id] [--stdin] [--force] [--wait none|sent|delivered]",
|
|
15667
16423
|
summary: "Send a message (text from arguments or stdin); --button puts buttons under it, each label its own (case aside)",
|
|
15668
16424
|
options: {
|
|
@@ -15683,7 +16439,7 @@ var TEXT_COMMANDS = {
|
|
|
15683
16439
|
args: [
|
|
15684
16440
|
"chat",
|
|
15685
16441
|
"message",
|
|
15686
|
-
"text
|
|
16442
|
+
"text...?"
|
|
15687
16443
|
],
|
|
15688
16444
|
usage: "edit <chat> <message> [text... | --text <text> | --stdin] [--force] [--wait none|confirmed]",
|
|
15689
16445
|
summary: "Replace the text of a message you sent (the id send gave; in a group, group edit)",
|
|
@@ -15707,7 +16463,7 @@ var TEXT_COMMANDS = {
|
|
|
15707
16463
|
"group send": {
|
|
15708
16464
|
method: "group.send",
|
|
15709
16465
|
target: "group",
|
|
15710
|
-
args: ["group", "text
|
|
16466
|
+
args: ["group", "text...?"],
|
|
15711
16467
|
usage: "group send <group> [text...] [--mention <member>]... [--reply <message>] [--button id:Label]... [--style id=primary|neutral|danger]... [--once] [--id id] [--stdin] [--force] [--wait none|sent] [--timeout s]",
|
|
15712
16468
|
summary: "Send to a group; mention members written as @name in the text; --button puts buttons under it",
|
|
15713
16469
|
options: {
|
|
@@ -15732,7 +16488,7 @@ var TEXT_COMMANDS = {
|
|
|
15732
16488
|
args: [
|
|
15733
16489
|
"group",
|
|
15734
16490
|
"message",
|
|
15735
|
-
"text
|
|
16491
|
+
"text...?"
|
|
15736
16492
|
],
|
|
15737
16493
|
usage: "group edit <group> <message> [text... | --text <text> | --stdin] [--mention <member>]... [--force] [--wait none|sent] [--timeout s]",
|
|
15738
16494
|
summary: "Replace the text of a message you sent to a group (the id group send gave)",
|
|
@@ -15773,13 +16529,15 @@ var ID_ARGS = /* @__PURE__ */ new Set([
|
|
|
15773
16529
|
"service",
|
|
15774
16530
|
"call"
|
|
15775
16531
|
]);
|
|
16532
|
+
/** A leading positional that may be left out (`name?`); a trailing one that may (`text...?`) shifts nothing. */
|
|
16533
|
+
var leadingOptional = (name) => name.endsWith("?") && !name.endsWith("...?");
|
|
15776
16534
|
/**
|
|
15777
16535
|
* Which of a command's positionals take an id, by index (a trailing `...` one covers the rest). An optional `name?`
|
|
15778
16536
|
* shifts the others, so it counts only where it and what may come in its place are all ids.
|
|
15779
16537
|
*/
|
|
15780
16538
|
function idSlot(command) {
|
|
15781
16539
|
const names = (command.args ?? []).map((name) => name.replace(/\?$/, ""));
|
|
15782
|
-
const shift = (command.args ?? []).filter(
|
|
16540
|
+
const shift = (command.args ?? []).filter(leadingOptional).length;
|
|
15783
16541
|
const rest = names.findIndex((name) => name.endsWith("..."));
|
|
15784
16542
|
const at = (index) => {
|
|
15785
16543
|
const name = rest !== -1 && index >= rest ? names[rest].slice(0, -3) : names[index];
|
|
@@ -15789,29 +16547,36 @@ function idSlot(command) {
|
|
|
15789
16547
|
}
|
|
15790
16548
|
/**
|
|
15791
16549
|
* The positionals of a command, by name; missing required ones are a usage error. A leading `name?` is optional: it
|
|
15792
|
-
* is given only when every required one is there too (`file accept [<chat>] <file>`).
|
|
16550
|
+
* is given only when every required one is there too (`file accept [<chat>] <file>`). A trailing `name...` takes the
|
|
16551
|
+
* rest and needs at least one word; `name...?` may take none (text from stdin, `chat rename`'s name back to the
|
|
16552
|
+
* contact's).
|
|
15793
16553
|
*/
|
|
15794
16554
|
function positionals(command, values) {
|
|
15795
16555
|
let names = command.args ?? [];
|
|
15796
|
-
const optional = names.filter(
|
|
16556
|
+
const optional = names.filter(leadingOptional);
|
|
15797
16557
|
if (optional.length) {
|
|
15798
16558
|
const skip = Math.max(0, optional.length - Math.max(0, values.length - (names.length - optional.length)));
|
|
15799
|
-
names = [...optional.slice(skip).map((name) => name.slice(0, -1)), ...names.filter((name) => !name
|
|
16559
|
+
names = [...optional.slice(skip).map((name) => name.slice(0, -1)), ...names.filter((name) => !leadingOptional(name))];
|
|
15800
16560
|
}
|
|
15801
16561
|
const out = {};
|
|
15802
16562
|
for (let i = 0; i < names.length; i++) {
|
|
15803
|
-
const name = names[i];
|
|
16563
|
+
const name = names[i].replace(/\?$/, "");
|
|
15804
16564
|
if (name.endsWith("...")) {
|
|
15805
16565
|
const rest = values.slice(i);
|
|
16566
|
+
if (!rest.length && !names[i].endsWith("?")) throw new CliError("usage", `Missing <${name.slice(0, -3)}>: ghostly ${command.usage}`);
|
|
15806
16567
|
out[name.slice(0, -3)] = rest.length ? rest.join(" ") : void 0;
|
|
15807
16568
|
return out;
|
|
15808
16569
|
}
|
|
15809
16570
|
if (values[i] === void 0) throw new CliError("usage", `Missing <${name}>: ghostly ${command.usage}`);
|
|
15810
16571
|
out[name] = values[i];
|
|
15811
16572
|
}
|
|
15812
|
-
|
|
16573
|
+
noMoreThan(names.length, values, command.usage);
|
|
15813
16574
|
return out;
|
|
15814
16575
|
}
|
|
16576
|
+
/** Words past a usage's arguments are refused, never dropped: `profile use my bot` would select `my`. */
|
|
16577
|
+
function noMoreThan(count, values, usage) {
|
|
16578
|
+
if (values.length > count) throw new CliError("usage", `Too many arguments: ghostly ${usage}`);
|
|
16579
|
+
}
|
|
15815
16580
|
//#endregion
|
|
15816
16581
|
//#region src/announce.ts
|
|
15817
16582
|
/**
|
|
@@ -15923,6 +16688,12 @@ var PlaybackQueue = class {
|
|
|
15923
16688
|
this.length = 0;
|
|
15924
16689
|
return ms;
|
|
15925
16690
|
}
|
|
16691
|
+
/** Drops up to that much from the front, in whole frames (audio whose time went by); returns how much, in milliseconds. */
|
|
16692
|
+
skip(ms) {
|
|
16693
|
+
const frames = Math.min(Math.floor(ms / 20), Math.floor(this.length / this.frame));
|
|
16694
|
+
if (frames > 0) this.take(frames * this.frame);
|
|
16695
|
+
return Math.max(0, frames) * 20;
|
|
16696
|
+
}
|
|
15926
16697
|
take(bytes) {
|
|
15927
16698
|
const out = Buffer.allocUnsafe(bytes);
|
|
15928
16699
|
let filled = 0;
|
|
@@ -15955,6 +16726,19 @@ function audioSocketPath(profileDir, callId) {
|
|
|
15955
16726
|
return join("/tmp", `ghostly-calls-${createHash("sha256").update(profileDir).digest("hex").slice(0, 24)}`, `${callId}.sock`);
|
|
15956
16727
|
}
|
|
15957
16728
|
/**
|
|
16729
|
+
* Removes the call sockets a daemon that died during a call left in this profile's folder (`audioSocketPath`; call
|
|
16730
|
+
* ids are 12 hex digits): no call outlives its daemon. A folder that is a link or someone else's is left alone.
|
|
16731
|
+
*/
|
|
16732
|
+
function clearAudioSockets(profileDir) {
|
|
16733
|
+
if (process.platform === "win32") return;
|
|
16734
|
+
const folder = dirname(audioSocketPath(profileDir, "0".repeat(12)));
|
|
16735
|
+
try {
|
|
16736
|
+
const info = lstatSync(folder);
|
|
16737
|
+
if (!info.isDirectory() || process.getuid && info.uid !== process.getuid()) return;
|
|
16738
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) if (entry.name.endsWith(".sock") && !entry.isDirectory()) rmSync(join(folder, entry.name), { force: true });
|
|
16739
|
+
} catch {}
|
|
16740
|
+
}
|
|
16741
|
+
/**
|
|
15958
16742
|
* Serves one call's audio to one program at a time: what the program writes is handed to `onAudio` as it comes (raw
|
|
15959
16743
|
* PCM, any amount); `write` gives it the caller's audio. A second connection replaces the first. `close` ends the
|
|
15960
16744
|
* program's connection (it reads EOF) and removes the socket.
|
|
@@ -16139,6 +16923,8 @@ var ReorderBuffer = class {
|
|
|
16139
16923
|
};
|
|
16140
16924
|
//#endregion
|
|
16141
16925
|
//#region src/calls/audio.ts
|
|
16926
|
+
/** The longest stall of the sender whose frames are still sent after it (see `catchUp`). */
|
|
16927
|
+
var MAX_CATCH_UP_MS = 1e3;
|
|
16142
16928
|
/**
|
|
16143
16929
|
* One call's audio: every 20 ms a frame from the queue (the program's audio, or silence) is encoded and sent; what
|
|
16144
16930
|
* comes in is put back in order, decoded, and handed on in 20 ms frames as soon as it is. Nothing waits on a clock
|
|
@@ -16156,11 +16942,23 @@ var CallAudio = class {
|
|
|
16156
16942
|
timer = null;
|
|
16157
16943
|
startedAt = 0;
|
|
16158
16944
|
ticks = 0;
|
|
16945
|
+
/** What was left in the queue after the last frame sent: the program's audio written ahead of the call. */
|
|
16946
|
+
ahead = 0;
|
|
16947
|
+
/** After a stall that was not replayed: how much of its audio is still to drop, down to what queue, and until when. */
|
|
16948
|
+
owed = 0;
|
|
16949
|
+
kept = 0;
|
|
16950
|
+
owedUntil = 0;
|
|
16159
16951
|
talking = false;
|
|
16952
|
+
/** The last silent frame's packet, how many silent frames in a row gave it, and that packet once it is kept (see `settle`). */
|
|
16953
|
+
lastQuiet = null;
|
|
16954
|
+
same = 0;
|
|
16955
|
+
quiet = null;
|
|
16160
16956
|
stopped = false;
|
|
16161
16957
|
/** Frames sent and received, for `call list`. */
|
|
16162
16958
|
sent = 0;
|
|
16163
16959
|
received = 0;
|
|
16960
|
+
/** When the last packet, RTP or RTCP, came from the contact (Date.now(); 0: none yet), for the call's watch. */
|
|
16961
|
+
heardAt = 0;
|
|
16164
16962
|
constructor(options) {
|
|
16165
16963
|
this.options = options;
|
|
16166
16964
|
const now = options.now ?? Date.now;
|
|
@@ -16189,20 +16987,46 @@ var CallAudio = class {
|
|
|
16189
16987
|
tick() {
|
|
16190
16988
|
if (this.stopped) return;
|
|
16191
16989
|
const { frame, silent } = this.queue.next();
|
|
16990
|
+
const still = silent || frame.equals(this.silence);
|
|
16192
16991
|
let payload;
|
|
16193
|
-
|
|
16194
|
-
|
|
16195
|
-
|
|
16196
|
-
|
|
16992
|
+
if (still && this.quiet) payload = this.quiet;
|
|
16993
|
+
else {
|
|
16994
|
+
try {
|
|
16995
|
+
payload = this.options.encoder.encode(frame, this.samples);
|
|
16996
|
+
} catch {
|
|
16997
|
+
return;
|
|
16998
|
+
}
|
|
16999
|
+
this.settle(still, payload);
|
|
16197
17000
|
}
|
|
16198
17001
|
const marker = !silent && !this.talking;
|
|
16199
17002
|
this.talking = !silent;
|
|
16200
17003
|
this.options.track.send(this.writer.packet(payload, marker));
|
|
16201
17004
|
this.sent++;
|
|
17005
|
+
this.ahead = this.queue.queuedMs;
|
|
17006
|
+
}
|
|
17007
|
+
/**
|
|
17008
|
+
* Keeps the packet silence encodes to once the encoder gave it SETTLED_FRAMES times in a row: it is sent again for
|
|
17009
|
+
* the silent frames that follow, without the encoder, until the program's audio is back.
|
|
17010
|
+
*/
|
|
17011
|
+
settle(still, payload) {
|
|
17012
|
+
if (!still) {
|
|
17013
|
+
this.lastQuiet = null;
|
|
17014
|
+
this.quiet = null;
|
|
17015
|
+
this.same = 0;
|
|
17016
|
+
return;
|
|
17017
|
+
}
|
|
17018
|
+
if (this.lastQuiet?.equals(payload)) {
|
|
17019
|
+
if (++this.same >= 25) this.quiet = this.lastQuiet;
|
|
17020
|
+
return;
|
|
17021
|
+
}
|
|
17022
|
+
this.lastQuiet = Buffer.from(payload);
|
|
17023
|
+
this.same = 1;
|
|
16202
17024
|
}
|
|
16203
17025
|
/** Handles one packet from the track. Exposed for tests. */
|
|
16204
17026
|
receive(packet) {
|
|
16205
|
-
if (this.stopped
|
|
17027
|
+
if (this.stopped) return;
|
|
17028
|
+
this.heardAt = (this.options.now ?? Date.now)();
|
|
17029
|
+
if (isRtcp(packet)) return;
|
|
16206
17030
|
const rtp = parseRtp(packet);
|
|
16207
17031
|
if (!rtp || rtp.payloadType !== this.options.payloadType) return;
|
|
16208
17032
|
for (const item of this.reorder.push(rtp)) {
|
|
@@ -16231,14 +17055,57 @@ var CallAudio = class {
|
|
|
16231
17055
|
const due = this.startedAt + (this.ticks + 1) * 20;
|
|
16232
17056
|
this.timer = setTimeout(() => {
|
|
16233
17057
|
if (this.stopped) return;
|
|
16234
|
-
if (now() - due
|
|
16235
|
-
this.
|
|
16236
|
-
this.
|
|
16237
|
-
|
|
16238
|
-
|
|
16239
|
-
|
|
17058
|
+
if (now() - due < 20) {
|
|
17059
|
+
this.ticks++;
|
|
17060
|
+
this.drop();
|
|
17061
|
+
this.tick();
|
|
17062
|
+
this.schedule();
|
|
17063
|
+
return;
|
|
17064
|
+
}
|
|
17065
|
+
setImmediate(() => {
|
|
17066
|
+
if (this.stopped) return;
|
|
17067
|
+
this.catchUp(now() - due);
|
|
17068
|
+
this.schedule();
|
|
17069
|
+
});
|
|
16240
17070
|
}, Math.max(0, due - now()));
|
|
16241
17071
|
}
|
|
17072
|
+
/**
|
|
17073
|
+
* Meets the clock again after the loop was busy: every frame that is due goes out at once, so a loop that turns
|
|
17074
|
+
* slower than a frame for a while (every 40 ms under a large file) keeps up, and a program writing at real time
|
|
17075
|
+
* stays there. The contact's jitter buffer takes the short burst. A stall past MAX_CATCH_UP_MS is not replayed: the
|
|
17076
|
+
* clock starts again and what the program wrote during it is dropped, or it would be heard that much late for the
|
|
17077
|
+
* rest of the call.
|
|
17078
|
+
*/
|
|
17079
|
+
catchUp(late) {
|
|
17080
|
+
const now = this.options.now ?? Date.now;
|
|
17081
|
+
if (late > 1e3) {
|
|
17082
|
+
this.owed = late;
|
|
17083
|
+
this.kept = this.ahead;
|
|
17084
|
+
this.owedUntil = now() + MAX_CATCH_UP_MS;
|
|
17085
|
+
this.startedAt = now();
|
|
17086
|
+
this.ticks = 0;
|
|
17087
|
+
this.drop();
|
|
17088
|
+
this.tick();
|
|
17089
|
+
return;
|
|
17090
|
+
}
|
|
17091
|
+
do {
|
|
17092
|
+
this.ticks++;
|
|
17093
|
+
this.drop();
|
|
17094
|
+
this.tick();
|
|
17095
|
+
} while (this.startedAt + (this.ticks + 1) * 20 <= now());
|
|
17096
|
+
}
|
|
17097
|
+
/**
|
|
17098
|
+
* Drops the audio of a stall that was not replayed, as it is read: a long one does not fit the program's socket, so
|
|
17099
|
+
* the rest of it comes over the next turns. What was queued before the stall (a clip written ahead) is kept.
|
|
17100
|
+
*/
|
|
17101
|
+
drop() {
|
|
17102
|
+
if (this.owed <= 0) return;
|
|
17103
|
+
if ((this.options.now ?? Date.now)() > this.owedUntil) {
|
|
17104
|
+
this.owed = 0;
|
|
17105
|
+
return;
|
|
17106
|
+
}
|
|
17107
|
+
this.owed -= this.queue.skip(Math.min(this.owed, this.queue.queuedMs - this.kept));
|
|
17108
|
+
}
|
|
16242
17109
|
};
|
|
16243
17110
|
//#endregion
|
|
16244
17111
|
//#region src/calls/media.ts
|
|
@@ -16524,6 +17391,7 @@ var CallManager = class {
|
|
|
16524
17391
|
this.host = host;
|
|
16525
17392
|
this.configPath = join(host.profileDir, "calls.json");
|
|
16526
17393
|
this.auto = readAuto(this.configPath);
|
|
17394
|
+
if (host.answers !== false) clearAudioSockets(host.profileDir);
|
|
16527
17395
|
}
|
|
16528
17396
|
get now() {
|
|
16529
17397
|
return (this.host.now ?? Date.now)();
|
|
@@ -16710,6 +17578,8 @@ var CallManager = class {
|
|
|
16710
17578
|
this.callable(call.chat);
|
|
16711
17579
|
call.rate = rateOf(options.rate, call.rate);
|
|
16712
17580
|
const stack = await this.stack();
|
|
17581
|
+
if (call.ended) throw new CliError("unavailable", "The call ended before it was answered");
|
|
17582
|
+
if (call.state !== "ringing") throw new CliError("refused", "That call is not ringing here");
|
|
16713
17583
|
call.state = "connecting";
|
|
16714
17584
|
this.disarm(call);
|
|
16715
17585
|
try {
|
|
@@ -16808,15 +17678,26 @@ var CallManager = class {
|
|
|
16808
17678
|
startedAt: this.now,
|
|
16809
17679
|
connectedAt: null,
|
|
16810
17680
|
timer: null,
|
|
17681
|
+
watcher: null,
|
|
17682
|
+
stalledFrom: null,
|
|
17683
|
+
stalls: 0,
|
|
16811
17684
|
ended: false
|
|
16812
17685
|
};
|
|
16813
17686
|
this.calls.set(call.id, call);
|
|
16814
17687
|
return call;
|
|
16815
17688
|
}
|
|
16816
|
-
/**
|
|
17689
|
+
/**
|
|
17690
|
+
* The call's socket (open from now on: a program may connect before the media is up) and its media. A call that
|
|
17691
|
+
* ended while its socket opened closes it here: `end` found none to close, and nothing lists the call any more.
|
|
17692
|
+
*/
|
|
16817
17693
|
async attach(call, create) {
|
|
16818
17694
|
const queue = call.queue = new PlaybackQueue(call.rate);
|
|
16819
|
-
|
|
17695
|
+
const socket = await AudioSocket.open(audioSocketPath(this.host.profileDir, call.id), call.rate, { onAudio: (chunk) => queue.push(chunk) });
|
|
17696
|
+
if (call.ended) {
|
|
17697
|
+
await socket.close();
|
|
17698
|
+
return;
|
|
17699
|
+
}
|
|
17700
|
+
call.socket = socket;
|
|
16820
17701
|
await this.connect(call, create);
|
|
16821
17702
|
}
|
|
16822
17703
|
/**
|
|
@@ -16824,6 +17705,7 @@ var CallManager = class {
|
|
|
16824
17705
|
* the call ended, or asked for a newer connection, while this one was made: then it is closed.
|
|
16825
17706
|
*/
|
|
16826
17707
|
async connect(call, create) {
|
|
17708
|
+
if (call.ended) return false;
|
|
16827
17709
|
const attempt = ++call.attempt;
|
|
16828
17710
|
call.media?.close();
|
|
16829
17711
|
call.media = null;
|
|
@@ -16867,12 +17749,58 @@ var CallManager = class {
|
|
|
16867
17749
|
direction: call.direction,
|
|
16868
17750
|
audio: this.audioInfo(call)
|
|
16869
17751
|
});
|
|
17752
|
+
this.watch(call);
|
|
17753
|
+
}
|
|
17754
|
+
/**
|
|
17755
|
+
* libjuice says nothing of a contact whose side went away (a crash, a closed lid, a lost network) until its consent
|
|
17756
|
+
* checks fail, about 30 s later: a connected call watches the contact's packets instead. An app sends them all along
|
|
17757
|
+
* (silence while muted, and RTCP), and so does the CLI. Nothing for STALL_MS: `call.stalled`; packets again:
|
|
17758
|
+
* `call.resumed`; nothing for STALL_END_MS: the call ends as `failed`, and the contact's app is told.
|
|
17759
|
+
*/
|
|
17760
|
+
watch(call) {
|
|
17761
|
+
const { afterMs, endMs } = this.host.stall ?? {
|
|
17762
|
+
afterMs: 5e3,
|
|
17763
|
+
endMs: 15e3
|
|
17764
|
+
};
|
|
17765
|
+
const since = Date.now();
|
|
17766
|
+
call.watcher = setInterval(() => {
|
|
17767
|
+
if (call.ended) return;
|
|
17768
|
+
const heardAt = Math.max(since, call.media?.audio.heardAt ?? 0);
|
|
17769
|
+
const silentMs = Date.now() - heardAt;
|
|
17770
|
+
const fields = {
|
|
17771
|
+
call: call.id,
|
|
17772
|
+
chat: call.chat,
|
|
17773
|
+
direction: call.direction
|
|
17774
|
+
};
|
|
17775
|
+
if (silentMs >= endMs) {
|
|
17776
|
+
process.stderr.write(`ghostly: call ${call.id}: nothing heard from the contact for ${Math.round(silentMs / 1e3)} s\n`);
|
|
17777
|
+
this.end(call, "failed", true);
|
|
17778
|
+
} else if (silentMs >= afterMs && call.stalledFrom === null) {
|
|
17779
|
+
call.stalledFrom = heardAt;
|
|
17780
|
+
call.stalls++;
|
|
17781
|
+
process.stderr.write(`ghostly: call ${call.id}: stalled, nothing heard from the contact for ${silentMs} ms\n`);
|
|
17782
|
+
this.host.emit("call.stalled", `call.stalled:${call.id}:${call.stalls}`, {
|
|
17783
|
+
...fields,
|
|
17784
|
+
silentMs
|
|
17785
|
+
});
|
|
17786
|
+
} else if (silentMs < afterMs && call.stalledFrom !== null) {
|
|
17787
|
+
const gapMs = heardAt - call.stalledFrom;
|
|
17788
|
+
call.stalledFrom = null;
|
|
17789
|
+
process.stderr.write(`ghostly: call ${call.id}: resumed after ${gapMs} ms\n`);
|
|
17790
|
+
this.host.emit("call.resumed", `call.resumed:${call.id}:${call.stalls}`, {
|
|
17791
|
+
...fields,
|
|
17792
|
+
silentMs: gapMs
|
|
17793
|
+
});
|
|
17794
|
+
}
|
|
17795
|
+
}, Math.min(1e3, afterMs / 5));
|
|
17796
|
+
call.watcher.unref?.();
|
|
16870
17797
|
}
|
|
16871
17798
|
async end(call, reason, tell, report = true) {
|
|
16872
17799
|
if (call.ended) return;
|
|
16873
17800
|
call.ended = true;
|
|
16874
17801
|
call.reason = reason;
|
|
16875
17802
|
this.disarm(call);
|
|
17803
|
+
if (call.watcher) clearInterval(call.watcher);
|
|
16876
17804
|
this.calls.delete(call.id);
|
|
16877
17805
|
if (report) process.stderr.write(`ghostly: call ${call.id}: ended (${reason})${call.media ? `, ice ${call.media.ice.state}` : ""}\n`);
|
|
16878
17806
|
call.media?.close();
|
|
@@ -16943,6 +17871,7 @@ var CallManager = class {
|
|
|
16943
17871
|
chat: call.chat,
|
|
16944
17872
|
direction: call.direction,
|
|
16945
17873
|
state: call.state,
|
|
17874
|
+
stalled: call.stalledFrom !== null,
|
|
16946
17875
|
video: call.video,
|
|
16947
17876
|
startedAt: call.startedAt,
|
|
16948
17877
|
connectedAt: call.connectedAt,
|
|
@@ -18428,10 +19357,11 @@ async function openPersistentIndexedDb(dir) {
|
|
|
18428
19357
|
const pending = /* @__PURE__ */ new WeakMap();
|
|
18429
19358
|
const snapshotPath = join(dir, SNAPSHOT);
|
|
18430
19359
|
const journalPath = join(dir, JOURNAL);
|
|
18431
|
-
|
|
18432
|
-
if (
|
|
19360
|
+
const snapshotFound = existsSync(snapshotPath), journalFound = existsSync(journalPath);
|
|
19361
|
+
if (snapshotFound) restore(classes, raw._databases, deserialize(readFileSync(snapshotPath)), (a, b) => factory.cmp(a, b));
|
|
19362
|
+
if (journalFound) replay(raw._databases, readFileSync(journalPath));
|
|
18433
19363
|
restoring = false;
|
|
18434
|
-
await writeSnapshot();
|
|
19364
|
+
if (journalFound || !snapshotFound) await writeSnapshot();
|
|
18435
19365
|
const storeProto = classes.ObjectStore.prototype;
|
|
18436
19366
|
const storeRecord = storeProto.storeRecord;
|
|
18437
19367
|
const deleteRecord = storeProto.deleteRecord;
|
|
@@ -18614,7 +19544,12 @@ async function probeClasses(factory) {
|
|
|
18614
19544
|
});
|
|
18615
19545
|
return classes;
|
|
18616
19546
|
}
|
|
18617
|
-
|
|
19547
|
+
/**
|
|
19548
|
+
* The snapshot's records come in key order (as `writeSnapshot` walks them), so each store's tree and each index's are
|
|
19549
|
+
* built whole from sorted arrays: storing record by record compared every key down the tree, a 70,000-message profile
|
|
19550
|
+
* took seconds. A snapshot not in order (never written so) is stored record by record.
|
|
19551
|
+
*/
|
|
19552
|
+
function restore(classes, databases, snapshot, cmp) {
|
|
18618
19553
|
if (snapshot?.format !== 1) throw new Error("This profile was last used by a newer version of ghostly (its store has a format this version does not read). Update ghostly to open it.");
|
|
18619
19554
|
for (const saved of snapshot.databases) {
|
|
18620
19555
|
const db = new classes.Database(saved.name, saved.version);
|
|
@@ -18627,16 +19562,103 @@ function restore(classes, databases, snapshot) {
|
|
|
18627
19562
|
store.rawIndexes.set(i.name, index);
|
|
18628
19563
|
}
|
|
18629
19564
|
db.rawObjectStores.set(s.name, store);
|
|
18630
|
-
|
|
19565
|
+
const records = s.records.map(([key, value]) => ({
|
|
18631
19566
|
key,
|
|
18632
19567
|
value: blobsFromDisk(value)
|
|
18633
|
-
}
|
|
19568
|
+
}));
|
|
19569
|
+
if (records.some((record, at) => at > 0 && cmp(records[at - 1].key, record.key) >= 0)) {
|
|
19570
|
+
for (const record of records) store.storeRecord(record, false);
|
|
19571
|
+
continue;
|
|
19572
|
+
}
|
|
19573
|
+
fill(store.records, records);
|
|
19574
|
+
for (const index of store.rawIndexes.values()) fill(index.records, indexEntries(index, records, cmp));
|
|
18634
19575
|
}
|
|
18635
19576
|
databases.set(saved.name, db);
|
|
18636
19577
|
}
|
|
18637
19578
|
}
|
|
18638
|
-
/**
|
|
19579
|
+
/**
|
|
19580
|
+
* An index's entries for records in key order, sorted by the index's key: the index derives them itself (key path,
|
|
19581
|
+
* multi-entry, invalid keys skipped) into a list instead of its tree. A stable sort keeps equal index keys in the
|
|
19582
|
+
* records' key order, the tree's own tie-break.
|
|
19583
|
+
*/
|
|
19584
|
+
function indexEntries(index, records, cmp) {
|
|
19585
|
+
const entries = [];
|
|
19586
|
+
const tree = index.records;
|
|
19587
|
+
index.records = {
|
|
19588
|
+
get: () => void 0,
|
|
19589
|
+
put: (entry) => {
|
|
19590
|
+
entries.push(entry);
|
|
19591
|
+
}
|
|
19592
|
+
};
|
|
19593
|
+
try {
|
|
19594
|
+
for (const record of records) if (!absent(index.keyPath, record.value)) index.storeRecord(record);
|
|
19595
|
+
} finally {
|
|
19596
|
+
index.records = tree;
|
|
19597
|
+
}
|
|
19598
|
+
return entries.sort((a, b) => cmp(a.key, b.key));
|
|
19599
|
+
}
|
|
19600
|
+
/**
|
|
19601
|
+
* Whether a key path surely finds nothing in a value: a plain object along it lacks the step (most messages have no
|
|
19602
|
+
* `card` for the card index). The index would throw and catch a DataError for each of those; anything else it judges.
|
|
19603
|
+
*/
|
|
19604
|
+
function absent(keyPath, value) {
|
|
19605
|
+
if (typeof keyPath !== "string" || keyPath === "") return false;
|
|
19606
|
+
let object = value;
|
|
19607
|
+
for (const step of keyPath.split(".")) {
|
|
19608
|
+
if (object === null || typeof object !== "object" || Array.isArray(object) || object instanceof Blob) return false;
|
|
19609
|
+
if (!Object.hasOwn(object, step)) return true;
|
|
19610
|
+
object = object[step];
|
|
19611
|
+
}
|
|
19612
|
+
return false;
|
|
19613
|
+
}
|
|
19614
|
+
/**
|
|
19615
|
+
* fake-indexeddb's red-black tree, built balanced from sorted records: every level full but the last, whose nodes
|
|
19616
|
+
* are red, so each path holds the same number of black nodes and later inserts rebalance as usual.
|
|
19617
|
+
*/
|
|
19618
|
+
function fill(store, sorted) {
|
|
19619
|
+
const deepest = sorted.length ? Math.floor(Math.log2(sorted.length)) : 0;
|
|
19620
|
+
const build = (low, high, parent, depth) => {
|
|
19621
|
+
if (low > high) return void 0;
|
|
19622
|
+
const middle = low + high >>> 1;
|
|
19623
|
+
const node = {
|
|
19624
|
+
record: sorted[middle],
|
|
19625
|
+
left: void 0,
|
|
19626
|
+
right: void 0,
|
|
19627
|
+
parent,
|
|
19628
|
+
deleted: false,
|
|
19629
|
+
red: depth > 0 && depth === deepest
|
|
19630
|
+
};
|
|
19631
|
+
node.left = build(low, middle - 1, node, depth + 1);
|
|
19632
|
+
node.right = build(middle + 1, high, node, depth + 1);
|
|
19633
|
+
return node;
|
|
19634
|
+
};
|
|
19635
|
+
store.records._root = build(0, sorted.length - 1, void 0, 0);
|
|
19636
|
+
store.records._numNodes = sorted.length;
|
|
19637
|
+
store.records._numTombstones = 0;
|
|
19638
|
+
}
|
|
19639
|
+
/**
|
|
19640
|
+
* Replays whole entries; a torn last entry (the process died mid-write) was never acknowledged and is dropped.
|
|
19641
|
+
*
|
|
19642
|
+
* A store's indexes are set aside while its ops replay and rebuilt once at the end: fake-indexeddb drops an
|
|
19643
|
+
* overwritten or deleted record from an index by walking every record of it, so a crash after a busy chat took
|
|
19644
|
+
* tens of seconds to minutes to start, one write at a time against the whole profile.
|
|
19645
|
+
*/
|
|
18639
19646
|
function replay(databases, journal) {
|
|
19647
|
+
const touched = /* @__PURE__ */ new Map();
|
|
19648
|
+
try {
|
|
19649
|
+
replayEntries(databases, journal, touched);
|
|
19650
|
+
} finally {
|
|
19651
|
+
for (const [store, indexes] of touched) {
|
|
19652
|
+
store.rawIndexes = indexes;
|
|
19653
|
+
for (const index of indexes.values()) {
|
|
19654
|
+
index.records.clear();
|
|
19655
|
+
if (!index.initialized) continue;
|
|
19656
|
+
for (const record of store.records.values()) index.storeRecord(record);
|
|
19657
|
+
}
|
|
19658
|
+
}
|
|
19659
|
+
}
|
|
19660
|
+
}
|
|
19661
|
+
function replayEntries(databases, journal, touched) {
|
|
18640
19662
|
let offset = 0;
|
|
18641
19663
|
while (offset + 4 <= journal.length) {
|
|
18642
19664
|
const length = journal.readUInt32BE(offset);
|
|
@@ -18651,6 +19673,10 @@ function replay(databases, journal) {
|
|
|
18651
19673
|
for (const op of ops) {
|
|
18652
19674
|
const store = databases.get(op.db)?.rawObjectStores.get(op.store);
|
|
18653
19675
|
if (!store) continue;
|
|
19676
|
+
if (!touched.has(store)) {
|
|
19677
|
+
touched.set(store, store.rawIndexes);
|
|
19678
|
+
store.rawIndexes = /* @__PURE__ */ new Map();
|
|
19679
|
+
}
|
|
18654
19680
|
if (op.kind === "put") store.storeRecord({
|
|
18655
19681
|
key: op.key,
|
|
18656
19682
|
value: blobsFromDisk(op.value)
|
|
@@ -18892,7 +19918,7 @@ async function startRuntime(paths, options = {}) {
|
|
|
18892
19918
|
const restored = join(paths.dir, RESTORED_MARK);
|
|
18893
19919
|
if (existsSync(restored)) {
|
|
18894
19920
|
try {
|
|
18895
|
-
const rows = await import("./assets/restoredRows-
|
|
19921
|
+
const rows = await import("./assets/restoredRows-C_84q0Pd.js");
|
|
18896
19922
|
await rows.markRestoredWallet();
|
|
18897
19923
|
await rows.markLeftOutFiles(leftOutIn(restored));
|
|
18898
19924
|
await store.compact();
|
|
@@ -18911,16 +19937,16 @@ async function startRuntime(paths, options = {}) {
|
|
|
18911
19937
|
await installEventSource();
|
|
18912
19938
|
await preloadWalletModules();
|
|
18913
19939
|
(await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
|
|
18914
|
-
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-
|
|
19940
|
+
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-BcYZhNgF.js").then((n) => n.o), import("./assets/fileBytes-Dbifu5I5.js")]);
|
|
18915
19941
|
registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
|
|
18916
|
-
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-
|
|
19942
|
+
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-D5FBZYW8.js").then((n) => n.t), import("./assets/endpoint-pgFkR2AS.js")]);
|
|
18917
19943
|
const network = hyperdhtNetwork();
|
|
18918
19944
|
const dht = mainlineNetwork();
|
|
18919
|
-
const mainline = dht.off ? null : new (await (import("./assets/mainline-
|
|
19945
|
+
const mainline = dht.off ? null : new (await (import("./assets/mainline-DVYdrus6.js"))).Mainline({
|
|
18920
19946
|
bootstrap: dht.bootstrap,
|
|
18921
19947
|
host: dht.host
|
|
18922
19948
|
});
|
|
18923
|
-
const transport = mainline || pinned ? new (await (import("./assets/mainline-
|
|
19949
|
+
const transport = mainline || pinned ? new (await (import("./assets/mainline-DVYdrus6.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
|
|
18924
19950
|
const server = new EngineServer({
|
|
18925
19951
|
...transport ? { transport } : {},
|
|
18926
19952
|
irohWeb: true,
|
|
@@ -18930,6 +19956,7 @@ async function startRuntime(paths, options = {}) {
|
|
|
18930
19956
|
automaticWallets: false,
|
|
18931
19957
|
fedimintSdk: nodeFedimintSdk(join(paths.dir, "fedimint")),
|
|
18932
19958
|
servicesSupport: true,
|
|
19959
|
+
apps: false,
|
|
18933
19960
|
staysOnline: process.env.GHOSTLY_HUB !== "0",
|
|
18934
19961
|
localFetch: nodeLocalFetch,
|
|
18935
19962
|
pushSend: (request) => nodePushSend(request),
|
|
@@ -18979,6 +20006,7 @@ async function openHost(paths, mode, version, options = {}) {
|
|
|
18979
20006
|
await hub.open();
|
|
18980
20007
|
const node = runtime.server.node;
|
|
18981
20008
|
const now = node.getState();
|
|
20009
|
+
const mark = runtime.server.historyMark;
|
|
18982
20010
|
const histories = /* @__PURE__ */ new Map();
|
|
18983
20011
|
for (const link of now.links) histories.set(link.id, await node.getMessages(link.id));
|
|
18984
20012
|
for (const group of now.groups) histories.set(`group:${group.id}`, await node.groupMessages({ groupId: group.id }));
|
|
@@ -18992,7 +20020,10 @@ async function openHost(paths, mode, version, options = {}) {
|
|
|
18992
20020
|
});
|
|
18993
20021
|
const manager = calls;
|
|
18994
20022
|
hub.onCallSignal((chat, signal) => manager.onSignal(chat, signal));
|
|
18995
|
-
runtime.server.attach(hub.sink
|
|
20023
|
+
runtime.server.attach(hub.sink, {
|
|
20024
|
+
mark,
|
|
20025
|
+
ids: histories.keys()
|
|
20026
|
+
});
|
|
18996
20027
|
} catch (error) {
|
|
18997
20028
|
await runtime.close().catch(() => {});
|
|
18998
20029
|
release();
|
|
@@ -19226,19 +20257,21 @@ function toTurn(event) {
|
|
|
19226
20257
|
}
|
|
19227
20258
|
function turn(event, source, where, message) {
|
|
19228
20259
|
const file = message.file;
|
|
20260
|
+
const place = where.chat ?? where.group ?? "", messageId = turnRef(place, message.id);
|
|
19229
20261
|
return {
|
|
19230
20262
|
seq: event.seq,
|
|
19231
|
-
id: `agent.turn:${
|
|
20263
|
+
id: `agent.turn:${source}:${place}:${messageId}`,
|
|
19232
20264
|
type: "agent.turn",
|
|
19233
20265
|
at: event.at,
|
|
19234
20266
|
source,
|
|
19235
20267
|
...where,
|
|
19236
|
-
messageId
|
|
20268
|
+
messageId,
|
|
19237
20269
|
timestamp: message.timestamp,
|
|
19238
20270
|
...message.press ? { press: message.press } : {},
|
|
19239
20271
|
untrusted: {
|
|
19240
20272
|
text: message.text,
|
|
19241
20273
|
name: message.nick ?? null,
|
|
20274
|
+
messageId: message.id,
|
|
19242
20275
|
...message.replyTo ? { replyTo: {
|
|
19243
20276
|
id: message.replyTo.id,
|
|
19244
20277
|
snippet: message.replyTo.snippet
|
|
@@ -19320,6 +20353,7 @@ async function post(url, event) {
|
|
|
19320
20353
|
try {
|
|
19321
20354
|
if ((await fetch(url, {
|
|
19322
20355
|
method: "POST",
|
|
20356
|
+
redirect: "manual",
|
|
19323
20357
|
headers: {
|
|
19324
20358
|
"content-type": "application/json",
|
|
19325
20359
|
"x-ghostly-event": event.type,
|
|
@@ -19426,7 +20460,14 @@ function requireProfile(g) {
|
|
|
19426
20460
|
createProfile(g.home, g.profile);
|
|
19427
20461
|
return;
|
|
19428
20462
|
}
|
|
19429
|
-
throw
|
|
20463
|
+
throw noProfile(g);
|
|
20464
|
+
}
|
|
20465
|
+
function noProfile(g) {
|
|
20466
|
+
return new CliError("not_found", `No profile ${g.profile}: make it with \`ghostly profile create ${g.profile}\``);
|
|
20467
|
+
}
|
|
20468
|
+
/** For a command that only reads: a mistyped profile is named as one, and `default` is not made by asking about it. */
|
|
20469
|
+
function knownProfile(g) {
|
|
20470
|
+
if (g.profile !== "default" && !profileExists(g.home, g.profile)) throw noProfile(g);
|
|
19430
20471
|
}
|
|
19431
20472
|
/** Said on stderr when the daemon runs another release than this command (it started before an upgrade). */
|
|
19432
20473
|
function versionWarning(daemon, cli) {
|
|
@@ -19508,7 +20549,10 @@ var SPECIAL = [
|
|
|
19508
20549
|
{
|
|
19509
20550
|
out: o("string", "The backup file to write"),
|
|
19510
20551
|
"passphrase-file": o("string", "The passphrase, from this file (else GHOSTLY_BACKUP_PASSPHRASE)"),
|
|
19511
|
-
passphrase:
|
|
20552
|
+
passphrase: {
|
|
20553
|
+
...o("boolean", "Do not encrypt. The file then holds the profile's keys and wallet secrets in the clear"),
|
|
20554
|
+
negatedOnly: true
|
|
20555
|
+
},
|
|
19512
20556
|
light: o("boolean", "Leave out files over 1 MB (voice messages over 4 MB); their messages stay")
|
|
19513
20557
|
}
|
|
19514
20558
|
],
|
|
@@ -19579,7 +20623,8 @@ var SPECIAL = [
|
|
|
19579
20623
|
days: o("number", "How long the proof holds"),
|
|
19580
20624
|
timeout: o("number", "Seconds to wait for an approval")
|
|
19581
20625
|
}
|
|
19582
|
-
]
|
|
20626
|
+
],
|
|
20627
|
+
["version", "This CLI's version (also ghostly --version)"]
|
|
19583
20628
|
];
|
|
19584
20629
|
function help() {
|
|
19585
20630
|
const rows = [
|
|
@@ -19645,7 +20690,9 @@ function commandHelp(words) {
|
|
|
19645
20690
|
const options = Object.entries(row.options ?? {}).filter(([, o]) => o.description);
|
|
19646
20691
|
if (options.length) {
|
|
19647
20692
|
lines.push("", "Options:");
|
|
19648
|
-
|
|
20693
|
+
const labels = options.map(([name, o]) => ` --${o.negatedOnly ? "no-" : ""}${name}${o.type === "boolean" ? "" : ` <${o.type === "number" ? "n" : "value"}>`}`);
|
|
20694
|
+
const width = Math.max(26, ...labels.map((label) => label.length + 2));
|
|
20695
|
+
options.forEach(([, o], i) => lines.push(labels[i].padEnd(width) + o.description));
|
|
19649
20696
|
}
|
|
19650
20697
|
if (Object.values(TEXT_COMMANDS).includes(row)) lines.push("", " -- ends the options: everything after it is the text, options included (\"-- -1 --wait sent\" sends", " all of it). Put options before --.");
|
|
19651
20698
|
lines.push("");
|
|
@@ -19733,6 +20780,7 @@ async function profileCommand(sub, argv) {
|
|
|
19733
20780
|
return;
|
|
19734
20781
|
}
|
|
19735
20782
|
case "list": {
|
|
20783
|
+
noMoreThan(0, parsed.positionals, "profile list");
|
|
19736
20784
|
const current = currentProfile(g.home);
|
|
19737
20785
|
print({
|
|
19738
20786
|
profiles: listProfiles(g.home).map((name) => ({
|
|
@@ -19747,14 +20795,17 @@ async function profileCommand(sub, argv) {
|
|
|
19747
20795
|
case "use": {
|
|
19748
20796
|
const [name] = parsed.positionals;
|
|
19749
20797
|
if (!name) throw new CliError("usage", "ghostly profile use <name>");
|
|
20798
|
+
noMoreThan(1, parsed.positionals, "profile use <name>");
|
|
19750
20799
|
selectProfile(g.home, name);
|
|
19751
20800
|
print({ current: name });
|
|
19752
20801
|
return;
|
|
19753
20802
|
}
|
|
19754
20803
|
case "show":
|
|
20804
|
+
noMoreThan(0, parsed.positionals, "profile show");
|
|
19755
20805
|
print(await withSession(g, (s) => s.call("profile.get")));
|
|
19756
20806
|
return;
|
|
19757
20807
|
case "set": {
|
|
20808
|
+
noMoreThan(0, parsed.positionals, "profile set [--name <name>] [--share-profile | --no-share-profile]");
|
|
19758
20809
|
const params = {};
|
|
19759
20810
|
if (parsed.options.name !== void 0) params.name = parsed.options.name;
|
|
19760
20811
|
if (parsed.options["share-profile"] !== void 0) params.shareProfile = parsed.options["share-profile"];
|
|
@@ -19763,6 +20814,7 @@ async function profileCommand(sub, argv) {
|
|
|
19763
20814
|
return;
|
|
19764
20815
|
}
|
|
19765
20816
|
case "backup": {
|
|
20817
|
+
noMoreThan(0, parsed.positionals, "profile backup --out <file> [--passphrase-file f | --no-passphrase] [--light]");
|
|
19766
20818
|
const out = parsed.options.out;
|
|
19767
20819
|
if (typeof out !== "string") throw new CliError("usage", "ghostly profile backup --out <file> [--passphrase-file f | --no-passphrase] [--light] (or GHOSTLY_BACKUP_PASSPHRASE)");
|
|
19768
20820
|
const unsealed = parsed.options.passphrase === false;
|
|
@@ -19785,6 +20837,7 @@ async function profileCommand(sub, argv) {
|
|
|
19785
20837
|
case "restore": {
|
|
19786
20838
|
const [file, name] = parsed.positionals;
|
|
19787
20839
|
if (!file || !name) throw new CliError("usage", "ghostly profile restore <file> <new profile> [--passphrase-file f] [--use]");
|
|
20840
|
+
noMoreThan(2, parsed.positionals, "profile restore <file> <new profile> [--passphrase-file f] [--use]");
|
|
19788
20841
|
const protection = await backupFileProtection(file);
|
|
19789
20842
|
const paths = await restoreProfile(g.home, checkProfileName(name), file, protection === "none" ? void 0 : backupPassphrase(parsed.options["passphrase-file"]));
|
|
19790
20843
|
if (parsed.options.use) selectProfile(g.home, name);
|
|
@@ -19872,7 +20925,13 @@ async function daemonCommand(argv) {
|
|
|
19872
20925
|
});
|
|
19873
20926
|
const g = globals(parsed);
|
|
19874
20927
|
pretty = g.pretty;
|
|
20928
|
+
if (!sub || [
|
|
20929
|
+
"status",
|
|
20930
|
+
"stop",
|
|
20931
|
+
"restart"
|
|
20932
|
+
].includes(sub)) noMoreThan(0, parsed.positionals, sub ? `daemon ${sub}` : "daemon [--detach]");
|
|
19875
20933
|
if (sub === "status") {
|
|
20934
|
+
knownProfile(g);
|
|
19876
20935
|
const client = await connectDaemon(g.paths.socket);
|
|
19877
20936
|
if (!client) {
|
|
19878
20937
|
print({
|
|
@@ -19894,6 +20953,7 @@ async function daemonCommand(argv) {
|
|
|
19894
20953
|
return;
|
|
19895
20954
|
}
|
|
19896
20955
|
if (sub === "stop") {
|
|
20956
|
+
knownProfile(g);
|
|
19897
20957
|
const client = await connectDaemon(g.paths.socket);
|
|
19898
20958
|
if (!client) throw new CliError("not_found", `No daemon runs profile ${g.profile}`);
|
|
19899
20959
|
print({
|
|
@@ -20143,6 +21203,7 @@ async function engineCommand(argv) {
|
|
|
20143
21203
|
const g = globals(parsed);
|
|
20144
21204
|
pretty = g.pretty;
|
|
20145
21205
|
if (parsed.options.list) {
|
|
21206
|
+
noMoreThan(0, parsed.positionals, "engine --list");
|
|
20146
21207
|
print({ methods: [...ENGINE_METHODS, ...ENGINE_READS].sort() });
|
|
20147
21208
|
return;
|
|
20148
21209
|
}
|
|
@@ -20186,6 +21247,7 @@ async function settingsCommand(sub, argv) {
|
|
|
20186
21247
|
const g = globals(parsed);
|
|
20187
21248
|
pretty = g.pretty;
|
|
20188
21249
|
if (sub === "get") {
|
|
21250
|
+
noMoreThan(0, parsed.positionals, "settings get [--show-secret]");
|
|
20189
21251
|
print(await withSession(g, (s) => s.call("settings.get", { showSecret: parsed.options["show-secret"] === true })));
|
|
20190
21252
|
return;
|
|
20191
21253
|
}
|
|
@@ -20198,6 +21260,7 @@ async function settingsCommand(sub, argv) {
|
|
|
20198
21260
|
} catch {
|
|
20199
21261
|
parsedValue = value;
|
|
20200
21262
|
}
|
|
21263
|
+
if (SETTABLE[key] === "string" && typeof parsedValue !== "string") parsedValue = value;
|
|
20201
21264
|
print(await withSession(g, (s) => s.call("settings.set", { [key]: parsedValue })));
|
|
20202
21265
|
return;
|
|
20203
21266
|
}
|
|
@@ -20358,7 +21421,7 @@ async function tableCommand(name, argv) {
|
|
|
20358
21421
|
* contact's, raw PCM); what the command says goes to stderr. It ends when the call does.
|
|
20359
21422
|
*/
|
|
20360
21423
|
async function callPipeCommand(argv) {
|
|
20361
|
-
const parsed = parseArgs(argv, {}, idSlot({ args: ["call
|
|
21424
|
+
const parsed = parseArgs(argv, {}, idSlot({ args: ["call...?"] }));
|
|
20362
21425
|
const g = globals(parsed);
|
|
20363
21426
|
const ref = parsed.positionals.join(" ") || void 0;
|
|
20364
21427
|
const client = await connectDaemon(g.paths.socket);
|
|
@@ -20400,7 +21463,7 @@ function helpAsked(argv) {
|
|
|
20400
21463
|
}
|
|
20401
21464
|
async function main(input) {
|
|
20402
21465
|
const argv = liftGlobals(input);
|
|
20403
|
-
const helpFor = argv.length === 0 ? [] : helpAsked(argv);
|
|
21466
|
+
const helpFor = argv.length === 0 || globalOption(argv[0]) ? [] : helpAsked(argv);
|
|
20404
21467
|
if (helpFor) {
|
|
20405
21468
|
process.stdout.write((helpFor.length ? commandHelp(helpFor) : help()) + "\n");
|
|
20406
21469
|
return 0;
|
|
@@ -20411,7 +21474,8 @@ async function main(input) {
|
|
|
20411
21474
|
}
|
|
20412
21475
|
const [first, second] = argv;
|
|
20413
21476
|
const two = second && !second.startsWith("-") ? `${first} ${second}` : "";
|
|
20414
|
-
|
|
21477
|
+
const text = (name) => Object.hasOwn(TEXT_COMMANDS, name), table = (name) => Object.hasOwn(COMMANDS, name);
|
|
21478
|
+
if (first === "profile" && !table(two)) {
|
|
20415
21479
|
await profileCommand(second, argv.slice(2));
|
|
20416
21480
|
return 0;
|
|
20417
21481
|
}
|
|
@@ -20439,19 +21503,19 @@ async function main(input) {
|
|
|
20439
21503
|
await callPipeCommand(argv.slice(2));
|
|
20440
21504
|
return -1;
|
|
20441
21505
|
}
|
|
20442
|
-
if (two &&
|
|
21506
|
+
if (two && text(two)) {
|
|
20443
21507
|
await textCommand(two, argv.slice(2));
|
|
20444
21508
|
return 0;
|
|
20445
21509
|
}
|
|
20446
|
-
if (two &&
|
|
21510
|
+
if (two && table(two)) {
|
|
20447
21511
|
await tableCommand(two, argv.slice(2));
|
|
20448
21512
|
return 0;
|
|
20449
21513
|
}
|
|
20450
|
-
if (
|
|
21514
|
+
if (text(first)) {
|
|
20451
21515
|
await textCommand(first, argv.slice(1));
|
|
20452
21516
|
return 0;
|
|
20453
21517
|
}
|
|
20454
|
-
if (
|
|
21518
|
+
if (table(first)) {
|
|
20455
21519
|
await tableCommand(first, argv.slice(1));
|
|
20456
21520
|
return 0;
|
|
20457
21521
|
}
|