nixamp 0.27.2 → 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 +39 -16
- package/backtoschool/dist/assets/{index-OE2xWZUH.css → index-Bot4FEXQ.css} +1 -1
- package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
- package/backtoschool/dist/index.html +13 -2
- 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-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/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.d.ts +2 -3
- package/dist/password-reset.js +4 -2
- package/dist/server.d.ts +2 -0
- package/dist/server.js +13 -1
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +32 -23
- package/package.json +1 -1
- package/src/event-writer.ts +126 -0
- package/src/live-api.ts +26 -2
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/mail-sender.ts +12 -0
- package/src/notify.ts +10 -7
- package/src/password-reset.ts +5 -5
- package/src/server.ts +14 -1
- package/src/shared-translation.ts +27 -19
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/{hls-3VKVEQE3-CAL4qBEq.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
- package/web/dist/assets/index-BgLxkXKe.js +3 -0
- package/web/dist/assets/{mpegts-H9oC2fAK.js → mpegts-CV0glck7.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Bkd9Sqr6.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +6 -6
- package/backtoschool/dist/assets/index-pQ_uH4bZ.js +0 -93
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- package/web/dist/assets/index-CLeEWlms.js +0 -3
|
@@ -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;
|
package/src/live-interpreter.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { audioError, transientAudioError } from "./live-recovery.ts";
|
|
1
2
|
import { encodeWav } from "./pcm-wav.ts";
|
|
2
3
|
import type { SpeakerTranscript, SpeakerTurn } from "./speaker-turns.ts";
|
|
3
4
|
export interface Caption { channel: string; at: number; until: number; text: string; language?: string; original?: string; sourceLanguage?: string; speaker?: string; voiceProfile?: "lower" | "higher" | "unknown"; }
|
|
@@ -58,17 +59,20 @@ export class Interpreter {
|
|
|
58
59
|
private controller: AbortController | null = null;
|
|
59
60
|
private translating: number | null = null;
|
|
60
61
|
private translationController: AbortController | null = null;
|
|
62
|
+
private recognitionFailures = 0;
|
|
63
|
+
private translationFailures = 0;
|
|
61
64
|
private translations: { lines: Caption[]; language: string; target: string; until: number }[] = [];
|
|
62
65
|
constructor(private readonly options: {
|
|
63
66
|
language: () => string; speakers: () => boolean; voices: () => VoiceChoice[];
|
|
64
67
|
channel: () => string; lines: (lines: Caption[]) => void;
|
|
65
|
-
status: (text: string) => void; failed: () => void;
|
|
68
|
+
status: (text: string) => void; failed: (message: string) => void;
|
|
66
69
|
fetcher?: typeof fetch;
|
|
67
70
|
}) {}
|
|
68
71
|
|
|
69
72
|
reset(): void {
|
|
70
73
|
this.generation++; this.controller?.abort(); this.translationController?.abort();
|
|
71
74
|
this.pending = null; this.running = null; this.translating = null;
|
|
75
|
+
this.recognitionFailures = this.translationFailures = 0;
|
|
72
76
|
this.translations = []; this.committedUntil = null; this.tracker.reset();
|
|
73
77
|
}
|
|
74
78
|
push(window: AudioWindow): void {
|
|
@@ -79,7 +83,7 @@ export class Interpreter {
|
|
|
79
83
|
private async json(path: string, init: RequestInit, signal: AbortSignal): Promise<any> {
|
|
80
84
|
const response = await (this.options.fetcher ?? fetch)(path, { ...init, signal });
|
|
81
85
|
const body = await response.json();
|
|
82
|
-
if (!response.ok) throw
|
|
86
|
+
if (!response.ok) throw audioError(body.error || "Audio translation is unavailable.", response.status);
|
|
83
87
|
return body;
|
|
84
88
|
}
|
|
85
89
|
private async run(generation: number): Promise<void> {
|
|
@@ -87,60 +91,68 @@ export class Interpreter {
|
|
|
87
91
|
try {
|
|
88
92
|
while (this.pending && generation === this.generation) {
|
|
89
93
|
const window = this.pending; this.pending = null;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
94
|
+
try {
|
|
95
|
+
if (Date.now() - window.until > 12_000) continue;
|
|
96
|
+
const controller = new AbortController(); this.controller = controller;
|
|
97
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]);
|
|
98
|
+
const speakers = this.options.speakers(), target = this.options.language();
|
|
99
|
+
const heard = await this.json(speakers ? "/api/v1/speech/speakers" : "/api/v1/speech/transcribe?live=1", {
|
|
100
|
+
method: "POST", headers: { "content-type": "audio/wav" }, body: new Uint8Array(encodeWav(speakers ? window.samples : window.samples.slice(-80_000))),
|
|
101
|
+
}, signal) as SpeakerTranscript & { text?: string };
|
|
102
|
+
if (generation !== this.generation) return;
|
|
103
|
+
this.recognitionFailures = 0;
|
|
104
|
+
const language = heard.language;
|
|
105
|
+
let lines: Caption[] = [];
|
|
106
|
+
if (speakers) {
|
|
107
|
+
const links = this.tracker.reconcile(heard.turns ?? [], window.at, this.options.voices());
|
|
108
|
+
// The watermark follows emitted words, not the newest capture
|
|
109
|
+
// interval: an overlapping window can recover audio that arrived
|
|
110
|
+
// while recognition/translation was busy. Keep unfinished phrases
|
|
111
|
+
// in that overlap so short capture intervals do not split every
|
|
112
|
+
// sentence (especially damaging when German changes word order).
|
|
113
|
+
const cutoff = this.committedUntil ?? window.freshAt;
|
|
114
|
+
const turns = heard.turns ?? [];
|
|
115
|
+
for (const [index, turn] of turns.entries()) {
|
|
116
|
+
const speaker = links.get(turn.speaker)!;
|
|
117
|
+
const words = turn.words.filter(word =>
|
|
118
|
+
window.at + (word.start + word.end) * 500 >= cutoff &&
|
|
119
|
+
(window.at + word.end * 1000 <= window.until - 100 || /[.!?。!?]$/.test(word.text)));
|
|
120
|
+
let phrase: typeof words = [];
|
|
121
|
+
const emit = (): void => {
|
|
122
|
+
if (!phrase.length) return;
|
|
123
|
+
lines.push({ channel: this.options.channel(), at: window.at + phrase[0]!.start * 1000,
|
|
124
|
+
until: window.at + phrase.at(-1)!.end * 1000, text: phrase.map(word => word.text).join(" ").trim(),
|
|
125
|
+
language, speaker: speaker.id, voiceProfile: turn.profile });
|
|
126
|
+
phrase = [];
|
|
127
|
+
};
|
|
128
|
+
for (const word of words) {
|
|
129
|
+
if (phrase.length && word.start - phrase.at(-1)!.end >= 0.45) emit();
|
|
130
|
+
phrase.push(word);
|
|
131
|
+
if (/[.!?。!?]$/.test(word.text) || phrase.map(word => word.text).join(" ").length >= 400) emit();
|
|
132
|
+
}
|
|
133
|
+
if (phrase.length && (index < turns.length - 1 ||
|
|
134
|
+
window.until - (window.at + phrase.at(-1)!.end * 1000) >= 350 ||
|
|
135
|
+
window.until - (window.at + phrase[0]!.start * 1000) >= 3000)) emit();
|
|
126
136
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
window.until - (window.at + phrase[0]!.start * 1000) >= 3000)) emit();
|
|
137
|
+
} else if (heard.text) {
|
|
138
|
+
lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
|
|
130
139
|
}
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
if (!lines.length) continue;
|
|
141
|
+
this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
|
|
142
|
+
// Drop expired work instead of turning off an otherwise healthy stream.
|
|
143
|
+
this.translations = this.translations.filter(item => Date.now() - item.until <= 12_000).slice(-11);
|
|
144
|
+
this.translations.push({ lines, language, target, until: window.until });
|
|
145
|
+
// Recognition of the next audio window can proceed while the text
|
|
146
|
+
// model translates this phrase. Voice synthesis is a third stage.
|
|
147
|
+
if (this.translating === null) void this.translate(this.generation);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (generation !== this.generation) return;
|
|
150
|
+
if (!transientAudioError(error) || ++this.recognitionFailures >= 3) throw error;
|
|
151
|
+
this.options.status("Speech recognition interrupted · listening for the next phrase…");
|
|
133
152
|
}
|
|
134
|
-
if (!lines.length) continue;
|
|
135
|
-
this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
|
|
136
|
-
if (this.translations.length >= 12) throw new Error("Translation fell behind. Try again after the language model has warmed up.");
|
|
137
|
-
this.translations.push({ lines, language, target, until: window.until });
|
|
138
|
-
// Recognition of the next audio window can proceed while the text
|
|
139
|
-
// model translates this phrase. Voice synthesis is a third stage.
|
|
140
|
-
if (this.translating === null) void this.translate(this.generation);
|
|
141
153
|
}
|
|
142
154
|
} catch (error) {
|
|
143
|
-
if (generation === this.generation) {
|
|
155
|
+
if (generation === this.generation) { const message = error instanceof Error ? error.message : "Live translation stopped."; this.reset(); this.options.failed(message); this.options.status(message); }
|
|
144
156
|
} finally { if (this.running === generation) this.running = null; }
|
|
145
157
|
}
|
|
146
158
|
|
|
@@ -149,26 +161,33 @@ export class Interpreter {
|
|
|
149
161
|
try {
|
|
150
162
|
while (this.translations.length && generation === this.generation) {
|
|
151
163
|
const item = this.translations.shift()!;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
try {
|
|
165
|
+
let { lines } = item;
|
|
166
|
+
const { language, target, until } = item;
|
|
167
|
+
if (Date.now() - until > 12_000) continue;
|
|
168
|
+
const controller = new AbortController(); this.translationController = controller;
|
|
169
|
+
if (target && language !== target) {
|
|
170
|
+
if (!language) throw audioError("The audio language could not be detected. Waiting for clearer speech.", 502);
|
|
171
|
+
const translated = await this.json("/api/v1/translate?live=1", {
|
|
172
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
173
|
+
body: JSON.stringify({ texts: lines.map(line => line.text), from: language, to: target }),
|
|
174
|
+
}, AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)])) as { texts: string[] };
|
|
175
|
+
if (translated.texts.length !== lines.length || translated.texts.some(text => !text.trim())) throw audioError("Translation omitted a phrase. Waiting for the next phrase.", 502);
|
|
176
|
+
lines = lines.map((line, i) => ({ ...line, original: line.text, sourceLanguage: language, language: target, text: translated.texts[i]! }));
|
|
177
|
+
}
|
|
178
|
+
if (generation !== this.generation) return;
|
|
179
|
+
if (Date.now() - until > 12_000) continue;
|
|
180
|
+
this.translationFailures = 0;
|
|
181
|
+
this.options.lines(lines.flatMap(line => splitCaption(line)));
|
|
182
|
+
this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (generation !== this.generation) return;
|
|
185
|
+
if (!transientAudioError(error) || ++this.translationFailures >= 3) throw error;
|
|
186
|
+
this.options.status("Translation interrupted · catching up with live speech…");
|
|
164
187
|
}
|
|
165
|
-
if (generation !== this.generation) return;
|
|
166
|
-
if (Date.now() - until > 12_000) throw new Error("Translation fell behind. Try again after the language model has warmed up.");
|
|
167
|
-
this.options.lines(lines.flatMap(line => splitCaption(line)));
|
|
168
|
-
this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
|
|
169
188
|
}
|
|
170
189
|
} catch (error) {
|
|
171
|
-
if (generation === this.generation) {
|
|
190
|
+
if (generation === this.generation) { const message = error instanceof Error ? error.message : "Live translation stopped."; this.reset(); this.options.failed(message); this.options.status(message); }
|
|
172
191
|
} finally { if (this.translating === generation) this.translating = null; }
|
|
173
192
|
}
|
|
174
193
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Access, billing and throttling failures are terminal. Only transient service
|
|
2
|
+
* and transport failures may recover, with a bounded consecutive failure count. */
|
|
3
|
+
export function transientAudioError(error: unknown): boolean {
|
|
4
|
+
const status = (error as { status?: number } | null)?.status;
|
|
5
|
+
if (typeof status === "number") return [408, 502, 503, 504].includes(status);
|
|
6
|
+
return error instanceof TypeError || (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name));
|
|
7
|
+
}
|
|
8
|
+
export function audioError(message: string, status: number): Error & { status: number } {
|
|
9
|
+
return Object.assign(new Error(message), { status });
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface MailSender { apiKey: string; from: string }
|
|
2
|
+
export interface BrandedMailSender extends MailSender { backtoschool?: MailSender }
|
|
3
|
+
|
|
4
|
+
/** Only the exact school origins select its verified sender and credentials. */
|
|
5
|
+
export function mailSender(options: BrandedMailSender, link: string): MailSender {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(link);
|
|
8
|
+
if (options.backtoschool && url.protocol === "https:" &&
|
|
9
|
+
["backtoschool.help", "www.backtoschool.help"].includes(url.hostname)) return options.backtoschool;
|
|
10
|
+
} catch { /* A malformed link never changes the sender. */ }
|
|
11
|
+
return options;
|
|
12
|
+
}
|
package/src/notify.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mailSender, type BrandedMailSender } from "./mail-sender.ts";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Telling followers a broadcaster went live, wherever they are.
|
|
3
5
|
*
|
|
@@ -119,27 +121,28 @@ export async function notifyAll(
|
|
|
119
121
|
* blocked, and no dependency: a fetch is the whole client.
|
|
120
122
|
*/
|
|
121
123
|
export function resendEmail(
|
|
122
|
-
{ apiKey, from, fetch = globalThis.fetch, onEvent }: {
|
|
123
|
-
|
|
124
|
-
from: string;
|
|
124
|
+
{ apiKey, from, backtoschool, reason = "You are getting this because you follow them on nixamp.", fetch = globalThis.fetch, onEvent }: BrandedMailSender & {
|
|
125
|
+
reason?: string;
|
|
125
126
|
fetch?: typeof globalThis.fetch;
|
|
126
127
|
onEvent?: (message: string) => void;
|
|
127
128
|
},
|
|
128
129
|
): (to: string, note: Notification) => Promise<boolean> {
|
|
129
130
|
return async (to, note) => {
|
|
130
131
|
try {
|
|
132
|
+
const sender = mailSender({apiKey, from, backtoschool}, note.url);
|
|
131
133
|
const response = await fetch("https://api.resend.com/emails", {
|
|
132
134
|
method: "POST",
|
|
133
|
-
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
135
|
+
headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
|
|
136
|
+
signal: AbortSignal.timeout(15_000),
|
|
134
137
|
body: JSON.stringify({
|
|
135
|
-
from,
|
|
138
|
+
from: sender.from,
|
|
136
139
|
to: [to],
|
|
137
140
|
subject: note.title,
|
|
138
|
-
text: `${note.body}\n\n${note.url}\n\
|
|
141
|
+
text: `${note.body}\n\n${note.url}\n\n${reason}`,
|
|
139
142
|
html:
|
|
140
143
|
`<p>${escapeHtml(note.body)}</p>` +
|
|
141
144
|
`<p><a href="${escapeHtml(note.url)}">${escapeHtml(note.url)}</a></p>` +
|
|
142
|
-
`<p style="color:#666;font-size:12px"
|
|
145
|
+
`<p style="color:#666;font-size:12px">${escapeHtml(reason)}</p>`,
|
|
143
146
|
}),
|
|
144
147
|
});
|
|
145
148
|
if (!response.ok) {
|
package/src/password-reset.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import type { Queryable } from "./follows.ts";
|
|
3
3
|
import type { Tokens } from "./tokens.ts";
|
|
4
|
+
import { mailSender, type BrandedMailSender } from "./mail-sender.ts";
|
|
4
5
|
|
|
5
6
|
export const RESET_MESSAGE = "If an account exists for that email, you’ll receive a password reset link shortly.";
|
|
6
7
|
export const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
|
|
@@ -115,17 +116,16 @@ export class PasswordResets {
|
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
/** Transactional recovery mail has no follower footer and never logs a token. */
|
|
118
|
-
export function resendPasswordReset(options: {
|
|
119
|
-
apiKey: string; from: string; fetch?: typeof globalThis.fetch;
|
|
120
|
-
}): ResetMail {
|
|
119
|
+
export function resendPasswordReset(options: BrandedMailSender & { fetch?: typeof globalThis.fetch }): ResetMail {
|
|
121
120
|
return async (email, link) => {
|
|
122
121
|
const host = new URL(link).hostname;
|
|
122
|
+
const sender = mailSender(options, link);
|
|
123
123
|
const response = await (options.fetch ?? globalThis.fetch)("https://api.resend.com/emails", {
|
|
124
124
|
method: "POST",
|
|
125
|
-
headers: { authorization: `Bearer ${
|
|
125
|
+
headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
|
|
126
126
|
signal: AbortSignal.timeout(15_000),
|
|
127
127
|
body: JSON.stringify({
|
|
128
|
-
from:
|
|
128
|
+
from: sender.from, to: [email], subject: `Reset your ${host} password`,
|
|
129
129
|
text: `Someone requested a password reset for your account on ${host}.\n\nChoose a new password using this link:\n${link}\n\nThis link expires in ${LIFETIME_MS / 60_000} minutes and works once. Resetting your password signs out existing sessions.\n\nIf you did not request this, you can ignore this email. Your password will stay the same.`,
|
|
130
130
|
}),
|
|
131
131
|
});
|
package/src/server.ts
CHANGED
|
@@ -87,6 +87,7 @@ import { createThrottle, presentedCredential, type Throttle } from "@profullstac
|
|
|
87
87
|
import { Durable } from "./durable.ts";
|
|
88
88
|
import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
|
|
89
89
|
import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.ts";
|
|
90
|
+
import { EventWriter } from "./event-writer.ts";
|
|
90
91
|
import { handleLiveApi } from "./live-api.ts";
|
|
91
92
|
import { LiveEvents, eventStructuredData, isTicketed, type LiveEvent } from "./live-events.ts";
|
|
92
93
|
import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.ts";
|
|
@@ -1759,6 +1760,7 @@ export interface HandlerOptions {
|
|
|
1759
1760
|
settingsSync?: SettingsSync;
|
|
1760
1761
|
/** Scheduled and live sessions, kept by NixAmp and shared by branded clients. */
|
|
1761
1762
|
events?: LiveEvents;
|
|
1763
|
+
eventWriter?: EventWriter;
|
|
1762
1764
|
/** Versioned panel layouts, including event and user overrides. */
|
|
1763
1765
|
layouts?: Layouts;
|
|
1764
1766
|
/** Persistent participation state that must not be coupled to live audio. */
|
|
@@ -1944,6 +1946,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1944
1946
|
|
|
1945
1947
|
if (options.events && await handleLiveApi(request, response, url, {
|
|
1946
1948
|
events: options.events,
|
|
1949
|
+
...(options.eventWriter ? {eventWriter: options.eventWriter} : {}),
|
|
1947
1950
|
...(options.accounts ? { accounts: options.accounts } : {}),
|
|
1948
1951
|
...(options.layouts ? { layouts: options.layouts } : {}),
|
|
1949
1952
|
...(options.rooms ? { rooms: options.rooms } : {}),
|
|
@@ -6123,6 +6126,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6123
6126
|
const favorites = pool ? new Favorites(pool) : undefined;
|
|
6124
6127
|
const settingsSync = pool ? new SettingsSync(pool) : undefined;
|
|
6125
6128
|
const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
|
|
6129
|
+
const schoolMailKey = process.env["BACKTOSCHOOL_RESEND_API_KEY"] ?? process.env["RESEND_API_KEY"];
|
|
6130
|
+
const schoolMail = schoolMailKey && process.env["BACKTOSCHOOL_MAIL_FROM"] ? {
|
|
6131
|
+
apiKey: schoolMailKey, from: process.env["BACKTOSCHOOL_MAIL_FROM"],
|
|
6132
|
+
} : undefined;
|
|
6126
6133
|
const events = pool ? new LiveEvents(pool) : undefined;
|
|
6127
6134
|
const layouts = pool ? new Layouts(pool) : undefined;
|
|
6128
6135
|
const rooms = pool ? new Rooms(pool) : undefined;
|
|
@@ -6651,7 +6658,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6651
6658
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
6652
6659
|
...(favorites ? { favorites } : {}),
|
|
6653
6660
|
...(settingsSync ? { settingsSync } : {}),
|
|
6654
|
-
...(events ? { events
|
|
6661
|
+
...(events ? { events, eventWriter: new EventWriter({
|
|
6662
|
+
openaiKey: process.env["OPENAI_API_KEY"], anthropicKey: process.env["ANTHROPIC_API_KEY"],
|
|
6663
|
+
openaiModel: process.env["NIXAMP_WRITER_OPENAI_MODEL"], claudeModel: process.env["NIXAMP_WRITER_CLAUDE_MODEL"],
|
|
6664
|
+
}) } : {}),
|
|
6655
6665
|
...(layouts ? { layouts } : {}),
|
|
6656
6666
|
...(rooms ? { rooms } : {}),
|
|
6657
6667
|
...(trollbox ? { trollbox } : {}),
|
|
@@ -6668,6 +6678,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6668
6678
|
resetMail: resendPasswordReset({
|
|
6669
6679
|
apiKey: process.env["RESEND_API_KEY"],
|
|
6670
6680
|
from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
|
|
6681
|
+
backtoschool: schoolMail,
|
|
6671
6682
|
}),
|
|
6672
6683
|
} : {}),
|
|
6673
6684
|
// Other people's OpenProfiles, read for the voice a line is spoken in,
|
|
@@ -6692,6 +6703,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6692
6703
|
email: resendEmail({
|
|
6693
6704
|
apiKey: process.env["RESEND_API_KEY"],
|
|
6694
6705
|
from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
|
|
6706
|
+
backtoschool: schoolMail,
|
|
6707
|
+
reason: "You received this email because a host invited you to join a live session.",
|
|
6695
6708
|
onEvent: (message) => console.log(message),
|
|
6696
6709
|
}),
|
|
6697
6710
|
}
|