Skip to content

Commit 767fe06

Browse files
committedApr 12, 2024
First draft at Recurse.com calendar integration
1 parent a0fa425 commit 767fe06

6 files changed

+281
-32
lines changed
 

‎README.md

+4
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ CREATE TABLE user_session (
4949

5050
Go to https://recurse.rctogether.com/apps and make a new application, then plug in your App ID and App secret into `ACTION_CABLE_APP_ID` and `ACTION_CABLE_APP_SECRET` in your `config.env`.
5151

52+
## Recurse.com Calendar
53+
54+
The calendar integration downloads the iCalendar export (`.ics`) from the Recurse.com calendar application. Go to [recurse.com/settings/calendar](https://www.recurse.com/settings/calendar). In the `Subscription URL` field, you'll find a URL. That URL will have a query parameter called `token`. Paste the value of that token (everything _after_ `token=`, not including those characters) into the `RECURSE_CALENDAR_TOKEN` value in your `config.env`.
55+
5256
## Super Secret Auth Bypass Token
5357

5458
In `config.env.template` you will see `SPECIAL_SECRET_AUTH_TOKEN_DONT_SHARE`. This is used to bypass authentication for the special case where a kiosk like RCTV needs a hard-coded authentication instead of RC OAuth. This should only be used for local testing or in the case of a kiosk, it should never be stored in plain text or be visible to anyone in plain text. RCTV will hide this specific URL to hide this token. `crypto.randomUUID()` is a good way to make a new UUID for this purpose. Maybe prepend a signifier like `rctv-` to the UUID produced, so if it gets leaked you have some inkling where its from.

‎config.env.template

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ ACTION_CABLE_APP_ID=<your action cable app ID>
55
ACTION_CABLE_APP_SECRET=<your action cable app secret>
66
SPECIAL_SECRET_AUTH_TOKEN_DONT_SHARE=<your special secret auth token (do not share it)>
77
MIXPANEL_TOKEN=<your mixpanel token here>
8+
RECURSE_CALENDAR_TOKEN=<your recurse.com/calendar token here>

‎html.js

+20-2
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ ${
199199
};
200200

201201
export const Room = ({
202-
roomHref,
202+
roomLocation,
203203
roomName,
204204
isEmpty,
205205
participants,
@@ -208,6 +208,14 @@ export const Room = ({
208208
noteDateTime,
209209
noteHowManyMinutesAgo,
210210
countPhrase,
211+
hasNowEvent,
212+
nowEventName,
213+
nowEventStartedHowManyMinutesAgo,
214+
nowEventCalendarUrl,
215+
hasNextEvent,
216+
nextEventName,
217+
nextEventStartsInHowLong,
218+
nextEventCalendarUrl,
211219
}) => `
212220
<div id="room-update-${roomName.replaceAll(
213221
" ",
@@ -219,7 +227,7 @@ export const Room = ({
219227
<span class="room__title">${roomName}</span>
220228
<a
221229
class="room__join"
222-
href="${roomHref}"
230+
href="${roomLocation}"
223231
target="_blank"
224232
rel="noopener noreferrer"
225233
>
@@ -230,6 +238,16 @@ export const Room = ({
230238
</dt>
231239
<dd class="room__details">
232240
${Participants({ participants })}
241+
${
242+
hasNowEvent
243+
? `<div><a href="${nowEventCalendarUrl}" target="_blank">${nowEventName}</a> started ${nowEventStartedHowManyMinutesAgo}</div> `
244+
: ""
245+
}
246+
${
247+
hasNextEvent
248+
? `<div><a href="${nextEventCalendarUrl}" target="_blank">${nextEventName}</a> starts ${nextEventStartsInHowLong}</div> `
249+
: ""
250+
}
233251
${Note({
234252
roomName,
235253
hasNote,

‎index.js

+129-25
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
escapeHtml,
2727
} from "./html.js";
2828
import expressWebsockets from "express-ws";
29-
29+
import ical from "node-ical";
3030
import fs from "node:fs";
3131

3232
// Catch and snuff all uncaught exceptions and uncaught promise rejections.
@@ -60,79 +60,79 @@ expressWebsockets(app);
6060

6161
const zoomRooms = [
6262
{
63-
href: "https://recurse.com/zoom/aegis",
63+
location: "https://www.recurse.com/zoom/aegis",
6464
roomName: "Aegis",
6565
},
6666
{
67-
href: "https://recurse.com/zoom/arca",
67+
location: "https://www.recurse.com/zoom/arca",
6868
roomName: "Arca",
6969
},
7070
{
71-
href: "https://recurse.com/zoom/edos",
71+
location: "https://www.recurse.com/zoom/edos",
7272
roomName: "Edos",
7373
},
7474
{
75-
href: "https://recurse.com/zoom/genera",
75+
location: "https://www.recurse.com/zoom/genera",
7676
roomName: "Genera",
7777
},
7878
{
79-
href: "https://recurse.com/zoom/midori",
79+
location: "https://www.recurse.com/zoom/midori",
8080
roomName: "Midori",
8181
},
8282
{
83-
href: "https://recurse.com/zoom/verve",
83+
location: "https://www.recurse.com/zoom/verve",
8484
roomName: "Verve",
8585
},
8686
{
87-
href: "https://recurse.com/zoom/couches",
87+
location: "https://www.recurse.com/zoom/couches",
8888
roomName: "Couches",
8989
},
9090
{
91-
href: "https://recurse.com/zoom/kitchen",
91+
location: "https://www.recurse.com/zoom/kitchen",
9292
roomName: "Kitchen",
9393
},
9494
{
95-
href: "https://recurse.com/zoom/pairing_station_1",
95+
location: "https://www.recurse.com/zoom/pairing_station_1",
9696
roomName: "Pairing Station 1",
9797
},
9898
{
99-
href: "https://recurse.com/zoom/pairing_station_2",
99+
location: "https://www.recurse.com/zoom/pairing_station_2",
100100
roomName: "Pairing Station 2",
101101
},
102102
{
103-
href: "https://recurse.com/zoom/pairing_station_3",
103+
location: "https://www.recurse.com/zoom/pairing_station_3",
104104
roomName: "Pairing Station 3",
105105
},
106106
{
107-
href: "https://recurse.com/zoom/pairing_station_4",
107+
location: "https://www.recurse.com/zoom/pairing_station_4",
108108
roomName: "Pairing Station 4",
109109
},
110110
{
111-
href: "https://recurse.com/zoom/pairing_station_5",
111+
location: "https://www.recurse.com/zoom/pairing_station_5",
112112
roomName: "Pairing Station 5",
113113
},
114114
{
115-
href: "https://recurse.rctogether.com/zoom_meetings/35980/join",
115+
location: "https://recurse.rctogether.com/zoom_meetings/35980/join",
116116
roomName: "Pairing Station 6",
117117
},
118118
{
119-
href: "https://recurse.rctogether.com/zoom_meetings/35983/join",
119+
location: "https://recurse.rctogether.com/zoom_meetings/35983/join",
120120
roomName: "Pairing Station 7",
121121
},
122122
{
123-
href: "https://recurse.com/zoom/pomodoro_room",
123+
location: "https://www.recurse.com/zoom/pomodoro_room",
124124
roomName: "Pomodoro Room",
125125
},
126126
{
127-
href: "https://recurse.com/zoom/presentation_space",
127+
location: "https://www.recurse.com/zoom/presentation_space",
128128
roomName: "Presentation Space",
129129
},
130130
{
131-
href: "https://recurse.com/zoom/faculty_area",
131+
location: "https://www.recurse.com/zoom/faculty_area",
132132
roomName: "Faculty Area",
133133
},
134134
{
135-
href: "https://recurse.com/zoom/faculty_lounge",
135+
location: "https://www.recurse.com/zoom/faculty_lounge",
136136
roomName: "Faculty Lounge",
137137
},
138138
];
@@ -142,6 +142,10 @@ const zoomRoomsByName = {};
142142
zoomRooms.forEach(({ roomName, ...rest }) => {
143143
zoomRoomsByName[roomName] = { roomName, ...rest };
144144
});
145+
const zoomRoomsByLocation = {};
146+
zoomRooms.forEach(({ location, ...rest }) => {
147+
zoomRoomsByLocation[location] = { location, ...rest };
148+
});
145149

146150
// Zoom Rooms that are reported but that we purposely don't track
147151
const silentZoomRooms = [
@@ -196,6 +200,9 @@ const secretAuthToken = process.env.SPECIAL_SECRET_AUTH_TOKEN_DONT_SHARE;
196200
// Mixpanel
197201
const mixpanelToken = process.env.MIXPANEL_TOKEN;
198202

203+
// Recurse.com Calendar
204+
const recurseCalendarToken = process.env.RECURSE_CALENDAR_TOKEN;
205+
199206
let inTheHubParticipantNames = [];
200207
let roomNameToParticipantNames = {};
201208
let participantNameToEntity = {};
@@ -612,10 +619,11 @@ app.ws("/websocket", async function (ws, req) {
612619
Room(
613620
mungeRoom({
614621
roomName,
615-
roomHref: zoomRoomsByName[roomName].href,
622+
roomLocation: zoomRoomsByName[roomName].location,
616623
roomNameToNote,
617624
roomNameToParticipantNames,
618625
participantNameToEntity,
626+
locationToNowAndNextEvents,
619627
}),
620628
),
621629
);
@@ -705,6 +713,60 @@ function cleanNotes() {
705713
}
706714
cleanNotes();
707715

716+
let locationToNowAndNextEvents = {};
717+
async function updateCalendar() {
718+
const now = new Date();
719+
const tomorrow = new Date();
720+
tomorrow.setTime(tomorrow.getTime() + 1000 * 60 * 60 * 24);
721+
const yesterday = new Date();
722+
yesterday.setTime(yesterday.getTime() - 1000 * 60 * 60 * 24);
723+
const soonish = new Date();
724+
soonish.setTime(soonish.getTime() + 1000 * 60 * 80); // 80 minutes
725+
const ics = await ical.async.fromURL(
726+
`https://www.recurse.com/calendar/events.ics?token=${recurseCalendarToken}&omit_cancelled_events=1&scope=all`,
727+
);
728+
729+
const locationToEvents = {};
730+
Object.entries(ics).forEach(([_, event]) => {
731+
const { location, start, end } = event;
732+
let keep = true;
733+
keep &&= event.type === "VEVENT";
734+
keep &&= location in zoomRoomsByLocation;
735+
keep &&= start >= yesterday; // Starts less than 24 hours ago
736+
keep &&= end <= tomorrow; // Ends less than 24 hours from now
737+
keep &&= now <= end; // Hasn't ended yet
738+
if (!keep) return;
739+
740+
if (!locationToEvents[location]) {
741+
locationToEvents[location] = [];
742+
}
743+
locationToEvents[location].push(event);
744+
});
745+
746+
locationToNowAndNextEvents = {};
747+
Object.entries(locationToEvents).forEach(([location, events]) => {
748+
events.sort((a, b) => a.start - b.start);
749+
750+
locationToNowAndNextEvents[location] = {
751+
now: [],
752+
next: [],
753+
};
754+
755+
events.forEach((event) => {
756+
const { start } = event;
757+
758+
if (start <= now) {
759+
locationToNowAndNextEvents[location].now.push(event);
760+
} else if (start <= soonish) {
761+
locationToNowAndNextEvents[location].next.push(event);
762+
}
763+
});
764+
});
765+
766+
setTimeout(updateCalendar, 1000 * 60 * 10);
767+
}
768+
updateCalendar();
769+
708770
// Currently unused, adds a text field to submit a note when you check in
709771
app.get(
710772
"/checkIntoHub.html",
@@ -844,10 +906,11 @@ const mungeRootBody = ({
844906
const rooms = zoomRooms.map(({ roomName }) =>
845907
mungeRoom({
846908
roomName,
847-
roomHref: zoomRoomsByName[roomName].href,
909+
roomLocation: zoomRoomsByName[roomName].location,
848910
roomNameToNote,
849911
roomNameToParticipantNames,
850912
participantNameToEntity,
913+
locationToNowAndNextEvents,
851914
}),
852915
);
853916

@@ -908,14 +971,15 @@ const mungeCustomization = ({
908971

909972
const mungeRoom = ({
910973
roomName,
911-
roomHref,
974+
roomLocation,
912975
roomNameToNote,
913976
roomNameToParticipantNames,
914977
participantNameToEntity,
978+
locationToNowAndNextEvents,
915979
}) => {
916980
return {
917981
roomName,
918-
roomHref,
982+
roomLocation,
919983
hasNote: Boolean(roomNameToNote[roomName]),
920984
noteContent: roomNameToNote[roomName]?.content ?? "",
921985
noteDateTime: roomNameToNote[roomName]?.date?.toISOString() ?? null,
@@ -930,6 +994,20 @@ const mungeRoom = ({
930994
participantNameToEntity[participantName]?.faceMarkerImagePath ??
931995
"recurse-community-bot.png",
932996
})) ?? [],
997+
hasNowEvent: locationToNowAndNextEvents[roomLocation]?.now?.[0],
998+
nowEventName: locationToNowAndNextEvents[roomLocation]?.now?.[0]?.summary,
999+
nowEventStartedHowManyMinutesAgo: howManyMinutesAgo(
1000+
locationToNowAndNextEvents[roomLocation]?.now?.[0]?.start,
1001+
),
1002+
nowEventCalendarUrl:
1003+
locationToNowAndNextEvents[roomLocation]?.now?.[0]?.url,
1004+
hasNextEvent: locationToNowAndNextEvents[roomLocation]?.next?.[0],
1005+
nextEventName: locationToNowAndNextEvents[roomLocation]?.next?.[0]?.summary,
1006+
nextEventStartsInHowLong: howLongInTheFuture(
1007+
locationToNowAndNextEvents[roomLocation]?.next?.[0]?.start,
1008+
),
1009+
nextEventCalendarUrl:
1010+
locationToNowAndNextEvents[roomLocation]?.next?.[0]?.url,
9331011
};
9341012
};
9351013

@@ -1103,11 +1181,37 @@ const howManyMinutesAgo = (date) => {
11031181
? "a half hour ago"
11041182
: difference < 60 * MIN
11051183
? "45 min ago"
1106-
: difference < 60 * MIN
1184+
: difference < 80 * MIN
11071185
? "over an hour ago"
11081186
: "a while ago";
11091187
};
11101188

1189+
const howLongInTheFuture = (date) => {
1190+
if (!date) return null;
1191+
const millisNow = Date.now();
1192+
const millisThen = date.getTime();
1193+
const difference = millisThen - millisNow;
1194+
return difference < 0
1195+
? "in the past?" // ???
1196+
: difference < 2 * MIN
1197+
? "now"
1198+
: difference < 5 * MIN
1199+
? "in a few minutes"
1200+
: difference < 10 * MIN
1201+
? "in five-ish minutes"
1202+
: difference < 20 * MIN
1203+
? "in 15 minutes"
1204+
: difference < 30 * MIN
1205+
? "in 20 minutes"
1206+
: difference < 45 * MIN
1207+
? "in a half hour"
1208+
: difference < 60 * MIN
1209+
? "in 45 min"
1210+
: difference < 80 * MIN
1211+
? "in just over an hour"
1212+
: "in quite a while";
1213+
};
1214+
11111215
const countPhrase = (count) => {
11121216
return count === 0 ? "empty" : count === 1 ? "1 person" : `${count} people`;
11131217
};

‎package-lock.json

+125-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "lucia-auth-getting-started-recurse-auth-with-express",
2+
"name": "rcverse",
33
"version": "1.0.0",
44
"description": "",
55
"main": "index.js",
@@ -18,6 +18,7 @@
1818
"express": "^4.18.3",
1919
"express-ws": "^5.0.2",
2020
"lucia": "^3.0.1",
21+
"node-ical": "^0.18.0",
2122
"oslo": "^1.1.3",
2223
"pg": "^8.11.3"
2324
}

0 commit comments

Comments
 (0)
Please sign in to comment.