@ghostlytools/cli 1.1.5 → 1.1.7
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 +1 -0
- package/dist/assets/{endpoint-CaSbSFDw.js → endpoint-D_3bYafG.js} +76 -3
- package/dist/assets/endpoint-D_3bYafG.js.map +1 -0
- package/dist/assets/{hyperdhtRelay-w9cwVqGZ.js → hyperdhtRelay-DvXgebV7.js} +3 -3
- package/dist/assets/{hyperdhtRelay-w9cwVqGZ.js.map → hyperdhtRelay-DvXgebV7.js.map} +1 -1
- package/dist/assets/idb-BIdus0WX.js.map +1 -1
- package/dist/assets/{mainline-BabMhm8L.js → mainline-B7wHcLaW.js} +2 -2
- package/dist/assets/{mainline-BabMhm8L.js.map → mainline-B7wHcLaW.js.map} +1 -1
- package/dist/assets/{server-TamKsfmC.js → server-GJW_5wGI.js} +370 -66
- package/dist/assets/server-GJW_5wGI.js.map +1 -0
- package/dist/assets/{src-C6qQYrAH.js → src-Cp3o_yHN.js} +608 -70
- package/dist/assets/src-Cp3o_yHN.js.map +1 -0
- package/dist/ghostly.mjs +1068 -49
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/assets/endpoint-CaSbSFDw.js.map +0 -1
- package/dist/assets/server-TamKsfmC.js.map +0 -1
- package/dist/assets/src-C6qQYrAH.js.map +0 -1
package/dist/ghostly.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { t as __exportAll } from "./assets/rolldown-runtime-8H4AJuhK.js";
|
|
3
|
-
import { $ as
|
|
3
|
+
import { $ as parseSshPublicKey, $a as signAppRevocation, $n as GROUP_FILE_LIMITS, Aa as replySnippet, An as nip05RootKey, At as didWebDocumentUrl, Bc as sanitizeAvatar, Bn as engineText, Bt as staticDidDocument, Cc as setLinkTraceSink, Cn as DOMAIN_FILE_MAX_BYTES, Co as callRtcConfig, Dn as domainWellKnownFile, Do as signalHasVideo, Dt as didGhostlyServiceEntry, En as domainTxtRecord, Eo as parseCallSignal, Et as checkDidSignature, Fa as buttonLabelClash, Fn as txtValue, Ft as normalizeDid, Ga as APP_STATEMENT_LIMITS, Gc as toZ32, Gi as irohRelayUrl, Hc as fromBase64Url$1, Hr as TYPING_REFRESH_MS, Ia as checkStatusCard, In as wellKnownUrl, It as parseDid, Ja as checkAppRevokeStatement, Ka as appFingerprint, Kc as utf8Decode, Ks as inviteLink, Kt as resolveDidDht, Ln as requestLnurlInvoice, Lr as ChatFiles, Lt as parseDidDocument, Ma as ITEM_STATES, Mc as openRelayPayload, Mn as recordsFromTxt, Mt as didWebFileNames, Na as RUN_RESULTS, Nn as recordsFromWellKnown, Nt as didWebFileUrl, On as encodeDnsQuery, Ot as didJwsAlg, Pa as STATUS_CARD_LIMITS, Pi as identityStatement, Pn as recordsName, Pt as didWebHost, Q as SshSigError, Qa as signAppObject, Qi as readImageMeta, Rn as resolveLightningDestination, Rt as parseDidSignaturePaste, Sn as pubkyHomeserverOf, So as buildSdpFromSignal, St as DID_FILE_MAX_BYTES, Tn as dnsProofName, To as extractParamsFromSdp, Tt as checkDidJws, Uc as randomBytes$1, Vn as decodeBolt11, Vr as TYPING_KINDS, Vt as strictBase64Url, Wa as APP_PREFIXES, Wc as toBase64Url$1, Wi as irohDescriptor, Wr as sanitizeTypingStatus, Y as groupName, Z as SSHSIG_NAMESPACE, Za as readAppRevocations, _n as normalizePubkyKey, _t as parseAtprotoDidDocument, a as checkAppStoreIndex, aa as baseMime, ar as isGroupMessageId, at as BitcoinAddressError, bn as pubkyProofPath, bo as RTC_CONFIG, bt as verifyAtprotoRecordProof, c as APP_BUNDLE_LIMITS, ca as voicePeaksOf, cr as readGroupWant, ct as ATPROTO_FULL_SCOPE, di as BUTTONS_CAPABILITY, dt as atprotoDidMethod, eo as canonicalJson, er as GROUP_HAVE_FRAME, et as parseSshSignature, f as assembleAppBundle, fa as RateWindow, fo as GhostlyHttpError, ft as atprotoProofRecord, gn as newPubkyProofFolder, go as LIMITS, gt as normalizeAtprotoHandle, h as readAppBundle, hn as isPubkyProofFolder, ht as isAtprotoDid, ia as VOICE_LIMITS, ir as groupFileFallback, it as verifyBitcoinMessage, ji as boundVerifiedIdentity, jn as normalizeDomain, jt as didWebFile, kc as RELAY_PAYLOAD_MAX_BYTES, ki as IdentityCheckUnavailable, kn as isPublicAddress, kt as didSignedMessages, lr as readGroupWantNo, lt as ATPROTO_PROOF_COLLECTION, m as isAppUrl, mn as isPubkyKey, mt as checkAtprotoProofRecord, na as sanitizeMime, nn as WALLET_NETWORKS, nr as GROUP_WANT_NO_FRAME, nt as sshSignCommand, o as readAppStore, oa as fileMessageText, oi as checkPushEndpoint, ot as decodeAnyBitcoinAddress, p as checkAppManifest, pn as PUBKY_PROOF_MAX_BYTES, pt as atprotoProofRkey, qa as appRef, qc as utf8Encode, ra as PLAYABLE_AUDIO, rc as seedSigner, rr as fetchesByItself, rt as verifySshSignature, s as signAppStore, sa as parseVoiceMeta, sn as walletNetworkOf, sr as readGroupHave, st as ATPROTO_DECLARED_SCOPE, t as appStoreDecision, ta as sanitizeFileName, to as canonicalJsonBytes, tt as sameSshKey, ut as ATPROTO_PROOF_SCOPE, vn as pubkyProofCapability, vt as shortAtprotoDid, wn as decodeDnsResponse, wo as callSignalHeardAt, wt as DidError, xn as homeserverWebEndpoint, xo as answersOffer, xt as DID_DOCUMENT_MAX_BYTES$1, yn as pubkyProofFile, yo as CALL_SIGNAL_MAX_AGE_MS, yt as AtprotoProofError, zc as MAX_AVATAR_LENGTH, zn as engineError, zr as transferEnded, zt as signingKeys } from "./assets/src-Cp3o_yHN.js";
|
|
4
4
|
import { c as fileStore, d as store$1, f as transact, i as STORES, l as openDb, o as databaseName, p as wrap } from "./assets/idb-BIdus0WX.js";
|
|
5
5
|
import { a as fileBytesOf, i as fileBytes, l as FILE_BYTES_STEP } from "./assets/fileBytes-3AQ2tJzW.js";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
@@ -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.1.7",
|
|
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",
|
|
@@ -674,6 +674,7 @@ var ENGINE_METHODS = [
|
|
|
674
674
|
"disableGroupLink",
|
|
675
675
|
"joinGroupByLink",
|
|
676
676
|
"sendGroupMessage",
|
|
677
|
+
"sendGroupFile",
|
|
677
678
|
"pressButton",
|
|
678
679
|
"groupMessages",
|
|
679
680
|
"messagePage",
|
|
@@ -1040,6 +1041,707 @@ function groupReply(reply, myKey) {
|
|
|
1040
1041
|
};
|
|
1041
1042
|
}
|
|
1042
1043
|
//#endregion
|
|
1044
|
+
//#region ../browser/src/engine/groupFiles.ts
|
|
1045
|
+
/** 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$1(randomBytes$1(12))) => `group-${groupId}-${direction}-${random}`;
|
|
1047
|
+
/** The group a local file id names, among `groupIds`. */
|
|
1048
|
+
var groupOfFile = (fileId, groupIds) => [...groupIds].find((g) => fileId.startsWith(`group-${g}-in-`) || fileId.startsWith(`group-${g}-out-`));
|
|
1049
|
+
/** What a file's message says: its caption, or (when the author wrote none) the file itself, not the line older apps show. */
|
|
1050
|
+
var groupFileText = (text, meta) => text === groupFileFallback(meta) ? fileMessageText(meta) : text;
|
|
1051
|
+
/** The profile's files store and file storage, as a 1:1 chat's files use them. */
|
|
1052
|
+
var storedGroupFiles = {
|
|
1053
|
+
list: (groupId) => fileStore.listForLink(`group:${groupId}`),
|
|
1054
|
+
get: (id) => fileStore.get(id),
|
|
1055
|
+
put: (file) => fileStore.put(file),
|
|
1056
|
+
patch: (id, fields) => fileStore.patch(id, fields),
|
|
1057
|
+
remove: (id) => removeStored(id),
|
|
1058
|
+
async write(id) {
|
|
1059
|
+
const stored = await fileStore.get(id);
|
|
1060
|
+
const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
|
|
1061
|
+
if (await bytes.size(id) !== null) await bytes.truncate(id, 0);
|
|
1062
|
+
if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
|
|
1063
|
+
const appender = new FileAppender(bytes, id, 0);
|
|
1064
|
+
return {
|
|
1065
|
+
append: (chunk) => appender.append(chunk),
|
|
1066
|
+
flush: () => appender.flush(),
|
|
1067
|
+
digest: async () => {
|
|
1068
|
+
await appender.close();
|
|
1069
|
+
return bytes.digest(id);
|
|
1070
|
+
},
|
|
1071
|
+
discard: async () => {
|
|
1072
|
+
await appender.close().catch(() => {});
|
|
1073
|
+
await bytes.remove(id);
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
},
|
|
1077
|
+
read: (file, offset, length) => readStored(file, offset, length),
|
|
1078
|
+
room: async () => (await fileBytes()).room()
|
|
1079
|
+
};
|
|
1080
|
+
/** Why a want waits, as the person reads it. */
|
|
1081
|
+
var NOBODY_CONNECTED = "Nobody you are connected to has this file yet";
|
|
1082
|
+
var GroupFileDesk = class {
|
|
1083
|
+
deps;
|
|
1084
|
+
kept = /* @__PURE__ */ new Map();
|
|
1085
|
+
/** `<group>\n<message id>` → local id. */
|
|
1086
|
+
byMessage = /* @__PURE__ */ new Map();
|
|
1087
|
+
wants = /* @__PURE__ */ new Map();
|
|
1088
|
+
edgesById = /* @__PURE__ */ new Map();
|
|
1089
|
+
/** My file about to be announced, by `<group>\n<digest>`: the message that announces it takes this local id. */
|
|
1090
|
+
own = /* @__PURE__ */ new Map();
|
|
1091
|
+
/** Announcements sent per group, and taken per member of a group (WISP 503 § Rules for both, Pace). */
|
|
1092
|
+
sentPace = /* @__PURE__ */ new Map();
|
|
1093
|
+
takenPace = /* @__PURE__ */ new Map();
|
|
1094
|
+
/** `<group>\n<message id>` a `group-have` named before its message came → its holders, newest first. */
|
|
1095
|
+
ahead = /* @__PURE__ */ new Map();
|
|
1096
|
+
/** A community's ids for its next `group-have`, and when one last went. */
|
|
1097
|
+
communityHave = /* @__PURE__ */ new Map();
|
|
1098
|
+
/** Bytes served today, and the day (UTC days since the epoch). */
|
|
1099
|
+
served = {
|
|
1100
|
+
day: 0,
|
|
1101
|
+
bytes: 0
|
|
1102
|
+
};
|
|
1103
|
+
constructor(deps) {
|
|
1104
|
+
this.deps = deps;
|
|
1105
|
+
}
|
|
1106
|
+
/** At start: each group's files as they were kept. One fetched by itself, or asked for, and not here yet is asked for again. */
|
|
1107
|
+
async load(groupIds) {
|
|
1108
|
+
for (const groupId of groupIds) for (const file of await this.deps.store.list(groupId).catch(() => [])) {
|
|
1109
|
+
if (!file.group || !file.metadata || !file.digest || this.kept.has(file.id)) continue;
|
|
1110
|
+
const { name, size, mime, voice, video, image } = file.metadata;
|
|
1111
|
+
const kept = {
|
|
1112
|
+
id: file.id,
|
|
1113
|
+
groupId,
|
|
1114
|
+
message: file.group.message,
|
|
1115
|
+
author: file.group.author,
|
|
1116
|
+
meta: {
|
|
1117
|
+
name,
|
|
1118
|
+
size,
|
|
1119
|
+
mime,
|
|
1120
|
+
d: file.digest,
|
|
1121
|
+
...voice && { voice },
|
|
1122
|
+
...video && { video },
|
|
1123
|
+
...image && { image }
|
|
1124
|
+
},
|
|
1125
|
+
mine: file.direction === "out",
|
|
1126
|
+
auto: !!file.group.auto,
|
|
1127
|
+
asked: !!file.group.asked,
|
|
1128
|
+
held: file.transfer?.state === "done",
|
|
1129
|
+
holders: []
|
|
1130
|
+
};
|
|
1131
|
+
this.add(kept);
|
|
1132
|
+
if (kept.held) this.show(kept, {
|
|
1133
|
+
state: "done",
|
|
1134
|
+
transferred: size,
|
|
1135
|
+
size,
|
|
1136
|
+
direction: kept.mine ? "out" : "in"
|
|
1137
|
+
});
|
|
1138
|
+
else if (kept.auto || kept.asked) this.wants.set(kept.id, this.newWant());
|
|
1139
|
+
else this.show(kept, {
|
|
1140
|
+
state: "transferring",
|
|
1141
|
+
stage: "asking",
|
|
1142
|
+
transferred: 0,
|
|
1143
|
+
size,
|
|
1144
|
+
direction: "in"
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
for (const [id] of this.wants) this.ask(this.kept.get(id));
|
|
1148
|
+
this.deps.changed();
|
|
1149
|
+
}
|
|
1150
|
+
/** An error when this group took as many announcements of mine as it takes in a minute; null when one may go now. */
|
|
1151
|
+
mayAnnounce(groupId) {
|
|
1152
|
+
return this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).wait() ? "You sent many files to this group just now. Wait a minute." : null;
|
|
1153
|
+
}
|
|
1154
|
+
/** My file, stored under `fileId` with the author's digest, is about to be announced in the group: its message takes it. */
|
|
1155
|
+
outgoing(groupId, fileId, meta) {
|
|
1156
|
+
this.own.set(`${groupId}\n${meta.d}`, fileId);
|
|
1157
|
+
}
|
|
1158
|
+
/** The announcement did not go: the file is not this group's after all. */
|
|
1159
|
+
notSent(groupId, meta) {
|
|
1160
|
+
this.own.delete(`${groupId}\n${meta.d}`);
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* A message of the group announces a file (mine, or a member's): the file its row shows, or none when the member
|
|
1164
|
+
* announced more than the group takes (its text shows). A member's is kept here unfetched, and fetched now when it is
|
|
1165
|
+
* one this device takes by itself. The same message again (a catch-up, a whole copy) is the same file.
|
|
1166
|
+
*/
|
|
1167
|
+
async announced(groupId, m, me) {
|
|
1168
|
+
const known = this.byMessage.get(`${groupId}\n${m.id}`);
|
|
1169
|
+
if (known) return messageFile(this.kept.get(known));
|
|
1170
|
+
const ownId = m.sender === me ? this.own.get(`${groupId}\n${m.file.d}`) : void 0;
|
|
1171
|
+
if (ownId) {
|
|
1172
|
+
this.own.delete(`${groupId}\n${m.file.d}`);
|
|
1173
|
+
this.pace(this.sentPace, groupId, GROUP_FILE_LIMITS.announcePerMinute, 6e4).take();
|
|
1174
|
+
const kept = {
|
|
1175
|
+
id: ownId,
|
|
1176
|
+
groupId,
|
|
1177
|
+
message: m.id,
|
|
1178
|
+
author: me,
|
|
1179
|
+
meta: m.file,
|
|
1180
|
+
mine: true,
|
|
1181
|
+
auto: false,
|
|
1182
|
+
asked: false,
|
|
1183
|
+
held: true,
|
|
1184
|
+
holders: []
|
|
1185
|
+
};
|
|
1186
|
+
this.add(kept);
|
|
1187
|
+
const transfer = {
|
|
1188
|
+
state: "done",
|
|
1189
|
+
transferred: m.file.size,
|
|
1190
|
+
size: m.file.size
|
|
1191
|
+
};
|
|
1192
|
+
await this.deps.store.patch(ownId, {
|
|
1193
|
+
group: {
|
|
1194
|
+
message: m.id,
|
|
1195
|
+
author: me
|
|
1196
|
+
},
|
|
1197
|
+
transfer
|
|
1198
|
+
}).catch(() => {});
|
|
1199
|
+
this.show(kept, {
|
|
1200
|
+
...transfer,
|
|
1201
|
+
direction: "out"
|
|
1202
|
+
});
|
|
1203
|
+
this.queueHave(kept);
|
|
1204
|
+
return messageFile(kept);
|
|
1205
|
+
}
|
|
1206
|
+
if (!this.pace(this.takenPace, `${groupId}\n${m.sender}`, GROUP_FILE_LIMITS.announceTaken, GROUP_FILE_LIMITS.announceWindowMs).take()) return void 0;
|
|
1207
|
+
const id = groupFileId(groupId, "in");
|
|
1208
|
+
const auto = this.deps.settings().autoDownloads && fetchesByItself(m.file, this.keptAutomatically(groupId));
|
|
1209
|
+
const holders = this.ahead.get(`${groupId}\n${m.id}`) ?? [];
|
|
1210
|
+
this.ahead.delete(`${groupId}\n${m.id}`);
|
|
1211
|
+
const kept = {
|
|
1212
|
+
id,
|
|
1213
|
+
groupId,
|
|
1214
|
+
message: m.id,
|
|
1215
|
+
author: m.sender,
|
|
1216
|
+
meta: m.file,
|
|
1217
|
+
mine: false,
|
|
1218
|
+
auto,
|
|
1219
|
+
asked: false,
|
|
1220
|
+
held: false,
|
|
1221
|
+
holders
|
|
1222
|
+
};
|
|
1223
|
+
const { name, size, mime, voice, video, image } = m.file;
|
|
1224
|
+
this.add(kept);
|
|
1225
|
+
await this.deps.store.put({
|
|
1226
|
+
id,
|
|
1227
|
+
linkId: `group:${groupId}`,
|
|
1228
|
+
direction: "in",
|
|
1229
|
+
digest: m.file.d,
|
|
1230
|
+
createdAt: this.deps.now(),
|
|
1231
|
+
metadata: {
|
|
1232
|
+
name,
|
|
1233
|
+
size,
|
|
1234
|
+
mime,
|
|
1235
|
+
timestamp: this.deps.now(),
|
|
1236
|
+
voice,
|
|
1237
|
+
video,
|
|
1238
|
+
image
|
|
1239
|
+
},
|
|
1240
|
+
transfer: {
|
|
1241
|
+
state: "transferring",
|
|
1242
|
+
transferred: 0,
|
|
1243
|
+
size
|
|
1244
|
+
},
|
|
1245
|
+
group: {
|
|
1246
|
+
message: m.id,
|
|
1247
|
+
author: m.sender,
|
|
1248
|
+
...auto && { auto: true }
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
if (auto) {
|
|
1252
|
+
this.wants.set(id, this.newWant());
|
|
1253
|
+
this.ask(kept);
|
|
1254
|
+
} else this.show(kept, {
|
|
1255
|
+
state: "transferring",
|
|
1256
|
+
stage: "asking",
|
|
1257
|
+
transferred: 0,
|
|
1258
|
+
size,
|
|
1259
|
+
direction: "in"
|
|
1260
|
+
});
|
|
1261
|
+
return messageFile(kept);
|
|
1262
|
+
}
|
|
1263
|
+
/** The person asks for a file this device did not fetch by itself (Download), or for one that stopped coming. */
|
|
1264
|
+
async download(fileId) {
|
|
1265
|
+
const kept = this.kept.get(fileId);
|
|
1266
|
+
if (!kept) throw new Error("No such file");
|
|
1267
|
+
if (kept.held) return;
|
|
1268
|
+
const room = await this.deps.store.room().catch(() => null);
|
|
1269
|
+
if (room !== null && kept.meta.size > room) throw new Error("Not enough space on this device for this file");
|
|
1270
|
+
if (!kept.asked) {
|
|
1271
|
+
kept.asked = true;
|
|
1272
|
+
await this.deps.store.patch(kept.id, { group: this.groupField(kept) }).catch(() => {});
|
|
1273
|
+
}
|
|
1274
|
+
const want = this.wants.get(kept.id);
|
|
1275
|
+
if (!want) this.wants.set(kept.id, this.newWant());
|
|
1276
|
+
else if (!want.wire) want.tried.clear();
|
|
1277
|
+
this.ask(kept);
|
|
1278
|
+
}
|
|
1279
|
+
/** The group is gone from this device: its files go too. */
|
|
1280
|
+
async drop(groupId) {
|
|
1281
|
+
for (const kept of [...this.kept.values()].filter((k) => k.groupId === groupId)) {
|
|
1282
|
+
this.kept.delete(kept.id);
|
|
1283
|
+
this.byMessage.delete(`${groupId}\n${kept.message}`);
|
|
1284
|
+
this.wants.delete(kept.id);
|
|
1285
|
+
this.deps.transfers.delete(kept.id);
|
|
1286
|
+
await this.deps.store.remove(kept.id).catch(() => {});
|
|
1287
|
+
}
|
|
1288
|
+
for (const [linkId, edge] of this.edgesById) if (edge.groupId === groupId) {
|
|
1289
|
+
edge.files.detach();
|
|
1290
|
+
this.edgesById.delete(linkId);
|
|
1291
|
+
}
|
|
1292
|
+
for (const key of [...this.ahead.keys()]) if (key.startsWith(`${groupId}\n`)) this.ahead.delete(key);
|
|
1293
|
+
this.communityHave.delete(groupId);
|
|
1294
|
+
this.deps.changed();
|
|
1295
|
+
}
|
|
1296
|
+
/** An edge's files/3 session opened (both sides agreed it) or closed. */
|
|
1297
|
+
session(groupId, peer, linkId, open) {
|
|
1298
|
+
const edge = this.edge(groupId, peer, linkId);
|
|
1299
|
+
if (open) edge.files.attach();
|
|
1300
|
+
else edge.files.detach();
|
|
1301
|
+
if (open) this.retry(groupId);
|
|
1302
|
+
}
|
|
1303
|
+
/** An edge came up and both sides said where they are: what this device holds goes to it, and wants waiting for a holder go. */
|
|
1304
|
+
edgeReady(groupId, peer, linkId) {
|
|
1305
|
+
if (!this.deps.ready(linkId)) return;
|
|
1306
|
+
const edge = this.edge(groupId, peer, linkId);
|
|
1307
|
+
if (this.deps.settings().serveFiles) {
|
|
1308
|
+
const held = [...this.kept.values()].filter((k) => k.groupId === groupId && k.held).map((k) => k.message).slice(-GROUP_FILE_LIMITS.haveIds);
|
|
1309
|
+
if (held.length) this.sendHave(edge, held);
|
|
1310
|
+
}
|
|
1311
|
+
this.retry(groupId);
|
|
1312
|
+
}
|
|
1313
|
+
/** A `group-want`, `group-want-no` or `group-have` on an edge, from the member it is pinned to. */
|
|
1314
|
+
async frame(groupId, peer, linkId, frame) {
|
|
1315
|
+
if (frame.t === "group-want") {
|
|
1316
|
+
const want = readGroupWant(frame, groupId);
|
|
1317
|
+
if (want) await this.serve(this.edge(groupId, peer, linkId), want.id);
|
|
1318
|
+
} else if (frame.t === "group-want-no") {
|
|
1319
|
+
const no = readGroupWantNo(frame, groupId);
|
|
1320
|
+
if (no) this.refused(groupId, peer, linkId, no.id, no.why);
|
|
1321
|
+
} else if (frame.t === "group-have") {
|
|
1322
|
+
const ids = readGroupHave(frame, groupId);
|
|
1323
|
+
if (ids) this.heard(groupId, peer, ids);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
/** A community's `group-have`, an application frame its signer sent the group. */
|
|
1327
|
+
communityApp(groupId, sender, frame) {
|
|
1328
|
+
const ids = readGroupHave(frame);
|
|
1329
|
+
if (ids) this.heard(groupId, sender, ids);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* A files/3 frame on an edge. An offer is taken only for a file this device asked that member for (`gm` naming the
|
|
1333
|
+
* message): nobody can push a file into another's storage. What it brings belongs to the group's message, not to the
|
|
1334
|
+
* edge as a chat.
|
|
1335
|
+
*/
|
|
1336
|
+
filesFrame(groupId, peer, linkId, frame) {
|
|
1337
|
+
const edge = this.edge(groupId, peer, linkId);
|
|
1338
|
+
if (frame.t === "pf-offer" && typeof frame.id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(frame.id) && !edge.taking.has(frame.id)) {
|
|
1339
|
+
const kept = isGroupMessageId(frame.gm) ? this.kept.get(this.byMessage.get(`${groupId}\n${frame.gm}`) ?? "") : void 0;
|
|
1340
|
+
const want = kept && this.wants.get(kept.id);
|
|
1341
|
+
if (!kept || kept.held || !want || want.linkId !== linkId || want.wire) {
|
|
1342
|
+
this.deps.sendFiles(linkId, {
|
|
1343
|
+
t: "pf-refuse",
|
|
1344
|
+
id: frame.id,
|
|
1345
|
+
why: "invalid"
|
|
1346
|
+
});
|
|
1347
|
+
return Promise.resolve();
|
|
1348
|
+
}
|
|
1349
|
+
edge.taking.set(frame.id, kept.id);
|
|
1350
|
+
want.wire = frame.id;
|
|
1351
|
+
want.movedAt = this.deps.now();
|
|
1352
|
+
}
|
|
1353
|
+
return edge.files.handle(frame);
|
|
1354
|
+
}
|
|
1355
|
+
/** Wants that waited too long for an offer, or for bytes, go to the next holder; `group-have`s gathered go. */
|
|
1356
|
+
tick() {
|
|
1357
|
+
const now = this.deps.now();
|
|
1358
|
+
for (const [id, want] of this.wants) {
|
|
1359
|
+
const kept = this.kept.get(id);
|
|
1360
|
+
if (!kept) {
|
|
1361
|
+
this.wants.delete(id);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
if (now - (want.wire ? want.movedAt : want.at) < GROUP_FILE_LIMITS.wantWaitMs) continue;
|
|
1365
|
+
if (want.wire) {
|
|
1366
|
+
const edge = want.linkId ? this.edgesById.get(want.linkId) : void 0;
|
|
1367
|
+
if (edge?.files.get("in", want.wire) && !transferEnded(edge.files.get("in", want.wire))) {
|
|
1368
|
+
edge.files.cancel("in", want.wire);
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
want.wire = void 0;
|
|
1372
|
+
}
|
|
1373
|
+
if (want.holder) want.tried.add(want.holder);
|
|
1374
|
+
else want.tried.clear();
|
|
1375
|
+
this.ask(kept);
|
|
1376
|
+
}
|
|
1377
|
+
for (const edge of this.edgesById.values()) {
|
|
1378
|
+
if (!edge.have.size || now - edge.haveAt < GROUP_FILE_LIMITS.haveEveryMs || !this.deps.ready(edge.linkId)) continue;
|
|
1379
|
+
this.sendHave(edge, [...edge.have]);
|
|
1380
|
+
}
|
|
1381
|
+
for (const [groupId, have] of this.communityHave) {
|
|
1382
|
+
if (!have.ids.size || now - have.at < GROUP_FILE_LIMITS.communityHaveEveryMs) continue;
|
|
1383
|
+
const ids = [...have.ids].slice(-GROUP_FILE_LIMITS.haveIds);
|
|
1384
|
+
have.ids.clear();
|
|
1385
|
+
have.at = now;
|
|
1386
|
+
this.deps.sendApp(groupId, {
|
|
1387
|
+
t: GROUP_HAVE_FRAME,
|
|
1388
|
+
ids
|
|
1389
|
+
}).catch(() => {});
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
newWant() {
|
|
1393
|
+
return {
|
|
1394
|
+
at: this.deps.now(),
|
|
1395
|
+
tried: /* @__PURE__ */ new Set(),
|
|
1396
|
+
moved: 0,
|
|
1397
|
+
movedAt: 0
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
/** Wants of this group that wait for a holder: asked now (an edge came up, or a member said it holds something). */
|
|
1401
|
+
retry(groupId) {
|
|
1402
|
+
for (const [id, want] of this.wants) {
|
|
1403
|
+
const kept = this.kept.get(id);
|
|
1404
|
+
if (kept?.groupId === groupId && !want.holder) this.ask(kept);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Asks the next holder for a file: the author first while its edge is up, then the holders whose edge is up. Only over
|
|
1409
|
+
* an edge this device has. (A private group past 16 members, or a community, reaches a holder it has no edge with
|
|
1410
|
+
* over a file session of the two members, WISP 503 § The file session; that session is not built yet, so such a
|
|
1411
|
+
* holder is not asked, and the file waits for one that is reachable.)
|
|
1412
|
+
*/
|
|
1413
|
+
ask(kept) {
|
|
1414
|
+
const want = this.wants.get(kept.id);
|
|
1415
|
+
if (!want || kept.held || want.wire) return;
|
|
1416
|
+
const membership = this.deps.membership(kept.groupId);
|
|
1417
|
+
const edges = this.deps.edges(kept.groupId);
|
|
1418
|
+
const order = [kept.author, ...kept.holders.filter((key) => key !== kept.author)].filter((key) => membership && key !== membership.me && membership.inRoster(key) && !want.tried.has(key));
|
|
1419
|
+
for (const key of order) {
|
|
1420
|
+
const linkId = edges.get(key);
|
|
1421
|
+
if (!linkId || !this.deps.ready(linkId) || !this.edgesById.get(linkId)?.files.live) continue;
|
|
1422
|
+
if (!this.deps.send(linkId, {
|
|
1423
|
+
t: "group-want",
|
|
1424
|
+
g: kept.groupId,
|
|
1425
|
+
id: kept.message
|
|
1426
|
+
})) {
|
|
1427
|
+
want.tried.add(key);
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
Object.assign(want, {
|
|
1431
|
+
holder: key,
|
|
1432
|
+
linkId,
|
|
1433
|
+
at: this.deps.now()
|
|
1434
|
+
});
|
|
1435
|
+
this.show(kept, {
|
|
1436
|
+
state: "transferring",
|
|
1437
|
+
stage: "queued",
|
|
1438
|
+
transferred: 0,
|
|
1439
|
+
size: kept.meta.size,
|
|
1440
|
+
direction: "in"
|
|
1441
|
+
});
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
Object.assign(want, {
|
|
1445
|
+
holder: void 0,
|
|
1446
|
+
linkId: void 0,
|
|
1447
|
+
at: this.deps.now()
|
|
1448
|
+
});
|
|
1449
|
+
this.show(kept, {
|
|
1450
|
+
state: "transferring",
|
|
1451
|
+
stage: "waiting",
|
|
1452
|
+
transferred: 0,
|
|
1453
|
+
size: kept.meta.size,
|
|
1454
|
+
direction: "in",
|
|
1455
|
+
note: NOBODY_CONNECTED
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
/** A holder's no: the next one is asked. One that deleted the file is no holder any more. */
|
|
1459
|
+
refused(groupId, peer, linkId, messageId, why) {
|
|
1460
|
+
const kept = this.kept.get(this.byMessage.get(`${groupId}\n${messageId}`) ?? "");
|
|
1461
|
+
const want = kept && this.wants.get(kept.id);
|
|
1462
|
+
if (!kept || !want || want.linkId !== linkId || want.wire) return;
|
|
1463
|
+
if (why === "gone") kept.holders = kept.holders.filter((key) => key !== peer);
|
|
1464
|
+
want.tried.add(peer);
|
|
1465
|
+
want.holder = want.linkId = void 0;
|
|
1466
|
+
this.ask(kept);
|
|
1467
|
+
}
|
|
1468
|
+
/** A member says it holds these files: remembered as a holder of each (newest first), and asked when a want waits. */
|
|
1469
|
+
heard(groupId, from, ids) {
|
|
1470
|
+
const membership = this.deps.membership(groupId);
|
|
1471
|
+
if (!membership || from === membership.me || !membership.inRoster(from)) return;
|
|
1472
|
+
const newest = (holders) => [from, ...holders.filter((key) => key !== from)].slice(0, GROUP_FILE_LIMITS.holders);
|
|
1473
|
+
for (const message of ids) {
|
|
1474
|
+
const key = `${groupId}\n${message}`, kept = this.kept.get(this.byMessage.get(key) ?? "");
|
|
1475
|
+
if (!kept) {
|
|
1476
|
+
this.ahead.set(key, newest(this.ahead.get(key) ?? []));
|
|
1477
|
+
if (this.ahead.size > GROUP_FILE_LIMITS.heardAhead) this.ahead.delete(this.ahead.keys().next().value);
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
if (kept.mine) continue;
|
|
1481
|
+
kept.holders = newest(kept.holders);
|
|
1482
|
+
const want = this.wants.get(kept.id);
|
|
1483
|
+
if (!want) continue;
|
|
1484
|
+
want.tried.delete(from);
|
|
1485
|
+
if (!want.holder) this.ask(kept);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
/** A member asks for a file: offered over files/3 on its edge, or a `group-want-no` saying why not. */
|
|
1489
|
+
async serve(edge, messageId) {
|
|
1490
|
+
const kept = this.kept.get(this.byMessage.get(`${edge.groupId}\n${messageId}`) ?? "");
|
|
1491
|
+
const here = !!kept?.held && !!await this.deps.store.get(kept.id).catch(() => void 0);
|
|
1492
|
+
const why = this.mayServe(edge, messageId, here ? kept : void 0);
|
|
1493
|
+
if (why || !kept) {
|
|
1494
|
+
this.deps.send(edge.linkId, {
|
|
1495
|
+
t: GROUP_WANT_NO_FRAME,
|
|
1496
|
+
g: edge.groupId,
|
|
1497
|
+
id: messageId,
|
|
1498
|
+
why: why ?? "gone"
|
|
1499
|
+
});
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const wire = toBase64Url$1(randomBytes$1(12));
|
|
1503
|
+
edge.serving.set(wire, kept.id);
|
|
1504
|
+
const { name, mime, size, voice, video, image } = kept.meta;
|
|
1505
|
+
const file = {
|
|
1506
|
+
id: wire,
|
|
1507
|
+
name,
|
|
1508
|
+
mime,
|
|
1509
|
+
size,
|
|
1510
|
+
timestamp: this.deps.now(),
|
|
1511
|
+
...voice && { voice },
|
|
1512
|
+
...video && { video },
|
|
1513
|
+
...image && { image }
|
|
1514
|
+
};
|
|
1515
|
+
edge.files.offer(file, kept.meta.d);
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Why this device does not serve a file now, or null when it does (WISP 503 § Asking, Serving limits): `refused` to
|
|
1519
|
+
* someone who could not read the announcement, `gone` when it is not here (`kept` undefined), `busy` when serving is
|
|
1520
|
+
* off or full.
|
|
1521
|
+
*/
|
|
1522
|
+
mayServe(edge, messageId, kept) {
|
|
1523
|
+
const membership = this.deps.membership(edge.groupId);
|
|
1524
|
+
if (!membership || !membership.couldRead(edge.peer, messageId)) return "refused";
|
|
1525
|
+
if (!kept) return "gone";
|
|
1526
|
+
if (!this.deps.settings().serveFiles || !edge.files.live) return "busy";
|
|
1527
|
+
const active = this.servingNow();
|
|
1528
|
+
if (active.length >= GROUP_FILE_LIMITS.serveAtOnce || active.some((a) => a.peer === edge.peer)) return "busy";
|
|
1529
|
+
const today = Math.floor(this.deps.now() / 864e5);
|
|
1530
|
+
if (this.served.day !== today) this.served = {
|
|
1531
|
+
day: today,
|
|
1532
|
+
bytes: 0
|
|
1533
|
+
};
|
|
1534
|
+
const reserved = active.reduce((sum, a) => sum + a.size, 0);
|
|
1535
|
+
return this.served.bytes + reserved + kept.meta.size > GROUP_FILE_LIMITS.serveBytesPerDay ? "busy" : null;
|
|
1536
|
+
}
|
|
1537
|
+
/** Transfers served now, all groups together: to whom, and their size. */
|
|
1538
|
+
servingNow() {
|
|
1539
|
+
const active = [];
|
|
1540
|
+
for (const edge of this.edgesById.values()) for (const wire of edge.serving.keys()) {
|
|
1541
|
+
const record = edge.files.get("out", wire);
|
|
1542
|
+
if (record && !transferEnded(record)) active.push({
|
|
1543
|
+
peer: edge.peer,
|
|
1544
|
+
size: record.file.size
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
return active;
|
|
1548
|
+
}
|
|
1549
|
+
/** Says on an edge which files this device holds. */
|
|
1550
|
+
sendHave(edge, ids) {
|
|
1551
|
+
for (let i = 0; i < ids.length; i += GROUP_FILE_LIMITS.haveIds) if (!this.deps.send(edge.linkId, {
|
|
1552
|
+
t: "group-have",
|
|
1553
|
+
g: edge.groupId,
|
|
1554
|
+
ids: ids.slice(i, i + GROUP_FILE_LIMITS.haveIds)
|
|
1555
|
+
})) return;
|
|
1556
|
+
edge.have.clear();
|
|
1557
|
+
edge.haveAt = this.deps.now();
|
|
1558
|
+
}
|
|
1559
|
+
/** This device holds a new file: the group hears it with the next `group-have` (batched, `haveEveryMs` at most on an edge). */
|
|
1560
|
+
queueHave(kept) {
|
|
1561
|
+
if (!this.deps.settings().serveFiles) return;
|
|
1562
|
+
if (this.deps.membership(kept.groupId)?.community) {
|
|
1563
|
+
let have = this.communityHave.get(kept.groupId);
|
|
1564
|
+
if (!have) this.communityHave.set(kept.groupId, have = {
|
|
1565
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1566
|
+
at: 0
|
|
1567
|
+
});
|
|
1568
|
+
have.ids.add(kept.message);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
for (const [key, linkId] of this.deps.edges(kept.groupId)) this.edge(kept.groupId, key, linkId).have.add(kept.message);
|
|
1572
|
+
}
|
|
1573
|
+
edge(groupId, peer, linkId) {
|
|
1574
|
+
const known = this.edgesById.get(linkId);
|
|
1575
|
+
if (known) return known;
|
|
1576
|
+
const edge = {
|
|
1577
|
+
linkId,
|
|
1578
|
+
groupId,
|
|
1579
|
+
peer,
|
|
1580
|
+
files: void 0,
|
|
1581
|
+
serving: /* @__PURE__ */ new Map(),
|
|
1582
|
+
taking: /* @__PURE__ */ new Map(),
|
|
1583
|
+
have: /* @__PURE__ */ new Set(),
|
|
1584
|
+
haveAt: 0
|
|
1585
|
+
};
|
|
1586
|
+
edge.files = new ChatFiles({
|
|
1587
|
+
send: (frame) => {
|
|
1588
|
+
const kept = frame.t === "pf-offer" ? this.kept.get(edge.serving.get(frame.id) ?? "") : void 0;
|
|
1589
|
+
return this.deps.sendFiles(linkId, kept ? {
|
|
1590
|
+
...frame,
|
|
1591
|
+
gm: kept.message
|
|
1592
|
+
} : frame);
|
|
1593
|
+
},
|
|
1594
|
+
decide: (file) => this.decide(edge, file),
|
|
1595
|
+
openTarget: (record) => this.openTarget(edge, record),
|
|
1596
|
+
openSource: (record) => this.openSource(edge, record),
|
|
1597
|
+
changed: (record, transferred) => this.transferChanged(edge, record, transferred),
|
|
1598
|
+
room: () => this.deps.store.room(),
|
|
1599
|
+
writable: () => this.deps.writable?.(linkId)
|
|
1600
|
+
});
|
|
1601
|
+
this.edgesById.set(linkId, edge);
|
|
1602
|
+
return edge;
|
|
1603
|
+
}
|
|
1604
|
+
/** An offer that answers my want (`filesFrame` checked that): taken, unless it is not the file announced or there is no room. */
|
|
1605
|
+
async decide(edge, file) {
|
|
1606
|
+
const kept = this.kept.get(edge.taking.get(file.id) ?? ""), want = kept && this.wants.get(kept.id);
|
|
1607
|
+
if (!kept || kept.held || want?.linkId !== edge.linkId || want.wire !== file.id || file.size !== kept.meta.size) return { refuse: "invalid" };
|
|
1608
|
+
const room = await this.deps.store.room().catch(() => null);
|
|
1609
|
+
return room !== null && file.size > room ? {
|
|
1610
|
+
refuse: "no-room",
|
|
1611
|
+
room
|
|
1612
|
+
} : "accept";
|
|
1613
|
+
}
|
|
1614
|
+
async openTarget(edge, record) {
|
|
1615
|
+
const kept = this.kept.get(edge.taking.get(record.id) ?? "");
|
|
1616
|
+
if (!kept) throw new Error("No such file");
|
|
1617
|
+
await kept.idle;
|
|
1618
|
+
const writer = await this.deps.store.write(kept.id);
|
|
1619
|
+
return {
|
|
1620
|
+
offset: 0,
|
|
1621
|
+
append: (bytes) => writer.append(bytes),
|
|
1622
|
+
flush: () => writer.flush(),
|
|
1623
|
+
verify: async () => await writer.digest() === kept.meta.d,
|
|
1624
|
+
discard: () => kept.idle = writer.discard().catch(() => {})
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
async openSource(edge, record) {
|
|
1628
|
+
const kept = this.kept.get(edge.serving.get(record.id) ?? "");
|
|
1629
|
+
const stored = kept && await this.deps.store.get(kept.id);
|
|
1630
|
+
if (!kept || !stored) throw new Error("The file is gone");
|
|
1631
|
+
return {
|
|
1632
|
+
read: (offset, length) => this.deps.store.read(stored, offset, length),
|
|
1633
|
+
digest: async () => kept.meta.d
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
transferChanged(edge, record, transferred) {
|
|
1637
|
+
if (record.direction === "out") {
|
|
1638
|
+
if (transferEnded(record) && edge.serving.has(record.id)) {
|
|
1639
|
+
const today = Math.floor(this.deps.now() / 864e5);
|
|
1640
|
+
if (this.served.day !== today) this.served = {
|
|
1641
|
+
day: today,
|
|
1642
|
+
bytes: 0
|
|
1643
|
+
};
|
|
1644
|
+
this.served.bytes += record.confirmed;
|
|
1645
|
+
edge.serving.delete(record.id);
|
|
1646
|
+
}
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const kept = this.kept.get(edge.taking.get(record.id) ?? ""), want = kept && this.wants.get(kept.id);
|
|
1650
|
+
if (!kept || !want || want.wire !== record.id || want.linkId !== edge.linkId) return;
|
|
1651
|
+
if (transferred !== want.moved) {
|
|
1652
|
+
want.moved = transferred;
|
|
1653
|
+
want.movedAt = this.deps.now();
|
|
1654
|
+
}
|
|
1655
|
+
const { size } = kept.meta;
|
|
1656
|
+
switch (record.state) {
|
|
1657
|
+
case "done":
|
|
1658
|
+
kept.held = true;
|
|
1659
|
+
this.wants.delete(kept.id);
|
|
1660
|
+
edge.taking.delete(record.id);
|
|
1661
|
+
this.deps.store.patch(kept.id, { transfer: {
|
|
1662
|
+
state: "done",
|
|
1663
|
+
transferred: size,
|
|
1664
|
+
size
|
|
1665
|
+
} }).catch(() => {});
|
|
1666
|
+
this.show(kept, {
|
|
1667
|
+
state: "done",
|
|
1668
|
+
transferred: size,
|
|
1669
|
+
size,
|
|
1670
|
+
direction: "in"
|
|
1671
|
+
});
|
|
1672
|
+
this.queueHave(kept);
|
|
1673
|
+
return;
|
|
1674
|
+
case "failed":
|
|
1675
|
+
case "declined":
|
|
1676
|
+
case "cancelled":
|
|
1677
|
+
edge.taking.delete(record.id);
|
|
1678
|
+
if (want.holder) want.tried.add(want.holder);
|
|
1679
|
+
Object.assign(want, {
|
|
1680
|
+
wire: void 0,
|
|
1681
|
+
holder: void 0,
|
|
1682
|
+
linkId: void 0,
|
|
1683
|
+
moved: 0
|
|
1684
|
+
});
|
|
1685
|
+
this.ask(kept);
|
|
1686
|
+
return;
|
|
1687
|
+
case "verifying":
|
|
1688
|
+
this.show(kept, {
|
|
1689
|
+
state: "transferring",
|
|
1690
|
+
stage: "verifying",
|
|
1691
|
+
transferred,
|
|
1692
|
+
size,
|
|
1693
|
+
direction: "in"
|
|
1694
|
+
});
|
|
1695
|
+
return;
|
|
1696
|
+
default: this.show(kept, {
|
|
1697
|
+
state: "transferring",
|
|
1698
|
+
transferred,
|
|
1699
|
+
size,
|
|
1700
|
+
direction: "in"
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
add(kept) {
|
|
1705
|
+
this.kept.set(kept.id, kept);
|
|
1706
|
+
this.byMessage.set(`${kept.groupId}\n${kept.message}`, kept.id);
|
|
1707
|
+
}
|
|
1708
|
+
/** Bytes this device fetched by itself from the group and keeps (or is fetching): what `autoBytesPerGroup` bounds. */
|
|
1709
|
+
keptAutomatically(groupId) {
|
|
1710
|
+
let sum = 0;
|
|
1711
|
+
for (const kept of this.kept.values()) if (kept.groupId === groupId && kept.auto && !kept.mine) sum += kept.meta.size;
|
|
1712
|
+
return sum;
|
|
1713
|
+
}
|
|
1714
|
+
groupField(kept) {
|
|
1715
|
+
return {
|
|
1716
|
+
message: kept.message,
|
|
1717
|
+
author: kept.author,
|
|
1718
|
+
...kept.auto && { auto: true },
|
|
1719
|
+
...kept.asked && { asked: true }
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
pace(windows, key, limit, ms) {
|
|
1723
|
+
let window = windows.get(key);
|
|
1724
|
+
if (!window) windows.set(key, window = new RateWindow(limit, ms, () => this.deps.now()));
|
|
1725
|
+
return window;
|
|
1726
|
+
}
|
|
1727
|
+
show(kept, view) {
|
|
1728
|
+
this.deps.transfers.set(kept.id, view);
|
|
1729
|
+
this.deps.changed();
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
function messageFile(kept) {
|
|
1733
|
+
const { name, size, mime, voice, video, image } = kept.meta;
|
|
1734
|
+
return {
|
|
1735
|
+
id: kept.id,
|
|
1736
|
+
name,
|
|
1737
|
+
size,
|
|
1738
|
+
mime,
|
|
1739
|
+
...voice && { voice },
|
|
1740
|
+
...video && { video },
|
|
1741
|
+
...image && { image }
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
//#endregion
|
|
1043
1745
|
//#region src/typing.ts
|
|
1044
1746
|
/**
|
|
1045
1747
|
* `typing --for` and `group typing --for`: a `start` holds 6 s on the other side (WISP 401 § Typing, WISP 902 · Group
|
|
@@ -1458,6 +2160,9 @@ function decodeWithFfmpeg(path, command = process.env.GHOSTLY_FFMPEG || "ffmpeg"
|
|
|
1458
2160
|
* Files and voice notes in a chat (WISP 1100, phase 3), as the app's composer sends them (packages/browser
|
|
1459
2161
|
* platform/services.ts `sendFile`): the bytes go into the profile's file store first, then the engine offers them.
|
|
1460
2162
|
* Large files follow files/3: the receiving side's person (here, a command) accepts or declines each offer.
|
|
2163
|
+
*
|
|
2164
|
+
* In a group (WISP 503 · Group Files) the file is announced as a group message, and each member's app fetches the
|
|
2165
|
+
* bytes from whoever holds them: by itself up to the automatic limits, on `file accept` past them.
|
|
1461
2166
|
*/
|
|
1462
2167
|
var TYPES$1 = {
|
|
1463
2168
|
".png": "image/png",
|
|
@@ -1501,16 +2206,143 @@ function filesOf(messages, transfers) {
|
|
|
1501
2206
|
}));
|
|
1502
2207
|
}
|
|
1503
2208
|
/**
|
|
1504
|
-
* The chat a file
|
|
1505
|
-
*
|
|
2209
|
+
* The chat a file command names, or else a group (`group:<id>`, or a group's id, prefix or name where no chat has it),
|
|
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
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* 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
|
+
* may be left out; given, it must be the file's own. `where` is what the answer names it by.
|
|
1506
2229
|
*/
|
|
1507
2230
|
function fileChat(ctx, params, fileId) {
|
|
2231
|
+
const groupId = groupOfFile(fileId, state(ctx).groups.map((g) => g.id));
|
|
2232
|
+
if (groupId) {
|
|
2233
|
+
const named = params.chat === void 0 ? void 0 : chatOrGroupOf(ctx, params);
|
|
2234
|
+
if (named && !("group" in named && named.group.id === groupId)) throw new CliError("not_found", `No file ${fileId} in this ${"group" in named ? "group" : "chat"}`);
|
|
2235
|
+
return {
|
|
2236
|
+
linkId: `group:${groupId}`,
|
|
2237
|
+
where: { group: groupId }
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
1508
2240
|
const link = params.chat === void 0 ? state(ctx).links.find((l) => fileId.startsWith(`${l.id}-in-`) || fileId.startsWith(`${l.id}-out-`)) : chatOf(ctx, params);
|
|
1509
2241
|
if (!link) throw new CliError("not_found", `No file ${fileId}`);
|
|
1510
2242
|
if (!fileId.startsWith(`${link.id}-`)) throw new CliError("not_found", `No file ${fileId} in this chat`);
|
|
1511
|
-
return
|
|
2243
|
+
return {
|
|
2244
|
+
linkId: link.id,
|
|
2245
|
+
where: { chat: link.id }
|
|
2246
|
+
};
|
|
1512
2247
|
}
|
|
1513
2248
|
var VOICE_ONLY = "A voice note is audio (webm, ogg, mp4, mpeg, aac, m4a or wav) of at most 15 minutes, with peaks from 0 to 255";
|
|
2249
|
+
/** `--voice [ms] [--peaks …]`: the voice description, its length and waveform measured from the sound when not given. */
|
|
2250
|
+
async function voiceOf(params, path, mime) {
|
|
2251
|
+
if (params.voice === void 0) return {};
|
|
2252
|
+
if (!PLAYABLE_AUDIO.test(baseMime(mime))) throw new CliError("bad_request", VOICE_ONLY);
|
|
2253
|
+
let duration = params.voice === true ? void 0 : num(params, "voice", 0, { min: 1 });
|
|
2254
|
+
let peaks = list(params, "peaks").map(Number);
|
|
2255
|
+
let warning;
|
|
2256
|
+
if (duration === void 0 || !peaks.length) {
|
|
2257
|
+
const measured = await measureVoice(path);
|
|
2258
|
+
if ("voice" in measured) {
|
|
2259
|
+
duration ??= measured.voice.duration;
|
|
2260
|
+
if (!peaks.length) peaks = measured.voice.peaks;
|
|
2261
|
+
} else if (duration === void 0) throw new CliError("bad_request", `Could not measure the voice note (${measured.problem}): give its length as --voice <ms>`);
|
|
2262
|
+
else warning = `Sent without a waveform: could not read the sound (${measured.problem})`;
|
|
2263
|
+
}
|
|
2264
|
+
const voice = parseVoiceMeta({
|
|
2265
|
+
duration,
|
|
2266
|
+
peaks: peaks.length ? peaks : new Array(VOICE_LIMITS.bars).fill(0)
|
|
2267
|
+
}, mime);
|
|
2268
|
+
if (!voice) throw new CliError("bad_request", VOICE_ONLY);
|
|
2269
|
+
return {
|
|
2270
|
+
voice,
|
|
2271
|
+
...warning ? { warning } : {}
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* A file announced in a group (WISP 503): its bytes staged under a group file id with their digest, as a chat's are, then
|
|
2276
|
+
* the announcement. This device is its first holder and serves the members who ask.
|
|
2277
|
+
*/
|
|
2278
|
+
async function sendToGroup(ctx, params, group, path, size) {
|
|
2279
|
+
if (size > GROUP_FILE_LIMITS.maxBytes) throw new CliError("refused", `Too large for a group (at most ${GROUP_FILE_LIMITS.maxBytes} bytes)`);
|
|
2280
|
+
if (!size) throw new CliError("refused", "An empty file cannot go to a group");
|
|
2281
|
+
const mime = mimeOf(path, str(params, "mime"));
|
|
2282
|
+
const replyTo = str(params, "reply");
|
|
2283
|
+
if (replyTo) {
|
|
2284
|
+
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
|
+
}
|
|
2286
|
+
const { voice, warning } = await voiceOf(params, path, mime);
|
|
2287
|
+
const source = await openAsBlob(path, { type: mime });
|
|
2288
|
+
const image = voice ? void 0 : await readImageMeta(source, mime);
|
|
2289
|
+
const fileId = groupFileId(group.id, "out");
|
|
2290
|
+
const file = {
|
|
2291
|
+
id: fileId,
|
|
2292
|
+
name: sanitizeFileName(str(params, "name") ?? basename(path)),
|
|
2293
|
+
size,
|
|
2294
|
+
mime,
|
|
2295
|
+
...voice ? { voice } : {},
|
|
2296
|
+
...image ? { image } : {}
|
|
2297
|
+
};
|
|
2298
|
+
const timestamp = Date.now();
|
|
2299
|
+
const bytes = await fileBytes();
|
|
2300
|
+
const digest = await bytes.stage(fileId, source);
|
|
2301
|
+
await fileStore.put({
|
|
2302
|
+
id: fileId,
|
|
2303
|
+
linkId: `group:${group.id}`,
|
|
2304
|
+
bytes: bytes.kind,
|
|
2305
|
+
digest,
|
|
2306
|
+
createdAt: timestamp,
|
|
2307
|
+
direction: "out",
|
|
2308
|
+
metadata: {
|
|
2309
|
+
name: file.name,
|
|
2310
|
+
size,
|
|
2311
|
+
mime,
|
|
2312
|
+
timestamp,
|
|
2313
|
+
...voice ? { voice } : {},
|
|
2314
|
+
...image ? { image } : {}
|
|
2315
|
+
},
|
|
2316
|
+
transfer: {
|
|
2317
|
+
state: "transferring",
|
|
2318
|
+
transferred: 0,
|
|
2319
|
+
size
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
endTyping(ctx, { groupId: group.id }, false);
|
|
2323
|
+
const result = await node(ctx).sendGroupFile({
|
|
2324
|
+
groupId: group.id,
|
|
2325
|
+
file,
|
|
2326
|
+
...replyTo ? { replyTo } : {}
|
|
2327
|
+
});
|
|
2328
|
+
if (result.error) {
|
|
2329
|
+
await removeStored(fileId).catch(() => {});
|
|
2330
|
+
throw result.refused ? new CliError("refused", result.error) : groupSendRefused(result.error);
|
|
2331
|
+
}
|
|
2332
|
+
return {
|
|
2333
|
+
group: group.id,
|
|
2334
|
+
messageId: result.messageId ?? null,
|
|
2335
|
+
file: {
|
|
2336
|
+
id: fileId,
|
|
2337
|
+
name: file.name,
|
|
2338
|
+
size,
|
|
2339
|
+
mime,
|
|
2340
|
+
...voice ? { voice: true } : {},
|
|
2341
|
+
...image ? { image } : {}
|
|
2342
|
+
},
|
|
2343
|
+
...warning ? { warning } : {}
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
1514
2346
|
var ACTIONS = [
|
|
1515
2347
|
"accept",
|
|
1516
2348
|
"decline",
|
|
@@ -1522,7 +2354,7 @@ var ACTIONS = [
|
|
|
1522
2354
|
];
|
|
1523
2355
|
var FILE_METHODS = {
|
|
1524
2356
|
async "file.send"(ctx, params) {
|
|
1525
|
-
const
|
|
2357
|
+
const target = chatOrGroupOf(ctx, params);
|
|
1526
2358
|
const path = resolve(str(params, "path", true));
|
|
1527
2359
|
let info;
|
|
1528
2360
|
try {
|
|
@@ -1531,6 +2363,8 @@ var FILE_METHODS = {
|
|
|
1531
2363
|
throw new CliError("not_found", `No file ${path}`);
|
|
1532
2364
|
}
|
|
1533
2365
|
if (!info.isFile()) throw new CliError("bad_request", `${path} is not a file`);
|
|
2366
|
+
if ("group" in target) return sendToGroup(ctx, params, target.group, path, info.size);
|
|
2367
|
+
const { link } = target;
|
|
1534
2368
|
if (link.profile && link.dataLink === "open" && !link.capabilities?.files) throw new CliError("unavailable", "The contact's app takes no files");
|
|
1535
2369
|
if (!link.capabilities?.largeFiles && info.size > LIMITS.maxFileBytes) throw new CliError("refused", `Too large for the contact's app (at most ${LIMITS.maxFileBytes} bytes)`);
|
|
1536
2370
|
if (typeof link.peerFileRoom === "number" && info.size > link.peerFileRoom) throw new CliError("refused", `Not enough space on the contact's device (${link.peerFileRoom} bytes free)`);
|
|
@@ -1539,25 +2373,7 @@ var FILE_METHODS = {
|
|
|
1539
2373
|
if (replyTo) {
|
|
1540
2374
|
if (!(await node(ctx).getMessages(link.id)).some((m) => m.id === replyTo || replyRef(m) === replyTo)) throw new CliError("not_found", "That message is not in this chat, or cannot be replied to");
|
|
1541
2375
|
}
|
|
1542
|
-
|
|
1543
|
-
if (params.voice !== void 0) {
|
|
1544
|
-
if (!PLAYABLE_AUDIO.test(baseMime(mime))) throw new CliError("bad_request", VOICE_ONLY);
|
|
1545
|
-
let duration = params.voice === true ? void 0 : num(params, "voice", 0, { min: 1 });
|
|
1546
|
-
let peaks = list(params, "peaks").map(Number);
|
|
1547
|
-
if (duration === void 0 || !peaks.length) {
|
|
1548
|
-
const measured = await measureVoice(path);
|
|
1549
|
-
if ("voice" in measured) {
|
|
1550
|
-
duration ??= measured.voice.duration;
|
|
1551
|
-
if (!peaks.length) peaks = measured.voice.peaks;
|
|
1552
|
-
} else if (duration === void 0) throw new CliError("bad_request", `Could not measure the voice note (${measured.problem}): give its length as --voice <ms>`);
|
|
1553
|
-
else warning = `Sent without a waveform: could not read the sound (${measured.problem})`;
|
|
1554
|
-
}
|
|
1555
|
-
voice = parseVoiceMeta({
|
|
1556
|
-
duration,
|
|
1557
|
-
peaks: peaks.length ? peaks : new Array(VOICE_LIMITS.bars).fill(0)
|
|
1558
|
-
}, mime);
|
|
1559
|
-
if (!voice) throw new CliError("bad_request", VOICE_ONLY);
|
|
1560
|
-
}
|
|
2376
|
+
const { voice, warning } = await voiceOf(params, path, mime);
|
|
1561
2377
|
const source = await openAsBlob(path, { type: mime });
|
|
1562
2378
|
const image = voice ? void 0 : await readImageMeta(source, mime);
|
|
1563
2379
|
const wireId = toBase64Url$1(randomBytes$1(12));
|
|
@@ -1620,34 +2436,58 @@ var FILE_METHODS = {
|
|
|
1620
2436
|
};
|
|
1621
2437
|
},
|
|
1622
2438
|
async "file.list"(ctx, params) {
|
|
1623
|
-
const
|
|
2439
|
+
const target = chatOrGroupOf(ctx, params);
|
|
2440
|
+
if ("group" in target) return {
|
|
2441
|
+
group: target.group.id,
|
|
2442
|
+
files: filesOf(await node(ctx).groupMessages({ groupId: target.group.id }), state(ctx).transfers)
|
|
2443
|
+
};
|
|
1624
2444
|
return {
|
|
1625
|
-
chat: link.id,
|
|
1626
|
-
files: filesOf(await node(ctx).getMessages(link.id), state(ctx).transfers)
|
|
2445
|
+
chat: target.link.id,
|
|
2446
|
+
files: filesOf(await node(ctx).getMessages(target.link.id), state(ctx).transfers)
|
|
1627
2447
|
};
|
|
1628
2448
|
},
|
|
1629
2449
|
/**
|
|
1630
2450
|
* files/3: answer an offer, or pause, resume or cancel a transfer, either way; `resend` a file sent from here that
|
|
1631
2451
|
* stopped moving (or failed), `request` again one that stopped arriving. Without a chat, the file's id names it.
|
|
2452
|
+
* A group's file (WISP 503) takes `accept` (download one this device did not fetch by itself) and `request`.
|
|
1632
2453
|
*/
|
|
1633
2454
|
async "file.action"(ctx, params) {
|
|
1634
2455
|
const fileId = str(params, "file", true);
|
|
1635
|
-
const
|
|
2456
|
+
const { linkId, where } = fileChat(ctx, params, fileId);
|
|
1636
2457
|
const action = oneOf(params, "action", ACTIONS, "accept");
|
|
2458
|
+
if ("group" in where) {
|
|
2459
|
+
if (action !== "accept" && action !== "request") throw new CliError("bad_request", "A group's file is downloaded (file accept) or asked for again (file request)");
|
|
2460
|
+
if (state(ctx).transfers[fileId]?.state === "done") throw new CliError("refused", "Nothing to ask for: the file is all here");
|
|
2461
|
+
try {
|
|
2462
|
+
await node(ctx).fileAction({
|
|
2463
|
+
linkId,
|
|
2464
|
+
fileId,
|
|
2465
|
+
action
|
|
2466
|
+
});
|
|
2467
|
+
} catch (error) {
|
|
2468
|
+
throw new CliError("refused", error instanceof Error ? error.message : String(error));
|
|
2469
|
+
}
|
|
2470
|
+
return {
|
|
2471
|
+
...where,
|
|
2472
|
+
file: fileId,
|
|
2473
|
+
action,
|
|
2474
|
+
transfer: state(ctx).transfers[fileId] ?? null
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
1637
2477
|
if (action === "resend" || action === "request") {
|
|
1638
|
-
const outgoing = fileId.startsWith(`${
|
|
2478
|
+
const outgoing = fileId.startsWith(`${linkId}-out-`);
|
|
1639
2479
|
if (outgoing !== (action === "resend")) throw new CliError("bad_request", outgoing ? "A file sent from here is sent again: file resend" : "A received file is asked for again: file request");
|
|
1640
2480
|
const transfer = state(ctx).transfers[fileId];
|
|
1641
2481
|
if (!transfer || transfer.state === "done") throw new CliError("refused", outgoing ? "Nothing to send again: it arrived whole" : "Nothing to ask for: the file is all here");
|
|
1642
2482
|
if (transfer.state === "failed" && (!outgoing || !transfer.retry)) throw new CliError("refused", `It ended: ${transfer.error ?? "failed"}`);
|
|
1643
2483
|
}
|
|
1644
2484
|
await node(ctx).fileAction({
|
|
1645
|
-
linkId
|
|
2485
|
+
linkId,
|
|
1646
2486
|
fileId,
|
|
1647
2487
|
action
|
|
1648
2488
|
});
|
|
1649
2489
|
return {
|
|
1650
|
-
|
|
2490
|
+
...where,
|
|
1651
2491
|
file: fileId,
|
|
1652
2492
|
action,
|
|
1653
2493
|
transfer: state(ctx).transfers[fileId] ?? null
|
|
@@ -1659,7 +2499,7 @@ var FILE_METHODS = {
|
|
|
1659
2499
|
*/
|
|
1660
2500
|
async "file.wait"(ctx, params) {
|
|
1661
2501
|
const fileId = str(params, "file", true);
|
|
1662
|
-
const
|
|
2502
|
+
const { where } = fileChat(ctx, params, fileId);
|
|
1663
2503
|
const stored = await fileStore.get(fileId);
|
|
1664
2504
|
if (!stored && !state(ctx).transfers[fileId]) throw new CliError("not_found", `No file ${fileId}`);
|
|
1665
2505
|
const transfer = await waitForState(ctx, (s) => {
|
|
@@ -1680,11 +2520,11 @@ var FILE_METHODS = {
|
|
|
1680
2520
|
});
|
|
1681
2521
|
if (transfer.state === "failed") throw new CliError("engine", transfer.error ?? "The transfer failed", {
|
|
1682
2522
|
file: fileId,
|
|
1683
|
-
|
|
2523
|
+
...where,
|
|
1684
2524
|
retry: !!transfer.retry
|
|
1685
2525
|
});
|
|
1686
2526
|
return {
|
|
1687
|
-
|
|
2527
|
+
...where,
|
|
1688
2528
|
file: fileId,
|
|
1689
2529
|
state: "done",
|
|
1690
2530
|
size: transfer.size
|
|
@@ -1936,7 +2776,7 @@ async function sendCard(ctx, params, kind) {
|
|
|
1936
2776
|
status: "running",
|
|
1937
2777
|
startedAt: now,
|
|
1938
2778
|
updatedAt: now
|
|
1939
|
-
} : { state: "active" },
|
|
2779
|
+
} : kind === "routine" ? { state: "active" } : {},
|
|
1940
2780
|
...fields,
|
|
1941
2781
|
kind,
|
|
1942
2782
|
id
|
|
@@ -2059,6 +2899,82 @@ async function flushCardUpdates(ctx) {
|
|
|
2059
2899
|
for (const pace of waiting) pace.flush();
|
|
2060
2900
|
await Promise.all(waiting.map((pace) => pace.flushed));
|
|
2061
2901
|
}
|
|
2902
|
+
/** A usage card's fields: all of them are said again by each report, so one not given goes from the card. */
|
|
2903
|
+
var USAGE_FIELDS = [
|
|
2904
|
+
"left",
|
|
2905
|
+
"used",
|
|
2906
|
+
"limit",
|
|
2907
|
+
"label",
|
|
2908
|
+
"account",
|
|
2909
|
+
"window",
|
|
2910
|
+
"resetsAt",
|
|
2911
|
+
"updatedAt",
|
|
2912
|
+
"windows"
|
|
2913
|
+
];
|
|
2914
|
+
/** The id a bot's usage card goes by when it names none: one live card per chat. */
|
|
2915
|
+
var USAGE_CARD_ID = "usage";
|
|
2916
|
+
/**
|
|
2917
|
+
* One chat's usage report (WISP 405 § Usage): the bot's usage card there, kept as one message. The first report sends
|
|
2918
|
+
* it; every later one edits it in place (quiet: no sound, no unread, no move in the list), paced as a task's updates.
|
|
2919
|
+
* A report is whole: what it does not say goes from the card. A card at its edits' cap gives way to a new message.
|
|
2920
|
+
*/
|
|
2921
|
+
async function reportUsage(ctx, ref, fields, params) {
|
|
2922
|
+
const target = chatOrGroup(ctx, ref);
|
|
2923
|
+
const id = typeof fields.id === "string" && fields.id ? fields.id : USAGE_CARD_ID;
|
|
2924
|
+
const report = {
|
|
2925
|
+
updatedAt: Date.now(),
|
|
2926
|
+
...fields,
|
|
2927
|
+
kind: "usage",
|
|
2928
|
+
id
|
|
2929
|
+
};
|
|
2930
|
+
checked$1(report);
|
|
2931
|
+
const sent = await cardMessage(ctx, target, "usage", id).catch(() => void 0);
|
|
2932
|
+
const wait = oneOf(params, "wait", ["none", "sent"], "none");
|
|
2933
|
+
if (!sent || (sent.edit?.seq ?? 0) >= STATUS_CARD_LIMITS.edits) return {
|
|
2934
|
+
...await sendCard(ctx, {
|
|
2935
|
+
...params,
|
|
2936
|
+
chat: ref,
|
|
2937
|
+
card: report,
|
|
2938
|
+
wait
|
|
2939
|
+
}, "usage"),
|
|
2940
|
+
updated: false
|
|
2941
|
+
};
|
|
2942
|
+
const patch = {};
|
|
2943
|
+
for (const key of USAGE_FIELDS) patch[key] = report[key] ?? null;
|
|
2944
|
+
return {
|
|
2945
|
+
...await updateCard(ctx, {
|
|
2946
|
+
...params,
|
|
2947
|
+
chat: ref,
|
|
2948
|
+
usage: id,
|
|
2949
|
+
card: patch,
|
|
2950
|
+
wait: wait === "none" ? "none" : target.group ? "sent" : "confirmed"
|
|
2951
|
+
}, "usage"),
|
|
2952
|
+
updated: true
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
/** `usage send`: one chat or group, or (`all`) every 1:1 chat with a contact; a chat that refuses is said, not fatal. */
|
|
2956
|
+
async function sendUsage(ctx, params) {
|
|
2957
|
+
const fields = fieldsOf(params);
|
|
2958
|
+
if (!bool(params, "all")) return reportUsage(ctx, str(params, "chat", true), fields, params);
|
|
2959
|
+
if (str(params, "chat")) throw new CliError("bad_request", "Name a chat or --all, not both");
|
|
2960
|
+
checked$1({
|
|
2961
|
+
updatedAt: Date.now(),
|
|
2962
|
+
...fields,
|
|
2963
|
+
kind: "usage",
|
|
2964
|
+
id: typeof fields.id === "string" && fields.id ? fields.id : USAGE_CARD_ID
|
|
2965
|
+
});
|
|
2966
|
+
const chats = state(ctx).links.filter((link) => link.profile && link.peerPubKeyZ32);
|
|
2967
|
+
const results = [];
|
|
2968
|
+
for (const link of chats) try {
|
|
2969
|
+
results.push(await reportUsage(ctx, link.id, fields, params));
|
|
2970
|
+
} catch (error) {
|
|
2971
|
+
results.push({
|
|
2972
|
+
chat: link.id,
|
|
2973
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
return { chats: results };
|
|
2977
|
+
}
|
|
2062
2978
|
var STATUS_CARD_METHODS = {
|
|
2063
2979
|
/** WISP 405 · Status Cards: a task card in a chat or a group; the answer names the task's id and its message. */
|
|
2064
2980
|
"task.send": (ctx, params) => sendCard(ctx, params, "task"),
|
|
@@ -2067,7 +2983,9 @@ var STATUS_CARD_METHODS = {
|
|
|
2067
2983
|
/** A routine card (its schedule, state, runs); `run` records one. */
|
|
2068
2984
|
"routine.send": (ctx, params) => sendCard(ctx, params, "routine"),
|
|
2069
2985
|
/** A routine's update, as a task's; `run` records a run as its last and the newest of its recent ones. */
|
|
2070
|
-
"routine.update": (ctx, params) => updateCard(ctx, params, "routine")
|
|
2986
|
+
"routine.update": (ctx, params) => updateCard(ctx, params, "routine"),
|
|
2987
|
+
/** WISP 405 § Usage: how much of a quota the bot has left, kept as one card per chat; `all` for every 1:1 chat. */
|
|
2988
|
+
"usage.send": sendUsage
|
|
2071
2989
|
};
|
|
2072
2990
|
//#endregion
|
|
2073
2991
|
//#region src/buttons.ts
|
|
@@ -13140,6 +14058,82 @@ var taskOptions = {
|
|
|
13140
14058
|
},
|
|
13141
14059
|
...cardOptions
|
|
13142
14060
|
};
|
|
14061
|
+
/**
|
|
14062
|
+
* `--also week=80` or `--also week=80@2026-10-10T00:00:00Z`: another window of a usage card, its percent left and, after
|
|
14063
|
+
* `@`, when it resets.
|
|
14064
|
+
*/
|
|
14065
|
+
function usageWindowOf(value) {
|
|
14066
|
+
const match = /^([^=]+)=(\d+(?:\.\d+)?)(?:@(.+))?$/.exec(value.trim());
|
|
14067
|
+
if (!match) throw new CliError("usage", `--also takes window=percent or window=percent@time, like week=80@2026-10-10T00:00:00Z, not ${JSON.stringify(value)}`);
|
|
14068
|
+
const resetsAt = cardTime(match[3], "also");
|
|
14069
|
+
return {
|
|
14070
|
+
window: match[1].trim(),
|
|
14071
|
+
left: Number(match[2]),
|
|
14072
|
+
...resetsAt !== void 0 && { resetsAt }
|
|
14073
|
+
};
|
|
14074
|
+
}
|
|
14075
|
+
/** A usage card's fields from its flags (WISP 405 § Usage); `--json` gives any of them, the flags win over it. */
|
|
14076
|
+
function usageFields(options) {
|
|
14077
|
+
const fields = { ...cardJson(options.json) };
|
|
14078
|
+
for (const key of [
|
|
14079
|
+
"id",
|
|
14080
|
+
"left",
|
|
14081
|
+
"used",
|
|
14082
|
+
"limit",
|
|
14083
|
+
"label",
|
|
14084
|
+
"account",
|
|
14085
|
+
"window"
|
|
14086
|
+
]) if (options[key] !== void 0) fields[key] = options[key];
|
|
14087
|
+
const resets = cardTime(options.resets, "resets");
|
|
14088
|
+
if (resets !== void 0) fields.resetsAt = resets;
|
|
14089
|
+
if (Array.isArray(options.also) && options.also.length) fields.windows = options.also.map(usageWindowOf);
|
|
14090
|
+
return fields;
|
|
14091
|
+
}
|
|
14092
|
+
/** Options `usage send` takes. */
|
|
14093
|
+
var usageOptions = {
|
|
14094
|
+
left: {
|
|
14095
|
+
type: "number",
|
|
14096
|
+
description: "Percent of the quota left, 0 to 100"
|
|
14097
|
+
},
|
|
14098
|
+
used: {
|
|
14099
|
+
type: "number",
|
|
14100
|
+
description: "How much is used, with --limit (instead of --left; the percent is worked out)"
|
|
14101
|
+
},
|
|
14102
|
+
limit: {
|
|
14103
|
+
type: "number",
|
|
14104
|
+
description: "The quota, with --used"
|
|
14105
|
+
},
|
|
14106
|
+
label: {
|
|
14107
|
+
type: "string",
|
|
14108
|
+
description: "What the quota is of, like Claude (24 characters)"
|
|
14109
|
+
},
|
|
14110
|
+
account: {
|
|
14111
|
+
type: "string",
|
|
14112
|
+
description: "Which of your accounts it is, short (24 characters)"
|
|
14113
|
+
},
|
|
14114
|
+
window: {
|
|
14115
|
+
type: "string",
|
|
14116
|
+
description: "The quota's window, as people say it: \"5 h\", \"week\" (16 characters)"
|
|
14117
|
+
},
|
|
14118
|
+
resets: {
|
|
14119
|
+
type: "string",
|
|
14120
|
+
description: "When the window starts again: milliseconds or a date like 2026-10-07T18:00:00Z"
|
|
14121
|
+
},
|
|
14122
|
+
also: {
|
|
14123
|
+
type: "list",
|
|
14124
|
+
description: "Another window, window=percent[@time], like week=80@2026-10-10T00:00:00Z (up to 3; shown in the details)"
|
|
14125
|
+
},
|
|
14126
|
+
id: {
|
|
14127
|
+
type: "string",
|
|
14128
|
+
description: "The card's id (default usage: one card per chat)"
|
|
14129
|
+
},
|
|
14130
|
+
all: {
|
|
14131
|
+
type: "boolean",
|
|
14132
|
+
description: "Every 1:1 chat with a contact, instead of one chat"
|
|
14133
|
+
},
|
|
14134
|
+
json: cardOptions.json,
|
|
14135
|
+
text: cardOptions.text
|
|
14136
|
+
};
|
|
13143
14137
|
//#endregion
|
|
13144
14138
|
//#region src/commands/calls.ts
|
|
13145
14139
|
/** Voice calls: one entry per command, in alphabetical order (test/commands.test.ts checks). */
|
|
@@ -13638,6 +14632,27 @@ var commands$6 = {
|
|
|
13638
14632
|
status: options.status,
|
|
13639
14633
|
for: options.for
|
|
13640
14634
|
})
|
|
14635
|
+
},
|
|
14636
|
+
"usage send": {
|
|
14637
|
+
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]",
|
|
14639
|
+
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
|
+
args: ["chat?"],
|
|
14641
|
+
options: {
|
|
14642
|
+
...usageOptions,
|
|
14643
|
+
...groupWait
|
|
14644
|
+
},
|
|
14645
|
+
params: ({ options }, { chat }) => {
|
|
14646
|
+
if (!chat && options.all !== true) throw new CliError("usage", "Name a chat or group, or give --all: ghostly usage send <chat|--all> --left <percent>");
|
|
14647
|
+
return {
|
|
14648
|
+
chat,
|
|
14649
|
+
all: options.all === true,
|
|
14650
|
+
card: usageFields(options),
|
|
14651
|
+
text: options.text,
|
|
14652
|
+
wait: options.wait,
|
|
14653
|
+
timeout: options.timeout
|
|
14654
|
+
};
|
|
14655
|
+
}
|
|
13641
14656
|
}
|
|
13642
14657
|
};
|
|
13643
14658
|
//#endregion
|
|
@@ -13647,7 +14662,7 @@ var commands$5 = {
|
|
|
13647
14662
|
"file accept": {
|
|
13648
14663
|
method: "file.action",
|
|
13649
14664
|
usage: "file accept [<chat>] <file>",
|
|
13650
|
-
summary: "Take a file the contact offers",
|
|
14665
|
+
summary: "Take a file the contact offers, or download a group's file that did not come by itself (<chat> may be the group)",
|
|
13651
14666
|
args: ["chat?", "file"],
|
|
13652
14667
|
params: (_, a) => ({
|
|
13653
14668
|
chat: a.chat,
|
|
@@ -13679,8 +14694,8 @@ var commands$5 = {
|
|
|
13679
14694
|
},
|
|
13680
14695
|
"file list": {
|
|
13681
14696
|
method: "file.list",
|
|
13682
|
-
usage: "file list <chat>",
|
|
13683
|
-
summary: "A chat's files and their transfers",
|
|
14697
|
+
usage: "file list <chat|group>",
|
|
14698
|
+
summary: "A chat's or a group's files and their transfers",
|
|
13684
14699
|
args: ["chat"],
|
|
13685
14700
|
params: (_, { chat }) => ({ chat })
|
|
13686
14701
|
},
|
|
@@ -13698,7 +14713,7 @@ var commands$5 = {
|
|
|
13698
14713
|
"file request": {
|
|
13699
14714
|
method: "file.action",
|
|
13700
14715
|
usage: "file request [<chat>] <file>",
|
|
13701
|
-
summary: "Ask again for a file that stopped arriving: it goes on from what is here",
|
|
14716
|
+
summary: "Ask again for a file that stopped arriving: it goes on from what is here (a group's file: asked of whoever holds it)",
|
|
13702
14717
|
args: ["chat?", "file"],
|
|
13703
14718
|
params: (_, a) => ({
|
|
13704
14719
|
chat: a.chat,
|
|
@@ -13767,13 +14782,13 @@ var commands$5 = {
|
|
|
13767
14782
|
},
|
|
13768
14783
|
"file send": {
|
|
13769
14784
|
method: "file.send",
|
|
13770
|
-
usage: "file send <chat> <path> [--name n] [--mime t] [--voice [ms] [--peaks 0,40,…]] [--reply <message>]",
|
|
13771
|
-
summary:
|
|
14785
|
+
usage: "file send <chat|group> <path> [--name n] [--mime t] [--voice [ms] [--peaks 0,40,…]] [--reply <message>]",
|
|
14786
|
+
summary: `Send a file (or, with --voice, a voice note), as a reply with --reply; to a group, every member's app fetches it (up to ${GROUP_FILE_LIMITS.maxBytes / 1024 / 1024} MiB)`,
|
|
13772
14787
|
args: ["chat", "path"],
|
|
13773
14788
|
options: {
|
|
13774
14789
|
name: {
|
|
13775
14790
|
type: "string",
|
|
13776
|
-
description: "The name the contact sees"
|
|
14791
|
+
description: "The name the contact (or the group) sees"
|
|
13777
14792
|
},
|
|
13778
14793
|
mime: {
|
|
13779
14794
|
type: "string",
|
|
@@ -16320,8 +17335,11 @@ var EventHub = class {
|
|
|
16320
17335
|
const shape = `${transfer.state}|${transfer.stage ?? ""}`, before = this.transfers.get(id);
|
|
16321
17336
|
this.transfers.set(id, shape);
|
|
16322
17337
|
if (quiet || before === shape) continue;
|
|
17338
|
+
const chat = state.links.find((l) => id.startsWith(`${l.id}-`))?.id ?? null;
|
|
17339
|
+
const group = chat ? void 0 : state.groups.find((g) => id.startsWith(`group-${g.id}-`))?.id;
|
|
16323
17340
|
const fields = {
|
|
16324
|
-
chat
|
|
17341
|
+
chat,
|
|
17342
|
+
...group ? { group } : {},
|
|
16325
17343
|
file: id,
|
|
16326
17344
|
direction: transfer.direction ?? (id.includes("-in-") ? "in" : "out"),
|
|
16327
17345
|
size: transfer.size,
|
|
@@ -17895,14 +18913,14 @@ async function startRuntime(paths, options = {}) {
|
|
|
17895
18913
|
(await import("./assets/ghostly_iroh_web-Dvzh3Y6E.js")).initSync({ module: irohWasmBytes() });
|
|
17896
18914
|
const [{ registerFileBytes }, { NodeFileBytes }] = await Promise.all([import("./assets/fileBytes-3AQ2tJzW.js").then((n) => n.o), import("./assets/fileBytes-CAZDydIe.js")]);
|
|
17897
18915
|
registerFileBytes("native", async () => new NodeFileBytes(paths.files), true);
|
|
17898
|
-
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-
|
|
18916
|
+
const [{ EngineServer }, { createHyperEndpoint }] = await Promise.all([import("./assets/server-GJW_5wGI.js").then((n) => n.t), import("./assets/endpoint-D_3bYafG.js")]);
|
|
17899
18917
|
const network = hyperdhtNetwork();
|
|
17900
18918
|
const dht = mainlineNetwork();
|
|
17901
|
-
const mainline = dht.off ? null : new (await (import("./assets/mainline-
|
|
18919
|
+
const mainline = dht.off ? null : new (await (import("./assets/mainline-B7wHcLaW.js"))).Mainline({
|
|
17902
18920
|
bootstrap: dht.bootstrap,
|
|
17903
18921
|
host: dht.host
|
|
17904
18922
|
});
|
|
17905
|
-
const transport = mainline || pinned ? new (await (import("./assets/mainline-
|
|
18923
|
+
const transport = mainline || pinned ? new (await (import("./assets/mainline-B7wHcLaW.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
|
|
17906
18924
|
const server = new EngineServer({
|
|
17907
18925
|
...transport ? { transport } : {},
|
|
17908
18926
|
irohWeb: true,
|
|
@@ -18426,6 +19444,7 @@ async function warnVersion(client) {
|
|
|
18426
19444
|
*/
|
|
18427
19445
|
function chatOnly(method, params) {
|
|
18428
19446
|
if (method.startsWith("task.") || method.startsWith("routine.") || method.startsWith("button.")) return false;
|
|
19447
|
+
if (method === "usage.send") return params.all === true;
|
|
18429
19448
|
return method.startsWith("chat.") || params.chat !== void 0 && params.group === void 0 && !method.startsWith("group.");
|
|
18430
19449
|
}
|
|
18431
19450
|
async function session(g, chat = false) {
|
|
@@ -19484,6 +20503,6 @@ async function run(argv) {
|
|
|
19484
20503
|
//#region src/bin.ts
|
|
19485
20504
|
run(process.argv.slice(2));
|
|
19486
20505
|
//#endregion
|
|
19487
|
-
export { BACKUP_MEDIA_TYPE as $, requireTurn as A,
|
|
20506
|
+
export { BACKUP_MEDIA_TYPE as $, requireTurn as A, paymentWireId as At, networkLabel as B, breezDatabasesFor as C, storedGroupFiles as Ct, sourceKey as D, replyRef as Dt, ProviderSources as E, receivedPairedReply as Et, sealSeed as F, streamStored as Ft, BITCOIN_INVOICE_ON_TESTNET as G, cashuMint as H, unsealSeed as I, fakesLightning as J, DEFAULT_MINTS as K, ModeChanged as L, setSingleWriterGate as M, readStored as Mt, intentRepository as N, removeStored as Nt, awayFrom as O, replyTo as Ot, newDeviceKey as P, storedSize as Pt, paysItsOwnInvoices as Q, ModeGate as R, breezDatabaseInUse as S, groupOfFile as St, lightningPaying as T, pairedWireReply as Tt, MESSAGE_DETAILS_MAX_BYTES as U, CASHU_MINT_SOURCE as V, WALLET_TYPES as W, isWorthlessMint as X, isTestMint as Y, mintNetwork as Z, LightningCards as _, verifyIdentity as _t, irohRelayProblem as a, reserveAdapterIds as at, normalizePhrase as b, groupFileId as bt, createFailure as c, SourceConfigError as ct, paymentNetwork as d, networkMode as dt, MAX_BACKUP_BYTES as et, paymentNetworksOf as f, redact as ft, CASHU_CARD as g, boundedIdentityFetch as gt, rpcHost as h, setOwnDidSource as ht, createIrohWebEndpoint as i, registeredOnchainProviders as it, setAwayWallets as j, FileAppender as jt, refuseAway as k, paymentAlias as kt, createTiming as l, SourceUnreachableError as lt, networkReason as m, resolveAtprotoDid as mt, loadFedimintSdk as n, onAdaptersChanged as nt, WALLET_NAMES as o, NothingSpentError as ot, walletInstances as p, assertPublicServer as pt, TEST_MINT as q, DEFAULT_IROH_RELAYS as r, registeredLightningProviders as rt, assertConfirmedReal as s, PROVIDER_PLATFORMS as st, fedimintDatabase as t, identityProviders as tt, crossNetwork as u, isNothingSpentError as ut, cardsKey as v, chosenResolver as vt, dropBreezDatabase as w, groupReply as wt, breezDatabase as x, groupFileText as xt, isRecoveryPhrase as y, GroupFileDesk as yt, WrongNetworkError as z };
|
|
19488
20507
|
|
|
19489
20508
|
//# sourceMappingURL=ghostly.mjs.map
|