Skip to content
Open
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
91 changes: 79 additions & 12 deletions bot/shared/handlers/exam-checker.handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,62 @@ const EXAM_CHECK_KEYWORDS = [
'تصحيح امتحان', 'تصحيح الامتحان', 'تقييم امتحان'
];

// Explicit exit words — the way OUT of an active exam-checker session.
// (English + Urdu + Arabic.) bd-2484: before this, the session had no text
// exit, so an accidental trigger trapped normal chat until a slash command.
const EXAM_EXIT_KEYWORDS = [
// English
'stop', 'cancel', 'exit', 'quit', 'end',
'stop exam', 'cancel exam', 'stop grading', 'cancel grading',
// Urdu
'روکیں', 'روکو', 'منسوخ', 'بند کرو', 'بند کریں',
// Arabic
'إلغاء', 'الغاء', 'توقف', 'إيقاف',
];

// Button prefixes for exam checker
const EXAM_BUTTON_PREFIX = 'ech_';

/**
* Check if a text message should trigger exam checker
* Check if a text message should START exam checking.
*
* bd-2484: ANCHORED intent — the message must BE a trigger phrase or LEAD with
* one. The old behaviour matched a keyword anywhere in the text (`.includes`),
* so a casual mention buried in a question — "Can you grade exam papers for
* me?" — silently started a real grading session and hijacked normal chat.
* Starting a feature should require clear intent, not an incidental word.
*
* @param {string} text - Message text
* @returns {boolean}
*/
function shouldTriggerExamChecker(text) {
if (!text) return false;
const normalizedText = text.toLowerCase().trim();
// Lowercase, and drop trailing punctuation/space so "grade exams?" still counts.
const normalized = text.toLowerCase().trim().replace(/[?!.\s]+$/g, '');

for (const keyword of EXAM_CHECK_KEYWORDS) {
if (normalizedText.includes(keyword.toLowerCase())) {
const kw = keyword.toLowerCase();
if (normalized === kw || normalized.startsWith(kw + ' ')) {
return true;
}
}

return false;
}

/**
* Check if a text message is an explicit request to LEAVE exam checking.
* Matches only the exit word itself (optionally punctuated) — never a sentence
* that merely contains "stop" ("how do I stop my students talking").
* @param {string} text - Message text
* @returns {boolean}
*/
function isExamExitText(text) {
if (!text) return false;
const t = text.toLowerCase().trim().replace(/[?!.\s]+$/g, '');
return EXAM_EXIT_KEYWORDS.includes(t);
}

/**
* Check if a button click belongs to exam checker
* @param {string} buttonId - Button ID
Expand Down Expand Up @@ -351,30 +386,60 @@ async function handleExamFlow(flowId, flowResponse, from, user) {
}

/**
* Handle cancel command for exam checker
* End the user's active exam-checker session, if any. This is THE way out of
* the state that (before bd-2484) trapped normal chat.
*
* Two callers, two modes:
* - notify:true → the teacher typed an explicit exit word ("stop"/"cancel").
* We end the session AND confirm it in chat.
* - notify:false → the teacher ran a slash command (/menu, /training…). We end
* the session SILENTLY and let the command run — mirrors the
* coaching escape fix (bd-2508): bypassing without ending the
* session lets the very next plain text get recaptured.
*
* @param {string} from - Phone number
* @param {Object} user - User object
* @returns {Promise<Object>}
* @param {{notify?: boolean}} [opts]
* @returns {Promise<boolean>} true if a session was actually ended
*/
async function handleExamCancel(from, user) {
if (!user) return { handled: false };
async function endActiveExamSession(from, user, { notify = false } = {}) {
if (!user) return false;

const state = await ExamCheckerOrchestrator.getSessionState(user.id);
if (!state.active) return false;

if (!state.active) {
return { handled: false };
await ExamCheckerOrchestrator.cancelSession(state.sessionId);

if (notify) {
await WhatsAppService.sendMessage(
from,
'✅ Exam checking cancelled. Say "check exams" whenever you\'re ready to grade papers.'
);
}

const response = await ExamCheckerOrchestrator.cancelSession(state.sessionId);
await WhatsAppService.sendMessage(from, response.text);
logToFile('🚪 Exam session ended via exit path', {
userId: user.id, sessionId: state.sessionId, notify,
});

return true;
}

return { handled: true };
/**
* Handle cancel command for exam checker (thin wrapper over endActiveExamSession).
* @param {string} from - Phone number
* @param {Object} user - User object
* @returns {Promise<Object>}
*/
async function handleExamCancel(from, user) {
const handled = await endActiveExamSession(from, user, { notify: true });
return { handled };
}

module.exports = {
// Detection functions
shouldTriggerExamChecker,
isExamCheckerButton,
isExamExitText,
hasActiveExamSession,

// Handler functions
Expand All @@ -383,8 +448,10 @@ module.exports = {
handleExamButton,
handleExamFlow,
handleExamCancel,
endActiveExamSession,

// Constants
EXAM_CHECK_KEYWORDS,
EXAM_EXIT_KEYWORDS,
EXAM_BUTTON_PREFIX
};
29 changes: 29 additions & 0 deletions bot/shared/handlers/text-message.handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,35 @@ async function handleTextMessage(message, from, messageBody, user = null) {
return; // Stop further processing
}

// ============================================================
// EXAM CHECKER — ESCAPE PATH (bd-2484): a waiting state must never trap
// normal chat. Checked BEFORE the trigger/capture block below.
// • an explicit stop/cancel word ENDS the session and confirms it, then
// returns (the message is fully handled);
// • a slash command ENDS the session SILENTLY and falls through so the
// command still runs — mirrors the coaching fix (bd-2508): bypassing the
// checker without ending the session lets the next plain text get
// recaptured, so the teacher escapes and is immediately caught again.
// Both are cheap no-ops (a single Redis-first lookup) when no exam session
// is active, and only run for slash commands or literal exit words.
// ============================================================
if (user) {
try {
const ExamCheckerHandler = require('./exam-checker.handler');
const isSlash = trimmedMessage.startsWith('/');
if (isSlash || ExamCheckerHandler.isExamExitText(trimmedMessage)) {
const ended = await ExamCheckerHandler.endActiveExamSession(from, user, { notify: !isSlash });
if (ended && !isSlash) {
logToFile('🚪 Exam checker exited by stop/cancel word', { userId: user.id });
typingController.stop();
return;
}
}
} catch (error) {
logToFile('⚠️ Exam checker escape-path check failed (non-fatal)', { error: error.message });
}
}

// ============================================================
// EXAM CHECKER DETECTION: Check for exam check trigger
//
Expand Down
40 changes: 40 additions & 0 deletions bot/shared/services/exam-checker/exam-session.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ const { logToFile } = require('../../utils/logger');
const REDIS_PREFIX = 'exam_session:';
const REDIS_TTL = 60 * 60 * 24; // 24 hours

// bd-2484: an accidental session (started but never used) is auto-expired after
// this long so it can't linger and recapture normal chat. Only ever applied to
// a `collecting_images` session with zero images — never to work in progress.
const STALE_COLLECTING_MS = 60 * 60 * 1000; // 1 hour

class ExamSessionService {
/**
* Get or create an exam session for a user
Expand Down Expand Up @@ -46,6 +51,39 @@ class ExamSessionService {
return this._createSession(userId);
}

/**
* True if this is an ACCIDENTAL session that should auto-expire: still
* collecting images, none uploaded, and untouched for over STALE_COLLECTING_MS.
* Never true once images exist or the session has advanced past collection.
* @param {object} session
* @returns {boolean}
*/
static _isStaleCollectingSession(session) {
if (!session || session.status !== 'collecting_images') return false;
if ((session.original_images || []).length > 0) return false;
const ts = Date.parse(session.updated_at || session.created_at || '');
if (!Number.isFinite(ts)) return false;
return (Date.now() - ts) > STALE_COLLECTING_MS;
}

/**
* If the session is a stale accidental one, cancel it (which clears Redis)
* and return true so callers treat it as "no active session".
* @param {object} session
* @returns {Promise<boolean>} true if it was expired
*/
static async _expireIfStale(session) {
if (!this._isStaleCollectingSession(session)) return false;
try {
await this.updateStatus(session.id, 'cancelled', { error_message: 'auto_expired_stale_collecting' });
logToFile('⏱️ Auto-expired stale exam session', { sessionId: session.id, userId: session.user_id });
} catch (error) {
logToFile('⚠️ Failed to auto-expire stale exam session', { sessionId: session.id, error: error.message });
await this._clearFromRedis(session.user_id);
}
return true;
}

/**
* Get active session for user (without creating new one)
* @param {string} userId - User UUID
Expand All @@ -55,6 +93,7 @@ class ExamSessionService {
// Check Redis first
const cachedSession = await this._getFromRedis(userId);
if (cachedSession) {
if (await this._expireIfStale(cachedSession)) return null;
return cachedSession;
}

Expand All @@ -69,6 +108,7 @@ class ExamSessionService {
.single();

if (data && !error) {
if (await this._expireIfStale(data)) return null;
await this._saveToRedis(userId, data);
return data;
}
Expand Down
58 changes: 58 additions & 0 deletions tests/exam-checker/bd-2484-session-staleness.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* bd-2484 — defense-in-depth: an ACCIDENTAL exam session (started but never
* used) must auto-expire, so it can't linger and recapture chat if the teacher
* never types an exit word or slash command.
*
* Guard is deliberately narrow: it only expires a session that is still in
* `collecting_images`, has ZERO images, and is untouched for over an hour. A
* session with images or one that has advanced past collection (a real grading
* in progress) is NEVER expired.
*/

jest.mock('../../bot/shared/config/supabase', () => ({ from: jest.fn() }));
jest.mock('../../bot/shared/services/cache/railway-redis.service', () => ({
redis: {},
get: jest.fn(async () => null),
set: jest.fn(async () => {}),
delete: jest.fn(async () => {}),
}));
jest.mock('../../bot/shared/utils/logger', () => ({ logToFile: jest.fn() }));

const ExamSessionService = require('../../bot/shared/services/exam-checker/exam-session.service');

const hoursAgo = (h) => new Date(Date.now() - h * 60 * 60 * 1000).toISOString();

describe('_isStaleCollectingSession', () => {
it('is stale: collecting_images, no images, untouched > 1h', () => {
expect(ExamSessionService._isStaleCollectingSession({
status: 'collecting_images', original_images: [], updated_at: hoursAgo(2),
})).toBe(true);
});

it('is NOT stale when recent (within the hour)', () => {
expect(ExamSessionService._isStaleCollectingSession({
status: 'collecting_images', original_images: [], updated_at: hoursAgo(0.2),
})).toBe(false);
});

it('is NOT stale when the teacher has already uploaded images', () => {
expect(ExamSessionService._isStaleCollectingSession({
status: 'collecting_images', original_images: [{ url: 'x' }], updated_at: hoursAgo(5),
})).toBe(false);
});

it('is NOT stale once the session has advanced past collection', () => {
expect(ExamSessionService._isStaleCollectingSession({
status: 'grading', original_images: [], updated_at: hoursAgo(5),
})).toBe(false);
});

it('falls back to created_at when updated_at is absent, and is safe on bad timestamps', () => {
expect(ExamSessionService._isStaleCollectingSession({
status: 'collecting_images', original_images: [], created_at: hoursAgo(3),
})).toBe(true);
expect(ExamSessionService._isStaleCollectingSession({
status: 'collecting_images', original_images: [], updated_at: 'not-a-date',
})).toBe(false);
});
});
Loading
Loading