From 5ac1b524f0a2e734ead7c224b79ce95223c4a5a3 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Mon, 16 Dec 2024 18:44:04 +0000 Subject: [PATCH] Add a FOSDEM JSON schedule loader --- src/backends/json/FosdemJsonScheduleLoader.ts | 122 +++++++++++++++ src/backends/json/JsonScheduleBackend.ts | 48 ++++-- .../FosdemJsonSchedule.schema.json | 140 ++++++++++++++++++ .../jsontypes/FosdemJsonSchedule.schema.d.ts | 104 +++++++++++++ src/config.ts | 18 +++ src/index.ts | 2 +- 6 files changed, 422 insertions(+), 12 deletions(-) create mode 100644 src/backends/json/FosdemJsonScheduleLoader.ts create mode 100644 src/backends/json/jsonschemas/FosdemJsonSchedule.schema.json create mode 100644 src/backends/json/jsontypes/FosdemJsonSchedule.schema.d.ts diff --git a/src/backends/json/FosdemJsonScheduleLoader.ts b/src/backends/json/FosdemJsonScheduleLoader.ts new file mode 100644 index 00000000..b410e423 --- /dev/null +++ b/src/backends/json/FosdemJsonScheduleLoader.ts @@ -0,0 +1,122 @@ +import { RoomKind } from "../../models/room_kinds"; +import { IAuditorium, IConference, IInterestRoom, IPerson, ITalk, Role } from "../../models/schedule"; +import { FOSDEMSpecificJSONSchedule, FOSDEMPerson, FOSDEMTrack, FOSDEMTalk } from "./jsontypes/FosdemJsonSchedule.schema"; +import * as moment from "moment"; +import { AuditoriumId, InterestId, TalkId } from "../IScheduleBackend"; +import { IConfig } from "../../config"; + +/** + * Loader and holder for FOSDEM-specific JSON schedules, acquired from the + * custom `/p/matrix` endpoint on the Pretalx instance. + */ +export class FosdemJsonScheduleLoader { + public readonly conference: IConference; + public readonly auditoriums: Map; + public readonly talks: Map; + public readonly interestRooms: Map; + public readonly conferenceId: string; + + constructor(jsonDesc: object, globalConfig: IConfig) { + // TODO: Validate and give errors. Assuming it's correct is a bit cheeky. + const jsonSchedule = jsonDesc as FOSDEMSpecificJSONSchedule; + + this.auditoriums = new Map(); + + for (let rawTrack of jsonSchedule.tracks) { + // Tracks are now (since 2025) mapped 1:1 to auditoria + const auditorium = this.convertAuditorium(rawTrack); + if (this.auditoriums.has(auditorium.id)) { + throw `Conflict in auditorium ID «${auditorium.id}»!`; + } + this.auditoriums.set(auditorium.id, auditorium); + } + + this.talks = new Map(); + + for (let rawTalk of jsonSchedule.talks) { + const talk = this.convertTalk(rawTalk); + if (this.talks.has(talk.id)) { + const conflictingTalk = this.talks.get(talk.id)!; + throw `Talk ID ${talk.id} is not unique — occupied by both «${talk.title}» and «${conflictingTalk.title}»!`; + } + const auditorium = this.auditoriums.get(talk.auditoriumId); + if (!auditorium) { + throw `Talk ID ${talk.id} relies on non-existent auditorium ${talk.auditoriumId}`; + } + auditorium.talks.set(talk.id, talk); + this.talks.set(talk.id, talk); + } + + // TODO: Interest rooms are currently not supported by the JSON schedule backend. + this.interestRooms = new Map(); + + this.conference = { + title: globalConfig.conference.name, + auditoriums: Array.from(this.auditoriums.values()), + interestRooms: Array.from(this.interestRooms.values()) + }; + } + + private convertPerson(person: FOSDEMPerson): IPerson { + if (! Object.values(Role).includes(person.event_role)) { + throw new Error("unknown role: " + person.event_role); + } + return { + id: person.person_id.toString(), + name: person.name, + matrix_id: person.matrix_id, + email: person.email, + // safety: checked above + role: person.event_role as Role, + }; + } + + private convertTalk(talk: FOSDEMTalk): ITalk { + const auditoriumId = talk.track.id.toString(); + const startMoment = moment.utc(talk.start_datetime, moment.ISO_8601, true); + const endMoment = startMoment.add(talk.duration, "minutes"); + + return { + id: talk.event_id.toString(), + title: talk.title, + + // Pretalx does not support this concept. FOSDEM 2024 ran with empty strings. From 2025 we hardcode this as empty for now. + subtitle: "", + + auditoriumId, + + // Hardcoded: all talks are now live from FOSDEM 2025 + prerecorded: false, + + // This is sketchy, but the QA start-time is not applicable except to prerecorded talks. + // Even then, it's not clear why it would be different from the end of the talk? + // This is overall a messy concept, but the only thing that matters now is whether this is + // null (Q&A disabled) or non-null (Q&A enabled, with reminder 5 minutes before the end of the talk slot). + // TODO overhaul replace with a boolean instead...? + qa_startTime: 0, + + // Since the talks are not pre-recorded, the livestream is considered ended when the event ends. + livestream_endTime: endMoment.valueOf(), + + speakers: talk.persons.map(person => this.convertPerson(person)), + + // Must .clone() here because .startOf() mutates the moment(!) + dateTs: startMoment.clone().startOf("day").valueOf(), + startTime: startMoment.valueOf(), + endTime: endMoment.valueOf(), + }; + } + + private convertAuditorium(track: FOSDEMTrack): IAuditorium { + return { + id: track.id.toString(), + slug: track.slug, + name: track.name, + kind: RoomKind.Auditorium, + // This will be populated afterwards + talks: new Map(), + // Hardcoded: FOSDEM is always physical now. + isPhysical: true, + }; + } +} diff --git a/src/backends/json/JsonScheduleBackend.ts b/src/backends/json/JsonScheduleBackend.ts index 80f05e60..8c3fd8c8 100644 --- a/src/backends/json/JsonScheduleBackend.ts +++ b/src/backends/json/JsonScheduleBackend.ts @@ -1,4 +1,4 @@ -import { IJsonScheduleBackendConfig } from "../../config"; +import { IConfig, IJsonScheduleBackendConfig, JsonScheduleFormat } from "../../config"; import { IConference, ITalk, IAuditorium, IInterestRoom } from "../../models/schedule"; import { AuditoriumId, InterestId, IScheduleBackend, TalkId } from "../IScheduleBackend"; import { JsonScheduleLoader } from "./JsonScheduleLoader"; @@ -6,9 +6,17 @@ import * as fetch from "node-fetch"; import * as path from "path"; import { LogService } from "matrix-bot-sdk"; import { readJsonFileAsync, writeJsonFileAsync } from "../../utils"; +import { FosdemJsonScheduleLoader } from "./FosdemJsonScheduleLoader"; + +interface ILoader { + conference: IConference; + talks: Map; + auditoriums: Map; + interestRooms: Map; +} export class JsonScheduleBackend implements IScheduleBackend { - constructor(private loader: JsonScheduleLoader, private cfg: IJsonScheduleBackendConfig, private wasFromCache: boolean, public readonly dataPath: string) { + constructor(private loader: ILoader, private cfg: IJsonScheduleBackendConfig, private globalConfig: IConfig, private wasFromCache: boolean, public readonly dataPath: string) { } @@ -16,8 +24,8 @@ export class JsonScheduleBackend implements IScheduleBackend { return this.wasFromCache; } - private static async loadConferenceFromCfg(dataPath: string, cfg: IJsonScheduleBackendConfig, allowUseCache: boolean): Promise<{loader: JsonScheduleLoader, cached: boolean}> { - let jsonDesc; + private static async loadConferenceFromCfg(dataPath: string, cfg: IJsonScheduleBackendConfig, globalConfig: IConfig, allowUseCache: boolean): Promise<{loader: ILoader, cached: boolean}> { + let jsonDesc: any; let cached = false; const cachedSchedulePath = path.join(dataPath, 'cached_schedule.json'); @@ -32,7 +40,12 @@ export class JsonScheduleBackend implements IScheduleBackend { } // Save a cached copy. - await writeJsonFileAsync(cachedSchedulePath, jsonDesc); + try { + await writeJsonFileAsync(cachedSchedulePath, jsonDesc); + } catch (ex) { + // Allow this to fail + LogService.warn("PretalxScheduleBackend", "Failed to cache copy of schedule.", ex); + } } catch (e) { // Fallback to cache — only if allowed if (! allowUseCache) throw e; @@ -56,16 +69,29 @@ export class JsonScheduleBackend implements IScheduleBackend { } } - return {loader: new JsonScheduleLoader(jsonDesc), cached}; + let loader: ILoader; + switch (cfg.scheduleFormat) { + case JsonScheduleFormat.FOSDEM: + loader = new FosdemJsonScheduleLoader(jsonDesc, globalConfig); + break; + case JsonScheduleFormat.Original: + case undefined: + loader = new JsonScheduleLoader(jsonDesc); + break; + default: + throw new Error(`Unknown JSON schedule format: ${cfg.scheduleFormat}`); + } + + return {loader, cached}; } - static async new(dataPath: string, cfg: IJsonScheduleBackendConfig): Promise { - const loader = await JsonScheduleBackend.loadConferenceFromCfg(dataPath, cfg, true); - return new JsonScheduleBackend(loader.loader, cfg, loader.cached, dataPath); + static async new(dataPath: string, cfg: IJsonScheduleBackendConfig, globalConfig: IConfig): Promise { + const loader = await JsonScheduleBackend.loadConferenceFromCfg(dataPath, cfg, globalConfig, true); + return new JsonScheduleBackend(loader.loader, cfg, globalConfig, loader.cached, dataPath); } async refresh(): Promise { - this.loader = (await JsonScheduleBackend.loadConferenceFromCfg(this.dataPath, this.cfg, false)).loader; + this.loader = (await JsonScheduleBackend.loadConferenceFromCfg(this.dataPath, this.cfg, this.globalConfig, false)).loader; // If we managed to load anything, this isn't from the cache anymore. this.wasFromCache = false; } @@ -87,4 +113,4 @@ export class JsonScheduleBackend implements IScheduleBackend { get interestRooms(): Map { return this.loader.interestRooms; } -} \ No newline at end of file +} diff --git a/src/backends/json/jsonschemas/FosdemJsonSchedule.schema.json b/src/backends/json/jsonschemas/FosdemJsonSchedule.schema.json new file mode 100644 index 00000000..0cdecd13 --- /dev/null +++ b/src/backends/json/jsonschemas/FosdemJsonSchedule.schema.json @@ -0,0 +1,140 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://matrix.org/conference-bot/FosdemJsonSchedule.schema.json", + "title": "FOSDEM-Specific JSON Schedule", + "description": "A simple FOSDEM-specific JSON format to describe the schedule for a FOSDEM conference driven by conference-bot.", + + "type": "object", + + "properties": { + "talks": { + "type": "array", + "items": { + "$ref": "#/definitions/talk" + } + }, + "tracks": { + "type": "array", + "items": { + "$ref": "#/definitions/track" + } + } + }, + + "required": [ "talks", "tracks" ], + + "definitions": { + + "track": { + "title": "FOSDEM Track", + "description": "Information about a sequence of talks", + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Stable ID for the track" + }, + "slug": { + "type": "string", + "description": "Stable semi-human-readable slug for the track" + }, + "name": { + "type": "string", + "description": "Human-readable name of the track" + }, + "type": { + "type": "string", + "description": "'devroom' or 'maintrack' (TODO encode this in schema)" + }, + "managers": { + "type": "array", + "description": "List of staff (co-ordinators right now) that apply to the entire track.", + "items": { + "$ref": "#/definitions/person" + } + } + }, + "required": [ "id", "slug", "name", "type", "managers" ] + }, + + + "talk": { + "title": "FOSDEM Talk", + "description": "Information about a scheduled talk", + "type": "object", + "properties": { + "event_id": { + "description": "Unique ID for the talk", + "type": "integer", + "minimum": 0 + }, + "title": { + "type": "string", + "description": "Human-readable name for the talk" + }, + "start_datetime": { + "type": "string", + "description": "Date and time, in RFC3339 format with Z timezone offset, of the start of the talk", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "duration": { + "type": "number", + "description": "Duration of the talk, in minutes" + }, + "persons": { + "type": "array", + "items": { + "$ref": "#/definitions/person" + } + }, + "track": { + "description": "Information about what track the talk is in. N.B. In practice more fields are contained here but only ID is used.", + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The Track ID of the track that the talk is in" + } + }, + "required": ["id"] + }, + "conference_room": { + "type": "string", + "description": "Name of the physical (in real life) room that the talk is held in." + } + }, + "required": [ "event_id", "title", "start_datetime", "duration", "persons", "track", "conference_room" ] + }, + + + "person": { + "title": "FOSDEM Person", + "description": "Information about someone who is giving a talk or is assisting with co-ordination", + "type": "object", + "properties": { + "person_id": { + "type": "number", + "description": "ID of the person" + }, + "event_role": { + "type": "string", + "description": "What kind of role the person has for this talk (speaker/coordinator)" + }, + "name": { + "type": "string", + "description": "The name of the person" + }, + "email": { + "type": "string", + "description": "The e-mail address of the person. May be an empty string if not available." + }, + "matrix_id": { + "type": "string", + "description": "The Matrix User ID of the person. May be an empty string if not available. Has historically not been validated thoroughly." + } + }, + "required": [ "person_id", "event_role", "name", "email", "matrix_id" ] + } + + } +} diff --git a/src/backends/json/jsontypes/FosdemJsonSchedule.schema.d.ts b/src/backends/json/jsontypes/FosdemJsonSchedule.schema.d.ts new file mode 100644 index 00000000..f3e23bc7 --- /dev/null +++ b/src/backends/json/jsontypes/FosdemJsonSchedule.schema.d.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * A simple FOSDEM-specific JSON format to describe the schedule for a FOSDEM conference driven by conference-bot. + */ +export interface FOSDEMSpecificJSONSchedule { + talks: FOSDEMTalk[]; + tracks: FOSDEMTrack[]; + [k: string]: unknown; +} +/** + * Information about a scheduled talk + */ +export interface FOSDEMTalk { + /** + * Unique ID for the talk + */ + event_id: number; + /** + * Human-readable name for the talk + */ + title: string; + /** + * Date and time, in RFC3339 format with Z timezone offset, of the start of the talk + */ + start_datetime: string; + /** + * Duration of the talk, in minutes + */ + duration: number; + persons: FOSDEMPerson[]; + /** + * Information about what track the talk is in. N.B. In practice more fields are contained here but only ID is used. + */ + track: { + /** + * The Track ID of the track that the talk is in + */ + id: number; + [k: string]: unknown; + }; + /** + * Name of the physical (in real life) room that the talk is held in. + */ + conference_room: string; + [k: string]: unknown; +} +/** + * Information about someone who is giving a talk or is assisting with co-ordination + */ +export interface FOSDEMPerson { + /** + * ID of the person + */ + person_id: number; + /** + * What kind of role the person has for this talk (speaker/coordinator) + */ + event_role: string; + /** + * The name of the person + */ + name: string; + /** + * The e-mail address of the person. May be an empty string if not available. + */ + email: string; + /** + * The Matrix User ID of the person. May be an empty string if not available. Has historically not been validated thoroughly. + */ + matrix_id: string; + [k: string]: unknown; +} +/** + * Information about a sequence of talks + */ +export interface FOSDEMTrack { + /** + * Stable ID for the track + */ + id: number; + /** + * Stable semi-human-readable slug for the track + */ + slug: string; + /** + * Human-readable name of the track + */ + name: string; + /** + * 'devroom' or 'maintrack' (TODO encode this in schema) + */ + type: string; + /** + * List of staff (co-ordinators right now) that apply to the entire track. + */ + managers: FOSDEMPerson[]; + [k: string]: unknown; +} diff --git a/src/config.ts b/src/config.ts index f3d43548..27e93a8a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -109,12 +109,30 @@ export interface IPrefixConfig { }; } +export enum JsonScheduleFormat { + /** + * Our original JSON schedule format. + */ + Original = "original", + + /** + * The FOSDEM-specific schedule format, available on the `/p/matrix` endpoint. + */ + FOSDEM = "fosdem", +} + export interface IJsonScheduleBackendConfig { backend: "json"; /** * Path or HTTP(S) URL to schedule. */ scheduleDefinition: string; + + /** + * What JSON schedule format to use. + * Defaults to original. + */ + scheduleFormat?: JsonScheduleFormat; } export enum PretalxScheduleFormat { diff --git a/src/index.ts b/src/index.ts index 3fb94e8f..30cd07dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,7 +72,7 @@ export class ConferenceBot { case "pretalx": return await PretalxScheduleBackend.new(config.dataPath, config.conference.schedule, config.conference.prefixes); case "json": - return await JsonScheduleBackend.new(config.dataPath, config.conference.schedule); + return await JsonScheduleBackend.new(config.dataPath, config.conference.schedule, config); default: throw new Error(`Unknown scheduling backend: choose penta, pretalx or json!`) }