@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/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 parseSshSignature, Aa as APP_STATEMENT_LIMITS, Ac as toZ32, Ai as irohDescriptor, An as normalizeDomain, Ar as TYPING_REFRESH_MS, At as didWebFile, Ba as canonicalJsonBytes, Bi as sanitizeFileName, Bn as decodeBolt11, Bt as strictBase64Url, Cn as decodeDnsResponse, Ct as DidError, Dc as fromBase64Url$1, Dn as encodeDnsQuery, Dt as didJwsAlg, En as domainWellKnownFile, Et as didGhostlyServiceEntry, Fn as wellKnownUrl, Ft as parseDid, Gt as resolveDidDht, Hi as PLAYABLE_AUDIO, Hs as seedSigner, Ia as readAppRevocations, In as requestLnurlInvoice, It as parseDidDocument, J as groupName, Jr as checkPushEndpoint, Ki as parseVoiceMeta, La as signAppObject, Ln as resolveLightningDestination, Lt as parseDidSignaturePaste, Ma as appRef, Mc as utf8Encode, Mn as recordsFromWellKnown, Mr as sanitizeTypingStatus, Mt as didWebFileUrl, Na as checkAppRevokeStatement, Nn as recordsName, Nt as didWebHost, Oc as randomBytes$1, On as isPublicAddress, Ot as didSignedMessages, Pn as txtValue, Pt as normalizeDid, Q as parseSshPublicKey, Qr as BUTTONS_CAPABILITY, Ra as signAppRevocation, Ri as readImageMeta, Rn as engineError, Rt as signingKeys, Sn as DOMAIN_FILE_MAX_BYTES, Tc as sanitizeAvatar, Tn as domainTxtRecord, Tt as checkDidSignature, Ui as VOICE_LIMITS, Vi as sanitizeMime, Wi as baseMime, X as SSHSIG_NAMESPACE, Z as SshSigError, Za as GhostlyHttpError, _a as ITEM_STATES, _c as openRelayPayload, _i as boundVerifiedIdentity, _n as pubkyProofCapability, _t as shortAtprotoDid, a as checkAppStoreIndex, ao as RTC_CONFIG, at as decodeAnyBitcoinAddress, ba as buttonLabelClash, bi as identityStatement, bn as homeserverWebEndpoint, bt as DID_DOCUMENT_MAX_BYTES$1, c as APP_BUNDLE_LIMITS, cc as setLinkTraceSink, co as callRtcConfig, ct as ATPROTO_PROOF_COLLECTION, d as assembleAppBundle, do as parseCallSignal, dt as atprotoProofRecord, et as sameSshKey, f as checkAppManifest, fn as PUBKY_PROOF_MAX_BYTES, fo as signalHasVideo, ft as atprotoProofRkey, gn as normalizePubkyKey, gt as parseAtprotoDidDocument, ha as replySnippet, hi as IdentityCheckUnavailable, hn as newPubkyProofFolder, ht as normalizeAtprotoHandle, io as CALL_SIGNAL_MAX_AGE_MS, it as BitcoinAddressError, ja as appFingerprint, jc as utf8Decode, ji as irohRelayUrl, jn as recordsFromTxt, js as inviteLink, jt as didWebFileNames, ka as APP_PREFIXES, kc as toBase64Url$1, kn as nip05RootKey, kr as TYPING_KINDS, kt as didWebDocumentUrl, lo as callSignalHeardAt, lt as ATPROTO_PROOF_SCOPE, m as readAppBundle, mc as RELAY_PAYLOAD_MAX_BYTES, mn as isPubkyProofFolder, mt as isAtprotoDid, nt as verifySshSignature, o as readAppStore, on as walletNetworkOf, oo as answersOffer, ot as ATPROTO_DECLARED_SCOPE, p as isAppUrl, pn as isPubkyKey, pt as checkAtprotoProofRecord, qi as voicePeaksOf, rt as verifyBitcoinMessage, s as signAppStore, so as buildSdpFromSignal, st as ATPROTO_FULL_SCOPE, t as appStoreDecision, tn as WALLET_NETWORKS, to as LIMITS, tt as sshSignCommand, uo as extractParamsFromSdp, ut as atprotoDidMethod, va as RUN_RESULTS, vn as pubkyProofFile, vt as AtprotoProofError, wc as MAX_AVATAR_LENGTH, wn as dnsProofName, wt as checkDidJws, xa as checkStatusCard, xn as pubkyHomeserverOf, xt as DID_FILE_MAX_BYTES, ya as STATUS_CARD_LIMITS, yn as pubkyProofPath, yt as verifyAtprotoRecordProof, za as canonicalJson, zn as engineText, zt as staticDidDocument } from "./assets/src-C6qQYrAH.js";
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.5",
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 belongs to: a file id starts with its chat's id, so the chat may be left out; given, it must be
1505
- * the file's own.
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 link;
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 link = chatOf(ctx, params);
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
- let voice, warning;
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 link = chatOf(ctx, params);
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 link = fileChat(ctx, params, fileId);
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(`${link.id}-out-`);
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: link.id,
2485
+ linkId,
1646
2486
  fileId,
1647
2487
  action
1648
2488
  });
1649
2489
  return {
1650
- chat: link.id,
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 link = fileChat(ctx, params, fileId);
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
- chat: link.id,
2523
+ ...where,
1684
2524
  retry: !!transfer.retry
1685
2525
  });
1686
2526
  return {
1687
- chat: link.id,
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: "Send a file (or, with --voice, a voice note), as a reply with --reply",
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: state.links.find((l) => id.startsWith(`${l.id}-`))?.id ?? null,
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-TamKsfmC.js").then((n) => n.t), import("./assets/endpoint-CaSbSFDw.js")]);
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-BabMhm8L.js"))).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-BabMhm8L.js"))).RelaysAndDht(mainline, pinned ? { relays: pinned } : {}, !!pinned) : null;
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, streamStored as At, networkLabel as B, breezDatabasesFor as C, replyTo as Ct, sourceKey as D, readStored as Dt, ProviderSources as E, FileAppender as Et, sealSeed as F, 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, intentRepository as N, awayFrom as O, removeStored as Ot, newDeviceKey as P, paysItsOwnInvoices as Q, ModeGate as R, breezDatabaseInUse as S, replyRef as St, lightningPaying as T, paymentWireId 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, pairedWireReply 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, refuseAway as k, storedSize 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, paymentAlias as wt, breezDatabase as x, receivedPairedReply as xt, isRecoveryPhrase as y, groupReply as yt, WrongNetworkError as z };
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