322 lines
8.5 KiB
TypeScript
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.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<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('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;
|