import { Plugin, Configurable, EventListener, DependencyLoad } from '@squeebot/core/lib/plugin'; import { async as ical } from 'node-ical'; import { logger } from '@squeebot/core/lib/core'; import { Formatter, IMessage, MessageResolver } from '@squeebot/core/lib/types'; import { fullIDMatcher, readableTime } from '@squeebot/core/lib/common'; interface CalendarConfiguration { name: string; url: string; timeFrame: number; overrideDescription?: string; rooms: string[]; } interface Event { id: string; name: string; start: number; end: number; description: string; from: CalendarConfiguration; } const loaded: CalendarConfiguration[] = []; let memcache: Event[] = []; function nts(): number { return Math.floor(Date.now() / 1000); } function utcNoHours(start: number): Date { const fts = new Date(start); return new Date(Date.UTC(fts.getFullYear(), fts.getMonth(), fts.getDate())); } function eventsFor(msg: IMessage): Event[] { return memcache.filter((ev: Event) => { if (!msg.fullRoomID) { return false; } if (!ev.from.rooms.length) { return true; } let matchesOne = false; for (const room of ev.from.rooms) { if (fullIDMatcher(msg.fullRoomID, room)) { matchesOne = true; } } return matchesOne; }); } function sortStartTime(a: Event, b: Event): number { return a.start - b.end; } function colorize(events: Event[], format: Formatter): string[] { const colorized: string[] = []; for (const event of events) { let color = 'gold'; if (event.start <= nts() && event.end > nts()) { color = 'green'; } else if (event.end < nts()) { color = 'red'; } colorized.push(format.color(color, event.name)); } return colorized; } function tellEvent(eventData: Event, msg: IMessage, countdown = true): void { if (!eventData) { return; } const keys = []; let startColor = 'gold'; let tstamp = ''; if (eventData.start > nts()) { const timeLeftStamp = countdown ? 'in ' + readableTime(eventData.start - nts()) : new Date(eventData.start * 1000); tstamp = 'starts ' + timeLeftStamp + '.'; } else if (eventData.start <= nts() && eventData.end > nts()) { const timeLeftStamp = countdown ? 'in ' + readableTime(eventData.end - nts()) : new Date(eventData.end * 1000); startColor = 'green'; tstamp = 'ends ' + timeLeftStamp + '.'; } else { startColor = 'red'; tstamp = 'is over :('; } let name = eventData.name; if (name.length > 64) { name = name.substr(0, 64) + '...'; } let description = eventData.description; if (description.length > 120) { description = description.substr(0, 120) + '...'; } keys.push(['field', 'Event', { type: 'title', color: 'green' }]); keys.push(['field', name, { color: startColor }]); keys.push(['bold', tstamp]); keys.push(['field', description || '', { type: 'content' }]); msg.resolve(keys); } async function fetchCalendars(): Promise { memcache = []; for (const cfg of loaded) { let result; let events = 0; try { result = await ical.fromURL(cfg.url); } catch (e: any) { logger.error('Calendar %s fetch failed:', cfg.name, e.stack); } for (const key in result) { const data = result[key]; if (!data.type || data.type !== 'VEVENT') { continue; } const start = Math.floor(new Date(data.start).getTime() / 1000); const end = Math.floor(new Date(data.end).getTime() / 1000); // Recurring events handling if ('rrule' in data) { const rrule = (data as any).rrule as any; const recurring = rrule.between(utcNoHours(Date.now()), utcNoHours(Date.now() + cfg.timeFrame * 1000), true); const originalDuration = end - start; for (const date of recurring) { const newStart = Math.floor(new Date(date).getTime() / 1000); const newEnd = newStart + originalDuration; if (newStart > nts() + cfg.timeFrame || newEnd < nts()) { continue; } memcache.push({ id: key, name: (data as any).summary, start: newStart, end: newEnd, from: cfg, description: (data as any).description || '', }); events++; } continue; } // Skip events that are over and start beyond the time frame if (start > nts() + cfg.timeFrame || end < nts()) { continue; } memcache.push({ id: key, name: data.summary, start, end, from: cfg, description: data.description || '', }); events++; } logger.log('[calendar] Fetched %d events from %s.', events, cfg.name); } } @Configurable({ calendars: [], updateInterval: '30 * * * *', }) class CalendarPlugin extends Plugin { @EventListener('pluginUnload') public unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { this.emit('pluginUnloaded', this); } } @DependencyLoad('simplecommands') addCalendarCommands(cmd: any): void { const cmds = [ { name: 'events', plugin: this.name, execute: async (msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[]): Promise => { if (simplified[0] && simplified[0] === 'refresh') { await fetchCalendars(); } let events = eventsFor(msg); if (!events.length) { msg.resolve('Events in %s: None currently scheduled.', msg.target?.name); return true; } events = events.filter((ev: Event) => ev.start < nts() + ev.from.timeFrame && ev.end > nts()) .slice(0, 8); if (!events.length) { msg.resolve('Events in %s: None currently scheduled.', msg.target?.name); return true; } events = events.sort(sortStartTime); msg.resolve('Events in %s:', msg.target?.name, colorize(events, msg.source.format).join(', ')); return true; }, description: 'Show a list of upcoming and ongoing events in the current room', }, { name: 'event', plugin: this.name, execute: async (msg: IMessage, msr: MessageResolver, spec: any, prefix: string, ...simplified: any[]): Promise => { let evt = simplified[0]; let countdown = true; let slice = 0; if (evt && evt === '-d' || evt === 'get-date' || evt === 'get-time') { countdown = false; evt = simplified[1]; slice = 1; } if (!evt) { msg.resolve('Please specify an event!'); return true; } const query = simplified.slice(slice).join(' '); let events = eventsFor(msg); if (!events.length) { msg.resolve('There are no events in %s.', msg.target?.name); return true; } events = events.filter((ev: Event) => { if (ev.start > nts() + ev.from.timeFrame || ev.end < nts()) { return false; } return ev.name.toLowerCase().match(query.toLowerCase()) != null; }).slice(0, 8); if (!events.length) { msg.resolve('No events match "%s".', query); return true; } else if (events.length > 1) { msg.resolve('Multiple events match "%s"! Here\'s the first one:', query); events = events.sort(sortStartTime); } tellEvent(events[0], msg, countdown); return true; }, usage: '', description: 'Show details of an event', } ]; cmd.registerCommand(cmds); } loadConfig(): void { for (const cfg of this.config.get('calendars', [])) { if (!cfg.name || !cfg.url) { logger.error('[%s] Invalid calendar configuration.', this.name); continue; } loaded.push(cfg); } } @DependencyLoad('cron') public initializeCron(cronPlugin: any): void { const expression = this.config.get('updateInterval', '30 * * * *'); cronPlugin.registerTimer( this, expression, () => fetchCalendars().catch( (error) => logger.error('[calendar] fetch error:', error.stack), ) ); } initialize(): void { this.loadConfig(); fetchCalendars().catch( (e) => logger.error('[%s] fetch error:', this.name, e.stack)); } } module.exports = CalendarPlugin;