nixamp 0.28.37 → 0.28.41
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 +6 -1
- package/backtoschool/dist/assets/{ar-CmpEhms8.js → ar-BQPKp0M2.js} +1 -1
- package/backtoschool/dist/assets/{cs-BiiJOLTm.js → cs-DjJnhGNB.js} +1 -1
- package/{web/dist/assets/da-M4GDhrcC.js → backtoschool/dist/assets/da-CbTvkIH7.js} +1 -1
- package/{web/dist/assets/de-D7yI8cd_.js → backtoschool/dist/assets/de-SsCkny26.js} +1 -1
- package/backtoschool/dist/assets/{es-BwQQh_C8.js → es-BBlBPcq1.js} +1 -1
- package/backtoschool/dist/assets/{fi-DKPYMsbo.js → fi-ffo35_4W.js} +1 -1
- package/backtoschool/dist/assets/{fr-DNekMW0h.js → fr-BVvavrk7.js} +1 -1
- package/{web/dist/assets/hi-DO7CO-fI.js → backtoschool/dist/assets/hi-DDy_zxu6.js} +1 -1
- package/{web/dist/assets/hls-3VKVEQE3-BCxdTCEU.js → backtoschool/dist/assets/hls-3VKVEQE3-QZWXCWjR.js} +1 -1
- package/backtoschool/dist/assets/hls-n74Cnh8A.js +42 -0
- package/backtoschool/dist/assets/{hu-gcw9ouWH.js → hu-BoJj7pZa.js} +1 -1
- package/backtoschool/dist/assets/{id-Bz51zxz6.js → id-CXN9gchF.js} +1 -1
- package/backtoschool/dist/assets/index-BaiHu9CD.css +1 -0
- package/backtoschool/dist/assets/index-Dovv39gH.js +112 -0
- package/backtoschool/dist/assets/{it-jO8u4ie7.js → it-C9g0xD6S.js} +1 -1
- package/{web/dist/assets/mpegts-DsEoUEPH.js → backtoschool/dist/assets/mpegts-GAwbg-pX.js} +1 -1
- package/{web/dist/assets/mpegts-LO6RVLD6-CC8jMmPy.js → backtoschool/dist/assets/mpegts-LO6RVLD6-BrVQzFhM.js} +1 -1
- package/backtoschool/dist/assets/native-C7JTKWJH-BUyIoj0P.js +1 -0
- package/{web/dist/assets/nl-BU0Mn-e-.js → backtoschool/dist/assets/nl-Ciq1Yrk4.js} +1 -1
- package/{web/dist/assets/ru-ClqNCOTW.js → backtoschool/dist/assets/ru-B_ZMUvh6.js} +1 -1
- package/backtoschool/dist/assets/{sv-CV2-POKP.js → sv-DH0ZO3Eh.js} +1 -1
- package/{web/dist/assets/uk-K5yQBF0h.js → backtoschool/dist/assets/uk-Cof-TUZd.js} +1 -1
- package/backtoschool/dist/assets/{vi-BDZew-O9.js → vi-D0QPpzbE.js} +1 -1
- package/{web/dist/assets/zh-a2v2x-3e.js → backtoschool/dist/assets/zh-CLTzTBOU.js} +1 -1
- package/backtoschool/dist/index.html +61 -10
- package/dist/classroom.d.ts +1 -1
- package/dist/classroom.js +4 -0
- package/dist/live-api.d.ts +10 -0
- package/dist/live-api.js +8 -0
- package/dist/live-events.js +1 -1
- package/dist/live-voice.d.ts +3 -0
- package/dist/live-voice.js +27 -4
- package/dist/locales/ar.d.ts +15 -0
- package/dist/locales/cs.d.ts +15 -0
- package/dist/locales/da.d.ts +15 -0
- package/dist/locales/de.d.ts +15 -0
- package/dist/locales/en.d.ts +15 -0
- package/dist/locales/es.d.ts +15 -0
- package/dist/locales/fi.d.ts +15 -0
- package/dist/locales/fr.d.ts +15 -0
- package/dist/locales/hi.d.ts +15 -0
- package/dist/locales/hu.d.ts +15 -0
- package/dist/locales/id.d.ts +15 -0
- package/dist/locales/it.d.ts +15 -0
- package/dist/locales/nl.d.ts +15 -0
- package/dist/locales/platforms.d.ts +19 -0
- package/dist/locales/platforms.js +19 -0
- package/dist/locales/ru.d.ts +15 -0
- package/dist/locales/sv.d.ts +15 -0
- package/dist/locales/uk.d.ts +15 -0
- package/dist/locales/vi.d.ts +15 -0
- package/dist/locales/zh.d.ts +15 -0
- package/dist/nixamp-link-api.d.ts +44 -0
- package/dist/nixamp-link-api.js +190 -0
- package/dist/nixamp-link-types.d.ts +33 -0
- package/dist/nixamp-link-types.js +5 -0
- package/dist/nixamp-link.d.ts +66 -0
- package/dist/nixamp-link.js +231 -0
- package/dist/notify.d.ts +6 -4
- package/dist/notify.js +14 -25
- package/dist/oauth-api.d.ts +1 -1
- package/dist/oauth-api.js +27 -9
- package/dist/oauth-server.d.ts +9 -1
- package/dist/oauth-server.js +24 -2
- package/dist/profiles.d.ts +85 -0
- package/dist/profiles.js +0 -0
- package/dist/server.d.ts +12 -2
- package/dist/server.js +205 -13
- package/dist/voice-provider.d.ts +9 -0
- package/dist/voice-provider.js +27 -0
- package/dist/watch-party.d.ts +8 -0
- package/dist/watch-party.js +21 -0
- package/package.json +6 -6
- package/src/classroom.ts +5 -1
- package/src/live-api.ts +13 -0
- package/src/live-events.ts +1 -1
- package/src/live-voice.ts +25 -3
- package/src/locales/platforms.ts +21 -0
- package/src/nixamp-link-api.ts +220 -0
- package/src/nixamp-link-types.ts +36 -0
- package/src/nixamp-link.ts +265 -0
- package/src/notify.ts +19 -34
- package/src/oauth-api.ts +30 -11
- package/src/oauth-server.ts +24 -2
- package/src/profiles.ts +0 -0
- package/src/server.ts +192 -15
- package/src/voice-provider.ts +22 -0
- package/src/watch-party.ts +23 -0
- package/web/dist/assets/{ar-DhdXPO3p.js → ar-BqLaLSMR.js} +1 -1
- package/web/dist/assets/{cs-CoTzYnZt.js → cs-CfoQJwKM.js} +1 -1
- package/{backtoschool/dist/assets/da-DueRd5zz.js → web/dist/assets/da-8MmH23fh.js} +1 -1
- package/{backtoschool/dist/assets/de-DIG6WxUZ.js → web/dist/assets/de-Brry3OMh.js} +1 -1
- package/web/dist/assets/{es-BMGb7XlT.js → es-CGxJs7b8.js} +1 -1
- package/web/dist/assets/{fi-CXNsmIrh.js → fi-BDAAZtto.js} +1 -1
- package/web/dist/assets/{fr-BpvyrESQ.js → fr-9sxYL0Qy.js} +1 -1
- package/{backtoschool/dist/assets/hi-CGlN72JQ.js → web/dist/assets/hi-Cc7ZsdL7.js} +1 -1
- package/web/dist/assets/hls-3VKVEQE3-Dx7qltzA.js +1 -0
- package/web/dist/assets/{hu-CosB3vlS.js → hu-CvB9aIyN.js} +1 -1
- package/web/dist/assets/{id-DNumDlwq.js → id-Bmrh06Hw.js} +1 -1
- package/web/dist/assets/index-BQuoiKg9.js +3 -0
- package/web/dist/assets/{index-jLCo7PIA.css → index-Bo3utcDw.css} +1 -1
- package/web/dist/assets/{it-IJKhyCGw.js → it-SbaxFUIP.js} +1 -1
- package/web/dist/assets/mpegts-BdwONu8t.js +3 -0
- package/web/dist/assets/mpegts-LO6RVLD6-CqW3DT1n.js +1 -0
- package/{backtoschool/dist/assets/nl-CnYXq0K4.js → web/dist/assets/nl-CqimSo3B.js} +1 -1
- package/{backtoschool/dist/assets/ru-BatrUfYu.js → web/dist/assets/ru-wIfpMpU9.js} +1 -1
- package/web/dist/assets/{sv-Df_aXTD6.js → sv-BAyIUHP5.js} +1 -1
- package/{backtoschool/dist/assets/uk-vLtqKpHm.js → web/dist/assets/uk-D2jFJm6l.js} +1 -1
- package/web/dist/assets/{vi-CYfcOUeA.js → vi-BdH3SLlR.js} +1 -1
- package/{backtoschool/dist/assets/zh-C9hBmQKA.js → web/dist/assets/zh-5xz5Agt-.js} +1 -1
- package/web/dist/index.html +33 -2
- package/web/dist/sw.js +23 -23
- package/backtoschool/dist/assets/index-CP4oTs6H.css +0 -1
- package/backtoschool/dist/assets/index-DDGO_FOw.js +0 -113
- package/web/dist/assets/index-CY3lAwM2.js +0 -3
package/dist/server.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { type ResetMail } from "./password-reset.ts";
|
|
|
11
11
|
import { Handles } from "./handles.ts";
|
|
12
12
|
import { OpenDirs } from "./opendirs.ts";
|
|
13
13
|
import { Servers } from "./servers.ts";
|
|
14
|
+
import { AccountProfiles } from "./profiles.ts";
|
|
15
|
+
import { NixampLinks } from "./nixamp-link.ts";
|
|
14
16
|
import { SignIn } from "./oauth.ts";
|
|
15
17
|
import { AuthorizationServer } from "./oauth-server.ts";
|
|
16
18
|
import { WatchParties } from "./watch-party.ts";
|
|
@@ -29,6 +31,7 @@ import { Names } from "./names.ts";
|
|
|
29
31
|
import { Certs } from "./certs.ts";
|
|
30
32
|
import { type Throttle } from "@profullstack/throttle";
|
|
31
33
|
import { type Notification } from "./notify.ts";
|
|
34
|
+
import { type VapidKeys } from "@profullstack/notifications/server";
|
|
32
35
|
import { EventWriter } from "./event-writer.ts";
|
|
33
36
|
import { LiveEvents, type LiveEvent } from "./live-events.ts";
|
|
34
37
|
import { Tickets } from "./tickets.ts";
|
|
@@ -669,6 +672,10 @@ export interface HandlerOptions {
|
|
|
669
672
|
servers?: Servers;
|
|
670
673
|
/** The name other people see, which is never the address they signed up with. */
|
|
671
674
|
handles?: Handles;
|
|
675
|
+
/** The account's card: a name, a homepage, a photo, a line. One table, read by every site here. */
|
|
676
|
+
accountProfiles?: AccountProfiles;
|
|
677
|
+
/** nixamp accounts connected to accounts here, where this site is a client of nixamp.com. */
|
|
678
|
+
links?: NixampLinks;
|
|
672
679
|
/** Open directories people have found, which anyone may read. */
|
|
673
680
|
openDirs?: OpenDirs;
|
|
674
681
|
/** True when this instance is reached over https, for the cookie's Secure. */
|
|
@@ -764,8 +771,11 @@ export interface HandlerOptions {
|
|
|
764
771
|
* Response it is refused with.
|
|
765
772
|
*/
|
|
766
773
|
throttle?: Throttle;
|
|
767
|
-
/**
|
|
768
|
-
|
|
774
|
+
/**
|
|
775
|
+
* The VAPID pair, read from the environment when the server starts. Only the
|
|
776
|
+
* public half leaves: it is what a browser needs before it can subscribe.
|
|
777
|
+
*/
|
|
778
|
+
vapidKeys?: VapidKeys | null;
|
|
769
779
|
}
|
|
770
780
|
/**
|
|
771
781
|
* The whole HTTP surface, as a plain function of a request — so a test can
|
package/dist/server.js
CHANGED
|
@@ -33,10 +33,13 @@ import { passwordResetPage } from "./password-reset-page.js";
|
|
|
33
33
|
import { anonymousHandle, Handles } from "./handles.js";
|
|
34
34
|
import { OpenDirs } from "./opendirs.js";
|
|
35
35
|
import { Servers } from "./servers.js";
|
|
36
|
+
import { AccountProfiles, hostCard, PHOTO_LIMIT } from "./profiles.js";
|
|
37
|
+
import { NixampLinks, nixampExchange } from "./nixamp-link.js";
|
|
38
|
+
import { handleNixampLinkApi, nixampLinkPath } from "./nixamp-link-api.js";
|
|
36
39
|
import { DeviceGrants } from "./device.js";
|
|
37
40
|
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
|
|
38
41
|
import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
|
|
39
|
-
import { AuthorizationServer, SCOPE_NAMES, clientsFrom } from "./oauth-server.js";
|
|
42
|
+
import { AuthorizationServer, BACKTOSCHOOL_CLIENT, SCOPE_NAMES, clientsFrom } from "./oauth-server.js";
|
|
40
43
|
import { handleOAuthApi, oauthApiPath } from "./oauth-api.js";
|
|
41
44
|
import { WatchParties } from "./watch-party.js";
|
|
42
45
|
import { needsAdmin, needsMember, Owner } from "./owner.js";
|
|
@@ -66,6 +69,7 @@ import { forbiddenLibrary, readLibrary } from "./library.js";
|
|
|
66
69
|
import { createThrottle, presentedCredential } from "@profullstack/throttle";
|
|
67
70
|
import { Durable } from "./durable.js";
|
|
68
71
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
72
|
+
import { vapidKeysFromEnv, vapidPublicKeyResponse } from "@profullstack/notifications/server";
|
|
69
73
|
import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.js";
|
|
70
74
|
import { EventWriter } from "./event-writer.js";
|
|
71
75
|
import { handleLiveApi } from "./live-api.js";
|
|
@@ -1138,6 +1142,12 @@ export function isSignInPath(path) {
|
|
|
1138
1142
|
path === "/api/v1/servers" ||
|
|
1139
1143
|
path.startsWith("/api/v1/servers/") ||
|
|
1140
1144
|
path === "/api/v1/me/handle" ||
|
|
1145
|
+
path === "/api/v1/me/profile" ||
|
|
1146
|
+
path.startsWith("/api/v1/me/profile/") ||
|
|
1147
|
+
// A stored photo is public: it is on every class the host runs.
|
|
1148
|
+
path.startsWith("/api/v1/profiles/") ||
|
|
1149
|
+
// "Connect nixamp" on a site that is a client of nixamp.com.
|
|
1150
|
+
nixampLinkPath(path) ||
|
|
1141
1151
|
// Public to read, so it must not be behind a share key either.
|
|
1142
1152
|
path === "/api/v1/opendirs" ||
|
|
1143
1153
|
path.startsWith("/api/v1/opendirs/") ||
|
|
@@ -1447,6 +1457,18 @@ export function createHandler(engine, options) {
|
|
|
1447
1457
|
secureCookies: options.secureCookies ?? false,
|
|
1448
1458
|
}))
|
|
1449
1459
|
return;
|
|
1460
|
+
// A site here that is a client of nixamp.com: connect, callback, streams.
|
|
1461
|
+
if (options.links && options.accounts && options.authServer && await handleNixampLinkApi(request, response, url, {
|
|
1462
|
+
links: options.links,
|
|
1463
|
+
accounts: options.accounts,
|
|
1464
|
+
issuer: options.site ?? DEFAULT_DIRECTORY,
|
|
1465
|
+
site: accountSite,
|
|
1466
|
+
redirectUris: options.authServer.client(BACKTOSCHOOL_CLIENT.id)?.redirectUris ?? [],
|
|
1467
|
+
home: "/#settings",
|
|
1468
|
+
secureCookies: options.secureCookies ?? false,
|
|
1469
|
+
fetcher: options.links.fetcher,
|
|
1470
|
+
}))
|
|
1471
|
+
return;
|
|
1450
1472
|
if (options.events && await handleLiveApi(request, response, url, {
|
|
1451
1473
|
events: options.events,
|
|
1452
1474
|
...(options.eventWriter ? { eventWriter: options.eventWriter } : {}),
|
|
@@ -1457,6 +1479,9 @@ export function createHandler(engine, options) {
|
|
|
1457
1479
|
...(eventSite ? { site: eventSite } : {}),
|
|
1458
1480
|
...(options.invites?.email ? { email: options.invites.email } : {}),
|
|
1459
1481
|
...(options.onEventUpdated ? { onEventUpdated: options.onEventUpdated } : {}),
|
|
1482
|
+
...(options.accountProfiles
|
|
1483
|
+
? { hostProfile: async (userId) => hostCard(await options.accountProfiles.of(userId), userId, accountSite) }
|
|
1484
|
+
: {}),
|
|
1460
1485
|
}))
|
|
1461
1486
|
return;
|
|
1462
1487
|
// The page explaining the reminder texts. Public for the same reason the
|
|
@@ -1781,9 +1806,12 @@ export function createHandler(engine, options) {
|
|
|
1781
1806
|
// --- where to reach a follower ----------------------------------------
|
|
1782
1807
|
if (path.startsWith("/api/v1/notify") && options.follows && options.accounts) {
|
|
1783
1808
|
// The key is public by design: it is what a browser needs before it can
|
|
1784
|
-
// ask permission, and it is useless without the private half.
|
|
1809
|
+
// ask permission, and it is useless without the private half. Served at
|
|
1810
|
+
// run time, never built into the page: a key baked in at build time is
|
|
1811
|
+
// how push quietly becomes "not supported" in production. 503 when this
|
|
1812
|
+
// instance has no keys, so the browser can say so.
|
|
1785
1813
|
if (path === "/api/v1/notify/key" && request.method === "GET") {
|
|
1786
|
-
|
|
1814
|
+
await answerWith(response, vapidPublicKeyResponse(options.vapidKeys));
|
|
1787
1815
|
return;
|
|
1788
1816
|
}
|
|
1789
1817
|
const me = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
@@ -1826,7 +1854,19 @@ export function createHandler(engine, options) {
|
|
|
1826
1854
|
}
|
|
1827
1855
|
if (path === "/api/v1/notify/subscribe") {
|
|
1828
1856
|
if (request.method === "DELETE") {
|
|
1829
|
-
|
|
1857
|
+
// The endpoint arrives in the query (older pages) or as the JSON
|
|
1858
|
+
// body @profullstack/notifications sends: { endpoint }.
|
|
1859
|
+
let endpoint = url.searchParams.get("endpoint") ?? "";
|
|
1860
|
+
if (!endpoint) {
|
|
1861
|
+
try {
|
|
1862
|
+
const body = JSON.parse(await readBody(request));
|
|
1863
|
+
if (typeof body.endpoint === "string")
|
|
1864
|
+
endpoint = body.endpoint;
|
|
1865
|
+
}
|
|
1866
|
+
catch {
|
|
1867
|
+
// No body: nothing to forget.
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1830
1870
|
await follows.removePush(endpoint);
|
|
1831
1871
|
json(response, 200, { ok: true });
|
|
1832
1872
|
return;
|
|
@@ -3137,6 +3177,146 @@ export function createHandler(engine, options) {
|
|
|
3137
3177
|
json(response, 405, { error: "GET or PUT" });
|
|
3138
3178
|
return;
|
|
3139
3179
|
}
|
|
3180
|
+
// --- the account's card -------------------------------------------------
|
|
3181
|
+
//
|
|
3182
|
+
// A name, a homepage, a photo and a line, said once. A class is made from
|
|
3183
|
+
// it, a settings page edits it, and an OpenProfile.md fills it in one move.
|
|
3184
|
+
if ((path === "/api/v1/me/profile" || path.startsWith("/api/v1/me/profile/")) && options.accountProfiles && options.accounts) {
|
|
3185
|
+
const profiles = options.accountProfiles;
|
|
3186
|
+
const who = await options.accounts.whoIs(tokenFrom(request.headers));
|
|
3187
|
+
if (who === null) {
|
|
3188
|
+
json(response, 401, { error: "not signed in" });
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
const persona = options.handles ? await options.handles.persona(who.id) : null;
|
|
3192
|
+
const answer = async () => {
|
|
3193
|
+
const profile = await profiles.of(who.id);
|
|
3194
|
+
// Read again: an import may just have remembered the address.
|
|
3195
|
+
const now = options.handles ? await options.handles.persona(who.id) : null;
|
|
3196
|
+
json(response, 200, {
|
|
3197
|
+
...profile,
|
|
3198
|
+
card: hostCard(profile, who.id, accountSite),
|
|
3199
|
+
openProfile: now?.profile ?? "",
|
|
3200
|
+
handle: now?.handle || fallbackHandle(who.id),
|
|
3201
|
+
});
|
|
3202
|
+
};
|
|
3203
|
+
if (path === "/api/v1/me/profile") {
|
|
3204
|
+
if (request.method === "GET") {
|
|
3205
|
+
await answer();
|
|
3206
|
+
return;
|
|
3207
|
+
}
|
|
3208
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
3209
|
+
let body;
|
|
3210
|
+
try {
|
|
3211
|
+
body = JSON.parse(await readBody(request));
|
|
3212
|
+
}
|
|
3213
|
+
catch {
|
|
3214
|
+
json(response, 400, { error: "bad JSON" });
|
|
3215
|
+
return;
|
|
3216
|
+
}
|
|
3217
|
+
const written = await profiles.set(who.id, body);
|
|
3218
|
+
if (written.error) {
|
|
3219
|
+
json(response, 422, { error: written.error });
|
|
3220
|
+
return;
|
|
3221
|
+
}
|
|
3222
|
+
await answer();
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
json(response, 405, { error: "GET or PUT" });
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
if (path === "/api/v1/me/profile/import") {
|
|
3229
|
+
if (request.method !== "POST") {
|
|
3230
|
+
json(response, 405, { error: "POST only" });
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
3233
|
+
let body = {};
|
|
3234
|
+
try {
|
|
3235
|
+
const raw = await readBody(request);
|
|
3236
|
+
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
3237
|
+
}
|
|
3238
|
+
catch {
|
|
3239
|
+
json(response, 400, { error: "bad JSON" });
|
|
3240
|
+
return;
|
|
3241
|
+
}
|
|
3242
|
+
const given = typeof body.url === "string" ? body.url.trim() : "";
|
|
3243
|
+
const from = given || persona?.profile || "";
|
|
3244
|
+
if (from === "") {
|
|
3245
|
+
json(response, 422, { error: "give the URL of your OpenProfile.md, like https://you.example/.well-known/openprofile.md" });
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
const imported = await profiles.importFrom(who.id, from);
|
|
3249
|
+
if (imported.error) {
|
|
3250
|
+
json(response, 422, { error: imported.error });
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
// The address is remembered, so the phone line reads the same file
|
|
3254
|
+
// for the voice, and a later import needs no URL.
|
|
3255
|
+
if (given && options.handles && given !== persona?.profile) {
|
|
3256
|
+
await options.handles.describe(who.id, fallbackHandle(who.id), { profile: given });
|
|
3257
|
+
}
|
|
3258
|
+
await answer();
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
if (path === "/api/v1/me/profile/photo") {
|
|
3262
|
+
if (request.method === "PUT" || request.method === "POST") {
|
|
3263
|
+
let bytes;
|
|
3264
|
+
try {
|
|
3265
|
+
bytes = await readBytes(request, PHOTO_LIMIT);
|
|
3266
|
+
}
|
|
3267
|
+
catch {
|
|
3268
|
+
json(response, 413, { error: "a photo may be up to 1 MB" });
|
|
3269
|
+
return;
|
|
3270
|
+
}
|
|
3271
|
+
const kept = await profiles.setPhoto(who.id, bytes);
|
|
3272
|
+
if (kept.error) {
|
|
3273
|
+
json(response, 422, { error: kept.error });
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
await answer();
|
|
3277
|
+
return;
|
|
3278
|
+
}
|
|
3279
|
+
if (request.method === "DELETE") {
|
|
3280
|
+
await profiles.removePhoto(who.id);
|
|
3281
|
+
await answer();
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
json(response, 405, { error: "PUT or DELETE" });
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
json(response, 404, { error: "no such profile route" });
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
// The stored photo, public: it is on every class the host runs, and a
|
|
3291
|
+
// page shows it to strangers by design. Cached by its etag.
|
|
3292
|
+
if (path.startsWith("/api/v1/profiles/") && path.endsWith("/photo") && options.accountProfiles) {
|
|
3293
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
3294
|
+
json(response, 405, { error: "GET only" });
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
const userId = decodeURIComponent(path.slice("/api/v1/profiles/".length, -"/photo".length));
|
|
3298
|
+
const photo = userId === "" ? null : await options.accountProfiles.photo(userId);
|
|
3299
|
+
if (photo === null) {
|
|
3300
|
+
json(response, 404, { error: "no photo" });
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
const etag = `"${photo.etag}"`;
|
|
3304
|
+
if (request.headers["if-none-match"] === etag) {
|
|
3305
|
+
response.writeHead(304, { etag, "cache-control": "public, max-age=86400" });
|
|
3306
|
+
response.end();
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
response.writeHead(200, {
|
|
3310
|
+
"content-type": photo.type,
|
|
3311
|
+
"content-length": photo.bytes.length,
|
|
3312
|
+
etag,
|
|
3313
|
+
"cache-control": "public, max-age=86400",
|
|
3314
|
+
"x-content-type-options": "nosniff",
|
|
3315
|
+
"access-control-allow-origin": "*",
|
|
3316
|
+
});
|
|
3317
|
+
response.end(request.method === "HEAD" ? undefined : Buffer.from(photo.bytes));
|
|
3318
|
+
return;
|
|
3319
|
+
}
|
|
3140
3320
|
// --- the servers this account runs ----------------------------------
|
|
3141
3321
|
//
|
|
3142
3322
|
// Kept against the account rather than the machine, so the list reads the
|
|
@@ -5497,7 +5677,11 @@ function sendFile(request, response, file) {
|
|
|
5497
5677
|
export function createServer(engine, options) {
|
|
5498
5678
|
const handle = createHandler(engine, options);
|
|
5499
5679
|
const onRequest = (request, response) => {
|
|
5500
|
-
handle(request, response).catch(() => {
|
|
5680
|
+
handle(request, response).catch((error) => {
|
|
5681
|
+
// Silent by default: a stack trace per bad request is a log nobody
|
|
5682
|
+
// reads. Said aloud when asked, which is how a 500 in a test is found.
|
|
5683
|
+
if (process.env["NIXAMP_DEBUG"])
|
|
5684
|
+
console.error(error);
|
|
5501
5685
|
if (!response.headersSent)
|
|
5502
5686
|
json(response, 500, { error: "server error" });
|
|
5503
5687
|
else
|
|
@@ -5849,8 +6033,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5849
6033
|
// The two things that were promises kept only in memory: a caller who was
|
|
5850
6034
|
// told they would be texted, and the ended stream a code still points at.
|
|
5851
6035
|
const durable = pool ? new Durable(pool, (message) => console.log(message)) : undefined;
|
|
5852
|
-
|
|
5853
|
-
const
|
|
6036
|
+
// Read at run time, and null unless both halves are set.
|
|
6037
|
+
const vapidKeys = vapidKeysFromEnv(process.env);
|
|
5854
6038
|
/**
|
|
5855
6039
|
* Tell a broadcaster's followers, on whatever they asked to be told on.
|
|
5856
6040
|
*
|
|
@@ -5888,11 +6072,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5888
6072
|
}),
|
|
5889
6073
|
}
|
|
5890
6074
|
: {}),
|
|
5891
|
-
...(
|
|
6075
|
+
...(vapidKeys
|
|
5892
6076
|
? {
|
|
5893
6077
|
push: webPush({
|
|
5894
|
-
|
|
5895
|
-
privateKey: vapidPrivateKey,
|
|
6078
|
+
...vapidKeys,
|
|
5896
6079
|
subject: process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY,
|
|
5897
6080
|
onEvent: (message) => console.log(message),
|
|
5898
6081
|
}),
|
|
@@ -5927,7 +6110,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5927
6110
|
};
|
|
5928
6111
|
void follows.audience(event.ownerId).then((audience) => notifyAll(audience, note, {
|
|
5929
6112
|
...(eventMail ? { email: resendEmail({ ...eventMail, onEvent: (message) => console.log(message) }) } : {}),
|
|
5930
|
-
...(
|
|
6113
|
+
...(vapidKeys ? { push: webPush({ ...vapidKeys, subject: nixampSite, onEvent: (message) => console.log(message) }) } : {}),
|
|
5931
6114
|
onGone: (endpoint) => follows.removePush(endpoint),
|
|
5932
6115
|
onEvent: (message) => console.log(message),
|
|
5933
6116
|
})).catch(() => undefined);
|
|
@@ -6249,7 +6432,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6249
6432
|
load: (next) => loadSource(tools, next, false),
|
|
6250
6433
|
tag: (next) => loadTagged(tools, next),
|
|
6251
6434
|
...(directory ? { directory } : {}),
|
|
6252
|
-
...(follows ? { follows,
|
|
6435
|
+
...(follows ? { follows, vapidKeys } : {}),
|
|
6253
6436
|
...(favorites ? { favorites } : {}),
|
|
6254
6437
|
...(settingsSync ? { settingsSync } : {}),
|
|
6255
6438
|
...(events ? { events, eventWriter: new EventWriter({
|
|
@@ -6341,7 +6524,16 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6341
6524
|
// The same pool the follows and reminders use: three small tables in
|
|
6342
6525
|
// one database do not want three sets of connections.
|
|
6343
6526
|
...(pool
|
|
6344
|
-
? {
|
|
6527
|
+
? {
|
|
6528
|
+
servers: new Servers(pool),
|
|
6529
|
+
handles: new Handles(pool),
|
|
6530
|
+
openDirs: new OpenDirs(pool),
|
|
6531
|
+
accountProfiles: new AccountProfiles(pool),
|
|
6532
|
+
// The school's side of "Connect nixamp": tokens from nixamp.com,
|
|
6533
|
+
// kept against the school account. The issuer is this same
|
|
6534
|
+
// program in production, reached over HTTP like any client.
|
|
6535
|
+
links: new NixampLinks(pool, nixampExchange(nixampSite, BACKTOSCHOOL_CLIENT.id), BACKTOSCHOOL_CLIENT.id),
|
|
6536
|
+
}
|
|
6345
6537
|
: {}),
|
|
6346
6538
|
signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
|
|
6347
6539
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SpeechError } from "./speech.ts";
|
|
2
|
+
/** Only fixed messages/codes leave this boundary: provider bodies can contain
|
|
3
|
+
* account information. Authorization and billing errors must not be retried
|
|
4
|
+
* as transient 502s by the live player. */
|
|
5
|
+
export declare function voiceProviderFailure(response: Response): Promise<{
|
|
6
|
+
error: SpeechError;
|
|
7
|
+
code: string;
|
|
8
|
+
cooldownMs: number;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SpeechError } from "./speech.js";
|
|
2
|
+
/** Only fixed messages/codes leave this boundary: provider bodies can contain
|
|
3
|
+
* account information. Authorization and billing errors must not be retried
|
|
4
|
+
* as transient 502s by the live player. */
|
|
5
|
+
export async function voiceProviderFailure(response) {
|
|
6
|
+
let code = "unknown";
|
|
7
|
+
try {
|
|
8
|
+
const body = await response.json();
|
|
9
|
+
const status = body?.detail?.status;
|
|
10
|
+
if (typeof status === "string" && ["quota_exceeded", "invalid_api_key", "missing_permissions", "subscription_required", "subscription_expired", "payment_required", "payment_issue", "too_many_concurrent_requests", "rate_limit_exceeded"].includes(status))
|
|
11
|
+
code = status;
|
|
12
|
+
}
|
|
13
|
+
catch { /* Non-JSON provider failures still have an HTTP status. */ }
|
|
14
|
+
if (code === "quota_exceeded")
|
|
15
|
+
return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs credits are exhausted. The site owner needs to check the provider balance.", 402) };
|
|
16
|
+
if (code === "payment_issue")
|
|
17
|
+
return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs blocked transcription because its subscription payment failed. The site owner needs to complete the outstanding ElevenLabs invoice.", 402) };
|
|
18
|
+
if (["subscription_required", "subscription_expired", "payment_required"].includes(code) || response.status === 402)
|
|
19
|
+
return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs billing needs attention. The site owner needs to check the provider subscription.", 402) };
|
|
20
|
+
if (["invalid_api_key", "missing_permissions"].includes(code) || [401, 403].includes(response.status))
|
|
21
|
+
return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs rejected transcription or voice access. The site owner needs to check the provider key, permissions, and billing.", 424) };
|
|
22
|
+
if (response.status === 429) {
|
|
23
|
+
const retry = Number(response.headers.get("retry-after"));
|
|
24
|
+
return { code, cooldownMs: Math.min(300_000, Math.max(30_000, Number.isFinite(retry) ? retry * 1000 : 0)), error: new SpeechError("ElevenLabs is rate limited. Wait before enabling translated audio again.", 429) };
|
|
25
|
+
}
|
|
26
|
+
return { code, cooldownMs: 10_000, error: new SpeechError("ElevenLabs is temporarily unavailable. Try translated audio again shortly.", 502) };
|
|
27
|
+
}
|
package/dist/watch-party.d.ts
CHANGED
|
@@ -86,6 +86,14 @@ export declare class WatchParties {
|
|
|
86
86
|
*/
|
|
87
87
|
bridge(ownerId: string, origin: PartyOrigin, input: BridgeInput): Promise<PartyView>;
|
|
88
88
|
byCode(origin: PartyOrigin, partyCode: string): Promise<PartyView | null>;
|
|
89
|
+
/**
|
|
90
|
+
* A party by its code alone, for somebody who has only the code.
|
|
91
|
+
*
|
|
92
|
+
* A code is unique per origin, not across them; a person typing one into
|
|
93
|
+
* nixamp.com is not asked which site it came from. The one still live, or
|
|
94
|
+
* the most recently touched, is the one they mean.
|
|
95
|
+
*/
|
|
96
|
+
byAnyCode(partyCode: string): Promise<PartyView | null>;
|
|
89
97
|
/** The party behind a nixamp room or slug, for a client that has only that. */
|
|
90
98
|
byEvent(reference: string): Promise<PartyView | null>;
|
|
91
99
|
/**
|
package/dist/watch-party.js
CHANGED
|
@@ -200,6 +200,27 @@ export class WatchParties {
|
|
|
200
200
|
return null;
|
|
201
201
|
return { party: partyFrom(row, event), event };
|
|
202
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* A party by its code alone, for somebody who has only the code.
|
|
205
|
+
*
|
|
206
|
+
* A code is unique per origin, not across them; a person typing one into
|
|
207
|
+
* nixamp.com is not asked which site it came from. The one still live, or
|
|
208
|
+
* the most recently touched, is the one they mean.
|
|
209
|
+
*/
|
|
210
|
+
async byAnyCode(partyCode) {
|
|
211
|
+
await this.ensure();
|
|
212
|
+
const code = cleanPartyCode(partyCode);
|
|
213
|
+
const { rows } = await this.options.db.query(`SELECT p.* FROM ${TABLE} p JOIN live_events e ON e.id = p.event_id
|
|
214
|
+
WHERE p.party_code = $1
|
|
215
|
+
ORDER BY (e.status = 'live') DESC, p.updated_at DESC LIMIT 1`, [code]);
|
|
216
|
+
const row = rows[0];
|
|
217
|
+
if (!row)
|
|
218
|
+
return null;
|
|
219
|
+
const event = await this.options.events.get(String(row["event_id"] ?? ""));
|
|
220
|
+
if (!event)
|
|
221
|
+
return null;
|
|
222
|
+
return { party: partyFrom(row, event), event };
|
|
223
|
+
}
|
|
203
224
|
/** The party behind a nixamp room or slug, for a client that has only that. */
|
|
204
225
|
async byEvent(reference) {
|
|
205
226
|
await this.ensure();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.41",
|
|
4
4
|
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@profullstack/auth-system": "^0.6.0",
|
|
49
49
|
"@profullstack/hqtui": "^0.5.0",
|
|
50
|
+
"@profullstack/notifications": "^0.1.0",
|
|
50
51
|
"@profullstack/synconfig": "^0.1.2",
|
|
51
52
|
"@profullstack/throttle": "0.2.2",
|
|
52
|
-
"@profullstack/x402-gateway": "0.
|
|
53
|
+
"@profullstack/x402-gateway": "^0.7.0",
|
|
53
54
|
"acme-client": "5.4.0",
|
|
54
|
-
"pg": "^8.23.0"
|
|
55
|
-
"web-push": "^3.6.7"
|
|
55
|
+
"pg": "^8.23.0"
|
|
56
56
|
},
|
|
57
57
|
"optionalDependencies": {
|
|
58
58
|
"@huggingface/transformers": "^4.2.0"
|
|
@@ -60,7 +60,6 @@
|
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^26",
|
|
62
62
|
"@types/pg": "^8.23.1",
|
|
63
|
-
"@types/web-push": "^3.6.4",
|
|
64
63
|
"typescript": "^7.0.2"
|
|
65
64
|
},
|
|
66
65
|
"trustedDependencies": [
|
|
@@ -82,6 +81,7 @@
|
|
|
82
81
|
},
|
|
83
82
|
"overrides": {
|
|
84
83
|
"https-proxy-agent": "7.0.6",
|
|
85
|
-
"tslib": "2.8.1"
|
|
84
|
+
"tslib": "2.8.1",
|
|
85
|
+
"@profullstack/x402-gateway": "^0.7.0"
|
|
86
86
|
}
|
|
87
87
|
}
|
package/src/classroom.ts
CHANGED
|
@@ -16,7 +16,7 @@ export function publicWebUrl(value: unknown): string | null {
|
|
|
16
16
|
} catch { return null; }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixamp"; url: string; embed: string; join: string } | null {
|
|
19
|
+
export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixamp" | "media"; url: string; embed: string; join: string } | null {
|
|
20
20
|
const safe = publicWebUrl(value);
|
|
21
21
|
if (!safe) return null;
|
|
22
22
|
const url = new URL(safe);
|
|
@@ -48,5 +48,9 @@ export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixa
|
|
|
48
48
|
player.searchParams.set("play", "live");
|
|
49
49
|
return classroomBroadcast(player.href);
|
|
50
50
|
}
|
|
51
|
+
// Direct media is loaded by the browser, never fetched by this server.
|
|
52
|
+
if (/\.(?:m3u8|mp4|webm|mp3|m4a|ogg|wav|ts)$/i.test(url.pathname)) {
|
|
53
|
+
return { provider: "media", url: safe, embed: safe, join: safe };
|
|
54
|
+
}
|
|
51
55
|
return null;
|
|
52
56
|
}
|
package/src/live-api.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface LiveApiOptions {
|
|
|
36
36
|
site?: string;
|
|
37
37
|
email?: (to: string, note: { title: string; body: string; url: string }) => Promise<boolean>;
|
|
38
38
|
onEventUpdated?: (event: LiveEvent, previous: LiveEvent) => void;
|
|
39
|
+
/**
|
|
40
|
+
* The account's own card -- name, homepage, photo -- so a class made
|
|
41
|
+
* without them carries the host's instead of nothing. What the class
|
|
42
|
+
* says explicitly still wins.
|
|
43
|
+
*/
|
|
44
|
+
hostProfile?: (userId: string) => Promise<{ hostName: string; homepageUrl: string; avatarUrl: string }>;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
const CORS = {
|
|
@@ -309,6 +315,13 @@ export async function handleLiveApi(
|
|
|
309
315
|
const account = await requiredAccount(request, response, options);
|
|
310
316
|
if (!account) return true;
|
|
311
317
|
const input = await body(request);
|
|
318
|
+
if (options.hostProfile) {
|
|
319
|
+
const card = await options.hostProfile(account.id);
|
|
320
|
+
for (const key of ["hostName", "homepageUrl", "avatarUrl"] as const) {
|
|
321
|
+
const given = input[key];
|
|
322
|
+
if (card[key] && (given === undefined || given === null || given === "")) input[key] = card[key];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
312
325
|
const event = await options.events.create(account.id, { ...input, title: input["title"] });
|
|
313
326
|
json(response, 201, view(event, account));
|
|
314
327
|
return true;
|
package/src/live-events.ts
CHANGED
|
@@ -307,7 +307,7 @@ function classroomSettings(input: CreateLiveEventInput | UpdateLiveEventInput, c
|
|
|
307
307
|
if (key === "hostName") settings[key] = value;
|
|
308
308
|
else if (key === "broadcastUrl") {
|
|
309
309
|
const broadcast = classroomBroadcast(value);
|
|
310
|
-
if (!broadcast) throw new LiveEventError("Use a
|
|
310
|
+
if (!broadcast) throw new LiveEventError("Use a Nixamp Share link, a direct HTTPS media link, or a Pairux live/join link. Admin links cannot be shared.", 422);
|
|
311
311
|
settings[key] = broadcast.url;
|
|
312
312
|
} else {
|
|
313
313
|
const safe = publicWebUrl(value);
|
package/src/live-voice.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { VoiceProfile } from "./voice-profile.ts";
|
|
|
6
6
|
import { Guard } from "./guard.ts";
|
|
7
7
|
import type { TranslationMeter } from "./translation-passes.ts";
|
|
8
8
|
import type { Queryable } from "./follows.ts";
|
|
9
|
+
import { voiceProviderFailure } from "./voice-provider.ts";
|
|
9
10
|
|
|
10
11
|
export const LIVE_VOICE_MODEL = "eleven_flash_v2_5";
|
|
11
12
|
export const LIVE_VOICE_RATE = 16_000;
|
|
@@ -37,6 +38,20 @@ export class LiveVoice {
|
|
|
37
38
|
private readonly dailyAudioSeconds: number;
|
|
38
39
|
private readonly userDailyChars: number;
|
|
39
40
|
private readonly userDailyAudioSeconds: number;
|
|
41
|
+
private providerPause: { until: number; error: SpeechError } | null = null;
|
|
42
|
+
|
|
43
|
+
private checkProvider(): void {
|
|
44
|
+
if (this.providerPause && this.providerPause.until > this.now()) throw this.providerPause.error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async providerFailed(response: Response, operation: string): Promise<never> {
|
|
48
|
+
const failure = await voiceProviderFailure(response);
|
|
49
|
+
const until = this.now() + failure.cooldownMs;
|
|
50
|
+
// Another in-flight response must not shorten an existing billing pause.
|
|
51
|
+
if (!this.providerPause || this.providerPause.until < until) this.providerPause = { until, error: failure.error };
|
|
52
|
+
console.error(JSON.stringify({ event: "voice_provider_failure", provider: "elevenlabs", operation, status: response.status, code: failure.code, cooldownMs: failure.cooldownMs }));
|
|
53
|
+
throw failure.error;
|
|
54
|
+
}
|
|
40
55
|
|
|
41
56
|
constructor(options: { apiKey?: string; fetcher?: typeof fetch; now?: () => number; charsPerMinute?: number; dailyChars?: number; dailyAudioSeconds?: number; userDailyChars?: number; userDailyAudioSeconds?: number; db?: Queryable; billing?: TranslationMeter } = {}) {
|
|
42
57
|
this.key = options.apiKey ?? process.env["ELEVENLABS_API_KEY"] ?? "";
|
|
@@ -60,6 +75,7 @@ export class LiveVoice {
|
|
|
60
75
|
async hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter = this.billing, resource = ""): Promise<SpeakerTranscript> {
|
|
61
76
|
if (!this.available()) throw new SpeechError("speaker voices are unavailable", 503);
|
|
62
77
|
await meter?.require(by, resource);
|
|
78
|
+
this.checkProvider();
|
|
63
79
|
const wav = decodeWav(bytes);
|
|
64
80
|
const seconds = wav.samples.length / wav.rate;
|
|
65
81
|
if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1) throw new SpeechError("send up to 15 seconds of mono 16 kHz WAV", 400);
|
|
@@ -77,6 +93,7 @@ export class LiveVoice {
|
|
|
77
93
|
await this.reserve(`scribe:user:${by}`, billed, this.userDailyAudioSeconds, 86_400_000);
|
|
78
94
|
await this.reserve("scribe:server", billed, this.dailyAudioSeconds, 86_400_000);
|
|
79
95
|
signal?.throwIfAborted();
|
|
96
|
+
this.checkProvider();
|
|
80
97
|
const form = new FormData();
|
|
81
98
|
// Canonical PCM prevents a crafted container from billing more audio
|
|
82
99
|
// than the duration we validated, and removes uploaded metadata.
|
|
@@ -91,8 +108,9 @@ export class LiveVoice {
|
|
|
91
108
|
method: "POST", headers: { "xi-api-key": this.key }, body: form,
|
|
92
109
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
93
110
|
});
|
|
94
|
-
if (!answer.ok)
|
|
111
|
+
if (!answer.ok) await this.providerFailed(answer, "transcription");
|
|
95
112
|
accepted = true;
|
|
113
|
+
console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "transcription", account: createHash("sha256").update(by).digest("hex").slice(0, 16), audioSeconds: seconds }));
|
|
96
114
|
if (reservation) await meter!.commit(reservation);
|
|
97
115
|
return speakerTurns(await answer.json() as ScribeResult, wav);
|
|
98
116
|
} catch (error) {
|
|
@@ -103,12 +121,13 @@ export class LiveVoice {
|
|
|
103
121
|
|
|
104
122
|
async voices(): Promise<LiveVoiceChoice[]> {
|
|
105
123
|
if (!this.available()) throw new SpeechError("translated audio needs ELEVENLABS_API_KEY on the account server", 503);
|
|
124
|
+
this.checkProvider();
|
|
106
125
|
if (!this.catalog || this.now() >= this.catalogUntil) {
|
|
107
126
|
this.catalogUntil = this.now() + 3600_000;
|
|
108
127
|
this.catalog = this.fetcher("https://api.elevenlabs.io/v2/voices?page_size=100&voice_type=default", {
|
|
109
128
|
headers: { "xi-api-key": this.key }, signal: AbortSignal.timeout(8000),
|
|
110
129
|
}).then(async response => {
|
|
111
|
-
if (!response.ok)
|
|
130
|
+
if (!response.ok) await this.providerFailed(response, "voices");
|
|
112
131
|
const body = await response.json() as { voices: { voice_id: string; name: string; labels?: Record<string, string> }[] };
|
|
113
132
|
return body.voices.filter(voice => /^[a-zA-Z0-9_-]+$/.test(voice.voice_id)).map(voice => ({
|
|
114
133
|
id: voice.voice_id, name: voice.name, gender: voice.labels?.["gender"] ?? "neutral", language: voice.labels?.["language"] ?? "",
|
|
@@ -238,6 +257,7 @@ export class LiveVoice {
|
|
|
238
257
|
let accepted = false;
|
|
239
258
|
try {
|
|
240
259
|
await this.charge(by, ask.channel ?? "direct", text.length);
|
|
260
|
+
this.checkProvider();
|
|
241
261
|
signal?.throwIfAborted();
|
|
242
262
|
reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
|
|
243
263
|
const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
|
|
@@ -246,9 +266,11 @@ export class LiveVoice {
|
|
|
246
266
|
body: JSON.stringify({ text, model_id: LIVE_VOICE_MODEL, language_code: ask.language }),
|
|
247
267
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
248
268
|
});
|
|
249
|
-
if (!response.ok
|
|
269
|
+
if (!response.ok) await this.providerFailed(response, "synthesis");
|
|
270
|
+
if (!response.body) throw new SpeechError("ElevenLabs returned no audio", 502);
|
|
250
271
|
// Once the provider accepts, aborting playback cannot refund heard audio.
|
|
251
272
|
accepted = true;
|
|
273
|
+
console.info(JSON.stringify({ event: "voice_provider_usage", provider: "elevenlabs", operation: "synthesis", account: createHash("sha256").update(by).digest("hex").slice(0, 16), characters: text.length }));
|
|
252
274
|
if (reservation) await meter!.commit(reservation);
|
|
253
275
|
const [play, keep] = response.body.tee();
|
|
254
276
|
void (async () => {
|