plugins-evert/calendar/plugin.ts

322 lines
8.5 KiB
TypeScript

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.substring(0, 64) + '...';
}
let description = eventData.description;
if (description.length > 120) {
description = description.substring(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<void> {
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<boolean> => {
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<boolean> => {
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: '<event>',
description: 'Show details of an event',
}
];
cmd.registerCommand(cmds);
}
loadConfig(): void {
for (const cfg of this.config.get<CalendarConfiguration[]>('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;