diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 2b0d452..e72e443 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -86,7 +86,7 @@ uit grades 'https://courses.uit.edu.vn/course/view.php?id=19207' ### `uit login` -Sign in to UIT Moodle. **UIT SSO is the default**; `--legacy` restores the v1.0/v1.1 Student ID/password flow for the old Moodle portal. +Sign in to UIT Moodle. **UIT SSO is the default**; `--legacy` opens the selected legacy Moodle portal in bundled Chromium. ```bash # Recommended: Sign in via UIT SSO in browser (default) @@ -95,16 +95,16 @@ uit login # Explicit SSO: uit login --sso -# Legacy Moodle: prompts for Student ID and password, then stores the returned token: +# Undergraduate legacy Moodle: uit login --legacy -# Non-interactive legacy token setup: -uit login --legacy --username YOUR_STUDENT_ID --password YOUR_PASSWORD +# Graduate legacy Moodle: +uit login --legacy --graduate ``` -Prefer the interactive browser login (`uit login`) or `uit login --legacy` for normal use. Passwords passed as command-line arguments can be saved in shell history. The CLI does not save your password; it stores only the session/token. +Both flows open the official portal in bundled Chromium. Sign in there; UIT stores the Moodle session cookies and `sesskey`, never the password. Web-service-token authentication is not supported. -SSO and token sessions are saved to `~/.uit/sessions.json` (mode `0600` on Unix) and shared with UIT Studio. The user ID is discovered during login and stored with the session. Re-run `uit login` at any time to refresh or rotate it. +Sessions are saved to `~/.uit/sessions.json` (mode `0600` on Unix) and shared with UIT Studio. The user ID is discovered during login and stored with the session. Re-run the relevant `uit login` command to refresh it. --- @@ -372,7 +372,7 @@ uit grades 19207 ### `uit functions [keyword]` -List the 420+ Moodle web service functions available to your token. Grouped by module. +List the Moodle API functions exposed to your signed-in account, grouped by module. ```bash uit functions # list all @@ -415,6 +415,27 @@ For full parameter schemas, see the [Moodle Web Service API functions reference] --- +### `uit notifications` and `uit inbox` + +Use the active CLI account. Lists return up to 20 entries; use `--offset` for +the next page. Listing or reading content does not mark it as read. + +| Command | Purpose | +|---|---| +| `uit notifications list [--full] [--offset ]` | List notifications; `--full` includes message bodies. | +| `uit notifications counts` | Show unread notification and conversation counts. | +| `uit notifications read ` | Mark one notification as read. | +| `uit notifications read-all` | Mark all account notifications as read. | +| `uit inbox list [--offset ]` | List conversations. | +| `uit inbox messages [--offset ]` | Read conversation messages; larger offsets load older messages. | +| `uit inbox read ` | Mark a conversation as read. | +| `uit inbox send "Message"` | Send a plain-text reply, up to 4096 UTF-8 bytes. | + +Add `--json` before the command for structured output; pages include `nextOffset` +(null at the end). After a failed send, check the conversation before retrying. + +--- + ### `uit mcp` Run the UIT Model Context Protocol (MCP) server for Codex over stdin/stdout, @@ -429,10 +450,28 @@ uit mcp install # Add or update [mcp_servers.uit] in ~/.codex/config.toml entry. The MCP server is available only from inside a UIT course workspace and uses the active session from `~/.uit/sessions.json`. -Agent mode also exposes `uit_submit_assignment`. It verifies the assignment and -the local file, then uploads and submits the file to Moodle. This is the only -write-capable UIT MCP tool and UIT Studio always asks for a fresh confirmation -immediately before it runs, including when YOLO mode is enabled. +| Tool | Purpose | Effect | +|---|---|---| +| `uit_courses` | List accessible courses. | Read | +| `uit_course_contents` | Read course modules, assignments, and announcements. | Read | +| `uit_read_resource` | Read a course resource. | Read | +| `uit_course_members` | List course members. | Read | +| `uit_course_grades` | Read course grades. | Read | +| `uit_download_material` | Download a course file. | Local write | +| `uit_submit_assignment` | Submit a local file to an assignment. | Moodle write | +| `uit_notifications` | Read a notification page (`offset?`). | Read | +| `uit_notification_counts` | Read unread counts: `[notifications, conversations]`; null means unavailable. | Read | +| `uit_inbox` | Read a conversation page (`offset?`). | Read | +| `uit_conversation_messages` | Read messages (`id`, `offset?`). | Read | +| `uit_mark_notification_read` | Mark one notification read (`id`). | Moodle write | +| `uit_mark_all_notifications_read` | Mark all account notifications read. | Moodle write | +| `uit_mark_conversation_read` | Mark a conversation read (`id`). | Moodle write | +| `uit_send_message` | Send an authorized reply (`id`, `text`). | Moodle write | + +Messaging tools use the active account and the same pagination as the CLI. +Sending is never retried automatically. Host approval policies apply; +`uit_submit_assignment` additionally requires fresh confirmation in Studio, +including with YOLO enabled. --- diff --git a/packages/uit-runtime/package.json b/packages/uit-runtime/package.json index 25642fe..9cb22b4 100644 --- a/packages/uit-runtime/package.json +++ b/packages/uit-runtime/package.json @@ -17,7 +17,7 @@ "LICENSE" ], "scripts": { - "postinstall": "node dist/studio-sso.js --install-browser", + "postinstall": "node dist/moodle-browser-login.js --install-browser", "prepack": "npm --prefix ../.. run build && node ../../scripts/prepare-runtime-package.mjs" }, "dependencies": { diff --git a/scripts/check-studio-package.mjs b/scripts/check-studio-package.mjs index 35c58f1..ebe5dcc 100644 --- a/scripts/check-studio-package.mjs +++ b/scripts/check-studio-package.mjs @@ -62,7 +62,7 @@ for (const required of [ "dist/session-health.js", "dist/studio-core.js", "dist/studio-thread-store.js", - "dist/studio-sso.js", + "dist/moodle-browser-login.js", "dist/studio-web-server.js", "dist/studio-web-launcher.js", "dist/uit-tools.js", diff --git a/scripts/package-studio-standalone.mjs b/scripts/package-studio-standalone.mjs index 566bd4f..094a426 100644 --- a/scripts/package-studio-standalone.mjs +++ b/scripts/package-studio-standalone.mjs @@ -195,7 +195,7 @@ async function main() { const browserRoot = join(runtimeRoot, "browsers"); const browserEnvironment = { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browserRoot, UIT_STUDIO_CHROMIUM_DIR: browserRoot }; delete browserEnvironment.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD; - const installScript = `import { installBundledChromium } from ${JSON.stringify(pathToFileURL(join(runtimeRoot, "dist", "studio-sso.js")).href)}; installBundledChromium();`; + const installScript = `import { installBundledChromium } from ${JSON.stringify(pathToFileURL(join(runtimeRoot, "dist", "moodle-browser-login.js")).href)}; installBundledChromium();`; run(nodePath, ["--input-type=module", "-e", installScript], { cwd: appRoot, env: browserEnvironment }); const executablePath = chromiumManifest(runtimeRoot, platform, architecture); console.log(`Bundled Chromium: ${executablePath}`); diff --git a/scripts/prepare-runtime-package.mjs b/scripts/prepare-runtime-package.mjs index 56e4523..f4f1db5 100644 --- a/scripts/prepare-runtime-package.mjs +++ b/scripts/prepare-runtime-package.mjs @@ -22,8 +22,9 @@ const sharedModules = [ "mcp-server", "mcp-entry", "studio-core", + "notifications", "studio-thread-store", - "studio-sso", + "moodle-browser-login", "studio-web-server", "studio-web-launcher", "moodle-session-client", diff --git a/src/api.ts b/src/api.ts index f7f345e..7fdf359 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,7 +4,7 @@ import { createHash, randomUUID } from "node:crypto"; import { basename, dirname } from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { get } from "./config.js"; +import { getActiveConfig } from "./config.js"; import { buildAjaxInfo, normalizeArgs, unwrapAjaxResponse } from "./ajax-helpers.js"; import type { ApiClient, MoodleRecord } from "./types.js"; @@ -16,6 +16,34 @@ declare module "./types.js" { export const MAX_PREVIEW_BYTES = 25 * 1024 * 1024; +/** Resolve Moodle paths without dropping an installation prefix such as /sdh. */ +export function resolveMoodleUrl(baseUrl: string, path: string): URL { + const base = new URL(baseUrl); + const prefix = base.pathname.replace(/\/+$/, ""); + const root = new URL(`${base.origin}${prefix}/`); + const target = new URL(path, root); + if (target.origin === base.origin && prefix && target.pathname !== prefix && !target.pathname.startsWith(`${prefix}/`)) { + target.pathname = `${prefix}${target.pathname.startsWith("/") ? "" : "/"}${target.pathname}`; + } + return target; +} + +function resolveMoodleRedirect(baseUrl: string, currentUrl: URL, location: string): URL { + const target = new URL(location, currentUrl); + const base = new URL(baseUrl); + const prefix = base.pathname.replace(/\/+$/, ""); + if (target.origin === base.origin && prefix && target.pathname !== prefix && !target.pathname.startsWith(`${prefix}/`)) { + target.pathname = `${prefix}${target.pathname.startsWith("/") ? "" : "/"}${target.pathname}`; + } + return target; +} + +function moodleBaseUrlFromPage(pageUrl: string): string { + const page = new URL(pageUrl); + const prefix = page.pathname.match(/^\/sdh(?:\/|$)/) ? "/sdh" : ""; + return page.origin + prefix; +} + function unavailableSessionMethod(error: unknown): boolean { const code = String((error as { errorcode?: string })?.errorcode || ""); if (code && !/^(?:moodle_exception|webservice_exception)$/i.test(code)) { @@ -104,7 +132,7 @@ function hasSubmissionFormError(html: string): boolean { function fileRecord(rawUrl: string, pageUrl: string): MoodleRecord | undefined { try { - const url = new URL(rawUrl, pageUrl); + const url = resolveMoodleUrl(moodleBaseUrlFromPage(pageUrl), rawUrl); if (url.origin !== new URL(pageUrl).origin || !/(?:token)?pluginfile\.php(?:\/|$)|\/mod_forum\/attachment(?:\/|$)/i.test(url.pathname)) return undefined; let filename = basename(url.pathname) || "resource"; try { filename = decodeURIComponent(filename); } catch { /* Preserve malformed Moodle filenames verbatim. */ } @@ -134,10 +162,10 @@ export function credentialFreeUrl(value: unknown): string | undefined { } /** Follow redirects manually so credentials never leave the authenticated origin. */ -export async function fetchCourseFile(baseUrl: string, fileUrl: string, headers: Record = {}, token?: string): Promise { +export async function fetchCourseFile(baseUrl: string, fileUrl: string, headers: Record = {}): Promise { const base = new URL(baseUrl); const installationPath = base.pathname.replace(/\/+$/, ""); - let url = new URL(fileUrl, `${baseUrl.replace(/\/+$/, "")}/`); + let url = resolveMoodleUrl(baseUrl, fileUrl); const signal = AbortSignal.timeout(120_000); for (let redirects = 0; redirects <= 5; redirects++) { if (url.origin !== base.origin || url.username || url.password) throw new Error("Refusing to send UIT credentials to another origin."); @@ -147,19 +175,15 @@ export async function fetchCourseFile(baseUrl: string, fileUrl: string, headers: url = new URL(clean); const path = url.pathname.slice(installationPath.length); const pluginfile = url.pathname.startsWith(`${installationPath}/`) && /^\/(?:webservice\/)?pluginfile\.php(?:\/|$)/.test(path); - if (token && !pluginfile) throw new Error("Unsupported token course file endpoint."); if (pluginfile) { - // https://moodledev.io/docs/4.5/apis/subsystems/external/files - // Mobile tokens use webservice/pluginfile; cookies use ordinary pluginfile. - url.pathname = `${installationPath}${path.replace(/^\/(?:webservice\/)?pluginfile\.php/, token ? "/webservice/pluginfile.php" : "/pluginfile.php")}`; + url.pathname = `${installationPath}${path.replace(/^\/(?:webservice\/)?pluginfile\.php/, "/pluginfile.php")}`; } - if (token) url.searchParams.set("token", token); const response = await fetch(url, { headers, redirect: "manual", signal }); if ([301, 302, 303, 307, 308].includes(response.status)) { await response.body?.cancel(); const location = response.headers.get("location"); if (!location) throw new Error("Course file redirect has no destination."); - url = new URL(location, url); + url = resolveMoodleRedirect(baseUrl, url, location); continue; } if (!response.ok) { @@ -250,68 +274,6 @@ export async function writeCourseFile(response: Response, destPath: string, opti } } -function appendParams(url: URL, params: Record): void { - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === null) continue; - url.searchParams.set(key, String(value)); - } -} - -export function createTokenApiClient(baseUrl: string, token: string): ApiClient { - const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); - const callWithToken = async (name: string, params: Record = {}): Promise => { - const url = new URL(`${normalizedBaseUrl}/webservice/rest/server.php`); - appendParams(url, { - ...params, - wstoken: token, - wsfunction: name, - moodlewsrestformat: "json" - }); - - const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - const data = await response.json(); - if (data && typeof data === "object" && "exception" in data) { - const error = new Error(data.message || data.error || JSON.stringify(data)) as Error & { - errorcode?: string; - moodleException?: string; - }; - if (typeof data.errorcode === "string") error.errorcode = data.errorcode; - if (typeof data.exception === "string") error.moodleException = data.exception; - throw error; - } - return data as T; - }; - - const uploadWithToken = async (filepath: string): Promise => { - const form = new FormData(); - form.append("token", token); - form.append("filearea", "draft"); - form.append("itemid", "0"); - form.append("file", await openAsBlob(filepath), basename(filepath)); - - const response = await fetch(`${normalizedBaseUrl}/webservice/upload.php`, { - method: "POST", - body: form, - signal: AbortSignal.timeout(120_000) - }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - const data = await response.json(); - if (Array.isArray(data) && data.length > 0) return data[0]; - if (data && typeof data === "object" && "error" in data) throw new Error(String(data.error)); - return data as MoodleRecord; - }; - - const downloadWithToken = async (fileUrl: string, destPath: string, options?: { atomic?: boolean }): Promise<{ sha256: string }> => { - return writeCourseFile(await fetchCourseFile(normalizedBaseUrl, fileUrl, {}, token), destPath, options); - }; - - return { - call: callWithToken, uploadFile: uploadWithToken, downloadFile: downloadWithToken, - readFile: async (fileUrl) => readCourseFile(await fetchCourseFile(normalizedBaseUrl, fileUrl, {}, token)) - }; -} - export async function call(name: string, params: Record = {}): Promise { return defaultApiClient.call(name, params); } @@ -349,7 +311,7 @@ export class NodeSessionApiClient implements ApiClient { } private async fetchHtmlPage(path: string): Promise<{ html: string; url: string }> { - const url = new URL(path, `${this.baseUrl}/`); + const url = resolveMoodleUrl(this.baseUrl, path); if (url.origin !== new URL(this.baseUrl).origin) throw new Error("Course page belongs to another origin."); const res = await fetch(url, { headers: { Cookie: this.cookieHeader }, @@ -407,7 +369,7 @@ export class NodeSessionApiClient implements ApiClient { course: courseId, name: name || "Activity", modname: modnameMatch?.[1] || (/\/mod\/([^/]+)\//i.exec(urlMatch?.[1] || "")?.[1]) || "resource", - url: urlMatch ? new URL(urlMatch[1].replace(/&/gi, "&"), pageUrl).toString() : "", + url: urlMatch ? resolveMoodleUrl(this.baseUrl, urlMatch[1].replace(/&/gi, "&")).toString() : "", description: htmlVisibleText(/class=["'][^"']*\b(?:contentwithoutlink|activity-description)\b[^"']*["'][^>]*>([\s\S]*?)<\/(?:div|section)>/i.exec(block)?.[1]), contents }); @@ -418,7 +380,7 @@ export class NodeSessionApiClient implements ApiClient { await Promise.all(modules.slice(start, start + 4).map(async (module) => { if (!["resource", "folder", "url"].includes(String(module.modname))) return; try { - const activityUrl = new URL(String(module.url || `/mod/${module.modname}/view.php?id=${module.id}`), pageUrl); + const activityUrl = resolveMoodleUrl(this.baseUrl, String(module.url || `/mod/${module.modname}/view.php?id=${module.id}`)); if (activityUrl.origin !== new URL(this.baseUrl).origin || !activityUrl.pathname.includes(`/mod/${module.modname}/`)) { throw new Error("Moodle returned an invalid activity URL."); } @@ -449,7 +411,7 @@ export class NodeSessionApiClient implements ApiClient { const modules = sections.flatMap((section) => section.modules || []).filter((module) => module.modname === "assign"); const assignments = await Promise.all(modules.map(async (module) => { try { - const activityUrl = new URL(String(module.url || `/mod/assign/view.php?id=${module.id}`), this.baseUrl); + const activityUrl = resolveMoodleUrl(this.baseUrl, String(module.url || `/mod/assign/view.php?id=${module.id}`)); if (activityUrl.origin !== new URL(this.baseUrl).origin || !activityUrl.pathname.includes("/mod/assign/")) { throw new Error("Moodle returned an invalid assignment URL."); } @@ -492,7 +454,7 @@ export class NodeSessionApiClient implements ApiClient { } private async fetchForumActivityHtml(module: MoodleRecord): Promise { - const activityUrl = new URL(String(module.url || ("/mod/forum/view.php?id=" + module.id)), this.baseUrl); + const activityUrl = resolveMoodleUrl(this.baseUrl, String(module.url || ("/mod/forum/view.php?id=" + module.id))); if (activityUrl.origin !== new URL(this.baseUrl).origin || !activityUrl.pathname.includes("/mod/forum/")) { throw new Error("Moodle returned an invalid forum URL."); } @@ -555,7 +517,7 @@ export class NodeSessionApiClient implements ApiClient { const path = byModule ? "/mod/forum/view.php?id=" + cmid + "&forceview=1&p=" + page + "&s=" + perpage : "/mod/forum/view.php?f=" + forumId + "&p=" + page + "&s=" + perpage; - const { html, url: pageUrl } = await this.fetchHtmlPage(path); + const { html } = await this.fetchHtmlPage(path); if (!/(?:discussion-list|forumheaderlist|forumnodiscuss|forumpost)/i.test(html)) { throw new Error("Unable to read forum discussions."); } @@ -581,7 +543,7 @@ export class NodeSessionApiClient implements ApiClient { let discussionUrl: URL | undefined; if (linkMatch) { try { - const candidate = new URL(linkMatch[2].replace(/&/gi, "&"), pageUrl); + const candidate = resolveMoodleUrl(this.baseUrl, linkMatch[2].replace(/&/gi, "&")); if (candidate.origin === new URL(this.baseUrl).origin && candidate.pathname.includes("/mod/forum/")) discussionUrl = candidate; } catch { /* Ignore malformed discussion links. */ } } @@ -598,7 +560,7 @@ export class NodeSessionApiClient implements ApiClient { const repliesMatch = /<([a-z0-9]+)\b[^>]*class=["'][^"']*\breplies\b[^"']*["'][^>]*>([\s\S]*?)<\/\1>/i.exec(row) || /]*class=["'][^"']*\btext-center\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i.exec(row); const count = Number(htmlText(repliesMatch?.[2] || repliesMatch?.[1])); - const cleanUrl = credentialFreeUrl(discussionUrl?.toString() || new URL("/mod/forum/discuss.php?d=" + discussion, pageUrl).toString()); + const cleanUrl = credentialFreeUrl(discussionUrl?.toString() || resolveMoodleUrl(this.baseUrl, "/mod/forum/discuss.php?d=" + discussion).toString()); discussions.push({ discussion, name: htmlText(title || (linkMatch ? linkMatch[3] : "")), @@ -699,7 +661,7 @@ export class NodeSessionApiClient implements ApiClient { }; if (avatar) { try { - const avatarUrl = credentialFreeUrl(new URL(avatar.replace(/&/gi, "&"), pageUrl).toString()); + const avatarUrl = credentialFreeUrl(resolveMoodleUrl(this.baseUrl, avatar.replace(/&/gi, "&")).toString()); if (avatarUrl) user.profileimageurl = avatarUrl; } catch { /* Ignore malformed avatar URLs. */ } } @@ -773,7 +735,7 @@ export class NodeSessionApiClient implements ApiClient { } if (module.modname === "forum") { - const activityUrl = new URL(String(module.url || `/mod/forum/view.php?id=${cmid}`), this.baseUrl); + const activityUrl = resolveMoodleUrl(this.baseUrl, String(module.url || `/mod/forum/view.php?id=${cmid}`)); if (activityUrl.origin !== new URL(this.baseUrl).origin || !activityUrl.pathname.includes("/mod/forum/")) { throw new Error("Moodle returned an invalid forum URL."); } @@ -969,7 +931,7 @@ export class NodeSessionApiClient implements ApiClient { } private async postHtmlForm(path: string, body: URLSearchParams): Promise<{ html: string; url: string }> { - const url = new URL(path, `${this.baseUrl}/`); + const url = resolveMoodleUrl(this.baseUrl, path); if (url.origin !== new URL(this.baseUrl).origin) throw new Error("Course page belongs to another origin."); const res = await fetch(url, { method: "POST", @@ -983,10 +945,10 @@ export class NodeSessionApiClient implements ApiClient { }); if ([301, 302, 303, 307, 308].includes(res.status)) { const location = res.headers.get("location") || ""; - if (/\/login(?:\/|$)/i.test(new URL(location, url).pathname)) { + const destination = resolveMoodleRedirect(this.baseUrl, url, location); + if (/\/login(?:\/|$)/i.test(destination.pathname)) { throw new Error("UIT session expired. Please sign in again."); } - const destination = new URL(location, url); if (destination.origin !== url.origin) throw new Error("Moodle form redirected to another origin."); return { html: "", url: destination.toString() }; } @@ -1021,10 +983,11 @@ export class NodeSessionApiClient implements ApiClient { } const response = await this.postHtmlForm(`/mod/assign/view.php?id=${cmid}`, body); - const responseUrl = new URL(response.url, `${this.baseUrl}/`); + const responseUrl = resolveMoodleUrl(this.baseUrl, response.url); + const installationPath = new URL(this.baseUrl).pathname.replace(/\/+$/, ""); if ( responseUrl.origin !== new URL(this.baseUrl).origin || - responseUrl.pathname !== "/mod/assign/view.php" || + responseUrl.pathname !== `${installationPath}/mod/assign/view.php` || responseUrl.searchParams.get("id") !== String(cmid) ) { throw new Error("Moodle returned an unexpected submission response."); @@ -1138,15 +1101,9 @@ export function createSessionApiClient( } export function getActiveApiClient(): ApiClient { - const authType = get("authType"); - if (authType === "sso") { - const sesskey = get("sesskey"); - const cookies = get("cookies"); - if (sesskey && cookies) { - return createSessionApiClient(get("baseUrl"), sesskey, cookies); - } - } - return createTokenApiClient(get("baseUrl"), get("token")); + const config = getActiveConfig(); + if (!config.sesskey || !config.cookies?.length) throw new Error("The active UIT browser session is incomplete. Sign in again."); + return createSessionApiClient(config.baseUrl, config.sesskey, config.cookies); } export const defaultApiClient: ApiClient = { diff --git a/src/cli.ts b/src/cli.ts index f3982e6..2136428 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,6 @@ import { cmdEvents, cmdFunctions, cmdGrades, - cmdInit, cmdOpen, cmdRaw, cmdReply, @@ -25,21 +24,28 @@ import { createContext } from "./commands.js"; import { runMcpServer, installMcpServer } from "./mcp-server.js"; -import { cmdLoginSso, type SsoLoginLauncher } from "./sso-login.js"; +import { cmdLoginLegacy, cmdLoginSso, type LegacyLoginLauncher, type SsoLoginLauncher } from "./sso-login.js"; import { VERSION } from "./version.js"; +import { registerNotificationCommands } from "./notification-commands.js"; const CURRENT_SITE_BASE_URL = "https://courses.uit.edu.vn"; const LEGACY_SITE_BASE_URL = "https://coursesold.uit.edu.vn"; +const GRADUATE_SITE_BASE_URL = `${LEGACY_SITE_BASE_URL}/sdh`; export const WORKFLOW = ` workflow: uit login -> sign in via UIT SSO (default) - uit login --legacy -> sign in to legacy Moodle with Student ID/password + uit login --legacy -> sign in to undergraduate legacy Moodle in Chromium + uit login --legacy --graduate -> sign in to graduate legacy Moodle in Chromium uit courses --current -> get course IDs uit contents -> browse modules (shows module IDs) uit view -> inspect any module (accepts module_id or assign_id) uit download -> download files (whole course or targeted) uit announcements -> read course announcements + uit notifications list -> read Moodle notifications + uit inbox list -> list Moodle conversations + uit inbox messages -> read messages + uit inbox send -> send a reply uit deadlines -> assignment IDs and due dates uit events -> upcoming events: assignments, quizzes, more uit grades -> view grades @@ -48,7 +54,7 @@ export const WORKFLOW = ` uit view-discussion -> read forum thread (shows post IDs) uit reply -> reply to a forum post uit open -> open in browser (module, course, or URL) - uit functions [keyword] -> discover 420+ raw API functions + uit functions [keyword] -> discover available Moodle API functions uit raw key=value -> call any Moodle API function ID chain: courses -> course_id -> contents / download / announcements / deadlines / grades @@ -117,7 +123,7 @@ function printLanding(): void { export function createProgram( api: ApiClient = defaultApiClient, - options: { openBrowser?: boolean; ssoLauncher?: SsoLoginLauncher } = {} + options: { openBrowser?: boolean; ssoLauncher?: SsoLoginLauncher; legacyLauncher?: LegacyLoginLauncher } = {} ): Command { const ctx = createContext(api); const program = new Command(); @@ -131,28 +137,19 @@ export function createProgram( .exitOverride(); program.hook("preAction", () => setJsonMode(Boolean(program.opts().json))); + registerNotificationCommands(program, api); program .command("login") .description("Sign in via UIT SSO (default) or legacy Moodle") .option("--sso", "Sign in via UIT SSO in browser window") - .option("--legacy", "Sign in to legacy Moodle with Student ID/password") - .option("-u, --username ", "Student ID for legacy token setup") - .option("-p, --password ", "Password for non-interactive legacy token setup") + .option("--legacy", "Sign in to legacy Moodle in a browser window") + .option("--graduate", "Use the graduate legacy portal (/sdh); requires --legacy") .action(async (opts) => { - const hasLegacyCredentials = Boolean(opts.username || opts.password); - if (opts.legacy && opts.sso) { - throw new CliError("Choose one login method: --sso or --legacy."); - } - if (opts.sso && hasLegacyCredentials) { - throw new CliError("--sso cannot be combined with --username or --password."); - } - if (opts.legacy || hasLegacyCredentials) { - await cmdInit({ - url: LEGACY_SITE_BASE_URL, - username: opts.username, - password: opts.password - }); + if (opts.legacy && opts.sso) throw new CliError("Choose one login method: --sso or --legacy."); + if (opts.graduate && !opts.legacy) throw new CliError("--graduate requires --legacy."); + if (opts.legacy) { + await cmdLoginLegacy({ url: opts.graduate ? GRADUATE_SITE_BASE_URL : LEGACY_SITE_BASE_URL }, options.legacyLauncher); return; } await cmdLoginSso({ url: CURRENT_SITE_BASE_URL }, options.ssoLauncher); @@ -259,7 +256,7 @@ export function createProgram( program .command("functions") - .description("List/search available Moodle API functions (420+)") + .description("List/search Moodle API functions exposed to your account") .argument("[query]", "Filter by keyword, e.g. 'assign', 'quiz', 'forum'", "") .action((query) => cmdFunctions({ query }, ctx)); diff --git a/src/commands.ts b/src/commands.ts index 15dcef9..640e5da 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,10 +2,8 @@ import { existsSync } from "node:fs"; import { basename, join, resolve, sep } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { createInterface } from "node:readline/promises"; -import { Writable } from "node:stream"; import { defaultApiClient } from "./api.js"; -import { get, save } from "./config.js"; +import { get } from "./config.js"; import { listH5pActivities, readH5pActivity } from "./h5p.js"; import type { ApiClient, MoodleRecord } from "./types.js"; import { extractH5pPackage } from "./unzip.js"; @@ -56,90 +54,6 @@ function formatSize(size: number): string { return size < 1_048_576 ? `${(size / 1024).toFixed(0)}KB` : `${(size / 1_048_576).toFixed(1)}MB`; } -async function initSiteInfo(token: string, baseUrl: string): Promise { - const url = new URL(`${baseUrl}/webservice/rest/server.php`); - url.searchParams.set("wstoken", token); - url.searchParams.set("wsfunction", "core_webservice_get_site_info"); - url.searchParams.set("moodlewsrestformat", "json"); - const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - return response.json() as Promise; -} - -export async function requestMobileToken(baseUrl: string, username: string, password: string): Promise { - const response = await fetch(`${baseUrl}/login/token.php`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - username, - password, - service: "moodle_mobile_app" - }), - signal: AbortSignal.timeout(15_000) - }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - const data = (await response.json()) as MoodleRecord; - if (data.error) die(String(data.error)); - if (!data.token) die("Moodle did not return a token", "Check your username/password and whether Moodle Mobile services are enabled."); - return String(data.token); -} - -async function promptText(label: string): Promise { - if (!process.stdin.isTTY) die("Cannot prompt for credentials without an interactive terminal."); - const rl = createInterface({ input: process.stdin, output: process.stderr }); - try { - return (await rl.question(label)).trim(); - } finally { - rl.close(); - } -} - -async function promptPassword(label: string): Promise { - if (!process.stdin.isTTY) die("Cannot prompt for credentials without an interactive terminal."); - process.stderr.write(label); - const mutedOutput = new Writable({ - write(_chunk, _encoding, callback) { - callback(); - } - }) as Writable & { isTTY?: boolean; columns?: number }; - mutedOutput.isTTY = true; - mutedOutput.columns = process.stderr.columns || 80; - - const rl = createInterface({ - input: process.stdin, - output: mutedOutput, - terminal: true - }); - try { - return await rl.question(""); - } finally { - rl.close(); - process.stderr.write("\n"); - } -} - -async function resolveInitToken(args: { token?: string; username?: string; password?: string }, baseUrl: string): Promise { - if (args.token) return args.token; - - const username = args.username || (await promptText("Student ID: ")); - const password = args.password || (await promptPassword("Password: ")); - if (!username) die("Student ID is required."); - if (!password) die("Password is required."); - - loading("Requesting Moodle token..."); - return requestMobileToken(baseUrl, username, password); -} - -export async function cmdInit(args: { token?: string; url: string; username?: string; password?: string }): Promise { - const baseUrl = args.url.replace(/\/+$/, ""); - const token = await resolveInitToken(args, baseUrl); - const data = await initSiteInfo(token, baseUrl); - if (data.exception) die(data.message || "invalid token"); - const userId = data.userid; - save(token, userId, baseUrl); - out({ status: "ok", user: data.fullname, user_id: userId, site: data.sitename }); -} - export async function cmdCourses(args: { current?: boolean }, ctx = createContext()): Promise { loading("Loading courses..."); let courses = await ctx.api.call("core_enrol_get_users_courses", { userid: get("userId") }); @@ -775,9 +689,7 @@ export async function cmdDownload( if (isH5p && args.extract) extractPackage(record, dest); results.push(record); } catch (error) { - let message = error instanceof Error ? error.message : String(error); - const token = get("token"); - if (token && message.includes(token)) message = message.replaceAll(token, "***"); + const message = error instanceof Error ? error.message : String(error); results.push({ file: file.filename, status: "error", error: message }); if (!isJsonMode()) console.log(` FAIL: ${message}`); } diff --git a/src/config.ts b/src/config.ts index 77d502a..3fd51c1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { CliError } from "./output.js"; -export interface SsoCookie { +export interface MoodleSessionCookie { name: string; value: string; domain?: string; @@ -13,33 +13,34 @@ export interface SsoCookie { httpOnly?: boolean; } -export interface SsoSessionData { +export interface MoodleBrowserSessionData { baseUrl: string; userId: number; sesskey: string; - cookies: SsoCookie[]; + cookies: MoodleSessionCookie[]; savedAt?: number; } -export interface LegacySessionData { - baseUrl: string; - userId: number; - token: string; +export interface LegacyBrowserSessionData extends MoodleBrowserSessionData { + authType: "session"; } +export type LegacySessionData = LegacyBrowserSessionData; + +export type SessionAuthType = "sso" | "session"; + export interface SessionsData { - sso?: SsoSessionData | null; + sso?: MoodleBrowserSessionData | null; legacy?: LegacySessionData[] | null; - active?: { authType: "token" | "sso"; baseUrl: string } | null; + active?: { authType: SessionAuthType; baseUrl: string } | null; } export interface Config { - authType: "token" | "sso"; + authType: SessionAuthType; baseUrl: string; userId: number | null; - token?: string; sesskey?: string; - cookies?: SsoCookie[]; + cookies?: MoodleSessionCookie[]; } export const getSessionsFilePath = (): string => join(homedir(), ".uit", "sessions.json"); @@ -83,42 +84,61 @@ export function writeSessionsFile(data: SessionsData): void { function load(): Config { if (cfg) return cfg; - // 1. Environment variable override (e.g. CI/CD or scripts) + // Web-service-token authentication is no longer supported. Fail explicitly + // instead of silently selecting another saved session when stale env vars remain. if (process.env.UIT_TOKEN) { - const baseUrl = (process.env.UIT_BASE_URL || "https://courses.uit.edu.vn").replace(/\/+$/, ""); - const userId = process.env.UIT_USER_ID ? Number.parseInt(process.env.UIT_USER_ID, 10) : null; - cfg = { - authType: "token", - token: process.env.UIT_TOKEN, - baseUrl, - userId: Number.isFinite(userId) ? userId : null - }; - return cfg; + throw new CliError("UIT_TOKEN authentication is no longer supported. Run uit login or uit login --legacy to sign in in a browser."); } - // 2. Read ~/.uit/sessions.json + // Read the shared browser-session store. const sessions = readSessionsFile(); - // 2a. Honor the last explicit CLI login when both session types exist. - if (sessions.active?.authType === "token") { - const record = (sessions.legacy || []).find((item) => item.baseUrl === sessions.active?.baseUrl); - if (record?.token) { - cfg = { authType: "token", token: record.token, baseUrl: record.baseUrl, userId: Number(record.userId) }; - return cfg; + // An explicit selection is authoritative. Never fall through to another + // account when the selected session is missing or uses a retired auth mode. + if (sessions.active) { + const activeBaseUrl = sessions.active.baseUrl?.replace(/\/+$/, ""); + if (sessions.active.authType === "session") { + const record = (sessions.legacy || []).find((item) => item.baseUrl?.replace(/\/+$/, "") === activeBaseUrl); + if (record && record.authType === "session" && record.sesskey && Array.isArray(record.cookies) && record.cookies.length) { + cfg = { + authType: "session", + baseUrl: record.baseUrl.replace(/\/+$/, ""), + userId: Number(record.userId), + sesskey: record.sesskey, + cookies: record.cookies + }; + return cfg; + } + throw new CliError("The selected UIT session is no longer saved. Sign in again."); } + if (sessions.active.authType === "sso") { + const record = sessions.sso; + if (record && record.baseUrl?.replace(/\/+$/, "") === activeBaseUrl && record.sesskey && Array.isArray(record.cookies) && record.cookies.length) { + cfg = { + authType: "sso", + baseUrl: record.baseUrl.replace(/\/+$/, ""), + userId: Number(record.userId), + sesskey: record.sesskey, + cookies: record.cookies + }; + return cfg; + } + throw new CliError("The selected UIT session is no longer saved. Sign in again."); + } + throw new CliError("The saved active UIT session uses an unsupported authentication method. Sign in again."); } - // 2b. Check SSO session + // With no explicit selection, use a saved SSO session before legacy sessions. if ( sessions.sso && sessions.sso.baseUrl && sessions.sso.sesskey && sessions.sso.userId && - Array.isArray(sessions.sso.cookies) + Array.isArray(sessions.sso.cookies) && + sessions.sso.cookies.length ) { cfg = { authType: "sso", - token: "", baseUrl: sessions.sso.baseUrl.replace(/\/+$/, ""), userId: Number(sessions.sso.userId), sesskey: sessions.sso.sesskey, @@ -127,20 +147,17 @@ function load(): Config { return cfg; } - // 2c. Check Legacy token session - if (sessions.legacy && sessions.legacy.length > 0) { - const record = sessions.legacy[0]; - if (record && record.token) { - const baseUrl = (record.baseUrl || "https://coursesold.uit.edu.vn").replace(/\/+$/, ""); - const userId = record.userId ? Number.parseInt(String(record.userId), 10) : null; - cfg = { - authType: "token", - token: record.token, - baseUrl, - userId: Number.isFinite(userId) ? userId : null - }; - return cfg; - } + // Use a saved legacy browser session if no account was explicitly selected. + const record = (sessions.legacy || []).find((item) => item.authType === "session" && item.sesskey && Array.isArray(item.cookies) && item.cookies.length); + if (record) { + cfg = { + authType: "session", + baseUrl: record.baseUrl.replace(/\/+$/, ""), + userId: Number(record.userId), + sesskey: record.sesskey, + cookies: record.cookies + }; + return cfg; } throw new CliError("No active UIT session found. Run: uit login (SSO) or uit login --legacy"); @@ -152,48 +169,45 @@ export function getActiveConfig(options: { fresh?: boolean } = {}): Readonly item.baseUrl !== cleanBaseUrl); - legacyList.unshift({ baseUrl: cleanBaseUrl, userId, token }); - sessions.legacy = legacyList; - sessions.active = { authType: "token", baseUrl: cleanBaseUrl }; + sessions.sso = sessionData; + sessions.active = { authType: "sso", baseUrl: sessionData.baseUrl.replace(/\/+$/, "") }; writeSessionsFile(sessions); const path = getSessionsFilePath(); - console.error(`Saved to ${path}`); + console.error(`SSO session saved to ${path}`); cfg = { - authType: "token", - token, - userId, - baseUrl: cleanBaseUrl + authType: "sso", + baseUrl: sessionData.baseUrl.replace(/\/+$/, ""), + userId: sessionData.userId, + sesskey: sessionData.sesskey, + cookies: sessionData.cookies }; return path; } -export function saveSsoSession(sessionData: SsoSessionData): string { +export function saveLegacyBrowserSession(sessionData: MoodleBrowserSessionData): string { + const cleanBaseUrl = sessionData.baseUrl.replace(/\/+$/, ""); const sessions = readSessionsFile(); - sessions.sso = sessionData; - sessions.active = { authType: "sso", baseUrl: sessionData.baseUrl.replace(/\/+$/, "") }; + sessions.legacy = (sessions.legacy || []).filter((item) => item.authType === "session" && item.baseUrl !== cleanBaseUrl); + sessions.legacy.unshift({ ...sessionData, baseUrl: cleanBaseUrl, authType: "session" }); + sessions.active = { authType: "session", baseUrl: cleanBaseUrl }; writeSessionsFile(sessions); const path = getSessionsFilePath(); - console.error(`SSO session saved to ${path}`); + console.error(`Legacy Moodle session saved to ${path}`); cfg = { - authType: "sso", - token: "", - baseUrl: sessionData.baseUrl.replace(/\/+$/, ""), + authType: "session", + baseUrl: cleanBaseUrl, userId: sessionData.userId, sesskey: sessionData.sesskey, cookies: sessionData.cookies @@ -202,12 +216,12 @@ export function saveSsoSession(sessionData: SsoSessionData): string { } /** Select an already-persisted account without changing or re-saving credentials. */ -export function activateSession(authType: "token" | "sso", baseUrl: string): void { +export function activateSession(authType: SessionAuthType, baseUrl: string): void { const cleanBaseUrl = baseUrl.replace(/\/+$/, ""); const sessions = readSessionsFile(); const available = authType === "sso" ? sessions.sso?.baseUrl?.replace(/\/+$/, "") === cleanBaseUrl - : (sessions.legacy || []).some((item) => item.baseUrl?.replace(/\/+$/, "") === cleanBaseUrl && Boolean(item.token)); + : (sessions.legacy || []).some((item) => item.baseUrl?.replace(/\/+$/, "") === cleanBaseUrl && item.authType === "session" && Boolean(item.sesskey && item.cookies?.length)); if (!available) throw new CliError("The selected UIT account is no longer saved. Sign in again."); sessions.active = { authType, baseUrl: cleanBaseUrl }; writeSessionsFile(sessions); @@ -227,10 +241,10 @@ export function deleteLegacySession(baseUrl?: string): void { if (baseUrl) { const clean = baseUrl.replace(/\/+$/, ""); sessions.legacy = (sessions.legacy || []).filter((item) => item.baseUrl !== clean); - if (sessions.active?.authType === "token" && sessions.active.baseUrl === clean) delete sessions.active; + if (sessions.active?.authType === "session" && sessions.active.baseUrl === clean) delete sessions.active; } else { sessions.legacy = []; - if (sessions.active?.authType === "token") delete sessions.active; + if (sessions.active?.authType === "session") delete sessions.active; } writeSessionsFile(sessions); cfg = undefined; diff --git a/src/desktop-service.ts b/src/desktop-service.ts index 2df9c5c..cb9fa75 100644 --- a/src/desktop-service.ts +++ b/src/desktop-service.ts @@ -4,37 +4,24 @@ import { createHash, randomUUID } from "node:crypto"; import { createInflateRaw } from "node:zlib"; import { homedir } from "node:os"; import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; -import { createTokenApiClient, credentialFreeUrl, defaultApiClient, MAX_PREVIEW_BYTES } from "./api.js"; +import { credentialFreeUrl, defaultApiClient, MAX_PREVIEW_BYTES } from "./api.js"; import { submitAssignmentFile } from "./assignment-submission.js"; -import { activateSession as selectActiveSession, get, save } from "./config.js"; -import { requestMobileToken } from "./commands.js"; +import { activateSession as selectActiveSession, get, type SessionAuthType } from "./config.js"; import { CodexClient } from "./codex-client.js"; import { readH5pActivity, type H5pContentSummary } from "./h5p.js"; import type { ApiClient, MoodleRecord } from "./types.js"; export { calendarMonth, listCalendarEvents, addAssignmentIntervals, calendarReminders } from "./calendar.js"; -export interface DesktopLoginInput { - username: string; - password: string; - baseUrl?: string; -} - export const CURRENT_SITE_BASE_URL = "https://courses.uit.edu.vn"; export interface DesktopSession { authenticated: boolean; - authMode?: "token" | "sso"; + authMode?: SessionAuthType; baseUrl?: string; userId?: number | null; } -export interface DesktopLoginResult { - session: DesktopSession; - api: ApiClient; - token?: string; -} - -export function activateSession(authMode: "token" | "sso", baseUrl: string): void { +export function activateSession(authMode: "sso" | "session", baseUrl: string): void { selectActiveSession(authMode, baseUrl); } @@ -47,7 +34,7 @@ export interface CourseSummary { startdate?: number; enddate?: number; baseUrl?: string; - authMode?: "token" | "sso"; + authMode?: SessionAuthType; siteLabel?: string; discoveredVia?: "url"; category?: { id?: number; name?: string }; @@ -141,7 +128,7 @@ function metadata(api: ApiClient, name: string, params: Record): // The default CLI client follows persisted configuration, unlike desktop session clients. let identity = ""; if (api === defaultApiClient) { - try { identity = createHash("sha256").update(`${get("baseUrl")}:${get("userId")}:${get("token")}`).digest("hex"); } + try { identity = createHash("sha256").update(JSON.stringify([get("baseUrl"), get("userId"), get("authType"), get("sesskey"), get("cookies")])).digest("hex"); } catch { /* The API reports missing CLI configuration when the call executes. */ } } const key = JSON.stringify([identity, name, params]); @@ -282,62 +269,13 @@ function unavailableFrom(value: unknown): Record | undefined { export function sessionStatus(): DesktopSession { try { const baseUrl = get("baseUrl").replace(/\/+$/, ""); - if (baseUrl === CURRENT_SITE_BASE_URL) { - return { authenticated: false, authMode: "sso", baseUrl, userId: get("userId") }; - } - return { authenticated: Boolean(get("token")), authMode: "token", baseUrl, userId: get("userId") }; + const authMode = get("authType"); + return { authenticated: Boolean(get("sesskey") && get("cookies")?.length), authMode, baseUrl, userId: get("userId") }; } catch { return { authenticated: false }; } } -export async function loginWithToken(input: DesktopLoginInput, persist = false): Promise { - const baseUrl = (input.baseUrl || "https://courses.uit.edu.vn").replace(/\/+$/, ""); - if (baseUrl === CURRENT_SITE_BASE_URL) { - throw new Error("The current UIT course site requires UIT SSO. Use the SSO sign-in button."); - } - const token = await requestMobileToken(baseUrl, input.username, input.password); - const url = new URL(`${baseUrl}/webservice/rest/server.php`); - url.searchParams.set("wstoken", token); - url.searchParams.set("wsfunction", "core_webservice_get_site_info"); - url.searchParams.set("moodlewsrestformat", "json"); - const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); - const info = (await response.json()) as MoodleRecord; - if (info.exception) throw new Error(String(info.message || "UIT authentication failed")); - const userId = Number(info.userid); - if (!Number.isInteger(userId) || userId <= 0) throw new Error("UIT did not return a valid student identity."); - if (persist) save(token, userId, baseUrl); - return { - session: { authenticated: true, authMode: "token", baseUrl, userId }, - api: createTokenApiClient(baseUrl, token), - token - }; -} - -export function createLegacySession(baseUrl: string, token: string, userId: number): DesktopLoginResult { - return { - session: { authenticated: true, authMode: "token", baseUrl, userId }, - api: createTokenApiClient(baseUrl, token), - token - }; -} - -export async function login(input: DesktopLoginInput): Promise { - return (await loginWithToken(input, true)).session; -} - -export function configuredLegacySession(): DesktopLoginResult | undefined { - try { - const session = sessionStatus(); - if (!session.authenticated || session.authMode !== "token" || !session.baseUrl) return undefined; - const token = get("token"); - return { session, api: createTokenApiClient(session.baseUrl, token), token }; - } catch { - return undefined; - } -} - function mapCourse(course: MoodleRecord): CourseSummary { return { id: Number(course.id), diff --git a/src/mcp-server.ts b/src/mcp-server.ts index d7a63ff..4583ab8 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -18,7 +18,7 @@ import { import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import type { ApiClient } from "./types.js"; -import { createTokenApiClient, createSessionApiClient } from "./api.js"; +import { createSessionApiClient } from "./api.js"; import { getActiveConfig } from "./config.js"; import { createUitToolExecutor, UIT_ASSIGNMENT_SUBMISSION_TOOL, UIT_TOOLS, type UitToolServices } from "./uit-tools.js"; import * as desktopService from "./desktop-service.js"; @@ -56,19 +56,11 @@ export function resolveAvailableSession(cwd: string = process.cwd()): { api: Api const config = getActiveConfig({ fresh: true }); const userId = Number(config.userId); if (!Number.isSafeInteger(userId) || userId <= 0) { - throw new Error("The active UIT session has no valid user ID. Sign in again or set UIT_USER_ID."); + throw new Error("The active UIT session has no valid user ID. Sign in again."); } - if (config.authType === "sso") { - if (!config.sesskey || !config.cookies) throw new Error("The active UIT SSO session is incomplete. Sign in again."); - return { - api: createSessionApiClient(config.baseUrl, config.sesskey, config.cookies), - userId, - baseUrl: config.baseUrl - }; - } - if (!config.token) throw new Error("The active UIT token session is incomplete. Sign in again."); + if (!config.sesskey || !config.cookies?.length) throw new Error("The active UIT browser session is incomplete. Sign in again."); return { - api: createTokenApiClient(config.baseUrl, config.token), + api: createSessionApiClient(config.baseUrl, config.sesskey, config.cookies), userId, baseUrl: config.baseUrl }; diff --git a/src/studio-sso.ts b/src/moodle-browser-login.ts similarity index 71% rename from src/studio-sso.ts rename to src/moodle-browser-login.ts index 38de3c5..f6bbf6f 100644 --- a/src/studio-sso.ts +++ b/src/moodle-browser-login.ts @@ -4,7 +4,7 @@ import { createRequire } from "node:module"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium, type Browser, type BrowserContext, type LaunchOptions } from "playwright"; -import type { SsoSessionData } from "./config.js"; +import type { MoodleBrowserSessionData } from "./config.js"; import { CliError } from "./output.js"; export const PLAYWRIGHT_VERSION = "1.63.0"; @@ -19,36 +19,54 @@ type ChromiumManifest = { executablePath: string; }; -export interface SsoBrowserRuntime { +export interface MoodleBrowserRuntime { executablePath(): string; launch(options?: LaunchOptions): Promise; } -export interface StudioSsoOptions { - runtime?: SsoBrowserRuntime; +export interface MoodleBrowserLoginOptions { + runtime?: MoodleBrowserRuntime; executablePath?: string; timeoutMs?: number; onStatus?: (message: string) => void; } -function normalizeBaseUrl(rawBaseUrl: string): string { +function parseBaseUrl(rawBaseUrl: string): URL { let parsed: URL; try { parsed = new URL(rawBaseUrl); } catch { - throw new CliError("UIT SSO requires a valid HTTPS course-site URL."); + throw new CliError("UIT login requires a valid HTTPS course-site URL."); } - if (parsed.protocol !== "https:" || parsed.hostname !== "courses.uit.edu.vn" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== "/") { + if (parsed.protocol !== "https:" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new CliError("UIT login requires an official HTTPS course-site URL without credentials, ports, or query parameters."); + } + return parsed; +} + +function normalizeSsoBaseUrl(rawBaseUrl: string): string { + const parsed = parseBaseUrl(rawBaseUrl); + if (parsed.hostname !== "courses.uit.edu.vn" || parsed.pathname !== "/") { throw new CliError("UIT SSO is available for the current UIT course site only."); } return parsed.origin; } +function normalizeLegacyBaseUrl(rawBaseUrl: string): string { + const parsed = parseBaseUrl(rawBaseUrl); + const pathname = parsed.pathname.replace(/\/+$/, ""); + if (parsed.hostname !== "coursesold.uit.edu.vn" || (pathname !== "" && pathname !== "/sdh")) { + throw new CliError("UIT Legacy login supports the undergraduate portal and the /sdh graduate portal only."); + } + return `${parsed.origin}${pathname}`; +} + function allowedNavigation(rawUrl: string, baseUrl: string): boolean { try { const target = new URL(rawUrl); const base = new URL(baseUrl); - return target.protocol === "https:" && (target.hostname === base.hostname || SSO_ALLOWED_HOSTS.has(target.hostname)); + const ssoRedirect = base.hostname === "courses.uit.edu.vn" && SSO_ALLOWED_HOSTS.has(target.hostname); + return target.protocol === "https:" && (target.hostname === base.hostname || ssoRedirect); } catch { return false; } @@ -66,11 +84,11 @@ function manifestPath(): string { return join(browserDirectory(), BROWSER_MANIFEST); } -function developmentExecutablePath(runtime: SsoBrowserRuntime): string | undefined { +function developmentExecutablePath(runtime: MoodleBrowserRuntime): string | undefined { // A source checkout may use the developer's Playwright cache for local tests // and `npm run dev`. Published packages and native artifacts must provide the // manifest below, so they never silently use that cache. - return existsSync(join(packageRoot(), "src", "studio-sso.ts")) ? runtime.executablePath() : undefined; + return existsSync(join(packageRoot(), "src", "moodle-browser-login.ts")) ? runtime.executablePath() : undefined; } function manifestExecutablePath(): string | undefined { @@ -94,7 +112,7 @@ function manifestExecutablePath(): string | undefined { return executablePath; } -function bundledExecutablePath(runtime: SsoBrowserRuntime, configured?: string): string { +function bundledExecutablePath(runtime: MoodleBrowserRuntime, configured?: string): string { const explicit = configured || process.env.UIT_STUDIO_CHROMIUM_EXECUTABLE; const executablePath = explicit || manifestExecutablePath() || developmentExecutablePath(runtime); if (executablePath && existsSync(executablePath)) return executablePath; @@ -132,7 +150,7 @@ function writeChromiumManifest(executablePath: string): void { writeFileSync(manifestPath(), `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o644 }); } -function sessionCookies(context: BrowserContext, baseUrl: string): Promise { +function sessionCookies(context: BrowserContext, baseUrl: string): Promise { return context.cookies(baseUrl).then((cookies) => cookies.map((cookie) => ({ name: cookie.name, value: cookie.value, @@ -143,12 +161,38 @@ function sessionCookies(context: BrowserContext, baseUrl: string): Promise 0 ? userId : 0; + } catch { + return 0; + } +} + function readIdentity(page: { evaluate(pageFunction: () => T): Promise }): Promise<{ sesskey: string; userId: number } | null> { return page.evaluate(() => { - const cfg = (globalThis as { M?: { cfg?: { sesskey?: unknown; userId?: unknown; userid?: unknown } } }).M?.cfg || {}; - const sesskey = String(cfg.sesskey || ""); - const userId = Number(cfg.userId || cfg.userid || 0); - return sesskey && Number.isInteger(userId) && userId > 0 ? { sesskey, userId } : null; + const cfg = (globalThis as { M?: { cfg?: { sesskey?: unknown } } }).M?.cfg || {}; + const loginInfo = document.querySelector(".logininfo"); + return { + sesskey: String(cfg.sesskey || ""), + origin: location.origin, + profileHref: loginInfo?.querySelector('a[href*="/user/profile.php"]')?.href || null + }; + }).then((snapshot) => { + const data = snapshot as MoodleIdentitySnapshot; + const sesskey = data.sesskey; + const userId = profileUserId(data.profileHref, data.origin); + return sesskey && Number.isSafeInteger(userId) && userId > 0 ? { sesskey, userId } : null; }).catch(() => null); } @@ -157,23 +201,30 @@ function readIdentity(page: { evaluate(pageFunction: () => T): Promise }): * shipped with the package. The context is intentionally ephemeral: only the * Moodle cookies, sesskey, and account ID leave the authentication browser. */ -export class StudioSsoService { - private readonly runtime: SsoBrowserRuntime; +export class MoodleBrowserLoginService { + private readonly runtime: MoodleBrowserRuntime; private readonly executablePath?: string; private readonly timeoutMs: number; private readonly onStatus?: (message: string) => void; private activeBrowser?: Browser; - constructor(options: StudioSsoOptions = {}) { + constructor(options: MoodleBrowserLoginOptions = {}) { this.runtime = options.runtime || chromium; this.executablePath = options.executablePath; this.timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS; this.onStatus = options.onStatus; } - async login(rawBaseUrl: string): Promise { - const baseUrl = normalizeBaseUrl(rawBaseUrl); - if (this.activeBrowser) throw new CliError("UIT SSO login is already in progress."); + async login(rawBaseUrl: string): Promise { + return this.loginAt(normalizeSsoBaseUrl(rawBaseUrl), "UIT SSO"); + } + + async loginLegacy(rawBaseUrl: string): Promise { + return this.loginAt(normalizeLegacyBaseUrl(rawBaseUrl), "UIT Legacy"); + } + + private async loginAt(baseUrl: string, portalName: string): Promise { + if (this.activeBrowser) throw new CliError("A UIT browser login is already in progress."); const executablePath = bundledExecutablePath(this.runtime, this.executablePath); const browser = await this.runtime.launch({ @@ -182,7 +233,7 @@ export class StudioSsoService { args: ["--window-size=980,760"] }); this.activeBrowser = browser; - this.onStatus?.("Opening bundled Chromium for UIT SSO login..."); + this.onStatus?.(`Opening bundled Chromium for ${portalName} login...`); try { const context = await browser.newContext({ viewport: { width: 980, height: 760 } }); @@ -194,12 +245,12 @@ export class StudioSsoService { }); const page = await context.newPage(); await page.goto(`${baseUrl}/login/index.php`, { waitUntil: "domcontentloaded", timeout: 60_000 }); - this.onStatus?.("Sign in with your UIT account in the Chromium window."); + this.onStatus?.(`Sign in to ${portalName} in the Chromium window.`); const startedAt = Date.now(); while (Date.now() - startedAt <= this.timeoutMs) { if (page.isClosed() || !browser.isConnected()) { - throw new CliError("UIT SSO login window was closed before login completed."); + throw new CliError(`${portalName} login window was closed before login completed.`); } let currentUrl = ""; try { currentUrl = page.url(); } catch { /* The page may be closing during a redirect. */ } @@ -217,7 +268,7 @@ export class StudioSsoService { } await new Promise((resolve) => setTimeout(resolve, 250)); } - throw new CliError("UIT SSO login timed out. Please try again."); + throw new CliError(`${portalName} login timed out. Please try again.`); } finally { await browser.close().catch(() => undefined); this.activeBrowser = undefined; diff --git a/src/moodle-session-client.ts b/src/moodle-session-client.ts index 8a5978b..d0c5c80 100644 --- a/src/moodle-session-client.ts +++ b/src/moodle-session-client.ts @@ -1,4 +1,4 @@ -import { fetchCourseFile, readCourseFile, writeCourseFile } from "./api.js"; +import { fetchCourseFile, readCourseFile, resolveMoodleUrl, writeCourseFile } from "./api.js"; import type { ApiClient, MoodleRecord } from "./types.js"; export interface BrowserSessionTransport { @@ -66,7 +66,7 @@ export class MoodleSessionApi implements ApiClient { } private async pageQuery(path: string, mapper: string): Promise { - const url = new URL(path, this.baseUrl).toString(); + const url = resolveMoodleUrl(this.baseUrl, path).toString(); if (new URL(url).origin !== new URL(this.baseUrl).origin) throw new Error("Course page belongs to another origin."); const script = `(async()=>{const pageUrl=${JSON.stringify(url)};const response=await fetch(pageUrl,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(30000)});if(!response.ok)throw new Error("HTTP "+response.status+": "+response.statusText);if(!/^(text\\/html|application\\/xhtml\\+xml)(;|$)/i.test(response.headers.get('content-type')||'')||/attachment/i.test(response.headers.get('content-disposition')||'')){await response.body?.cancel();throw new Error('Expected a Moodle HTML page, not a download.');}const html=await response.text();const doc=new DOMParser().parseFromString(html,"text/html");if(doc.querySelector('input[name="logintoken"],input[type="password"]'))throw new Error("UIT session expired. Please sign in again.");if(doc.querySelector('.errorbox,[data-rel="fatalerror"]'))throw new Error('Moodle could not display this page.');return (${mapper})(doc,pageUrl);})()`; return await this.transport.execute(script) as T; @@ -175,7 +175,7 @@ export class MoodleSessionApi implements ApiClient { if(value>0)result[field]=value; } const grader=links.find((url)=>url.pathname.endsWith('/mod/assign/view.php')&&Number(url.searchParams.get('id'))===${Number(module.id)}&&url.searchParams.get('action')==='grader'); - if(modname==='assign'&&!result.instance&&grader)result.graderUrl=new URL('/mod/assign/view.php?id='+${Number(module.id)}+'&action=grader',pageUrl).toString(); + if(modname==='assign'&&!result.instance&&grader){const prefix=new URL(pageUrl).pathname.match(/^\/sdh(?:\/|$)/)?'/sdh':'';result.graderUrl=new URL(prefix+'/mod/assign/view.php?id='+${Number(module.id)}+'&action=grader',pageUrl).toString();} return result; }`); // The grader app exposes data-assignmentid. Only visit an existing read-only @@ -241,7 +241,8 @@ export class MoodleSessionApi implements ApiClient { const times=Array.from(row.querySelectorAll('time[data-timestamp]')).map((time)=>Number(time.dataset.timestamp)); const replies=row.querySelector('.replies a,.replies,td.text-center span'); const count=Number(replies?.textContent?.trim()); - return {discussion,name:link?.getAttribute('title')||link?.textContent?.trim()||'',url:new URL('/mod/forum/discuss.php?d='+discussion,pageUrl).toString(),userfullname:row.querySelector('.author .author-info > div,.author a[href*="/user/"]')?.textContent?.trim(),...(times[0]?{created:times[0]}:{}),...(times.length?{timemodified:times[times.length-1]}:{}),...(replies&&Number.isFinite(count)?{numreplies:count}:{})}; + const prefix=new URL(pageUrl).pathname.match(/^\/sdh(?:\/|$)/)?'/sdh':''; + return {discussion,name:link?.getAttribute('title')||link?.textContent?.trim()||'',url:new URL(prefix+'/mod/forum/discuss.php?d='+discussion,pageUrl).toString(),userfullname:row.querySelector('.author .author-info > div,.author a[href*="/user/"]')?.textContent?.trim(),...(times[0]?{created:times[0]}:{}),...(times.length?{timemodified:times[times.length-1]}:{}),...(replies&&Number.isFinite(count)?{numreplies:count}:{})}; }).filter(Boolean); }`); for (let start = 0; start < discussions.length; start += 4) { diff --git a/src/notification-commands.ts b/src/notification-commands.ts new file mode 100644 index 0000000..9472460 --- /dev/null +++ b/src/notification-commands.ts @@ -0,0 +1,88 @@ +import { Command, InvalidArgumentError } from "commander"; +import { get } from "./config.js"; +import { createNotificationHandlers } from "./notifications.js"; +import { CliError, htmlToText, isJsonMode, out, table, ts } from "./output.js"; +import type { ApiClient, MoodleRecord } from "./types.js"; + +function parseNumber(value: string, minimum: number): number { + const number = Number(value); + if (!/^\d+$/.test(value) || !Number.isSafeInteger(number) || number < minimum) { + throw new InvalidArgumentError(`Expected an integer greater than or equal to ${minimum}.`); + } + return number; +} + +function displayText(value: unknown): string { + // Remote message content must not emit terminal control sequences. + return htmlToText(String(value || "")).replace(/[\p{Cc}\p{Cf}]/gu, (character) => character === "\n" || character === "\t" ? character : ""); +} + +export function registerNotificationCommands(program: Command, api: ApiClient): void { + async function run(action: keyof ReturnType, args: MoodleRecord = {}): Promise { + const userId = get("userId"); + if (!Number.isSafeInteger(userId) || !userId || userId < 1) throw new CliError("The active account has no valid user ID.", "Run uit login, or set UIT_USER_ID when using UIT_TOKEN."); + const account = { userId, baseUrl: get("baseUrl"), api }; + return createNotificationHandlers(() => [account])[action]({ ...args, baseUrl: account.baseUrl, userId }); + } + + function page(result: MoodleRecord, kind: "notifications" | "inbox" | "messages") { + if (isJsonMode()) { out(result); return; } + const rows = result.items.map((item: MoodleRecord) => ({ + id: item.id, + state: kind === "notifications" ? (item.read ? "Read" : "Unread") : String(item.unread || 0), + date: ts(item.timecreated || item.messages?.[0]?.timecreated), + text: displayText(kind === "notifications" ? item.subject : kind === "inbox" ? item.name : item.text), + sender: result.members?.find((member: MoodleRecord) => member.id === item.userId)?.name || item.userId + })); + if (kind === "messages") { + for (const row of rows.reverse()) { + console.log(`[${row.id}] ${displayText(row.sender)} · ${row.date}\n${row.text}\n`); + } + if (!rows.length) out("(no messages)"); + } else { + table(rows, [["id", "ID", 10], ["state", kind === "notifications" ? "Status" : "Unread", 8], ["date", "Date", 19], ["text", kind === "notifications" ? "Subject" : "Conversation", 0]]); + if (kind === "notifications") out(`Unread notifications: ${result.unread}`); + } + if (result.nextOffset !== null) out(`More available: repeat this command with --offset ${result.nextOffset}`); + } + + const notifications = program.command("notifications").description("Moodle notifications for the active account (separate from course announcements)"); + notifications.command("list").description("List the latest 20 notifications without marking them read") + .option("--offset ", "Skip this many notifications", (value) => parseNumber(value, 0), 0) + .option("--full", "Include notification bodies in text output") + .action(async (options) => { + const result = await run("notifications:list", { offset: options.offset }); + page(result, "notifications"); + if (options.full && !isJsonMode()) for (const item of result.items) out(`\n[${item.id}] ${displayText(item.subject)}\n${displayText(item.text)}`); + }); + notifications.command("counts").description("Show unread notification and conversation counts") + .action(async () => { + const [notifications, conversations] = await run("notifications:counts"); + if (notifications === null && conversations === null) throw new CliError("Could not read unread counts.", "Check your active account with uit notifications list or sign in again."); + out({ notifications: notifications ?? (isJsonMode() ? null : "Unavailable"), conversations: conversations ?? (isJsonMode() ? null : "Unavailable") }); + }); + notifications.command("read").description("Mark one notification as read on Moodle") + .argument("", "Notification ID", (value) => parseNumber(value, 1)) + .action(async (id) => { await run("notifications:read", { id }); out({ status: "read", notificationId: id }); }); + notifications.command("read-all").description("Mark all notifications in the active account as read on Moodle") + .action(async () => { await run("notifications:read", { all: true }); out({ status: "read", all: true }); }); + + const inbox = program.command("inbox").description("Moodle conversations for the active account"); + inbox.command("list").description("List 20 conversations without marking messages read") + .option("--offset ", "Skip this many conversations", (value) => parseNumber(value, 0), 0) + .action(async (options) => page(await run("inbox:list", { offset: options.offset }), "inbox")); + inbox.command("messages").description("Read the latest 20 messages; larger offsets load older messages") + .argument("", "Conversation ID", (value) => parseNumber(value, 1)) + .option("--offset ", "Skip this many newest messages", (value) => parseNumber(value, 0), 0) + .action(async (id, options) => page(await run("inbox:messages", { id, offset: options.offset }), "messages")); + inbox.command("read").description("Mark all messages in a conversation as read on Moodle") + .argument("", "Conversation ID", (value) => parseNumber(value, 1)) + .action(async (id) => { await run("inbox:read", { id }); out({ status: "read", conversationId: id }); }); + inbox.command("send").description("Send one plain-text reply to an existing conversation (never retried automatically)") + .argument("", "Conversation ID", (value) => parseNumber(value, 1)) + .argument("", "Message text, at most 4096 UTF-8 bytes") + .action(async (id, text) => { + const messages = await run("inbox:send", { id, text }); + out({ status: "sent", conversationId: id, messageIds: messages.map((message: MoodleRecord) => message.id) }); + }); +} diff --git a/src/notifications.ts b/src/notifications.ts new file mode 100644 index 0000000..74da40a --- /dev/null +++ b/src/notifications.ts @@ -0,0 +1,96 @@ +import type { ApiClient, MoodleRecord } from "./types.js"; + +type Account = { baseUrl: string; userId: number; api: ApiClient }; +const PAGE_SIZE = 20; + +function integer(value: unknown, minimum = 1): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) throw new Error("Invalid ID or page offset."); + return value; +} + +function safeLink(value: unknown, baseUrl: string): string | undefined { + if (typeof value !== "string" || !value) return; + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.origin !== new URL(baseUrl).origin || url.username || url.password) return; + if ([...url.searchParams.keys()].some((key) => /token|sesskey|password/i.test(key))) return; + return url.href; + } catch { return; } +} + +function message(item: MoodleRecord) { + return { id: item.id, userId: item.useridfrom, text: String(item.text || ""), timecreated: item.timecreated }; +} + +export function createNotificationHandlers(accounts: () => Account[], openExternal?: (url: string) => Promise) { + const links = new WeakMap>(); + const handler = (operation: (account: Account, input: MoodleRecord) => Promise) => async (raw: unknown) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Account input is required."); + const input = raw as MoodleRecord; + const userId = integer(input.userId); + const account = accounts().find((item) => item.baseUrl === input.baseUrl && item.userId === userId); + if (!account) throw new Error("This account is disconnected. Reconnect it in Course accounts."); + try { + const result = await operation(account, input); + if (!accounts().some((item) => item.api === account.api && item.userId === account.userId)) throw new Error("Account changed. Refresh this view."); + return result; + } catch (error) { + const code = (error as { errorcode?: string }).errorcode; + if (["servicerequireslogin", "invalidtoken", "requireloginerror"].includes(code || "")) throw new Error("Session expired. Sign in to this account again.", { cause: error }); + if (["servicenotavailable", "accessexception", "disabled"].includes(code || "")) throw new Error("This Moodle site does not allow this operation for your account.", { cause: error }); + throw error; + } + }; + return { + "notifications:counts": handler(async ({ api, userId }) => { + const results = await Promise.allSettled([ + api.call("message_popup_get_unread_popup_notification_count", { useridto: userId }), + api.call("core_message_get_unread_conversations_count", { useridto: userId }) + ]); + return results.map((result) => result.status === "fulfilled" ? Number(result.value) : null); + }), + "notifications:list": handler(async ({ api, userId, baseUrl }, input) => { + const offset = integer(input.offset ?? 0, 0); + const result = await api.call("message_popup_get_popup_notifications", { useridto: userId, newestfirst: 1, limit: PAGE_SIZE, offset }); + const items = (result.notifications || []).map((entry: MoodleRecord) => { + const url = safeLink(entry.contexturl, baseUrl); + let cached = links.get(api); + if (!cached) { cached = new Map(); links.set(api, cached); } + if (url) cached.set(entry.id, url); + return { id: entry.id, subject: String(entry.subject || "Notification"), text: String(entry.fullmessagehtml || entry.fullmessage || entry.text || ""), timecreated: entry.timecreated, read: entry.read === true || entry.read === 1, canOpen: Boolean(url) }; + }); + return { items, unread: Number(result.unreadcount), nextOffset: items.length === PAGE_SIZE ? offset + PAGE_SIZE : null }; + }), + "notifications:read": handler(async ({ api, userId }, input) => input.all === true + ? api.call("core_message_mark_all_notifications_as_read", { useridto: userId }) + : api.call("core_message_mark_notification_read", { notificationid: integer(input.id) })), + "notifications:open": handler(async ({ api }, input) => { + const url = links.get(api)?.get(integer(input.id)); + if (!url) throw new Error("Refresh notifications before opening this link."); + if (!openExternal) throw new Error("Opening notification links is unavailable in this client."); + await openExternal(url); + }), + "inbox:list": handler(async ({ api, userId }, input) => { + const offset = integer(input.offset ?? 0, 0); + const result = await api.call("core_message_get_conversations", { userid: userId, limitfrom: offset, limitnum: PAGE_SIZE }); + const items = (result.conversations || []).map((entry: MoodleRecord) => ({ + id: entry.id, name: String(entry.name || (entry.members || []).filter((member: MoodleRecord) => member.id !== userId).map((member: MoodleRecord) => member.fullname).join(", ") || "Personal notes"), + unread: Number(entry.unreadcount || 0), messages: (entry.messages || []).map(message) + })); + return { items, nextOffset: items.length === PAGE_SIZE ? offset + PAGE_SIZE : null }; + }), + "inbox:messages": handler(async ({ api, userId }, input) => { + const offset = integer(input.offset ?? 0, 0); + const result = await api.call("core_message_get_conversation_messages", { currentuserid: userId, convid: integer(input.id), limitfrom: offset, limitnum: PAGE_SIZE, newest: 1 }); + const items = (result.messages || []).map(message); + return { items, members: (result.members || []).map((member: MoodleRecord) => ({ id: member.id, name: String(member.fullname || "Member") })), nextOffset: items.length === PAGE_SIZE ? offset + PAGE_SIZE : null }; + }), + "inbox:read": handler(async ({ api, userId }, input) => api.call("core_message_mark_all_conversation_messages_as_read", { userid: userId, conversationid: integer(input.id) })), + "inbox:send": handler(async ({ api }, input) => { + const id = integer(input.id); + if (typeof input.text !== "string" || !input.text.trim() || Buffer.byteLength(input.text, "utf8") > 4096) throw new Error("Write a message of at most 4096 UTF-8 bytes."); + const result = await api.call("core_message_send_messages_to_conversation", { conversationid: id, messages: [{ text: input.text, textformat: 2 }] }); + return result.map(message); + }) + }; +} diff --git a/src/session-health.ts b/src/session-health.ts index bdd18ae..4b77135 100644 --- a/src/session-health.ts +++ b/src/session-health.ts @@ -11,8 +11,6 @@ const authenticationErrorCodes = new Set([ "servicerequireslogin", "invalidsesskey", "notloggedin", - "invalidtoken", - "tokenexpired", "sessionexpired", "sessionnotauthenticated", "notauthenticated", @@ -29,7 +27,7 @@ function isAuthenticationError(value: unknown): boolean { const item = record(value); const code = String(item?.errorcode || "").toLowerCase().replace(/[\s_-]+/g, ""); if (authenticationErrorCodes.has(code)) return true; - return /(?:session|token|sesskey|authentication)\s+(?:is\s+)?(?:expired|invalid|failed)|(?:session|token|sesskey)\s+(?:has\s+)?expired|(?:not\s+authenticated|not\s+logged\s+in)|(?:sign|log)\s+in\s+again|(?:requires?|needs?)\s+(?:a\s+)?login|invalid\s+(?:session|token|sesskey)|phiên\s+đăng\s+nhập\s+đã\s+(?:hết\s+hạn|đăng\s+xuất)|dịch\s+vụ\s+web\s+không\s+tồn\s+tại|token\s+không\s+(?:hợp\s+lệ|được\s+tìm\s+thấy)/i.test(String(item?.message || value)); + return /(?:session|sesskey|authentication)\s+(?:is\s+)?(?:expired|invalid|failed)|(?:session|sesskey)\s+(?:has\s+)?expired|(?:not\s+authenticated|not\s+logged\s+in)|(?:sign|log)\s+in\s+again|(?:requires?|needs?)\s+(?:a\s+)?login|invalid\s+(?:session|sesskey)|phiên\s+đăng\s+nhập\s+đã\s+(?:hết\s+hạn|đăng\s+xuất)/i.test(String(item?.message || value)); } /** Classify a failed live account request without exposing provider error text to the UI. */ diff --git a/src/sso-login.ts b/src/sso-login.ts index 827c099..e1f6399 100644 --- a/src/sso-login.ts +++ b/src/sso-login.ts @@ -1,23 +1,34 @@ -import { saveSsoSession, type SsoSessionData } from "./config.js"; -import { StudioSsoService } from "./studio-sso.js"; +import { saveLegacyBrowserSession, saveSsoSession, type MoodleBrowserSessionData } from "./config.js"; +import { MoodleBrowserLoginService } from "./moodle-browser-login.js"; import { out, loading } from "./output.js"; -export type SsoLoginLauncher = (baseUrl: string) => Promise; +export type SsoLoginLauncher = (baseUrl: string) => Promise; -export async function defaultSsoLauncher(baseUrl: string): Promise { - loading("Opening browser for UIT SSO login..."); - const service = new StudioSsoService({ +async function defaultBrowserLogin(baseUrl: string, authType: "sso" | "legacy"): Promise { + const portalName = authType === "sso" ? "UIT SSO" : "UIT Legacy"; + loading(`Opening browser for ${portalName} login...`); + const service = new MoodleBrowserLoginService({ onStatus: (message) => { if (!message.startsWith("Opening bundled Chromium")) console.error(message); } }); - return service.login(baseUrl); + return authType === "sso" ? service.login(baseUrl) : service.loginLegacy(baseUrl); +} + +export function defaultSsoLauncher(baseUrl: string): Promise { + return defaultBrowserLogin(baseUrl, "sso"); +} + +export type LegacyLoginLauncher = (baseUrl: string) => Promise; + +export function defaultLegacyLauncher(baseUrl: string): Promise { + return defaultBrowserLogin(baseUrl, "legacy"); } export async function cmdLoginSso( args: { url?: string }, launcher: SsoLoginLauncher = defaultSsoLauncher -): Promise { +): Promise { const baseUrl = (args.url || "https://courses.uit.edu.vn").replace(/\/+$/, ""); const sessionData = await launcher(baseUrl); @@ -33,3 +44,22 @@ export async function cmdLoginSso( console.error(`\n✓ Successfully signed in via SSO as user ID ${sessionData.userId}.`); return sessionData; } + +export async function cmdLoginLegacy( + args: { url: string }, + launcher: LegacyLoginLauncher = defaultLegacyLauncher +): Promise { + const baseUrl = args.url.replace(/\/+$/, ""); + const sessionData = await launcher(baseUrl); + saveLegacyBrowserSession(sessionData); + + out({ + status: "ok", + auth: "legacy-session", + user_id: sessionData.userId, + site: baseUrl + }); + + console.error(`\n✓ Successfully signed in to UIT Legacy as user ID ${sessionData.userId}.`); + return sessionData; +} diff --git a/src/studio-core.ts b/src/studio-core.ts index 7feb6ca..87ef1b1 100644 --- a/src/studio-core.ts +++ b/src/studio-core.ts @@ -1,11 +1,12 @@ import type { CalendarEvent } from "./calendar.js"; +import { createNotificationHandlers } from "./notifications.js"; import { classifySessionError, type SessionHealthState } from "./session-health.js"; import { existsSync } from "node:fs"; import { lstat, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import type { ApiClient } from "./types.js"; -import type { SsoSessionData } from "./config.js"; +import { readSessionsFile, resetConfigCache, writeSessionsFile, type LegacySessionData, type MoodleBrowserSessionData, type MoodleSessionCookie, type SessionsData } from "./config.js"; import { isCodexThreadNotFoundError, type CodexJsonValue, @@ -27,17 +28,20 @@ import { readStudioThreadStore, writeStudioThreadStore } from "./studio-thread-s import { UIT_ASSIGNMENT_SUBMISSION_TOOL } from "./uit-tools.js"; type JsonRecord = Record; -export interface StudioSsoResult { - session: SsoSessionData; +export interface StudioBrowserLoginResult { + session: MoodleBrowserSessionData; api: ApiClient; } export interface StudioHost { readonly userDataPath: string; /** Authenticate in the package-owned Playwright Chromium context. */ - ssoLogin: (baseUrl: string) => Promise; + ssoLogin: (baseUrl: string) => Promise; + /** Authenticate a legacy Moodle portal in the same managed Chromium context. */ + legacyLogin: (baseUrl: string) => Promise; /** Restore the shared session store without opening an authentication browser. */ - restoreSsoSession: (session: SsoSessionData) => Promise; + restoreSsoSession: (session: MoodleBrowserSessionData) => Promise; + restoreLegacySession: (session: MoodleBrowserSessionData) => Promise; /** Cancel an active authentication browser and optionally clear its storage. */ clearSsoBrowserData: (options: { clearStorage: boolean }) => Promise; ensureMcpConfig(): Promise; @@ -52,7 +56,7 @@ type CourseReference = { courseId: number; baseUrl?: string; userId?: number }; type ConnectedCourse = CourseSummary & { baseUrl: string; userId: number; - authMode: "token" | "sso"; + authMode: "sso" | "session"; siteLabel: string; discoveredVia?: "url"; }; @@ -63,10 +67,11 @@ type AuthenticatedCourseSession = { baseUrl: string; userId: number; api: ApiClient; - authMode: "token" | "sso"; - token?: string; + authMode: "sso" | "session"; + sesskey?: string; + cookies?: MoodleSessionCookie[]; }; -type SsoSession = Omit & { sesskey: string }; +type SsoSession = Omit & { authMode: "sso"; sesskey: string; cookies: MoodleSessionCookie[] }; type ThreadBinding = CourseReference & { baseUrl: string; userId: number; @@ -197,6 +202,8 @@ let codex!: CodexClient; let ssoSession: SsoSession | undefined; let webSsoLoginPromise: Promise | undefined; let webSsoLoginId: symbol | undefined; +const legacyBrowserLoginPromises = new Map>(); +const legacyBrowserLoginIds = new Map(); const legacySessions = new Map(); const threadBindings = new Map(); const approvals = new Map(); @@ -216,7 +223,6 @@ let studioLifecycleWrite = Promise.resolve(); let studioTurnInterruption: Promise | undefined; let studioLifecycleClosed = false; let idleLockTimer: NodeJS.Timeout | undefined; -const SESSIONS_FILE = join(homedir(), ".uit", "sessions.json"); const LINKED_COURSES_STORE_VERSION = 2; const CURRENT_SITE_BASE_URL = "https://courses.uit.edu.vn"; @@ -256,16 +262,6 @@ async function loadService() { // Startup should not make the Studio unavailable. Starting a thread does // require this preflight and will surface an actionable error instead. } - const configured = process.env.UIT_DISABLE_CONFIG === "1" ? undefined : service.configuredLegacySession?.(); - if (configured?.session?.baseUrl && typeof configured.session.userId === "number") { - legacySessions.set(configured.session.baseUrl, { - baseUrl: configured.session.baseUrl, - userId: configured.session.userId, - authMode: "token", - api: configured.api, - token: configured.token - }); - } await restorePersistedLegacySessions(); await restorePersistedSsoSession(); await restorePersistedThreadBindings(); @@ -614,43 +610,27 @@ function requireCourseFileUrl(value: unknown, baseUrl: string): string { return fileUrl.toString(); } -async function readPersistedSessions(): Promise { - try { - const raw = JSON.parse(await readFile(SESSIONS_FILE, "utf8")); - if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw; - return {}; - } catch { - return {}; - } +function readPersistedSessions(): SessionsData { + return readSessionsFile(); } -async function writePersistedSessions(data: JsonRecord): Promise { - if (process.env.UIT_DISABLE_CONFIG === "1") return; - try { - const dir = join(homedir(), ".uit"); - await mkdir(dir, { recursive: true }); - await writeFile(`${SESSIONS_FILE}.part`, JSON.stringify(data, null, 2), { mode: 0o600 }); - await rename(`${SESSIONS_FILE}.part`, SESSIONS_FILE); - } catch (error) { - console.error("Could not persist sessions:", errorMessage(error)); - } +function writePersistedSessions(data: SessionsData): void { + writeSessionsFile(data); + resetConfigCache(); } -async function persistSsoSession(sessionData: JsonRecord): Promise { +async function persistSsoSession(sessionData: MoodleBrowserSessionData): Promise { if (process.env.UIT_DISABLE_CONFIG === "1") return; - try { - const data = await readPersistedSessions(); - data.sso = sessionData; - await writePersistedSessions(data); - } catch (error) { - console.error("Could not persist SSO session:", errorMessage(error)); - } + const data = readPersistedSessions(); + data.sso = sessionData; + data.active = { authType: "sso", baseUrl: sessionData.baseUrl }; + writePersistedSessions(data); } -function normalizeSsoSessionData(value: unknown, expectedBaseUrl?: string): SsoSessionData { - if (!isRecord(value)) throw new Error("The SSO provider returned an invalid session."); +function normalizeBrowserSessionData(value: unknown, expectedBaseUrl?: string): MoodleBrowserSessionData { + if (!isRecord(value)) throw new Error("The Moodle browser login returned an invalid session."); const baseUrl = normalizeSiteUrl(value.baseUrl); - if (expectedBaseUrl && baseUrl !== expectedBaseUrl) throw new Error("The SSO provider returned a different course site."); + if (expectedBaseUrl && baseUrl !== expectedBaseUrl) throw new Error("The Moodle browser login returned a different course site."); const userId = Number(value.userId); const sesskey = typeof value.sesskey === "string" ? value.sesskey : ""; const cookies = Array.isArray(value.cookies) @@ -661,33 +641,41 @@ function normalizeSsoSessionData(value: unknown, expectedBaseUrl?: string): SsoS ...(typeof cookie.path === "string" ? { path: cookie.path } : {}), ...(typeof cookie.secure === "boolean" ? { secure: cookie.secure } : {}), ...(typeof cookie.httpOnly === "boolean" ? { httpOnly: cookie.httpOnly } : {}) - })).filter((cookie) => cookie.name.length > 0 && cookie.value.length > 0) + })).filter((cookie) => /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(cookie.name) && cookie.value.length > 0 && !/[;\r\n]/.test(cookie.value)) : []; if (!Number.isSafeInteger(userId) || userId <= 0 || !sesskey || cookies.length === 0) { - throw new Error("The SSO provider returned an incomplete session."); + throw new Error("The Moodle browser login returned an incomplete session."); } return { baseUrl, userId, sesskey, cookies, savedAt: Date.now() }; } -function normalizeSsoResult(value: unknown, expectedBaseUrl?: string): StudioSsoResult { +function normalizeBrowserLoginResult(value: unknown, expectedBaseUrl?: string): StudioBrowserLoginResult { if (!isRecord(value) || !isRecord(value.api) || typeof value.api.call !== "function") { - throw new Error("The SSO provider did not return a usable course API."); + throw new Error("The Moodle browser login did not return a usable course API."); } return { - session: normalizeSsoSessionData(value.session, expectedBaseUrl), + session: normalizeBrowserSessionData(value.session, expectedBaseUrl), api: value.api as ApiClient }; } -async function installSsoResult(result: StudioSsoResult, expectedBaseUrl: string): Promise { - const normalized = normalizeSsoResult(result, expectedBaseUrl); +async function installSsoResult(result: StudioBrowserLoginResult, expectedBaseUrl: string, persist = true): Promise { + const normalized = normalizeBrowserLoginResult(result, expectedBaseUrl); + const previous = ssoSession; ssoSession = { baseUrl: normalized.session.baseUrl, userId: normalized.session.userId, + authMode: "sso", sesskey: normalized.session.sesskey, + cookies: normalized.session.cookies, api: normalized.api }; - await persistSsoSession(normalized.session); + try { + if (persist) await persistSsoSession(normalized.session); + } catch (error) { + ssoSession = previous; + throw error; + } return { authenticated: true, authMode: "sso", @@ -696,30 +684,57 @@ async function installSsoResult(result: StudioSsoResult, expectedBaseUrl: string }; } +async function installLegacyBrowserResult(result: StudioBrowserLoginResult, expectedBaseUrl: string): Promise { + const normalized = normalizeBrowserLoginResult(result, expectedBaseUrl); + const session: AuthenticatedCourseSession = { + baseUrl: normalized.session.baseUrl, + userId: normalized.session.userId, + authMode: "session", + sesskey: normalized.session.sesskey, + cookies: normalized.session.cookies, + api: normalized.api + }; + const previous = legacySessions.get(session.baseUrl); + legacySessions.set(session.baseUrl, session); + try { + await persistLegacySessions(session.baseUrl); + } catch (error) { + if (previous) legacySessions.set(session.baseUrl, previous); + else legacySessions.delete(session.baseUrl); + throw error; + } + await disconnectAccount(session.baseUrl); + return { authenticated: true, authMode: "session", baseUrl: session.baseUrl, userId: session.userId }; +} + async function deletePersistedSsoSession() { if (process.env.UIT_DISABLE_CONFIG === "1") return; - try { - const data = await readPersistedSessions(); - delete data.sso; - await writePersistedSessions(data); - } catch { /* A missing persisted SSO session is harmless. */ } + const data = readPersistedSessions(); + delete data.sso; + if (data.active?.authType === "sso") delete data.active; + writePersistedSessions(data); } -async function persistLegacySessions(): Promise { +async function persistLegacySessions(activeBaseUrl?: string): Promise { if (process.env.UIT_DISABLE_CONFIG === "1") return; - try { - const records = []; - for (const session of legacySessions.values()) { - if (session.baseUrl && session.userId && session.token) { - records.push({ baseUrl: session.baseUrl, userId: session.userId, token: session.token }); - } + const records: LegacySessionData[] = []; + for (const session of legacySessions.values()) { + if (session.baseUrl && session.userId && session.authMode === "session" && session.sesskey && session.cookies?.length) { + records.push({ + authType: "session", + baseUrl: session.baseUrl, + userId: session.userId, + sesskey: session.sesskey, + cookies: session.cookies + }); } - const data = await readPersistedSessions(); - data.legacy = records; - await writePersistedSessions(data); - } catch (error) { - console.error("Could not persist legacy sessions:", errorMessage(error)); } + const data = readPersistedSessions(); + data.legacy = records; + const activeRecord = activeBaseUrl ? records.find((record) => record.baseUrl === activeBaseUrl) : undefined; + if (activeRecord) data.active = { authType: "session", baseUrl: activeRecord.baseUrl }; + else if (data.active?.authType === "session" && !records.some((record) => record.baseUrl === data.active?.baseUrl)) delete data.active; + writePersistedSessions(data); } async function restorePersistedLegacySessions() { @@ -728,16 +743,20 @@ async function restorePersistedLegacySessions() { const data = await readPersistedSessions(); if (Array.isArray(data.legacy)) { for (const item of data.legacy) { - if (item && item.baseUrl && item.token && item.userId) { + if (item && item.baseUrl && item.userId) { const baseUrl = normalizeSiteUrl(item.baseUrl); - if (!isCurrentSite(baseUrl) && service.createLegacySession) { - const restored = service.createLegacySession(baseUrl, item.token, Number(item.userId)); + if (isCurrentSite(baseUrl)) continue; + if (item.authType === "session" && item.sesskey && Array.isArray(item.cookies)) { + const saved = normalizeBrowserSessionData(item, baseUrl); + const restored = await host.restoreLegacySession(saved); + if (!restored || restored.session.userId !== saved.userId) continue; legacySessions.set(baseUrl, { baseUrl, - userId: Number(item.userId), - authMode: "token", - api: restored.api, - token: item.token + userId: saved.userId, + authMode: "session", + sesskey: saved.sesskey, + cookies: saved.cookies, + api: restored.api }); } } @@ -755,11 +774,11 @@ async function restorePersistedSsoSession() { const saved = data?.sso; if (!saved || !saved.baseUrl || !saved.userId || !saved.sesskey) return; - const savedSession = normalizeSsoSessionData(saved); + const savedSession = normalizeBrowserSessionData(saved); const restored = await host.restoreSsoSession(savedSession); if (!restored) return; if (restored.session.userId !== savedSession.userId) throw new Error("The restored SSO account did not match the saved account."); - await installSsoResult(restored, savedSession.baseUrl); + await installSsoResult(restored, CURRENT_SITE_BASE_URL, false); } catch (error) { console.error("Could not auto-restore SSO session:", errorMessage(error)); } @@ -969,6 +988,8 @@ async function readThreadRollout(threadId: string, afterMtime = 0): Promise { webSsoLoginId = undefined; webSsoLoginPromise = undefined; + legacyBrowserLoginIds.clear(); + legacyBrowserLoginPromises.clear(); ssoSession = undefined; await host.clearSsoBrowserData({ clearStorage }); if (clearStorage) { @@ -1002,6 +1023,29 @@ async function startSsoLogin(rawBaseUrl: unknown, forceReauthentication = false) return promise; } +async function startLegacyBrowserLogin(rawBaseUrl: unknown): Promise { + const baseUrl = normalizeSiteUrl(rawBaseUrl); + if (isCurrentSite(baseUrl)) throw new Error("The current UIT course site requires UIT SSO."); + const pending = legacyBrowserLoginPromises.get(baseUrl); + if (pending) return pending; + + const loginId = Symbol("legacy-browser-login"); + const promise = (async () => { + const result = await host.legacyLogin(baseUrl); + if (legacyBrowserLoginIds.get(baseUrl) !== loginId) throw new Error("UIT Legacy login was cancelled."); + return installLegacyBrowserResult(result, baseUrl); + })(); + promise.finally(() => { + if (legacyBrowserLoginIds.get(baseUrl) === loginId) { + legacyBrowserLoginIds.delete(baseUrl); + legacyBrowserLoginPromises.delete(baseUrl); + } + }).catch(() => undefined); + legacyBrowserLoginIds.set(baseUrl, loginId); + legacyBrowserLoginPromises.set(baseUrl, promise); + return promise; +} + async function verifiedCourse(rawInput: unknown): Promise { const reference = courseSession(rawInput); const key = JSON.stringify([reference.session.baseUrl, reference.session.userId, reference.courseId]); @@ -1407,6 +1451,7 @@ async function checkCalendarReminders(): Promise { export function createStudioHandlers(): Record { const handlers: Record = { + ...createNotificationHandlers(allCourseSessions, (url) => host.openExternal(url)), "threads:read": () => readStudioThreadStore(host.userDataPath), "threads:write": (rawInput) => writeStudioThreadStore(host.userDataPath, rawInput), "studio:lease": (rawInput) => updateStudioClientLease(rawInput), @@ -1458,15 +1503,9 @@ export function createStudioHandlers(): Record { }, "session:login": async (rawInput) => { const input = requireObject(rawInput, "Login input"); - const baseUrl = normalizeSiteUrl(input?.baseUrl || CURRENT_SITE_BASE_URL); + const baseUrl = normalizeSiteUrl(requireString(input.baseUrl, "Course site")); if (isCurrentSite(baseUrl)) throw new Error("The current UIT course site requires UIT SSO. Use the SSO sign-in button."); - const username = requireString(input.username, "Student ID"); - const password = requireString(input.password, "Password"); - const result = await service.loginWithToken({ username, password, baseUrl }, false); - await disconnectAccount(baseUrl); - if (typeof result.session.userId !== "number") throw new Error("The legacy UIT account did not return a valid account ID."); - legacySessions.set(baseUrl, { baseUrl, userId: result.session.userId, authMode: "token", api: result.api, token: result.token }); - await persistLegacySessions(); + await startLegacyBrowserLogin(baseUrl); return sessionStatusPayload(); }, "session:sso-login": async (rawInput) => { @@ -1478,16 +1517,35 @@ export function createStudioHandlers(): Record { }, "session:logout": async (rawInput) => { const input = rawInput === undefined || rawInput === null ? {} : requireObject(rawInput, "Logout input"); - await disconnectAccount(input.baseUrl ? normalizeSiteUrl(input.baseUrl) : undefined); + const onlyLegacy = input.legacy === true; + const targetBaseUrl = input.baseUrl ? normalizeSiteUrl(input.baseUrl) : undefined; + if (targetBaseUrl && legacyBrowserLoginIds.has(targetBaseUrl)) { + legacyBrowserLoginIds.delete(targetBaseUrl); + legacyBrowserLoginPromises.delete(targetBaseUrl); + await host.clearSsoBrowserData({ clearStorage: false }); + } + if (onlyLegacy && !targetBaseUrl) { + const pendingBaseUrls = [...legacyBrowserLoginIds.keys()]; + legacyBrowserLoginIds.clear(); + legacyBrowserLoginPromises.clear(); + if (pendingBaseUrls.length) await host.clearSsoBrowserData({ clearStorage: false }); + for (const baseUrl of legacySessions.keys()) await disconnectAccount(baseUrl); + } else { + await disconnectAccount(targetBaseUrl); + } if (input.baseUrl) { - const baseUrl = normalizeSiteUrl(requireString(input.baseUrl, "Course site")); + const baseUrl = targetBaseUrl!; if (isCurrentSite(baseUrl)) await clearSsoSession({ clearStorage: true }); else legacySessions.delete(baseUrl); + } else if (onlyLegacy) { + legacySessions.clear(); + await persistLegacySessions(); } else { await clearSsoSession({ clearStorage: true }); legacySessions.clear(); + await persistLegacySessions(); } - await persistLegacySessions(); + if (input.baseUrl && !isCurrentSite(targetBaseUrl!)) await persistLegacySessions(); return sessionStatusPayload(); }, "courses:list": listConnectedCourses, diff --git a/src/studio-web-server.ts b/src/studio-web-server.ts index c75c949..801035a 100644 --- a/src/studio-web-server.ts +++ b/src/studio-web-server.ts @@ -18,14 +18,14 @@ import { realpathSync } from "node:fs"; import { dirname, extname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { createSessionApiClient } from "./api.js"; -import type { SsoSessionData } from "./config.js"; +import type { MoodleBrowserSessionData } from "./config.js"; import { createStudioCore, type StudioCore, type StudioHandler, type StudioHost } from "./studio-core.js"; -import { StudioSsoService } from "./studio-sso.js"; +import { MoodleBrowserLoginService } from "./moodle-browser-login.js"; type JsonRecord = Record; @@ -352,16 +352,18 @@ export function createStudioWebHost(options: { platform?: NodeJS.Platform; }): StudioHost { const platform = options.platform || process.platform; - const ssoService = new StudioSsoService(); - const sessionResult = (session: SsoSessionData) => ({ + const browserLoginService = new MoodleBrowserLoginService(); + const sessionResult = (session: MoodleBrowserSessionData) => ({ session, api: createSessionApiClient(session.baseUrl, session.sesskey, session.cookies) }); return { userDataPath: options.userDataPath, - ssoLogin: async (baseUrl) => sessionResult(await ssoService.login(baseUrl)), + ssoLogin: async (baseUrl) => sessionResult(await browserLoginService.login(baseUrl)), + legacyLogin: async (baseUrl) => sessionResult(await browserLoginService.loginLegacy(baseUrl)), restoreSsoSession: async (session) => session.cookies.length > 0 ? sessionResult(session) : null, - clearSsoBrowserData: async (_options) => ssoService.cancel(), + restoreLegacySession: async (session) => session.cookies.length > 0 ? sessionResult(session) : null, + clearSsoBrowserData: async (_options) => browserLoginService.cancel(), ensureMcpConfig: async () => { if (process.env.UIT_DISABLE_CONFIG === "1") return; const mcp = await import("./mcp-server.js"); diff --git a/src/uit-tools.ts b/src/uit-tools.ts index d145c1a..a47125f 100644 --- a/src/uit-tools.ts +++ b/src/uit-tools.ts @@ -1,6 +1,7 @@ import { realpathSync, statSync } from "node:fs"; import { relative, resolve, sep } from "node:path"; import type { ApiClient } from "./types.js"; +import { createNotificationHandlers } from "./notifications.js"; export const UIT_ASSIGNMENT_SUBMISSION_TOOL = "uit_submit_assignment"; @@ -9,6 +10,7 @@ export interface UitToolSpec { name: string; description: string; inputSchema: Record; + annotations?: { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean; openWorldHint: boolean }; } export interface UitToolContext { @@ -41,6 +43,28 @@ export interface UitToolServices { const courseIdInput = { type: "integer", description: "Course ID from uit_courses or the current course." }; +const messagingActions = { + uit_notifications: "notifications:list", + uit_notification_counts: "notifications:counts", + uit_mark_notification_read: "notifications:read", + uit_mark_all_notifications_read: "notifications:read", + uit_inbox: "inbox:list", + uit_conversation_messages: "inbox:messages", + uit_mark_conversation_read: "inbox:read", + uit_send_message: "inbox:send" +} as const; + +function messagingTool(name: keyof typeof messagingActions, description: string, properties: Record, required: string[] = [], readOnly = true): UitToolSpec { + return { + type: "function", name, description, + inputSchema: { type: "object", properties, required, additionalProperties: false }, + annotations: { readOnlyHint: readOnly, destructiveHint: false, idempotentHint: name !== "uit_send_message", openWorldHint: true } + }; +} + +const messageOffset = { type: "integer", minimum: 0, description: "Pagination offset; use nextOffset from the previous result. Default 0; 20 entries per page." }; +const conversationId = { type: "integer", minimum: 1, description: "Conversation ID from uit_inbox for the active account." }; + function resourceInput( kind: "module" | "file" | "assignment" | "announcement", idDescription: string, @@ -70,6 +94,14 @@ const resourceSchema: Record = { /** The one source of truth for the UIT tools exposed to Codex. */ export const UIT_TOOLS: UitToolSpec[] = [ + messagingTool("uit_notifications", "Read Moodle notifications for the active account, newest first. Does not mark them read. These are separate from course announcements.", { offset: messageOffset }), + messagingTool("uit_notification_counts", "Read unread notification and conversation counts for the active account. Returns [notificationCount, conversationCount]; null means unavailable, not zero.", {}), + messagingTool("uit_mark_notification_read", "Mark one Moodle notification as read. Changes server state; use only when the user requests it.", { id: { type: "integer", minimum: 1, description: "Notification ID from uit_notifications." } }, ["id"], false), + messagingTool("uit_mark_all_notifications_read", "Mark all notifications in the active Moodle account as read. Changes server state; use only when the user requests marking all notifications read.", {}, [], false), + messagingTool("uit_inbox", "List Moodle conversations for the active account without marking messages read.", { offset: messageOffset }), + messagingTool("uit_conversation_messages", "Read 20 messages from an existing Moodle conversation, newest first. Use nextOffset for older messages. Does not mark messages read.", { id: conversationId, offset: messageOffset }, ["id"]), + messagingTool("uit_mark_conversation_read", "Mark all messages in a Moodle conversation as read. Changes server state; use only when the user requests it.", { id: conversationId }, ["id"], false), + messagingTool("uit_send_message", "Send one plain-text reply to an existing Moodle conversation. Only send a message the user explicitly authorized. Never automatically retry a failed send; inspect the conversation first to avoid duplicate delivery.", { id: conversationId, text: { type: "string", minLength: 1, maxLength: 4096, description: "Authorized message text; maximum 4096 UTF-8 bytes." } }, ["id", "text"], false), { type: "function", name: "uit_courses", @@ -236,6 +268,20 @@ export function createUitToolExecutor(services: UitToolServices) { ): Promise { const args = rawArgs || {}; + if (Object.hasOwn(messagingActions, requestedName)) { + const name = requestedName as keyof typeof messagingActions; + const spec = UIT_TOOLS.find((tool) => tool.name === name)!; + const properties = spec.inputSchema.properties as Record; + if (typeof args !== "object" || Array.isArray(args) || Object.keys(args).some((key) => !Object.hasOwn(properties, key))) { + throw new Error("Unexpected messaging tool arguments. Use the advertised schema; the account is selected by the authenticated session."); + } + const handlers = createNotificationHandlers(() => [context]); + return await handlers[messagingActions[name]]({ + ...args, baseUrl: context.baseUrl, userId: context.userId, + ...(name === "uit_mark_all_notifications_read" ? { all: true } : {}) + }); + } + switch (requestedName) { case "uit_courses": return await services.listCourses(context.api, context.userId); diff --git a/studio/renderer/index.html b/studio/renderer/index.html index 5e9e36a..3c84126 100644 --- a/studio/renderer/index.html +++ b/studio/renderer/index.html @@ -19,6 +19,7 @@ + @@ -72,6 +73,27 @@ +