This repository has been archived on 2025-06-19. You can view files and clone it, but cannot push or open issues or pull requests.
Files
Timetable-V2-Notify/index.js

365 lines
12 KiB
JavaScript

import * as sdk from "matrix-js-sdk";
import fetch from "node-fetch";
import process from "process";
import fs from "fs";
import "dotenv/config";
import { JsonDB, Config } from "node-json-db";
import { TimetableClient } from "./timetable.js";
const authPassword = process.env.AUTH_PASSWORD;
const db = new JsonDB(new Config("data/data", true, true, "/"));
const timetable = new TimetableClient(
process.env.TIMETABLE_ENDPOINT,
process.env.TIMETABLE_TOKEN
);
await db.push("/rooms", {}, false);
// Login
const userId = process.env.MATRIX_USER;
const client = sdk.createClient({
baseUrl: process.env.MATRIX_URL,
accessToken: process.env.MATRIX_TOKEN,
userId: userId,
fetchFn: fetch,
});
const displayName = process.env.MATRIX_DISPLAYNAME || "Timetable V2";
(async () => {
const profileInfo = await client.getProfileInfo(userId);
if (profileInfo.displayname != displayName) {
await client.setProfileInfo("displayname", {
displayname: displayName,
});
const file = fs.readFileSync("./images/profile-picture.png");
const content = await client.uploadContent(file, {
name: "profile-picture.png",
type: "image/png",
});
await client.setProfileInfo("avatar_url", {
avatar_url: content.content_uri,
});
}
})();
client.once("sync", function (state) {
if (state === "PREPARED") {
console.log("sync finished");
}
});
// Auto-join invited rooms
client.on("RoomMember.membership", function (_, member) {
if (member.membership === "invite" && member.userId === userId) {
client
.joinRoom(member.roomId)
.then(function () {
console.log("Auto-joined %s", member.roomId);
})
.catch(() => {
client.leave(member.roomId);
});
}
});
function formatUpdateMessage(update) {
let message = "";
if (update.type == "addition") message += "🆕";
else if (update.type == "deletion") message += "🗑";
else if (update.type == "change") message += "✏";
if (update.type == "addition" || update.type == "deletion") {
message += " ";
if (update.change.type == "cancellation") message += "🚫";
else message += "📑";
}
message += ` <b>${update.lesson}th Lesson</b><br><ul>\n`;
if (update.type == "addition" || update.type == "deletion") {
message += `\n<li><b>Subject:</b> ${
update.change.change.subject || "Unknown"
}`;
if (update.change.change.teacher) {
message += `</li>\n<li><b>Teacher:</b> <del>${
update.change.teacher || "Unknown"
}</del> ${update.change.change.teacher}`;
} else {
message += `</li>\n<li><b>Teacher:</b> ${
update.change.teacher || "Unknown"
}`;
}
message += `</li>\n<li><b>Room:</b> ${
update.change.change.room || "Unknown"
}`;
if (update.change.notes) {
message += `</li>\n<li><b>Notes:</b></li>\n${update.change.notes}`;
}
} else {
for (const change of Object.keys(update.change)) {
const changeValue = update.change[change];
message += `<br><li>\n${
change.charAt(0).toUpperCase() + change.slice(1)
}: <del>${changeValue.before}</del> ${changeValue.after}</li>`;
}
}
return message;
}
// Check for timetable updates
setInterval(async () => {
const history = await timetable.getUpdates();
const lastCheck = await db.getObjectDefault("/lastTimetableUpdate", 0);
const updates = history.filter((entry) => entry.updatedAt > lastCheck);
await db.push("/lastTimetableUpdate", new Date().getTime());
const rooms = await db.getData("/rooms");
for (const update of updates) {
for (const roomId of Object.keys(rooms)) {
const room = rooms[roomId];
if (!update.class.includes(room.filter)) continue;
console.log(update);
let message = formatUpdateMessage(update);
client.sendHtmlMessage(roomId, plainText(message), message);
}
}
}, process.env.TIMETABLE_INTERVAL || 1000 * 60);
// Handle events
client.on("Room.timeline", async function (event, room) {
if (event.getLocalAge() > 5000) return;
if (event.event.sender == userId) return;
const senderPowerLevel = client
.getRoom(room.roomId)
.getMember(event.event.sender).powerLevel;
if (senderPowerLevel <= 40) {
if (
event.getType() == "m.room.message" &&
event.event.content.body == "help"
) {
client.sendTextMessage(
room.roomId,
"⛔ You need at least powerlevel 40 moderate this bot"
);
}
return;
}
if (
!(await db.getObjectDefault(`/authStatus/${room.roomId}`, false)) &&
authPassword
) {
if (event.getType() !== "m.room.message") return;
if (!event.event.content.body.startsWith("login")) {
const response =
"🔐 Not authenticated! Use <code>login [password]</code> to login";
client.sendHtmlMessage(room.roomId, plainText(response), response);
return;
}
if (event.event.content.body.split("login ")[1] == authPassword) {
await db.push(`/authStatus/${room.roomId}`, true);
client.sendTextMessage(room.roomId, "🔑 This room is now authenticated");
client
.redactEvent(room.roomId, event.event.event_id, undefined, {
reason: "Redacted login password",
})
.catch((e) => {
console.warn("Could not redact password in " + room.roomId);
});
return;
} else {
client.sendTextMessage(room.roomId, "❌ Invalid password");
return;
}
}
try {
if (event.getType() === "m.room.message") {
await handleMessage(event, room.roomId);
} else if (
event.getType() === "m.reaction" ||
event.getType() === "m.room.redaction"
) {
await handleReaction(event, room.roomId);
}
} catch (e) {
client.sendTextMessage(
room.roomId,
"⛔ Your command failed executing! Please try again"
);
}
});
function plainText(message) {
return message.replace("<li>", "- ").replace(/<[^>]+>/g, "");
}
function sendReaction(room, toEvent, text) {
client.sendEvent(room, "m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: toEvent,
key: text,
},
});
}
async function handleMessage(event, room) {
const body = event.event.content.body;
const sender = event.event.sender;
const userState = await db.getObjectDefault(`/rooms/${room}/state`);
if (!userState) {
if (body == "help") {
// Send help message
const helpMessage = `<b>Available commands</b>:<ul>
<li> <code>help</code>: List available commands </li>
<li> <code>info</code>: Get information about the current room's configuration </li>
<li> <code>filter [class (eg. 10c)]</code>: Set the class to filter for. Use only filter available in the timetable v2 app.</li>
<li> <code>timetable</code>: Set your timetable</li>
<li> <code>groups</code>: Configure your timetable groups <i>[Not implemented yet]</i></li>
<li> <code>reset</code>: Reset the configuration for this room</li>
<li> <code>logout</code>: Logout and reset the configuration for room</li>
`;
client.sendHtmlMessage(room, plainText(helpMessage), helpMessage);
} else if (body == "info") {
// Send room information
const roomData = await db.getObjectDefault(`/rooms/${room}`);
if (!roomData) {
client.sendTextMessage(
room,
"⛔ This room has no configuration data yet"
);
return;
}
const response = `<b>Current Room Configuration</b>:
<br>Filter: <code>${roomData.filter || "None"}</code>
<br>Timetable ID: <code>${roomData.timetable || "Not set"}</code>
<br>Timetable Groups: <code>${(roomData.groups || []).join(", ")}</code>`;
client.sendHtmlMessage(room, plainText(response), response);
} else if (body.startsWith("filter ")) {
// Set filtering
const filter = body.split(" ")[1].toLowerCase();
const response = `👍 Now filtering for <code>${filter}</code>`;
await db.push(`/rooms/${room}/filter`, filter);
client.sendHtmlMessage(room, plainText(response), response);
} else if (body == "timetable") {
// Timetable
if (!(await db.getObjectDefault(`/rooms/${room}/filter`))) {
client.sendTextMessage(room, "⛔ You need to configure a filter first");
return;
}
// Query timetables
const timetables = await timetable.getTimetables(
await db.getData(`/rooms/${room}/filter`)
);
await db.push(`/rooms/${room}/state`, "timetable");
let response = `🛠 Which timetable do you want to use?<ul>\n`;
// Build response message
for (const timetable of timetables.timetables) {
response += `<li><code>${timetable.id}</code>: ${timetable.title}</li>\n`;
}
client.sendHtmlMessage(room, plainText(response), response);
} else if (body == "groups") {
if (!(await db.getObjectDefault(`/rooms/${room}/timetable`))) {
client.sendTextMessage(room, "⛔ You need to select a timetable first");
return;
}
// Timetable groups
await db.push(`/rooms/${room}/state`, "groups");
await db.push(`/rooms/${room}/stateData/sender`, event.sender.userId);
const groups = await timetable.getGroups(
await db.getData(`/rooms/${room}/filter`),
await db.getData(`/rooms/${room}/timetable`)
);
let response = `🛠 Set your timetable groups by reacting with the correct group. React with ✅ to confirm your selection.`;
response +=
"<br>\n<b>Warning:</b> Timetable group support is WIP. You can configure your timetable groups, but they will not be taken into account while processing updates just yet. ";
const message = await client.sendHtmlMessage(
room,
plainText(response),
response
);
for (const group of groups) {
sendReaction(room, message.event_id, group);
}
sendReaction(room, message.event_id, "✅");
} else if (body == "reset") {
// Reset room
await db.delete(`/rooms/${room}`);
const response = "⚠ The configuration for this room was reset!";
client.sendHtmlMessage(room, plainText(response), response);
} else if (body == "logout") {
await db.delete(`/rooms/${room}`);
await db.delete(`/authStatus/${room}`);
const response = "🔐 This room was logged out!";
client.sendHtmlMessage(room, plainText(response), response);
} else {
const response =
"Unknown command! Type <code>help</code> for a list of valid commands";
client.sendHtmlMessage(room, plainText(response), response);
}
} else {
// If user is in setup mode
if (body == "abort") {
await db.delete(`/rooms/${room}/state`);
const response = `❌ Aborted current action`;
client.sendHtmlMessage(room, plainText(response), response);
} else if (userState == "timetable") {
const timetable = parseInt(body);
if (!timetable) {
client.sendTextMessage(room, "Please enter the timetable id!");
return;
}
await db.push(`/rooms/${room}/timetable`, timetable);
await db.delete(`/rooms/${room}/state`);
const response = `✅ Now using timetable <code>${timetable}</code>`;
client.sendHtmlMessage(room, plainText(response), response);
} else {
const response =
"⚠ You are currently in setup mode. Complete the setup or type <code>abort</code> to execute other commands.";
client.sendHtmlMessage(room, plainText(response), response);
}
}
}
async function handleReaction(event, room) {
const state = await db.getObjectDefault(`/rooms/${room}/state`);
if (state == "groups") {
const stateData = await db.getObjectDefault(`/rooms/${room}/stateData`);
if (event.sender.userId != stateData.sender) return;
if (event.event.content["m.relates_to"].key == "✅") {
if (!stateData.groups) stateData.groups = {};
await db.push(`/rooms/${room}/groups`, Object.values(stateData.groups));
await db.delete(`/rooms/${room}/stateData`);
await db.delete(`/rooms/${room}/state`);
const response = `✅ Now using timetable groups <code>${Object.values(
stateData.groups
).join(", ")}</code>`;
client.sendHtmlMessage(room, plainText(response), response);
} else if (event.getType() === "m.reaction") {
await db.push(
`/rooms/${room}/stateData/groups/${event.event.event_id}`,
event.event.content["m.relates_to"].key
);
} else {
await db.delete(`/rooms/${room}/stateData/groups/${event.event.redacts}`);
}
}
}
client.startClient(0);
process.on("SIGINT", () => {
console.info("Interrupted");
process.exit(0);
});