nixamp 0.27.1 → 0.27.3
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 +53 -20
- package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-Bot4FEXQ.css} +1 -1
- package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
- package/backtoschool/dist/index.html +36 -9
- package/dist/accounts.d.ts +2 -0
- package/dist/accounts.js +9 -2
- package/dist/classroom.d.ts +15 -0
- package/dist/classroom.js +54 -0
- package/dist/event-writer.d.ts +39 -0
- package/dist/event-writer.js +139 -0
- package/dist/live-api.d.ts +2 -0
- package/dist/live-api.js +37 -2
- package/dist/live-events.d.ts +7 -1
- package/dist/live-events.js +88 -12
- package/dist/live-interpreter.d.ts +3 -1
- package/dist/live-interpreter.js +109 -83
- package/dist/live-recovery.d.ts +6 -0
- package/dist/live-recovery.js +11 -0
- package/dist/live-voice.d.ts +1 -1
- package/dist/live-voice.js +7 -7
- package/dist/mail-sender.d.ts +9 -0
- package/dist/mail-sender.js +11 -0
- package/dist/notify.d.ts +3 -3
- package/dist/notify.js +8 -5
- package/dist/password-reset-page.d.ts +2 -0
- package/dist/password-reset-page.js +106 -0
- package/dist/password-reset.d.ts +34 -0
- package/dist/password-reset.js +121 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +109 -3
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +57 -40
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +2 -0
- package/dist/translation-passes.d.ts +13 -8
- package/dist/translation-passes.js +31 -20
- package/dist/upgrade-allowance.d.ts +23 -0
- package/dist/upgrade-allowance.js +70 -0
- package/package.json +1 -1
- package/src/accounts.ts +8 -2
- package/src/classroom.ts +52 -0
- package/src/event-writer.ts +126 -0
- package/src/live-api.ts +26 -2
- package/src/live-events.ts +81 -13
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/live-voice.ts +7 -7
- package/src/mail-sender.ts +12 -0
- package/src/notify.ts +10 -7
- package/src/password-reset-page.ts +108 -0
- package/src/password-reset.ts +134 -0
- package/src/server.ts +91 -3
- package/src/shared-translation.ts +47 -33
- package/src/tokens.ts +3 -0
- package/src/translation-passes.ts +35 -25
- package/src/types/auth-system.d.ts +10 -0
- package/src/upgrade-allowance.ts +69 -0
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
- package/web/dist/assets/index-BgLxkXKe.js +3 -0
- package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
- package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-CV0glck7.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +7 -7
- package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- package/web/dist/assets/index-3WZwGw04.js +0 -3
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* each listening account reserves 5x base provider cost for paid access. */
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { SpeechError } from "./speech.js";
|
|
5
|
+
import { UpgradeAllowances } from "./upgrade-allowance.js";
|
|
5
6
|
export const TRANSLATION_MULTIPLIER = 5;
|
|
6
7
|
export const TRANSLATION_PLANS = [
|
|
7
8
|
{ id: "day", name: "Day", days: 1, priceCents: 500 },
|
|
@@ -40,13 +41,16 @@ export class TranslationPasses {
|
|
|
40
41
|
merchantUntil = 0;
|
|
41
42
|
fetcher;
|
|
42
43
|
now;
|
|
44
|
+
free;
|
|
43
45
|
constructor(options) {
|
|
44
46
|
this.options = options;
|
|
45
47
|
this.fetcher = options.fetcher ?? fetch;
|
|
46
48
|
this.now = options.now ?? Date.now;
|
|
49
|
+
this.free = new UpgradeAllowances(options.db, this.now);
|
|
47
50
|
}
|
|
48
51
|
async ensure() {
|
|
49
52
|
this.schema ??= (async () => {
|
|
53
|
+
await this.free.ensure();
|
|
50
54
|
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_wallets (
|
|
51
55
|
by_account TEXT PRIMARY KEY, balance_micros BIGINT NOT NULL CHECK (balance_micros >= 0), expires_at TIMESTAMPTZ NOT NULL)`);
|
|
52
56
|
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_orders (
|
|
@@ -93,41 +97,48 @@ export class TranslationPasses {
|
|
|
93
97
|
const row = by ? (await this.options.db.query("SELECT balance_micros, expires_at FROM translation_wallets WHERE by_account = $1 AND expires_at > $2", [by, new Date(this.now())])).rows[0] : null;
|
|
94
98
|
const orders = by ? (await this.options.db.query("SELECT id, payment_id, plan FROM translation_orders WHERE by_account = $1 AND status = 'pending' AND payment_id IS NOT NULL ORDER BY created_at DESC LIMIT 5", [by])).rows.map(order => ({ id: String(order["id"]), plan: String(order["plan"]), url: `https://coinpayportal.com/pay/${order["payment_id"]}` })) : [];
|
|
95
99
|
return { required: true, available: config.coins.length > 0, balanceMicros: Number(row?.["balance_micros"] ?? 0),
|
|
96
|
-
expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders,
|
|
100
|
+
expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders, free: await this.free.access(by),
|
|
97
101
|
rates: { transcriptionHour: 0.22, voiceThousand: 0.05, multiplier: TRANSLATION_MULTIPLIER } };
|
|
98
102
|
}
|
|
99
|
-
async
|
|
103
|
+
async begin(by, resource) {
|
|
100
104
|
await this.ensure();
|
|
105
|
+
if (await this.free.begin(by, "translation", resource))
|
|
106
|
+
return;
|
|
107
|
+
await this.require(by, resource);
|
|
108
|
+
}
|
|
109
|
+
async require(by, resource = "") {
|
|
110
|
+
await this.ensure();
|
|
111
|
+
if ((await this.free.active([by], "translation", resource, true)).length)
|
|
112
|
+
return;
|
|
101
113
|
const found = await this.options.db.query("SELECT by_account FROM translation_wallets WHERE by_account = $1 AND expires_at > $2 AND balance_micros > 0", [by, new Date(this.now())]);
|
|
102
114
|
if (!found.rows.length)
|
|
103
|
-
throw new SpeechError("Buy a
|
|
115
|
+
throw new SpeechError("Buy a pass or start a remaining free session: this session has no free time or audio credit.", 402);
|
|
104
116
|
}
|
|
105
|
-
async eligible(accounts) {
|
|
117
|
+
async eligible(accounts, resource = "") {
|
|
106
118
|
await this.ensure();
|
|
107
119
|
const result = await this.options.db.query("SELECT by_account FROM translation_wallets WHERE by_account = ANY($1::text[]) AND expires_at > $2 AND balance_micros > 0", [accounts, new Date(this.now())]);
|
|
108
|
-
return result.rows.map(row => String(row["by_account"]));
|
|
120
|
+
return [...new Set([...result.rows.map(row => String(row["by_account"])), ...await this.free.active(accounts, "translation", resource, true)])];
|
|
109
121
|
}
|
|
110
|
-
async reserve(by, kind, units) {
|
|
111
|
-
await this.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
WHERE by_account = $1 AND expires_at > $3 AND balance_micros >= $2 RETURNING by_account)
|
|
116
|
-
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
117
|
-
SELECT $4, by_account, $5, $6, $7, $2, $3 FROM debit RETURNING id`, [by, charge, new Date(this.now()), id, kind, units, cost]);
|
|
118
|
-
if (!result.rows.length)
|
|
119
|
-
throw new SpeechError("Your translation balance is used up or expired. Buy another pass to continue.", 402);
|
|
120
|
-
return id;
|
|
122
|
+
async reserve(by, kind, units, resource = "") {
|
|
123
|
+
const result = await this.reserveMany([by], kind, units, resource);
|
|
124
|
+
if (!result.length)
|
|
125
|
+
throw new SpeechError("This free session ended and audio credit is used up. Start a remaining free session or buy a pass.", 402);
|
|
126
|
+
return result[0].id;
|
|
121
127
|
}
|
|
122
128
|
/** Batch a shared stream's access charges in one database round trip. */
|
|
123
|
-
async reserveMany(accounts, kind, units) {
|
|
129
|
+
async reserveMany(accounts, kind, units, resource = "") {
|
|
124
130
|
await this.ensure();
|
|
125
131
|
const { cost, charge } = translationCost(kind, units);
|
|
126
|
-
const
|
|
132
|
+
const now = this.now(), key = resource ? this.free.key("translation", resource) : "";
|
|
133
|
+
const result = await this.options.db.query(`WITH free AS (
|
|
134
|
+
SELECT DISTINCT by_account FROM upgrade_daily_uses WHERE by_account = ANY($1::text[])
|
|
135
|
+
AND (sessions->>$7)::bigint > $8), debit AS (
|
|
127
136
|
UPDATE translation_wallets SET balance_micros = balance_micros - $2
|
|
128
|
-
WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2
|
|
137
|
+
WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2
|
|
138
|
+
AND by_account NOT IN (SELECT by_account FROM free) RETURNING by_account), admitted AS (
|
|
139
|
+
SELECT by_account, 0::bigint AS charge FROM free UNION ALL SELECT by_account, $2::bigint FROM debit)
|
|
129
140
|
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
130
|
-
SELECT gen_random_uuid()::text, by_account, $4, $5, $6,
|
|
141
|
+
SELECT gen_random_uuid()::text, by_account, $4, $5, $6, charge, $3 FROM admitted RETURNING id, by_account`, [[...new Set(accounts)], charge, new Date(now), kind, units, cost, key, now]);
|
|
131
142
|
return result.rows.map(row => ({ by: String(row["by_account"]), id: String(row["id"]) }));
|
|
132
143
|
}
|
|
133
144
|
async commit(id) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Queryable } from "./follows.ts";
|
|
2
|
+
export declare const FREE_UPGRADE_SESSIONS = 5;
|
|
3
|
+
export declare const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
4
|
+
export interface UpgradeAllowance {
|
|
5
|
+
limit: number;
|
|
6
|
+
remaining: number;
|
|
7
|
+
reconnectSeconds: number;
|
|
8
|
+
resets: string;
|
|
9
|
+
activeUntil: string | null;
|
|
10
|
+
}
|
|
11
|
+
export declare class UpgradeAllowances {
|
|
12
|
+
private readonly db;
|
|
13
|
+
private readonly now;
|
|
14
|
+
private schema;
|
|
15
|
+
constructor(db: Queryable, now?: () => number);
|
|
16
|
+
ensure(): Promise<void>;
|
|
17
|
+
key(product: string, resource: string): string;
|
|
18
|
+
access(by?: string): Promise<UpgradeAllowance>;
|
|
19
|
+
/** One atomic row serializes concurrent starts across tabs, products and replicas.
|
|
20
|
+
* Active use renews a short lease. Reconnecting reuses it; idle leases expire. */
|
|
21
|
+
begin(by: string, product: string, resource: string): Promise<boolean>;
|
|
22
|
+
active(accounts: string[], product: string, resource: string, renew?: boolean): Promise<string[]>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Daily free access shared by Nixamp's paid panels and upgrades.
|
|
2
|
+
* Claim only on explicit use, never from balance polling or provider chunks. */
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { SpeechError } from "./speech.js";
|
|
5
|
+
export const FREE_UPGRADE_SESSIONS = 5;
|
|
6
|
+
export const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
7
|
+
const DAY = 86_400_000;
|
|
8
|
+
export class UpgradeAllowances {
|
|
9
|
+
db;
|
|
10
|
+
now;
|
|
11
|
+
schema = null;
|
|
12
|
+
constructor(db, now = Date.now) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.now = now;
|
|
15
|
+
}
|
|
16
|
+
async ensure() {
|
|
17
|
+
this.schema ??= this.db.query(`CREATE TABLE IF NOT EXISTS upgrade_daily_uses (
|
|
18
|
+
by_account TEXT NOT NULL, day BIGINT NOT NULL,
|
|
19
|
+
uses INTEGER NOT NULL CHECK (uses BETWEEN 1 AND 5),
|
|
20
|
+
sessions JSONB NOT NULL, PRIMARY KEY (by_account, day))`).then(() => undefined)
|
|
21
|
+
.catch(error => { this.schema = null; throw error; });
|
|
22
|
+
await this.schema;
|
|
23
|
+
}
|
|
24
|
+
key(product, resource) {
|
|
25
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(product) || !/^[\w-]{1,80}$/.test(resource))
|
|
26
|
+
throw new SpeechError("Choose a panel session.", 400);
|
|
27
|
+
return createHash("sha256").update(`${product}|${resource}`).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
async access(by) {
|
|
30
|
+
await this.ensure();
|
|
31
|
+
const now = this.now(), day = Math.floor(now / DAY);
|
|
32
|
+
const rows = by ? (await this.db.query("SELECT uses, day, sessions FROM upgrade_daily_uses WHERE by_account = $1", [by])).rows : [];
|
|
33
|
+
const today = rows.find(row => Number(row["day"]) === day);
|
|
34
|
+
const active = Math.max(0, ...rows.flatMap(row => Object.values(row["sessions"]).map(Number)));
|
|
35
|
+
return { limit: FREE_UPGRADE_SESSIONS, remaining: Math.max(0, FREE_UPGRADE_SESSIONS - Number(today?.["uses"] ?? 0)),
|
|
36
|
+
reconnectSeconds: FREE_UPGRADE_RECONNECT_SECONDS, resets: new Date((day + 1) * DAY).toISOString(), activeUntil: active > now ? new Date(active).toISOString() : null };
|
|
37
|
+
}
|
|
38
|
+
/** One atomic row serializes concurrent starts across tabs, products and replicas.
|
|
39
|
+
* Active use renews a short lease. Reconnecting reuses it; idle leases expire. */
|
|
40
|
+
async begin(by, product, resource) {
|
|
41
|
+
if (!by)
|
|
42
|
+
throw new SpeechError("Sign in to use your free sessions.", 401);
|
|
43
|
+
const key = this.key(product, resource);
|
|
44
|
+
if ((await this.active([by], product, resource, true)).length)
|
|
45
|
+
return true;
|
|
46
|
+
await this.ensure();
|
|
47
|
+
const now = this.now(), day = Math.floor(now / DAY), until = now + FREE_UPGRADE_RECONNECT_SECONDS * 1000;
|
|
48
|
+
const result = await this.db.query(`INSERT INTO upgrade_daily_uses AS daily (by_account, day, uses, sessions)
|
|
49
|
+
VALUES ($1, $2, 1, jsonb_build_object($3::text, $5::bigint))
|
|
50
|
+
ON CONFLICT (by_account, day) DO UPDATE SET
|
|
51
|
+
uses = CASE WHEN COALESCE((daily.sessions->>$3)::bigint, 0) > $4 THEN daily.uses ELSE daily.uses + 1 END,
|
|
52
|
+
sessions = daily.sessions || EXCLUDED.sessions
|
|
53
|
+
WHERE COALESCE((daily.sessions->>$3)::bigint, 0) > $4 OR daily.uses < $6
|
|
54
|
+
RETURNING uses`, [by, day, key, now, until, FREE_UPGRADE_SESSIONS]);
|
|
55
|
+
return result.rows.length > 0;
|
|
56
|
+
}
|
|
57
|
+
async active(accounts, product, resource, renew = false) {
|
|
58
|
+
if (!resource || !accounts.length)
|
|
59
|
+
return [];
|
|
60
|
+
const key = this.key(product, resource);
|
|
61
|
+
await this.ensure();
|
|
62
|
+
const now = this.now();
|
|
63
|
+
const result = renew
|
|
64
|
+
? await this.db.query(`UPDATE upgrade_daily_uses SET sessions = sessions || jsonb_build_object($2::text, $4::bigint)
|
|
65
|
+
WHERE by_account = ANY($1::text[]) AND (sessions->>$2)::bigint > $3 RETURNING by_account`, [accounts, key, now, now + FREE_UPGRADE_RECONNECT_SECONDS * 1000])
|
|
66
|
+
: await this.db.query(`SELECT by_account FROM upgrade_daily_uses
|
|
67
|
+
WHERE by_account = ANY($1::text[]) AND (sessions->>$2)::bigint > $3`, [accounts, key, now]);
|
|
68
|
+
return result.rows.map(row => String(row["by_account"]));
|
|
69
|
+
}
|
|
70
|
+
}
|
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* terminal better than either -- OAuth 2.0 reached through the device grant in
|
|
20
20
|
* device.ts -- and what it ends with is a token from tokens.ts.
|
|
21
21
|
*/
|
|
22
|
-
import { createAuthSystem, PostgresAdapter } from "@profullstack/auth-system";
|
|
22
|
+
import { createAuthSystem, createPasswordUtils, PostgresAdapter } from "@profullstack/auth-system";
|
|
23
|
+
import { PasswordResets } from "./password-reset.ts";
|
|
23
24
|
import { Identities, type Identity, type Users } from "./oauth.ts";
|
|
24
25
|
import type { Queryable } from "./follows.ts";
|
|
25
26
|
import { type IssuedToken, looksLikeToken, type TokenKind, type TokenRecord, Tokens } from "./tokens.ts";
|
|
@@ -129,6 +130,7 @@ export class Accounts {
|
|
|
129
130
|
private readonly system: AuthLike;
|
|
130
131
|
/** Null only where a test injected an auth system and no storage. */
|
|
131
132
|
readonly tokens: Tokens | null;
|
|
133
|
+
readonly passwordResets: PasswordResets | null;
|
|
132
134
|
private readonly identities: Identities | null;
|
|
133
135
|
|
|
134
136
|
constructor(options: AccountsOptions) {
|
|
@@ -151,6 +153,8 @@ export class Accounts {
|
|
|
151
153
|
passwordOptions: { ...PASSWORD_RULES },
|
|
152
154
|
}) as AuthLike);
|
|
153
155
|
this.tokens = adapter ? new Tokens(adapter) : null;
|
|
156
|
+
this.passwordResets = adapter && this.tokens
|
|
157
|
+
? new PasswordResets(adapter, this.tokens, createPasswordUtils({ ...PASSWORD_RULES })) : null;
|
|
154
158
|
this.identities = adapter ? new Identities(adapter, adapter) : null;
|
|
155
159
|
}
|
|
156
160
|
|
|
@@ -216,7 +220,9 @@ export class Accounts {
|
|
|
216
220
|
}
|
|
217
221
|
}
|
|
218
222
|
try {
|
|
219
|
-
|
|
223
|
+
const account = readClaims(await this.system.validateToken(token));
|
|
224
|
+
if (account && this.passwordResets && !(await this.passwordResets.acceptsLegacyToken(account.id, token))) return null;
|
|
225
|
+
return account;
|
|
220
226
|
} catch {
|
|
221
227
|
return null;
|
|
222
228
|
}
|
package/src/classroom.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Public classroom metadata, shared by the API and the small school client. */
|
|
2
|
+
export interface ClassroomSettings {
|
|
3
|
+
broadcastUrl?: string;
|
|
4
|
+
hostName?: string;
|
|
5
|
+
homepageUrl?: string;
|
|
6
|
+
avatarUrl?: string;
|
|
7
|
+
recurrence?: "daily" | "weekly";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function publicWebUrl(value: unknown): string | null {
|
|
11
|
+
if (typeof value !== "string" || value.length > 2048) return null;
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value);
|
|
14
|
+
if (url.protocol !== "https:" || url.username || url.password) return null;
|
|
15
|
+
return url.href;
|
|
16
|
+
} catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function classroomBroadcast(value: unknown): { provider: "pairux" | "nixamp"; url: string; embed: string; join: string } | null {
|
|
20
|
+
const safe = publicWebUrl(value);
|
|
21
|
+
if (!safe) return null;
|
|
22
|
+
const url = new URL(safe);
|
|
23
|
+
if (!url.port && (url.hostname === "pairux.com" || url.hostname === "www.pairux.com")) {
|
|
24
|
+
const code = /^\/(?:l|join|embed)\/([A-Za-z0-9_-]{1,64})\/?$/.exec(url.pathname)?.[1];
|
|
25
|
+
if (!code) return null;
|
|
26
|
+
return { provider: "pairux", url: `https://pairux.com/l/${code}`, embed: `https://pairux.com/embed/${code}`, join: `https://pairux.com/join/${code}` };
|
|
27
|
+
}
|
|
28
|
+
if (!url.port && (url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com")) {
|
|
29
|
+
if (url.pathname !== "/") return null;
|
|
30
|
+
// Never publish an admin credential in a classroom share link.
|
|
31
|
+
const server = url.searchParams.get("url") || "";
|
|
32
|
+
// The server's Share panel supplies a viewer URL without a track selection.
|
|
33
|
+
const play = url.searchParams.get("play") || (server ? "live" : "");
|
|
34
|
+
if (/\/admin\//i.test(server) || /\/admin\//i.test(play) || url.searchParams.has("key")) return null;
|
|
35
|
+
if (server && !publicWebUrl(server)) return null;
|
|
36
|
+
if (!/^(?:live|channel:[^\s]+|track:\d+)$/.test(play) && !publicWebUrl(play)) return null;
|
|
37
|
+
const clean = new URL("https://nixamp.com/");
|
|
38
|
+
if (server) clean.searchParams.set("url", server);
|
|
39
|
+
clean.searchParams.set("play", play);
|
|
40
|
+
const original = clean.href;
|
|
41
|
+
clean.searchParams.set("embed", "1");
|
|
42
|
+
return { provider: "nixamp", url: original, embed: clean.href, join: original };
|
|
43
|
+
}
|
|
44
|
+
// A self-hosted Nixamp viewer link can be played through the shared client.
|
|
45
|
+
if (url.hostname.endsWith(".nixamp.com") && /^\/view\/[A-Za-z0-9_-]+\/?$/.test(url.pathname) && !url.search) {
|
|
46
|
+
const player = new URL("https://nixamp.com/");
|
|
47
|
+
player.searchParams.set("url", url.href);
|
|
48
|
+
player.searchParams.set("play", "live");
|
|
49
|
+
return classroomBroadcast(player.href);
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Guard } from "./guard.ts";
|
|
2
|
+
|
|
3
|
+
export interface EventDraft { title: string; description: string; topic: string }
|
|
4
|
+
export interface WriterInput extends EventDraft {
|
|
5
|
+
hostName: string; startsAt: string; timezone: string; duration: string; recurrence: string;
|
|
6
|
+
}
|
|
7
|
+
export interface EventDraftResult { draft: EventDraft; provider: "openai" | "claude" }
|
|
8
|
+
export class EventWriterError extends Error {
|
|
9
|
+
constructor(message: string, readonly status: number, readonly retryAfter?: number) { super(message); }
|
|
10
|
+
}
|
|
11
|
+
class Refused extends EventWriterError {
|
|
12
|
+
constructor() { super("The writer could not draft these notes. Try rephrasing the event details.", 422); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const LIMITS = {title: 160, description: 5000, topic: 100, hostName: 100, startsAt: 40, timezone: 100, duration: 20, recurrence: 20} as const;
|
|
16
|
+
export function writerInput(value: unknown): WriterInput {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new EventWriterError("Send event details as an object.", 400);
|
|
18
|
+
const source = value as Record<string, unknown>;
|
|
19
|
+
const result = {} as WriterInput;
|
|
20
|
+
for (const key of Object.keys(LIMITS) as Array<keyof WriterInput>) {
|
|
21
|
+
const field = source[key] ?? "";
|
|
22
|
+
if (typeof field !== "string" || field.length > LIMITS[key]) throw new EventWriterError(`The ${key} field is too long or is not text.`, 422);
|
|
23
|
+
result[key] = field.trim();
|
|
24
|
+
}
|
|
25
|
+
if (!result.title && !result.description && !result.topic) throw new EventWriterError("Add a title, description, or topic first, then click Write with AI.", 422);
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SCHEMA = {
|
|
30
|
+
type: "object", properties: { title: {type: "string"}, description: {type: "string"}, topic: {type: "string"} },
|
|
31
|
+
required: ["title", "description", "topic"], additionalProperties: false,
|
|
32
|
+
};
|
|
33
|
+
const INSTRUCTIONS = `You write useful, welcoming event details for BackToSchool.help, a live learning platform.
|
|
34
|
+
Use the supplied form text as the writing prompt. Rough notes and requests in that text describe what the host wants to teach.
|
|
35
|
+
Return a clear title (at most 160 characters), a description (at most 5000 characters, usually 100–250 words), and a short topic (at most 100 characters).
|
|
36
|
+
Write in the language of the host's notes. Describe what students will learn and what will happen, using only information supported by the notes.
|
|
37
|
+
Keep concrete names and facts. Do not invent dates, prices, qualifications, prerequisites, links, credentials, or promises. Omit unknown logistics. Do not expand into unrelated subjects.
|
|
38
|
+
The schedule and host name are context only; never propose changing them. Return plain text in the required JSON fields, with no HTML. Short paragraphs or simple bullet lists are fine.`;
|
|
39
|
+
|
|
40
|
+
function draftFrom(text: string): EventDraft {
|
|
41
|
+
const value: unknown = JSON.parse(text);
|
|
42
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid draft");
|
|
43
|
+
const row = value as Record<string, unknown>;
|
|
44
|
+
const draft = {} as EventDraft;
|
|
45
|
+
for (const key of ["title", "description", "topic"] as const) {
|
|
46
|
+
const field = row[key];
|
|
47
|
+
if (typeof field !== "string" || !field.trim() || field.length > LIMITS[key]) throw new Error("invalid draft");
|
|
48
|
+
draft[key] = field.trim();
|
|
49
|
+
}
|
|
50
|
+
return draft;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface EventWriterOptions {
|
|
54
|
+
openaiKey?: string; anthropicKey?: string;
|
|
55
|
+
openaiModel?: string; claudeModel?: string;
|
|
56
|
+
fetch?: typeof globalThis.fetch;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Server-side only. Drafting has no access to the event store and cannot publish. */
|
|
61
|
+
export class EventWriter {
|
|
62
|
+
private readonly guard = new Guard();
|
|
63
|
+
private readonly active = new Set<string>();
|
|
64
|
+
constructor(private readonly options: EventWriterOptions) {}
|
|
65
|
+
|
|
66
|
+
async draft(accountId: string, value: unknown, signal?: AbortSignal): Promise<EventDraftResult> {
|
|
67
|
+
if (!accountId) throw new EventWriterError("Sign in to use the AI writer.", 401);
|
|
68
|
+
const input = writerInput(value);
|
|
69
|
+
if (!this.options.openaiKey && !this.options.anthropicKey) throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
|
|
70
|
+
if (this.active.has(accountId)) throw new EventWriterError("A draft is already being written. Wait for it or cancel it first.", 429, 5);
|
|
71
|
+
if (this.active.size >= 4) throw new EventWriterError("The AI writer is busy. Try again in a moment.", 503, 10);
|
|
72
|
+
const limit = this.guard.check(accountId, {allowed: 6, windowMs: 10 * 60_000});
|
|
73
|
+
if (!limit.ok) throw new EventWriterError("You have requested several drafts. Try again in a few minutes.", 429, limit.retryAfter);
|
|
74
|
+
this.active.add(accountId);
|
|
75
|
+
try {
|
|
76
|
+
for (const provider of ["openai", "claude"] as const) {
|
|
77
|
+
const key = provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
|
|
78
|
+
if (!key) continue;
|
|
79
|
+
if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
|
|
80
|
+
try {
|
|
81
|
+
const draft = await this.generate(provider, key, input, signal);
|
|
82
|
+
if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
|
|
83
|
+
return {draft, provider};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (signal?.aborted) throw new EventWriterError("Draft cancelled.", 499);
|
|
87
|
+
// Refusals are a response to the content, not a provider outage.
|
|
88
|
+
if (error instanceof Refused) throw error;
|
|
89
|
+
// Never log prompts, credentials, or a provider's raw response body.
|
|
90
|
+
console.warn(`nixamp: event writer ${provider} request failed`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new EventWriterError("The AI writer could not finish this draft. Your text is unchanged; please try again.", 503);
|
|
94
|
+
} finally { this.active.delete(accountId); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async generate(provider: "openai" | "claude", key: string, input: WriterInput, signal?: AbortSignal): Promise<EventDraft> {
|
|
98
|
+
const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 25_000);
|
|
99
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
100
|
+
const prompt = JSON.stringify(input);
|
|
101
|
+
const openai = provider === "openai";
|
|
102
|
+
const response = await (this.options.fetch ?? globalThis.fetch)(openai ? "https://api.openai.com/v1/responses" : "https://api.anthropic.com/v1/messages", {
|
|
103
|
+
method: "POST", signal: requestSignal,
|
|
104
|
+
headers: { "content-type": "application/json", ...(openai ? {authorization: `Bearer ${key}`} : {"x-api-key": key, "anthropic-version": "2023-06-01"}) },
|
|
105
|
+
body: JSON.stringify(openai ? {
|
|
106
|
+
model: this.options.openaiModel ?? "gpt-5-mini", store: false,
|
|
107
|
+
instructions: INSTRUCTIONS, input: [{role: "user", content: prompt}],
|
|
108
|
+
reasoning: {effort: "minimal"}, max_output_tokens: 2048,
|
|
109
|
+
text: {format: {type: "json_schema", name: "event_draft", strict: true, schema: SCHEMA}},
|
|
110
|
+
} : {
|
|
111
|
+
model: this.options.claudeModel ?? "claude-haiku-4-5", max_tokens: 2048,
|
|
112
|
+
system: INSTRUCTIONS, messages: [{role: "user", content: prompt}],
|
|
113
|
+
output_config: {format: {type: "json_schema", schema: SCHEMA}},
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error("provider unavailable"); }
|
|
117
|
+
const body = await response.json() as Record<string, unknown>;
|
|
118
|
+
const blocks = openai
|
|
119
|
+
? (Array.isArray(body["output"]) ? body["output"].flatMap(item => item?.type === "message" && Array.isArray(item.content) ? item.content : []) : [])
|
|
120
|
+
: (Array.isArray(body["content"]) ? body["content"] : []);
|
|
121
|
+
if (body["stop_reason"] === "refusal" || blocks.some(block => block?.type === "refusal")) throw new Refused();
|
|
122
|
+
if (openai ? body["status"] !== "completed" : body["stop_reason"] !== "end_turn") throw new Error("incomplete draft");
|
|
123
|
+
const text = blocks.filter(block => block?.type === (openai ? "output_text" : "text")).map(block => typeof block.text === "string" ? block.text : "").join("");
|
|
124
|
+
return draftFrom(text);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/live-api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventWriterError, type EventWriter } from "./event-writer.ts";
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
3
|
import { tokenFrom, type Account, type Accounts } from "./accounts.ts";
|
|
3
4
|
import {
|
|
@@ -26,6 +27,7 @@ import { HAND_RAISE_STATES, RoomError, type Rooms } from "./rooms.ts";
|
|
|
26
27
|
|
|
27
28
|
export interface LiveApiOptions {
|
|
28
29
|
events: LiveEvents;
|
|
30
|
+
eventWriter?: EventWriter;
|
|
29
31
|
accounts?: Accounts;
|
|
30
32
|
layouts?: Layouts;
|
|
31
33
|
rooms?: Rooms;
|
|
@@ -52,13 +54,13 @@ function json(response: ServerResponse, code: number, body: unknown): void {
|
|
|
52
54
|
response.end(value);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
async function body(request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
57
|
+
async function body(request: IncomingMessage, limit = 64 * 1024): Promise<Record<string, unknown>> {
|
|
56
58
|
const chunks: Buffer[] = [];
|
|
57
59
|
let size = 0;
|
|
58
60
|
for await (const chunk of request) {
|
|
59
61
|
const buffer = chunk as Buffer;
|
|
60
62
|
size += buffer.length;
|
|
61
|
-
if (size >
|
|
63
|
+
if (size > limit) throw new LiveEventError("body too large", 413);
|
|
62
64
|
chunks.push(buffer);
|
|
63
65
|
}
|
|
64
66
|
try {
|
|
@@ -256,6 +258,21 @@ export async function handleLiveApi(
|
|
|
256
258
|
!path.startsWith("/api/v1/layout-presets")) return false;
|
|
257
259
|
|
|
258
260
|
try {
|
|
261
|
+
if (path === "/api/v1/events/ai-draft") {
|
|
262
|
+
if (request.method !== "POST") { json(response, 405, {error: "POST only"}); return true; }
|
|
263
|
+
const account = await requiredAccount(request, response, options);
|
|
264
|
+
if (!account) return true;
|
|
265
|
+
if (!options.eventWriter) { json(response, 503, {error: "The AI writer is temporarily unavailable. You can keep writing here."}); return true; }
|
|
266
|
+
const input = await body(request, 12 * 1024);
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
const closed = () => controller.abort();
|
|
269
|
+
response.once("close", closed);
|
|
270
|
+
try {
|
|
271
|
+
const result = await options.eventWriter.draft(account.id, input, controller.signal);
|
|
272
|
+
if (!response.destroyed) json(response, 200, result);
|
|
273
|
+
} finally { response.off("close", closed); }
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
259
276
|
if (path === "/api/v1/events") {
|
|
260
277
|
if (request.method === "GET") {
|
|
261
278
|
const account = await signedIn(request, options.accounts);
|
|
@@ -624,6 +641,13 @@ export async function handleLiveApi(
|
|
|
624
641
|
json(response, 404, { error: "no such endpoint" });
|
|
625
642
|
return true;
|
|
626
643
|
} catch (error) {
|
|
644
|
+
if (error instanceof EventWriterError) {
|
|
645
|
+
if (!response.destroyed) {
|
|
646
|
+
if (error.retryAfter) response.setHeader("retry-after", String(error.retryAfter));
|
|
647
|
+
json(response, error.status, {error: error.message});
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
627
651
|
if (error instanceof LiveEventError || error instanceof LayoutError || error instanceof RoomError) {
|
|
628
652
|
json(response, error.status, { error: error.message });
|
|
629
653
|
return true;
|