nixamp 0.68.2 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +12 -2
- package/dist/school-cli.d.ts +2 -0
- package/dist/school-cli.js +151 -0
- package/dist/school-client.d.ts +61 -0
- package/dist/school-client.js +148 -0
- package/dist/school-discovery.d.ts +32 -0
- package/dist/school-discovery.js +204 -0
- package/dist/school-mcp.d.ts +180 -0
- package/dist/school-mcp.js +143 -0
- package/dist/server.js +10 -0
- package/dist/station-mcp.d.ts +6 -6
- package/package.json +1 -1
- package/src/main.ts +8 -0
- package/src/mcp.ts +14 -2
- package/src/school-cli.ts +131 -0
- package/src/school-client.ts +180 -0
- package/src/school-discovery.ts +212 -0
- package/src/school-mcp.ts +153 -0
- package/src/server.ts +9 -0
- package/web/dist/assets/{ar-BUn23r3N.js → ar-BAUYJ7CG.js} +1 -1
- package/web/dist/assets/{cs-FwQ-DhBJ.js → cs-D5Njj-lD.js} +1 -1
- package/web/dist/assets/{da-C8OtQ3BT.js → da-CQ52TUpt.js} +1 -1
- package/web/dist/assets/{de-CldRtYgJ.js → de-slj7FWP_.js} +1 -1
- package/web/dist/assets/{es-DSPjVfW5.js → es-wy-duArI.js} +1 -1
- package/web/dist/assets/{fi-XgwcnMwU.js → fi-D8tuiwIa.js} +1 -1
- package/web/dist/assets/{fr-BH9o9Stx.js → fr-AlVe-2YY.js} +1 -1
- package/web/dist/assets/{hi-DuMMcoiX.js → hi-BkrehL5x.js} +1 -1
- package/web/dist/assets/{hu-B2Hiutdz.js → hu-_3tyj6FI.js} +1 -1
- package/web/dist/assets/{id-BNctMtkh.js → id-O2_s9_ZD.js} +1 -1
- package/web/dist/assets/{it-C_ADjBqX.js → it-D6kuTye8.js} +1 -1
- package/web/dist/assets/{main-BVDC-f0Q.js → main-Bh0dWH3R.js} +2 -2
- package/web/dist/assets/{nl-BPTAZ6gI.js → nl-DOdu_ltu.js} +1 -1
- package/web/dist/assets/{ru-CMSzyodc.js → ru-B39HLLYh.js} +1 -1
- package/web/dist/assets/{sv-BqdBoD8p.js → sv-DTDAE9DG.js} +1 -1
- package/web/dist/assets/{uk-C3lM_pHS.js → uk-DmM6P2_W.js} +1 -1
- package/web/dist/assets/{vi-CkVfVKSH.js → vi-8EoMeh4v.js} +1 -1
- package/web/dist/assets/{zh-BDiROZo0.js → zh-DkGyIChT.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
package/dist/main.js
CHANGED
|
@@ -10,6 +10,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.js"
|
|
|
10
10
|
import { USAGE as STATION_USAGE } from "./station-usage.js";
|
|
11
11
|
import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.js";
|
|
12
12
|
import { MUSIC_USAGE } from "./music-cli.js";
|
|
13
|
+
import { SCHOOL_USAGE } from "./school-cli.js";
|
|
13
14
|
import { createApp, themes } from "@profullstack/hqtui";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { resolve } from "node:path";
|
|
@@ -211,6 +212,7 @@ keeps serving its browser remote. One per user.
|
|
|
211
212
|
nixamp admin who is connected, and re-stream to them
|
|
212
213
|
nixamp station make a folder into a station others can join (see: nixamp station help)
|
|
213
214
|
nixamp music the AI label's songs as files: catalog, buy (10¢, $1, $2), what you bought (nixamp music help)
|
|
215
|
+
nixamp school classes on backtoschool.help: teach a station live, check, prune (nixamp school help)
|
|
214
216
|
|
|
215
217
|
From inside the player, d hands the music to a daemon without stopping it.
|
|
216
218
|
`,
|
|
@@ -378,6 +380,8 @@ export function helpFor(topic) {
|
|
|
378
380
|
return LIBRARY_USAGE;
|
|
379
381
|
if (topic === "music")
|
|
380
382
|
return MUSIC_USAGE;
|
|
383
|
+
if (topic === "school")
|
|
384
|
+
return SCHOOL_USAGE;
|
|
381
385
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
382
386
|
}
|
|
383
387
|
/**
|
|
@@ -540,6 +544,11 @@ export async function main() {
|
|
|
540
544
|
process.exitCode = await music(rest);
|
|
541
545
|
return;
|
|
542
546
|
}
|
|
547
|
+
if (first === "school" || first === "class" || first === "classes") {
|
|
548
|
+
const { school } = await import("./school-cli.js");
|
|
549
|
+
process.exitCode = await school(rest);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
543
552
|
if (first === "station" || first === "stations") {
|
|
544
553
|
const { station } = await import("./station-cli.js");
|
|
545
554
|
process.exitCode = await station(rest);
|
package/dist/mcp.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface McpOptions {
|
|
|
37
37
|
/** How a file is described for its record; the tests hand in a fake. */
|
|
38
38
|
describe?: (path: string) => Promise<Described>;
|
|
39
39
|
say?: (line: string) => void;
|
|
40
|
+
/** Where the class tools go; backtoschool.help (or NIXAMP_SCHOOL_URL) otherwise. */
|
|
41
|
+
school?: string;
|
|
40
42
|
}
|
|
41
43
|
/** A tool answer, in the shape MCP wants: content blocks, and a flag for failure. */
|
|
42
44
|
export interface ToolResult {
|
package/dist/mcp.js
CHANGED
|
@@ -33,6 +33,7 @@ import { personaLines, readPersona, readVoices, writePersona } from "./profile.j
|
|
|
33
33
|
import { STATION_TOOLS, callStationTool, isStationTool } from "./station-mcp.js";
|
|
34
34
|
import { PUBLIC_TOOLS, callPublicTool, isPublicTool } from "./directory-mcp.js";
|
|
35
35
|
import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.js";
|
|
36
|
+
import { SCHOOL_TOOLS, callSchoolTool, isSchoolTool } from "./school-mcp.js";
|
|
36
37
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
37
38
|
const STRING = { type: "string" };
|
|
38
39
|
export const TOOLS = [
|
|
@@ -244,6 +245,7 @@ export const TOOLS = [
|
|
|
244
245
|
...STATION_TOOLS,
|
|
245
246
|
...PUBLIC_TOOLS,
|
|
246
247
|
...LIBRARY_TOOLS,
|
|
248
|
+
...SCHOOL_TOOLS,
|
|
247
249
|
];
|
|
248
250
|
function said(line) {
|
|
249
251
|
return `${line.createdAt} ${line.handle}: ${line.body}`;
|
|
@@ -302,6 +304,14 @@ export async function callTool(name, args, options = {}) {
|
|
|
302
304
|
return failed(error.message);
|
|
303
305
|
}
|
|
304
306
|
}
|
|
307
|
+
// backtoschool.help classes: the same token, sent to the school's host.
|
|
308
|
+
if (isSchoolTool(name)) {
|
|
309
|
+
return callSchoolTool(name, args, {
|
|
310
|
+
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
|
311
|
+
...(options.session !== undefined ? { token: options.session?.token ?? null } : {}),
|
|
312
|
+
...(options.school ? { site: options.school } : {}),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
305
315
|
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
306
316
|
if (isStationTool(name) || isLibraryTool(name)) {
|
|
307
317
|
const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
|
|
@@ -612,8 +622,8 @@ export async function handleMessage(message, options = {}) {
|
|
|
612
622
|
return reply({
|
|
613
623
|
protocolVersion: PROTOCOL_VERSION,
|
|
614
624
|
capabilities: { tools: { listChanged: false } },
|
|
615
|
-
serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
|
|
616
|
-
instructions: "Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
625
|
+
serverInfo: { name: "nixamp", title: "nixamp: stations, classes, watch parties, rooms and transcripts", version: "2" },
|
|
626
|
+
instructions: "Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Classes on backtoschool.help: classes_list and class_get for anybody; class_teach puts a station link on the air as a class in one call, classes_check finds live classes whose broadcast stopped playing. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
617
627
|
});
|
|
618
628
|
}
|
|
619
629
|
// Notifications carry no id and are answered with silence, which is what
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const SCHOOL_USAGE = "nixamp school: classes on backtoschool.help (sign in with `nixamp login`)\n\n nixamp school [list] [--status live|scheduled|draft|ended] [--mine]\n nixamp school show SLUG one class, and whether its broadcast plays\n nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]\n make a class of a broadcast and start it now\n nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]\n nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]\n nixamp school start SLUG\n nixamp school end SLUG\n nixamp school delete SLUG\n nixamp school check your live classes, and which ones no longer play\n nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)\n\nLINK is what plays: a station link (`nixamp station show ID`), a nixamp.com share link, a\nPairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.\n --json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)\n";
|
|
2
|
+
export declare function school(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp school`: classes on backtoschool.help from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* A class is a live event on nixamp's accounts, so `nixamp login` is the
|
|
5
|
+
* sign-in here too. `teach` is the short path: a station link in, a class on
|
|
6
|
+
* the air out. `check` and `prune` exist because a class stays live after
|
|
7
|
+
* its broadcast is gone, and the page then shows a player that never plays.
|
|
8
|
+
*/
|
|
9
|
+
import { broadcastHealth, checkClasses, classUrl, createClass, deleteClass, getClass, listClasses, moveClass, teach, updateClass, } from "./school-client.js";
|
|
10
|
+
export const SCHOOL_USAGE = `nixamp school: classes on backtoschool.help (sign in with \`nixamp login\`)
|
|
11
|
+
|
|
12
|
+
nixamp school [list] [--status live|scheduled|draft|ended] [--mine]
|
|
13
|
+
nixamp school show SLUG one class, and whether its broadcast plays
|
|
14
|
+
nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]
|
|
15
|
+
make a class of a broadcast and start it now
|
|
16
|
+
nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]
|
|
17
|
+
nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]
|
|
18
|
+
nixamp school start SLUG
|
|
19
|
+
nixamp school end SLUG
|
|
20
|
+
nixamp school delete SLUG
|
|
21
|
+
nixamp school check your live classes, and which ones no longer play
|
|
22
|
+
nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)
|
|
23
|
+
|
|
24
|
+
LINK is what plays: a station link (\`nixamp station show ID\`), a nixamp.com share link, a
|
|
25
|
+
Pairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.
|
|
26
|
+
--json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)
|
|
27
|
+
`;
|
|
28
|
+
function flag(argv, name) {
|
|
29
|
+
const at = argv.indexOf(name);
|
|
30
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
31
|
+
}
|
|
32
|
+
/** The first argument after the command that is not a flag or a flag's value. */
|
|
33
|
+
function positional(argv) {
|
|
34
|
+
const valued = new Set(["--title", "--description", "--topic", "--starts", "--link", "--status"]);
|
|
35
|
+
for (let at = 1; at < argv.length; at += 1) {
|
|
36
|
+
const one = argv[at];
|
|
37
|
+
if (valued.has(one)) {
|
|
38
|
+
at += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!one.startsWith("--"))
|
|
42
|
+
return one;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function fields(argv) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [name, key] of [["--title", "title"], ["--description", "description"], ["--topic", "topic"], ["--starts", "startsAt"], ["--link", "broadcastUrl"]]) {
|
|
49
|
+
const value = flag(argv, name);
|
|
50
|
+
if (value !== undefined)
|
|
51
|
+
out[key] = value;
|
|
52
|
+
}
|
|
53
|
+
for (const visibility of ["public", "unlisted", "private"])
|
|
54
|
+
if (argv.includes(`--${visibility}`))
|
|
55
|
+
out["visibility"] = visibility;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function print(item) {
|
|
59
|
+
console.log(`${item.status.padEnd(9)} ${item.title}\n ${classUrl(item)}${item.broadcastUrl ? `\n plays ${item.broadcastUrl}` : ""}`);
|
|
60
|
+
}
|
|
61
|
+
export async function school(argv) {
|
|
62
|
+
const [sub = "list"] = argv;
|
|
63
|
+
const json = argv.includes("--json");
|
|
64
|
+
const slug = positional(argv);
|
|
65
|
+
const out = (value, human) => { if (json)
|
|
66
|
+
console.log(JSON.stringify(value, null, 2));
|
|
67
|
+
else
|
|
68
|
+
human(); };
|
|
69
|
+
try {
|
|
70
|
+
if (sub === "help" || argv.includes("--help")) {
|
|
71
|
+
console.log(SCHOOL_USAGE);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
if (sub === "list") {
|
|
75
|
+
const status = flag(argv, "--status");
|
|
76
|
+
const items = await listClasses({ ...(status ? { status } : {}), mine: argv.includes("--mine") });
|
|
77
|
+
out(items, () => { if (items.length === 0)
|
|
78
|
+
console.log("No classes match."); for (const one of items)
|
|
79
|
+
print(one); });
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
if (sub === "check" || sub === "prune") {
|
|
83
|
+
const checked = await checkClasses();
|
|
84
|
+
const broken = checked.filter((one) => one.health.ok === false);
|
|
85
|
+
const act = sub === "prune" ? (argv.includes("--delete") ? "delete" : argv.includes("--end") ? "end" : "") : "";
|
|
86
|
+
for (const { item } of act ? broken : []) {
|
|
87
|
+
if (act === "delete")
|
|
88
|
+
await deleteClass(item.id);
|
|
89
|
+
else
|
|
90
|
+
await moveClass(item.id, "end");
|
|
91
|
+
}
|
|
92
|
+
out(checked.map(({ item, health }) => ({ slug: item.slug, ok: health.ok, reason: health.reason, ...(act && health.ok === false ? { done: act } : {}) })), () => {
|
|
93
|
+
if (checked.length === 0)
|
|
94
|
+
console.log("You have no live classes.");
|
|
95
|
+
for (const { item, health } of checked)
|
|
96
|
+
console.log(`${health.ok === true ? "plays " : health.ok === false ? "BROKEN" : " ? "} ${item.slug} (${health.reason})`);
|
|
97
|
+
if (sub === "prune" && broken.length > 0)
|
|
98
|
+
console.log(act ? `\n${act === "delete" ? "Deleted" : "Ended"} ${broken.length}.` : `\n${broken.length} no longer play. --end or --delete them.`);
|
|
99
|
+
});
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
if (sub === "teach" || sub === "create") {
|
|
103
|
+
const input = fields(argv);
|
|
104
|
+
if (slug)
|
|
105
|
+
input["broadcastUrl"] = slug;
|
|
106
|
+
if (!input["title"]) {
|
|
107
|
+
console.error(`nixamp school ${sub} needs --title`);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
if (sub === "teach" && !input["broadcastUrl"]) {
|
|
111
|
+
console.error("nixamp school teach needs the LINK that plays");
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
const item = sub === "teach" ? await teach(input) : await createClass(input);
|
|
115
|
+
out(item, () => { print(item); if (sub === "create")
|
|
116
|
+
console.log(`\nnixamp school start ${item.slug} puts it on the air.`); });
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (!slug) {
|
|
120
|
+
console.error(`nixamp school ${sub} needs a class (its slug or /live/ link)\n\n${SCHOOL_USAGE}`);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
if (sub === "show") {
|
|
124
|
+
const item = await getClass(slug);
|
|
125
|
+
const health = await broadcastHealth(item.broadcastUrl);
|
|
126
|
+
out({ ...item, broadcast: health }, () => { print(item); console.log(` broadcast: ${health.ok === true ? "playing" : health.ok === false ? "NOT playing" : "unknown"} (${health.reason})`); });
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (sub === "update") {
|
|
130
|
+
const item = await updateClass(slug, fields(argv));
|
|
131
|
+
out(item, () => print(item));
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (sub === "start" || sub === "end") {
|
|
135
|
+
const item = await moveClass(slug, sub);
|
|
136
|
+
out(item, () => print(item));
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (sub === "delete" || sub === "rm") {
|
|
140
|
+
await deleteClass(slug);
|
|
141
|
+
out({ ok: true, slug }, () => console.log(`Deleted ${slug}.`));
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
console.error(`nixamp school: no command "${sub}".\n\n${SCHOOL_USAGE}`);
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
console.error(`nixamp school: ${error.message}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export declare const SCHOOL_SITE = "https://backtoschool.help";
|
|
2
|
+
type Fetcher = typeof fetch;
|
|
3
|
+
export interface SchoolOptions {
|
|
4
|
+
/** backtoschool.help unless NIXAMP_SCHOOL_URL says otherwise; the tests hand in a local server. */
|
|
5
|
+
site?: string;
|
|
6
|
+
/** The session on disk (or NIXAMP_TOKEN) unless given; null means signed out. */
|
|
7
|
+
token?: string | null;
|
|
8
|
+
fetcher?: Fetcher;
|
|
9
|
+
}
|
|
10
|
+
export interface SchoolClass {
|
|
11
|
+
id: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
title: string;
|
|
14
|
+
status: string;
|
|
15
|
+
visibility: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
version: number;
|
|
18
|
+
ownerId: string;
|
|
19
|
+
broadcastUrl?: string | null;
|
|
20
|
+
description?: string;
|
|
21
|
+
topic?: string;
|
|
22
|
+
hostName?: string;
|
|
23
|
+
startsAt?: string | null;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare class SchoolError extends Error {
|
|
27
|
+
readonly status: number;
|
|
28
|
+
constructor(message: string, status?: number);
|
|
29
|
+
}
|
|
30
|
+
export declare function schoolSite(options?: SchoolOptions): string;
|
|
31
|
+
export declare function classUrl(item: Pick<SchoolClass, "slug">, options?: SchoolOptions): string;
|
|
32
|
+
export declare function listClasses(query?: {
|
|
33
|
+
status?: string;
|
|
34
|
+
kind?: string;
|
|
35
|
+
mine?: boolean;
|
|
36
|
+
limit?: number;
|
|
37
|
+
}, options?: SchoolOptions): Promise<SchoolClass[]>;
|
|
38
|
+
/** A class by its slug, id, or the /live/<slug> link. */
|
|
39
|
+
export declare function getClass(slug: string, options?: SchoolOptions): Promise<SchoolClass>;
|
|
40
|
+
export declare function createClass(input: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
|
|
41
|
+
export declare function updateClass(slug: string, patch: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
|
|
42
|
+
/** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
|
|
43
|
+
export declare function moveClass(slug: string, action: "start" | "end" | "doors" | "encore" | "cancel" | "archive", options?: SchoolOptions): Promise<SchoolClass>;
|
|
44
|
+
export declare function deleteClass(slug: string, options?: SchoolOptions): Promise<void>;
|
|
45
|
+
/** Make a class of a broadcast and put it on the air in one go. */
|
|
46
|
+
export declare function teach(input: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
|
|
47
|
+
export interface Health {
|
|
48
|
+
ok: boolean | null;
|
|
49
|
+
reason: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Does this broadcast play? true and false are answers; null is "cannot tell
|
|
53
|
+
* from here" (a Pairux room), which a prune must leave alone.
|
|
54
|
+
*/
|
|
55
|
+
export declare function broadcastHealth(link: string | null | undefined, fetcher?: Fetcher, timeoutMs?: number): Promise<Health>;
|
|
56
|
+
/** Your live classes, each with whether its broadcast plays. */
|
|
57
|
+
export declare function checkClasses(options?: SchoolOptions, status?: string): Promise<Array<{
|
|
58
|
+
item: SchoolClass;
|
|
59
|
+
health: Health;
|
|
60
|
+
}>>;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help as a client: its classes are nixamp's live events, served
|
|
3
|
+
* by the same process on another host, so one nixamp token signs in to both.
|
|
4
|
+
*
|
|
5
|
+
* The CLI (school-cli.ts) and the MCP tools (school-mcp.ts) are both this
|
|
6
|
+
* file; neither talks HTTP itself.
|
|
7
|
+
*
|
|
8
|
+
* `broadcastHealth` is the reason this exists as much as the CRUD: a class
|
|
9
|
+
* stays "live" until somebody ends it, long after the channel it points at
|
|
10
|
+
* was stopped, and the page then shows a player that never plays. It asks the
|
|
11
|
+
* broadcast itself, with only what the public link already carries.
|
|
12
|
+
*/
|
|
13
|
+
import { readSession } from "./session.js";
|
|
14
|
+
export const SCHOOL_SITE = "https://backtoschool.help";
|
|
15
|
+
export class SchoolError extends Error {
|
|
16
|
+
status;
|
|
17
|
+
constructor(message, status = 0) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function schoolSite(options = {}) {
|
|
23
|
+
return (options.site ?? process.env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE).replace(/\/+$/, "");
|
|
24
|
+
}
|
|
25
|
+
export function classUrl(item, options = {}) {
|
|
26
|
+
return `${schoolSite(options)}/live/${item.slug}`;
|
|
27
|
+
}
|
|
28
|
+
function tokenOf(options) {
|
|
29
|
+
if (options.token !== undefined)
|
|
30
|
+
return options.token;
|
|
31
|
+
return readSession()?.token ?? null;
|
|
32
|
+
}
|
|
33
|
+
async function call(path, init = {}, options = {}) {
|
|
34
|
+
const token = tokenOf(options);
|
|
35
|
+
if (init.auth && !token)
|
|
36
|
+
throw new SchoolError("Not signed in. Run `nixamp login`, or set NIXAMP_TOKEN.", 401);
|
|
37
|
+
const headers = { accept: "application/json" };
|
|
38
|
+
if (token)
|
|
39
|
+
headers["authorization"] = `Bearer ${token}`;
|
|
40
|
+
if (init.body !== undefined)
|
|
41
|
+
headers["content-type"] = "application/json";
|
|
42
|
+
const response = await (options.fetcher ?? fetch)(`${schoolSite(options)}${path}`, {
|
|
43
|
+
method: init.method ?? "GET",
|
|
44
|
+
headers,
|
|
45
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
46
|
+
});
|
|
47
|
+
const body = (await response.json().catch(() => ({})));
|
|
48
|
+
if (!response.ok)
|
|
49
|
+
throw new SchoolError(String(body["error"] ?? `backtoschool.help answered ${response.status}`), response.status);
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
const eventOf = (body) => (body["event"] ?? body);
|
|
53
|
+
const ref = (value) => encodeURIComponent(value.replace(/^.*\/live\//, "").replace(/[?#].*$/, ""));
|
|
54
|
+
export async function listClasses(query = {}, options = {}) {
|
|
55
|
+
const params = new URLSearchParams();
|
|
56
|
+
if (query.status)
|
|
57
|
+
params.set("status", query.status);
|
|
58
|
+
if (query.kind)
|
|
59
|
+
params.set("kind", query.kind);
|
|
60
|
+
if (query.mine)
|
|
61
|
+
params.set("mine", "true");
|
|
62
|
+
params.set("limit", String(query.limit ?? 60));
|
|
63
|
+
const body = await call(`/api/v1/events?${params}`, { auth: query.mine === true }, options);
|
|
64
|
+
return body["events"] ?? [];
|
|
65
|
+
}
|
|
66
|
+
/** A class by its slug, id, or the /live/<slug> link. */
|
|
67
|
+
export async function getClass(slug, options = {}) {
|
|
68
|
+
return eventOf(await call(`/api/v1/events/${ref(slug)}`, {}, options));
|
|
69
|
+
}
|
|
70
|
+
export async function createClass(input, options = {}) {
|
|
71
|
+
return eventOf(await call("/api/v1/events", { method: "POST", body: { kind: "class", visibility: "public", ...input }, auth: true }, options));
|
|
72
|
+
}
|
|
73
|
+
export async function updateClass(slug, patch, options = {}) {
|
|
74
|
+
const current = await getClass(slug, options);
|
|
75
|
+
return eventOf(await call(`/api/v1/events/${ref(current.id)}`, { method: "PATCH", body: { ...patch, version: current.version }, auth: true }, options));
|
|
76
|
+
}
|
|
77
|
+
/** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
|
|
78
|
+
export async function moveClass(slug, action, options = {}) {
|
|
79
|
+
const current = await getClass(slug, options);
|
|
80
|
+
return eventOf(await call(`/api/v1/events/${ref(current.id)}/${action}`, { method: "POST", body: { version: current.version }, auth: true }, options));
|
|
81
|
+
}
|
|
82
|
+
export async function deleteClass(slug, options = {}) {
|
|
83
|
+
await call(`/api/v1/events/${ref(slug)}`, { method: "DELETE", auth: true }, options);
|
|
84
|
+
}
|
|
85
|
+
/** Make a class of a broadcast and put it on the air in one go. */
|
|
86
|
+
export async function teach(input, options = {}) {
|
|
87
|
+
const made = await createClass(input, options);
|
|
88
|
+
return moveClass(made.slug, "start", options);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Does this broadcast play? true and false are answers; null is "cannot tell
|
|
92
|
+
* from here" (a Pairux room), which a prune must leave alone.
|
|
93
|
+
*/
|
|
94
|
+
export async function broadcastHealth(link, fetcher = fetch, timeoutMs = 8000) {
|
|
95
|
+
if (!link)
|
|
96
|
+
return { ok: false, reason: "no broadcast link" };
|
|
97
|
+
let url;
|
|
98
|
+
try {
|
|
99
|
+
url = new URL(link);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { ok: false, reason: "not a link" };
|
|
103
|
+
}
|
|
104
|
+
const get = (target, headers = {}) => fetcher(target, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
105
|
+
try {
|
|
106
|
+
// A station page: its own token opens its state.
|
|
107
|
+
const station = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1];
|
|
108
|
+
if (station && url.hostname.endsWith(".nixamp.com")) {
|
|
109
|
+
const response = await get(`${url.origin}/api/v1/stations/${station}/state?t=${encodeURIComponent(url.searchParams.get("t") ?? "")}`);
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
return { ok: false, reason: `station ${station} answered ${response.status}` };
|
|
112
|
+
const state = (await response.json());
|
|
113
|
+
return state.state === "live" ? { ok: true, reason: `station ${station} is live` } : { ok: false, reason: `station ${station} is ${state.state ?? "not live"}` };
|
|
114
|
+
}
|
|
115
|
+
// nixamp.com/?url=<server>/view/<listen key>&play=channel:<id>: the listen key reads the server's channels.
|
|
116
|
+
if ((url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com") && url.searchParams.get("url")) {
|
|
117
|
+
const server = new URL(url.searchParams.get("url"));
|
|
118
|
+
const key = /\/view\/([^/?#]+)/.exec(server.pathname)?.[1] ?? "";
|
|
119
|
+
const play = url.searchParams.get("play") ?? "live";
|
|
120
|
+
const response = await get(`${server.origin}/api/streams?k=${encodeURIComponent(key)}`);
|
|
121
|
+
if (!response.ok)
|
|
122
|
+
return { ok: false, reason: `${server.host} answered ${response.status}` };
|
|
123
|
+
const streams = (await response.json());
|
|
124
|
+
if (play.startsWith("channel:")) {
|
|
125
|
+
const id = play.slice("channel:".length);
|
|
126
|
+
return (streams.channels ?? []).some((one) => one.id === id)
|
|
127
|
+
? { ok: true, reason: `channel ${id} is on ${server.hostname}` }
|
|
128
|
+
: { ok: false, reason: `channel ${id} is no longer on ${server.hostname}` };
|
|
129
|
+
}
|
|
130
|
+
if (play === "live")
|
|
131
|
+
return streams.server?.live ? { ok: true, reason: `${server.hostname} is live` } : { ok: false, reason: `${server.hostname} is not live` };
|
|
132
|
+
return { ok: true, reason: `${server.hostname} is up` };
|
|
133
|
+
}
|
|
134
|
+
if (url.hostname === "pairux.com" || url.hostname === "www.pairux.com")
|
|
135
|
+
return { ok: null, reason: "a Pairux room; cannot tell from here" };
|
|
136
|
+
// Direct media: one byte is enough to know it is there.
|
|
137
|
+
const response = await get(url.href, { range: "bytes=0-0" });
|
|
138
|
+
return response.ok ? { ok: true, reason: `${url.hostname} serves it` } : { ok: false, reason: `${url.hostname} answered ${response.status}` };
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
return { ok: false, reason: `unreachable: ${error.message}` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Your live classes, each with whether its broadcast plays. */
|
|
145
|
+
export async function checkClasses(options = {}, status = "live") {
|
|
146
|
+
const items = await listClasses({ mine: true, status }, options);
|
|
147
|
+
return Promise.all(items.map(async (item) => ({ item, health: await broadcastHealth(item.broadcastUrl, options.fetcher ?? fetch) })));
|
|
148
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help's own front door for agents: llms.txt, an OpenAPI
|
|
3
|
+
* document of the class API, and MCP at /mcp/public (reading) and /mcp
|
|
4
|
+
* (teaching, as the bearer).
|
|
5
|
+
*
|
|
6
|
+
* The school is served by the nixamp process under another Host, so before
|
|
7
|
+
* this its /llms.txt and /mcp answered as nixamp.com: an agent landing on
|
|
8
|
+
* the school was told about radio stations and handed no class tools. The
|
|
9
|
+
* routes here run only for the school's host, ahead of nixamp's.
|
|
10
|
+
*
|
|
11
|
+
* The tools are school-mcp.ts, called over HTTP against the school's own
|
|
12
|
+
* origin with the caller's token, exactly as from a CLI. Sign-in is nixamp's:
|
|
13
|
+
* one account, one token, and nixamp.com is the authorization server.
|
|
14
|
+
*/
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
+
export interface SchoolDiscoveryContext {
|
|
17
|
+
/** This school's origin, e.g. https://backtoschool.help. */
|
|
18
|
+
site: string;
|
|
19
|
+
/** nixamp.com: where tokens come from and where OAuth is. */
|
|
20
|
+
accountSite: string;
|
|
21
|
+
version: string;
|
|
22
|
+
whoIs: (token: string) => Promise<unknown>;
|
|
23
|
+
token: string;
|
|
24
|
+
readBody: (request: IncomingMessage, limit: number) => Promise<string>;
|
|
25
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
26
|
+
fetcher?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
/** Is this host the school? backtoschool.help (or NIXAMP_SCHOOL_URL), with or without www. */
|
|
29
|
+
export declare function isSchoolSite(site: string | undefined, env?: NodeJS.ProcessEnv): boolean;
|
|
30
|
+
export declare function schoolLlmsTxt(site: string, accountSite: string): string;
|
|
31
|
+
export declare function schoolOpenapi(site: string, accountSite: string, version: string): Record<string, unknown>;
|
|
32
|
+
export declare function handleSchoolDiscovery(request: IncomingMessage, response: ServerResponse, path: string, ctx: SchoolDiscoveryContext): Promise<boolean>;
|