Skip to content
This repository has been archived by the owner on Nov 6, 2024. It is now read-only.

feat: Quick-reply buttons for schedule/attendance #49

Merged
merged 1 commit into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 66 additions & 24 deletions src/states/render-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ const renderRelativeDate = (d: number): string => {
const relativeDate = new Date(
dateToIST(new Date()).getTime() + d * DAY_TO_MINUTE * MINUTE_TO_MS
);
return `${relativeDate.getFullYear()}-${relativeDate.getMonth() + 1
}-${relativeDate.getDate()}`;
return `${relativeDate.getFullYear()}-${
relativeDate.getMonth() + 1
}-${relativeDate.getDate()}`;
};

const toFormattedPercent = (total: number, went: number) =>
Expand All @@ -46,13 +47,15 @@ export const renderAttendance = (attendance: V1AttendanceRecords) => {
let text = "";
for (let i = 0; i < attendance.records.length; i += 1) {
const record = attendance.records[i];
text += `*Course*: ${record.course?.name ?? "<Unknown>"} *| Code*: ${record?.course?.code || "<Unknown>"
}
=> ${record?.attendance?.attended}/${record?.attendance?.held
} (${toFormattedPercent(
record?.attendance?.held ?? 0,
record?.attendance?.attended ?? 1
)}%)
text += `*Course*: ${record.course?.name ?? "<Unknown>"} *| Code*: ${
record?.course?.code || "<Unknown>"
}
=> ${record?.attendance?.attended}/${
record?.attendance?.held
} (${toFormattedPercent(
record?.attendance?.held ?? 0,
record?.attendance?.attended ?? 1
)}%)

`;
}
Expand All @@ -70,11 +73,12 @@ export const renderCourses = (courses: V1Courses) => {
const { type } = course;
const code = course.ref?.code;
const name = course.ref?.name;
const attendance = `${course?.attendance?.attended}/${course?.attendance?.held
} (${toFormattedPercent(
course?.attendance?.held ?? 0,
course?.attendance?.attended ?? 1
)}%)`;
const attendance = `${course?.attendance?.attended}/${
course?.attendance?.held
} (${toFormattedPercent(
course?.attendance?.held ?? 0,
course?.attendance?.attended ?? 1
)}%)`;
const internalMarks = `${course?.internalMarks?.have}/${course?.internalMarks?.max}`;
text += `
*Course*: ${name} *| Code*: ${code}
Expand Down Expand Up @@ -176,7 +180,7 @@ export const renderAmizoneMenu = () => ({
{
id: "3",
title: "Courses",
description: "(and internals)"
description: "(and internals)",
},
{
id: "4",
Expand All @@ -198,6 +202,35 @@ export const renderAmizoneMenu = () => ({
},
});

export const renderQuickAttendanceButtons = () => ({
type: "button",
header: {
type: "text",
text: "Attendance",
},
body: {
text: "Quick Attendance Checkout",
},
action: {
buttons: [
{
type: "reply",
reply: {
id: "yesterday_attendance",
title: "Yesterday's", // Check schedule -> 28
},
},
{
type: "reply",
reply: {
id: "today_attendance",
title: "Today's",
},
},
],
},
});

export const renderClassScheduleDateList = () => {
const dates = new Array(5);
for (let i = 0; i < 5; i += 1) {
Expand Down Expand Up @@ -230,19 +263,28 @@ export const renderClassScheduleDateList = () => {
};

export const renderExamSchedule = (schedule: V1ExaminationSchedule) => {
const exams = schedule?.exams?.map((exam) => {
const { mode, time: serialTime, course, location } = exam;
// HACK: In general, we should treat the incoming times as UTC and interpret them timezone-agnostically.
// However at the moment I don't feel like dealing with timezones, so I'm just going to subtract 5:30 hours from the time.
const time = serialTime ? dateToIST(new Date(Date.parse(serialTime) - OFFSET_IST * MINUTE_TO_MS)) : undefined;
return `*👉* ${course?.code} ${course?.name}
*⏲️:* ${time ? time.toLocaleString() : "N/A"} (Mode: ${mode})` + (location ? `\n*📍* ${location}` : "");
}).join("\n\n");
const exams = schedule?.exams
?.map((exam) => {
const { mode, time: serialTime, course, location } = exam;
// HACK: In general, we should treat the incoming times as UTC and interpret them timezone-agnostically.
// However at the moment I don't feel like dealing with timezones, so I'm just going to subtract 5:30 hours from the time.
const time = serialTime
? dateToIST(
new Date(Date.parse(serialTime) - OFFSET_IST * MINUTE_TO_MS)
)
: undefined;
return (
`*👉* ${course?.code} ${course?.name}
*⏲️:* ${time ? time.toLocaleString() : "N/A"} (Mode: ${mode})` +
(location ? `\n*📍* ${location}` : "")
);
})
.join("\n\n");

return `*${schedule.title}*

${exams}`;
}
};

export const renderFacultyFeedbackInstructions =
() => `This method will submit feedback for *all* your faculty in a single step.
Expand Down
123 changes: 116 additions & 7 deletions src/states/state-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BotHandlerContext, User, states } from "./states.js";
import { BotHandlerContext, states, User } from "./states.js";
import {
renderAmizoneMenu,
renderQuickAttendanceButtons,
renderAttendance,
renderCourses,
renderSchedule,
Expand All @@ -14,6 +15,15 @@ import {
} from "./render-messages.js";
import { firstNonEmpty, newAmizoneClient } from "../utils.js";

// === Utilities ===
const OFFSET_IST = 330;
const MINUTE_TO_MS = 60_000;
const DAY_TO_MINUTE = 24 * 60;
const currentTzOffset = new Date().getTimezoneOffset();

const dateToIST = (date: Date): Date =>
new Date(date.getTime() + (OFFSET_IST - currentTzOffset) * MINUTE_TO_MS);

const validateAmizoneCredentials = async (
username: string,
password: string
Expand Down Expand Up @@ -79,6 +89,10 @@ export const handleExpectPassword = async (
if (credentialsAreValid) {
updatedUser.amizoneCredentials.password = password;
updatedUser.state = states.LOGGED_IN;
await ctx.bot.sendInteractiveMessage(
payload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(payload.sender, renderAmizoneMenu());
return updatedUser;
}
Expand Down Expand Up @@ -164,14 +178,17 @@ const amizoneMenuHandlersMap: Map<string, StateHandlerFunction> = new Map([
async (ctx): StateHandlerFunctionOut => {
try {
const amizoneClient = newAmizoneClient(ctx.user.amizoneCredentials);
const examSchedule = await amizoneClient.amizoneServiceGetExamSchedule();
return { success: true, message: renderExamSchedule(examSchedule.data) };
}
catch (err) {
const examSchedule =
await amizoneClient.amizoneServiceGetExamSchedule();
return {
success: true,
message: renderExamSchedule(examSchedule.data),
};
} catch (err) {
return { success: false, message: "" };
}
}
]
},
],
]);

/**
Expand All @@ -191,6 +208,13 @@ export const handleLoggedIn = async (ctx: BotHandlerContext): Promise<User> => {
updatedUser.state = states.NEW_USER;
await ctx.bot.sendMessage(payload.sender, "Logged Out!");
return updatedUser;
} else if (
payload.interactive.title === "Yesterday's" ||
payload.interactive.title === "Today's"
) {
// Handle attendance button click
await handleReplyAttendanceButton(ctx);
return updatedUser;
}

const messageHandler = amizoneMenuHandlersMap.get(inputMessage);
Expand All @@ -201,6 +225,10 @@ export const handleLoggedIn = async (ctx: BotHandlerContext): Promise<User> => {
payload.sender,
"Invalid option selected. Try again?"
);
await ctx.bot.sendInteractiveMessage(
payload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(payload.sender, renderAmizoneMenu());
return updatedUser;
}
Expand All @@ -211,12 +239,20 @@ export const handleLoggedIn = async (ctx: BotHandlerContext): Promise<User> => {
payload.sender,
"Unsuccessful. Either Amizone is down or you need to login again (hint: menu has a _logout_ option)"
);
await ctx.bot.sendInteractiveMessage(
payload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(payload.sender, renderAmizoneMenu());
return updatedUser;
}

if (typeof message === "string") {
await ctx.bot.sendMessage(payload.sender, message);
await ctx.bot.sendInteractiveMessage(
payload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(payload.sender, renderAmizoneMenu());
}

Expand All @@ -228,6 +264,63 @@ export const handleLoggedIn = async (ctx: BotHandlerContext): Promise<User> => {
return updatedUser;
};

export const handleReplyAttendanceButton = async (
ctx: BotHandlerContext
): Promise<User> => {
const { payload } = ctx;
const updatedUser = structuredClone(ctx.user);

// Check if the user clicked either "Today's Attendance" or "Yesterday's Attendance"
if (
payload.interactive.title === "Today's" ||
payload.interactive.title === "Yesterday's"
) {
let selectedDate;

if (payload.interactive.title === "Today's") {
selectedDate = new Date(dateToIST(new Date()).getTime());
} else {
selectedDate = new Date(
dateToIST(new Date()).getTime() - 1 * DAY_TO_MINUTE * MINUTE_TO_MS
);
}

try {
// Fetch attendance data for the selected date using your Amizone API client
const [year, month, day] = [
selectedDate.getFullYear(),
selectedDate.getMonth() + 1,
selectedDate.getDate(),
];
const attendance = await newAmizoneClient(
ctx.user.amizoneCredentials
).amizoneServiceGetClassSchedule(year, month, day);

// Send the attendance data as a message to the user
if (
attendance.data.classes !== undefined &&
attendance.data.classes.length > 0
) {
await ctx.bot.sendMessage(
payload.sender,
renderSchedule(attendance.data)
);
} else {
await ctx.bot.sendMessage(payload.sender, "no schedule available.");
}
await ctx.bot.sendInteractiveMessage(
payload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(payload.sender, renderAmizoneMenu());
} catch (error) {
// Handle errors (e.g., API request error)
console.error("Error fetching attendance for the selected date:", error);
}
}
return updatedUser;
};

export const handleScheduleDateInput = async (ctx: BotHandlerContext) => {
const { payload: whatsappPayload } = ctx;
const dateInput = firstNonEmpty(
Expand Down Expand Up @@ -273,6 +366,10 @@ export const handleScheduleDateInput = async (ctx: BotHandlerContext) => {
"no schedule available."
);
}
await ctx.bot.sendInteractiveMessage(
whatsappPayload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(
whatsappPayload.sender,
renderAmizoneMenu()
Expand All @@ -295,6 +392,10 @@ export const handleFacultyFeedbackRating = async (ctx: BotHandlerContext) => {

if (message.toLowerCase().trim() === "cancel") {
await ctx.bot.sendMessage(whatsappPayload.sender, "Cancelled.");
await ctx.bot.sendInteractiveMessage(
whatsappPayload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(
whatsappPayload.sender,
renderAmizoneMenu()
Expand Down Expand Up @@ -345,6 +446,10 @@ export const handleFacultyFeedbackRating = async (ctx: BotHandlerContext) => {
whatsappPayload.sender,
"No feedback to fill at the moment"
);
await ctx.bot.sendInteractiveMessage(
whatsappPayload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(
whatsappPayload.sender,
renderAmizoneMenu()
Expand All @@ -357,6 +462,10 @@ export const handleFacultyFeedbackRating = async (ctx: BotHandlerContext) => {
// @ts-ignore
renderFacultyFeedbackConfirmation(feedback.data.filledFor)
);
await ctx.bot.sendInteractiveMessage(
whatsappPayload.sender,
renderQuickAttendanceButtons()
);
await ctx.bot.sendInteractiveMessage(
whatsappPayload.sender,
renderAmizoneMenu()
Expand Down