diff --git a/apps/classbot/README.md b/apps/classbot/README.md index 1e0e602..c87b927 100644 --- a/apps/classbot/README.md +++ b/apps/classbot/README.md @@ -45,7 +45,7 @@ npm run release:check ## 운영 배포 -1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v4 운영 DB는 [`db/migrations/005_kakao_name_registration.sql`](./db/migrations/005_kakao_name_registration.sql)을 적용한다. +1. Supabase 프로젝트의 SQL 편집기에서 [`db/schema.sql`](./db/schema.sql)을 적용한다. 기존 v5 운영 DB는 [`db/migrations/006_kakao_personal_event_actions.sql`](./db/migrations/006_kakao_personal_event_actions.sql)을 적용한다. 2. 기존 Quilo Render 서비스에 Cron·카카오 스킬 비밀값을 설정한다. Supabase 연결과 관리자 로그인은 기존 Quilo 설정을 그대로 쓴다. 3. 기존 Quilo 서비스를 재배포한다. 루트 `postinstall`이 일정 관리 화면을 함께 빌드한다. 4. `GET /schedule/api/health`가 `ok: true`와 `storage: supabase`를 반환하는지 확인한다. @@ -62,6 +62,7 @@ npm run release:check - Event API 활성화: 준비가 끝난 뒤 `KAKAO_EVENT_ENABLED=true` - 최초 이름 등록: 챗봇에 명단 이름 그대로 `이름 등록 구민준` 입력. 초대 코드는 필요 없다. - 등록 후 조회: `오늘 일정`, `내일 시간표`, `시간표 전체`, `파일 리스트`처럼 이름 없이 사용 +- 개인 일정 변경: `내일 영어 과제 추가`, `수학 수행평가 22일로 변경`, `방금 일정 완료`처럼 입력하고 10분 안에 확인. 반 전체 일정은 카카오에서 변경할 수 없음 - 명시적 대상 조회: 미등록 상태이거나 다른 구성원의 공개 일정을 볼 때만 `오늘 일정 등록이름`처럼 이름을 맨 뒤에 입력 Event API의 POST 성공은 접수 성공일 뿐 실제 전송 완료가 아니다. 이 서비스는 `taskId` 결과 조회 후에만 `sent`로 기록하며, 실패는 자동 재시도하지 않는다. 상세 운영 정책은 [`server/README.md`](./server/README.md), 공식 연동 근거와 열품타 정책은 [`docs/integrations.md`](./docs/integrations.md)를 참고한다. diff --git a/apps/classbot/db/migrations/006_kakao_personal_event_actions.sql b/apps/classbot/db/migrations/006_kakao_personal_event_actions.sql new file mode 100644 index 0000000..d3df414 --- /dev/null +++ b/apps/classbot/db/migrations/006_kakao_personal_event_actions.sql @@ -0,0 +1,29 @@ +-- Classbot schema v6: short-lived, confirmation-gated Kakao personal event actions. + +create table if not exists public.classbot_kakao_pending_actions ( + member_id uuid primary key references public.classbot_members(id) on delete cascade, + class_id uuid not null references public.classbot_classes(id) on delete cascade, + action text not null check (action in ('create', 'update', 'complete', 'delete')), + event_id uuid references public.classbot_events(id) on delete cascade, + payload jsonb not null check (jsonb_typeof(payload) = 'object'), + expires_at timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) on delete cascade, + check ((action = 'create' and event_id is null) or (action <> 'create' and event_id is not null)) +); + +create index if not exists classbot_kakao_pending_actions_expiry_idx + on public.classbot_kakao_pending_actions(class_id, expires_at); + +drop trigger if exists set_updated_at on public.classbot_kakao_pending_actions; +create trigger set_updated_at +before update on public.classbot_kakao_pending_actions +for each row execute function public.classbot_set_updated_at(); + +alter table public.classbot_kakao_pending_actions enable row level security; + +insert into public.classbot_schema_meta(id, version, applied_at) +values (1, 6, now()) +on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; diff --git a/apps/classbot/db/schema.sql b/apps/classbot/db/schema.sql index b3410b8..ff9da64 100644 --- a/apps/classbot/db/schema.sql +++ b/apps/classbot/db/schema.sql @@ -7,7 +7,7 @@ create table if not exists public.classbot_schema_meta ( ); insert into public.classbot_schema_meta(id, version, applied_at) -values (1, 5, now()) +values (1, 6, now()) on conflict (id) do update set version = excluded.version, applied_at = excluded.applied_at; create or replace function public.classbot_health_check() @@ -677,6 +677,23 @@ create table if not exists public.classbot_kakao_states ( create index if not exists classbot_kakao_states_expiry_idx on public.classbot_kakao_states(class_id, pending_expires_at); +create table if not exists public.classbot_kakao_pending_actions ( + member_id uuid primary key references public.classbot_members(id) on delete cascade, + class_id uuid not null references public.classbot_classes(id) on delete cascade, + action text not null check (action in ('create', 'update', 'complete', 'delete')), + event_id uuid references public.classbot_events(id) on delete cascade, + payload jsonb not null check (jsonb_typeof(payload) = 'object'), + expires_at timestamptz not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + foreign key (class_id, member_id) + references public.classbot_members(class_id, id) on delete cascade, + check ((action = 'create' and event_id is null) or (action <> 'create' and event_id is not null)) +); + +create index if not exists classbot_kakao_pending_actions_expiry_idx + on public.classbot_kakao_pending_actions(class_id, expires_at); + create table if not exists public.classbot_notifications ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classbot_classes(id) on delete cascade, @@ -739,6 +756,7 @@ begin 'classbot_notices', 'classbot_files', 'classbot_kakao_states', + 'classbot_kakao_pending_actions', 'classbot_notifications' ] loop @@ -761,6 +779,7 @@ alter table public.classbot_events enable row level security; alter table public.classbot_notices enable row level security; alter table public.classbot_files enable row level security; alter table public.classbot_kakao_states enable row level security; +alter table public.classbot_kakao_pending_actions enable row level security; alter table public.classbot_notifications enable row level security; alter table public.classbot_audit_logs enable row level security; diff --git a/apps/classbot/docs/deployment.md b/apps/classbot/docs/deployment.md index 42238dc..028f379 100644 --- a/apps/classbot/docs/deployment.md +++ b/apps/classbot/docs/deployment.md @@ -32,7 +32,7 @@ select to_regprocedure('public.classbot_replace_member_timetable(uuid,uuid,jsonb)') is not null as member_timetable_rpc; ``` -기대값은 schema version `5`와 모든 RPC의 `true`다. 기존 v4 운영 DB에는 전체 스키마 대신 [`005_kakao_name_registration.sql`](../db/migrations/005_kakao_name_registration.sql)을 적용할 수 있다. 모든 일정 관리 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. +기대값은 schema version `6`와 모든 RPC의 `true`다. 기존 v5 운영 DB에는 전체 스키마 대신 [`006_kakao_personal_event_actions.sql`](../db/migrations/006_kakao_personal_event_actions.sql)을 적용할 수 있다. 모든 일정 관리 테이블은 RLS가 활성화되고 anon/authenticated 정책은 만들지 않는다. 서버만 service role key로 접근한다. ## 3. 기존 Render 서비스 설정 diff --git a/apps/classbot/server/services/chatbot-read-features.js b/apps/classbot/server/services/chatbot-read-features.js new file mode 100644 index 0000000..7a189b5 --- /dev/null +++ b/apps/classbot/server/services/chatbot-read-features.js @@ -0,0 +1,223 @@ +import { endOfSeoulDay, getSeoulParts, startOfSeoulDay } from "../time.js"; + +const FILE_COMMAND_WORDS = new Set([ + "찾기", "찾아줘", "찾아주세요", "검색", "검색해줘", "검색해주세요", + "열기", "열어줘", "열어주세요", "보여줘", "보여주세요", "알려줘", "알려주세요", +]); + +function normalizedText(value) { + return String(value || "") + .normalize("NFKC") + .trim() + .replace(/[?!.,。]+$/u, "") + .replace(/\s+/g, " "); +} + +function compactText(value) { + return normalizedText(value).replace(/\s+/g, "").toLocaleLowerCase("ko"); +} + +function normalizedSearchText(value) { + return normalizedText(value) + .toLocaleLowerCase("ko") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +function searchTokens(value) { + return normalizedSearchText(value) + .split(" ") + .map((token) => token.trim()) + .filter((token) => token && !FILE_COMMAND_WORDS.has(token)); +} + +function requestedFileType(value) { + const type = String(value || "").toLocaleLowerCase("ko"); + if (type === "pdf") return "pdf"; + if (type === "이미지") return "image"; + return "file"; +} + +export function isTodayBriefingCommand(command) { + const compact = compactText(command); + return new Set(["오늘브리핑", "오늘요약", "브리핑"]).has(compact); +} + +export function readFileBrowseSpec(command, { allowRaw = false } = {}) { + const text = normalizedText(command); + if (!text || /^(?:자료|파일)\s*(?:목록|리스트)$/iu.test(text)) return null; + + if (/^(?:최근|최신|새로)(?:\s*(?:올라온|등록된))?\s*(?:자료|파일)(?:\s*(?:목록|리스트|보여줘|보여주세요))?$/iu.test(text)) { + return { kind: "recent", requestedType: "file", query: "", explicit: true }; + } + + const prefix = text.match(/^(파일|pdf|이미지)\s+(.+)$/iu); + if (prefix) { + return { + kind: "search", + requestedType: requestedFileType(prefix[1]), + query: normalizedText(prefix[2]), + explicit: true, + }; + } + + const suffix = text.match(/^(.+?)\s+(자료|파일|pdf|이미지)(?:\s+(?:찾기|찾아줘|찾아주세요|검색|검색해줘|검색해주세요|열어줘|열어주세요|보여줘|보여주세요))?$/iu); + if (suffix) { + return { + kind: "search", + requestedType: requestedFileType(suffix[2]), + query: normalizedText(suffix[1]), + explicit: true, + }; + } + + return allowRaw ? { + kind: "search", + requestedType: "file", + query: text, + explicit: /(?:학습지|프린트|자료|파일|pdf|이미지)/iu.test(text), + } : null; +} + +function isPdf(file) { + return String(file?.mime_type || "").toLocaleLowerCase("ko") === "application/pdf" + || String(file?.filename || "").toLocaleLowerCase("ko").endsWith(".pdf"); +} + +function isImage(file) { + return String(file?.mime_type || "").toLocaleLowerCase("ko").startsWith("image/"); +} + +function matchesType(file, requestedType) { + return requestedType === "file" + || (requestedType === "pdf" && isPdf(file)) + || (requestedType === "image" && isImage(file)); +} + +function fileSearchText(file) { + return normalizedSearchText([ + file?.alias, + file?.filename, + file?.description, + file?.mime_type, + ].filter(Boolean).join(" ")); +} + +function fileTimestamp(file) { + const value = new Date(file?.created_at || file?.updated_at || 0).getTime(); + return Number.isFinite(value) ? value : 0; +} + +export function rankBrowseFileCandidates(files, spec) { + const eligible = (Array.isArray(files) ? files : []).filter((file) => matchesType(file, spec?.requestedType || "file")); + if (spec?.kind === "recent") { + return eligible + .sort((a, b) => fileTimestamp(b) - fileTimestamp(a)) + .slice(0, 3) + .map((file) => ({ file, score: 1 })); + } + + const tokens = searchTokens(spec?.query); + if (!tokens.length) return []; + return eligible + .map((file) => { + const haystack = fileSearchText(file); + const matched = tokens.filter((token) => haystack.includes(token)).length; + return { file, score: matched / tokens.length, matched }; + }) + .filter((item) => item.matched === tokens.length) + .sort((a, b) => b.score - a.score || fileTimestamp(b.file) - fileTimestamp(a.file)) + .slice(0, 3) + .map(({ file, score }) => ({ file, score })); +} + +function compactTimetable(rows) { + if (!rows.length) return "없음"; + return rows + .slice(0, 8) + .map((row) => `${row.period}교시 ${row.subject}`) + .join(" · "); +} + +function eventDday(event, now) { + const today = startOfSeoulDay(now).getTime(); + const due = startOfSeoulDay(new Date(event.due_at)).getTime(); + const days = Math.round((due - today) / 86_400_000); + if (days === 0) return "오늘"; + if (days === 1) return "내일"; + return `D-${days}`; +} + +function compactEvents(events, now) { + if (!events.length) return "없음"; + return events + .slice(0, 3) + .map((event) => `• ${event.subject ? `${event.subject} ` : ""}${event.title} (${eventDday(event, now)})`) + .join("\n"); +} + +function recentBy(items, fields) { + return [...(Array.isArray(items) ? items : [])].sort((a, b) => { + const timestamp = (item) => { + const raw = fields.map((field) => item?.[field]).find(Boolean) || 0; + const value = new Date(raw).getTime(); + return Number.isFinite(value) ? value : 0; + }; + return timestamp(b) - timestamp(a); + }); +} + +async function optionalList(loader) { + try { + return await loader(); + } catch { + return []; + } +} + +export async function buildTodayBriefing({ store, member, now = new Date() }) { + const parts = getSeoulParts(now); + const [timetable, events, notices, files] = await Promise.all([ + parts.weekday >= 1 && parts.weekday <= 5 + ? (async () => { + if (typeof store.listMemberTimetable === "function") { + const personal = await store.listMemberTimetable(member.id, { weekday: parts.weekday, date: parts.dateKey }); + if (personal.length) return personal; + } + return store.listTimetable({ weekday: parts.weekday }); + })() + : Promise.resolve([]), + store.listEvents({ + from: startOfSeoulDay(now).toISOString(), + to: endOfSeoulDay(new Date(startOfSeoulDay(now).getTime() + 2 * 86_400_000)).toISOString(), + status: "scheduled", + targetMemberId: member.id, + }), + optionalList(() => store.listNotices({ status: "published", limit: 5 })), + typeof store.listFiles === "function" + ? optionalList(() => store.listFiles({ targetMemberId: member.id })) + : Promise.resolve([]), + ]); + + const latestNotice = recentBy(notices, ["published_at", "created_at"])[0]; + const latestFiles = recentBy( + files.filter((file) => file?.member_id == null || file.member_id === member.id) + .filter((file) => String(file?.status || "active").toLocaleLowerCase("ko") === "active"), + ["created_at", "updated_at"], + ).slice(0, 2); + const fileSummary = latestFiles.length + ? latestFiles.map((file) => file.alias || file.filename).join(" · ") + : "없음"; + + return [ + `${member.display_name}님의 오늘 브리핑`, + "", + `시간표: ${compactTimetable(timetable)}`, + "", + "오늘·임박 일정", + compactEvents(events, now), + "", + `새 공지: ${latestNotice?.title || "없음"}`, + `최근 자료: ${fileSummary}`, + ].join("\n"); +} diff --git a/apps/classbot/server/services/chatbot-read-features.test.js b/apps/classbot/server/services/chatbot-read-features.test.js new file mode 100644 index 0000000..b014b0e --- /dev/null +++ b/apps/classbot/server/services/chatbot-read-features.test.js @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { MemoryStore } from "../store/memory-store.js"; +import { handleKakaoCommand } from "./commands.js"; +import { + buildTodayBriefing, + isTodayBriefingCommand, + rankBrowseFileCandidates, + readFileBrowseSpec, +} from "./chatbot-read-features.js"; + +const config = { classCode: "2-4", className: "2학년 4반", timezone: "Asia/Seoul" }; +const now = new Date("2026-07-15T03:00:00.000Z"); + +function fixture() { + const store = new MemoryStore(config); + const member = store.members[0]; + member.display_name = "홍길동"; + member.status = "active"; + member.kakao_user_key = "joined-hong"; + member.kakao_user_key_type = "botUserKey"; + store.events = []; + store.notices = []; + store.files = []; + return { store, member }; +} + +async function ask(store, utterance, makeFileUrl = async (file) => `https://files.example.test/${file.id}`) { + return handleKakaoCommand({ + store, + now, + makeFileUrl, + payload: { userRequest: { utterance, user: { id: "joined-hong" } } }, + }); +} + +function output(response) { + return response.template.outputs[0]; +} + +test("브리핑과 자연어 자료 검색 명령을 단순한 읽기 명령으로 분류한다", () => { + assert.equal(isTodayBriefingCommand("오늘 브리핑"), true); + assert.equal(isTodayBriefingCommand("오늘 요약?"), true); + assert.equal(isTodayBriefingCommand("이번 주 요약"), false); + + assert.deepEqual(readFileBrowseSpec("수학 자료", { allowRaw: true }), { + kind: "search", requestedType: "file", query: "수학", explicit: true, + }); + assert.deepEqual(readFileBrowseSpec("김종수T 파일", { allowRaw: true }), { + kind: "search", requestedType: "file", query: "김종수T", explicit: true, + }); + assert.deepEqual(readFileBrowseSpec("영어 PDF", { allowRaw: true }), { + kind: "search", requestedType: "pdf", query: "영어", explicit: true, + }); + assert.equal(readFileBrowseSpec("최근 올라온 파일", { allowRaw: true }).kind, "recent"); +}); + +test("자료 검색은 별칭·파일명·설명·MIME 토큰을 모두 활용하고 최근순을 지원한다", () => { + const files = [ + { id: "math", alias: "미적분 정리", filename: "calculus.pdf", description: "수학 김종수T 학습지", mime_type: "application/pdf", created_at: "2026-07-14T00:00:00Z" }, + { id: "english", alias: "독해 연습", filename: "english-reading.pdf", description: "영어 학습지", mime_type: "application/pdf", created_at: "2026-07-15T00:00:00Z" }, + { id: "image", alias: "영어 단어", filename: "words.png", description: "영어", mime_type: "image/png", created_at: "2026-07-16T00:00:00Z" }, + ]; + + assert.deepEqual( + rankBrowseFileCandidates(files, { kind: "search", requestedType: "file", query: "김종수T 학습지" }).map((item) => item.file.id), + ["math"], + ); + assert.deepEqual( + rankBrowseFileCandidates(files, { kind: "search", requestedType: "pdf", query: "영어" }).map((item) => item.file.id), + ["english"], + ); + assert.deepEqual( + rankBrowseFileCandidates(files, { kind: "recent", requestedType: "file", query: "" }).map((item) => item.file.id), + ["image", "english", "math"], + ); +}); + +test("오늘 브리핑은 개인 시간표·3일 이내 일정·최신 공지와 자료만 짧게 요약한다", async () => { + const { store, member } = fixture(); + store.memberTimetable = [{ + id: "personal-timetable", class_id: store.classroom.id, member_id: member.id, + weekday: 3, period: 1, subject: "개인수학", activity: "심화", teacher: "", room: "", memo: "", + effective_from: "2026-03-01", effective_to: null, + }]; + await store.createEvent({ member_id: member.id, subject: "영어", title: "개인 과제", due_at: "2026-07-16T18:00:00+09:00" }); + await store.createEvent({ member_id: store.members[1].id, subject: "물리", title: "타인 과제", due_at: "2026-07-16T18:00:00+09:00" }); + await store.createEvent({ subject: "화학", title: "나흘 뒤 일정", due_at: "2026-07-19T18:00:00+09:00" }); + store.notices = [ + { id: "old", title: "중요하지만 오래된 공지", status: "published", pinned: true, published_at: "2026-07-10T00:00:00Z" }, + { id: "new", title: "최신 공지", status: "published", pinned: false, published_at: "2026-07-15T00:00:00Z" }, + ]; + store.files = [ + { id: "old-file", alias: "이전 자료", filename: "old.pdf", mime_type: "application/pdf", member_id: null, status: "active", created_at: "2026-07-10T00:00:00Z" }, + { id: "new-file", alias: "새 자료", filename: "new.pdf", mime_type: "application/pdf", member_id: member.id, status: "active", created_at: "2026-07-15T00:00:00Z" }, + ]; + + const briefing = await buildTodayBriefing({ store, member, now }); + assert.match(briefing, /홍길동님의 오늘 브리핑/); + assert.match(briefing, /1교시 개인수학/); + assert.match(briefing, /개인 과제 \(내일\)/); + assert.doesNotMatch(briefing, /타인 과제|나흘 뒤 일정/); + assert.match(briefing, /새 공지: 최신 공지/); + assert.match(briefing, /최근 자료: 새 자료 · 이전 자료/); + assert.ok(briefing.length < 500); +}); + +test("등록 사용자의 오늘 브리핑을 한 응답과 5개 이하 Quick Reply로 보낸다", async () => { + const { store } = fixture(); + const response = await ask(store, "오늘 브리핑"); + assert.match(output(response).simpleText.text, /홍길동님의 오늘 브리핑/); + assert.ok(response.template.quickReplies.length <= 5); +}); + +test("과목·교사·형식 검색은 한 건이면 열고 여러 건이면 기존 후보 확인을 재사용한다", async () => { + const { store, member } = fixture(); + store.files = [ + { id: "math", alias: "미적분 정리", filename: "calculus.pdf", description: "수학 김종수T 학습지", mime_type: "application/pdf", member_id: null, status: "active", created_at: "2026-07-12T00:00:00Z" }, + { id: "english-a", alias: "영어 독해", filename: "reading.pdf", description: "영어 학습지", mime_type: "application/pdf", member_id: null, status: "active", created_at: "2026-07-14T00:00:00Z" }, + { id: "english-b", alias: "영어 문법", filename: "grammar.pdf", description: "영어 학습지", mime_type: "application/pdf", member_id: member.id, status: "active", created_at: "2026-07-15T00:00:00Z" }, + ]; + + const direct = await ask(store, "김종수T 학습지"); + assert.equal(output(direct).textCard.title, "미적분 정리"); + + const candidates = await ask(store, "영어 PDF"); + assert.match(output(candidates).simpleText.text, /비슷한 후보/); + assert.match(output(candidates).simpleText.text, /영어 문법|영어 독해/); + assert.ok(candidates.template.quickReplies.length <= 5); + + const selected = await ask(store, "맞아"); + assert.match(output(selected).textCard.title, /영어 문법|영어 독해/); +}); + +test("최근 파일 검색은 최신 3개만 후보로 제시하고 타인의 개인 자료는 숨긴다", async () => { + const { store, member } = fixture(); + const other = store.members[1]; + store.files = [ + { id: "first", alias: "첫 자료", filename: "first.pdf", mime_type: "application/pdf", member_id: null, status: "active", created_at: "2026-07-13T00:00:00Z" }, + { id: "second", alias: "둘째 자료", filename: "second.pdf", mime_type: "application/pdf", member_id: member.id, status: "active", created_at: "2026-07-14T00:00:00Z" }, + { id: "third", alias: "셋째 자료", filename: "third.pdf", mime_type: "application/pdf", member_id: null, status: "active", created_at: "2026-07-15T00:00:00Z" }, + { id: "hidden", alias: "타인 자료", filename: "hidden.pdf", mime_type: "application/pdf", member_id: other.id, status: "active", created_at: "2026-07-16T00:00:00Z" }, + { id: "old", alias: "오래된 자료", filename: "old.pdf", mime_type: "application/pdf", member_id: null, status: "active", created_at: "2026-07-12T00:00:00Z" }, + ]; + + const response = await ask(store, "최근 올라온 파일"); + const copy = output(response).simpleText.text; + assert.match(copy, /셋째 자료|둘째 자료|첫 자료/); + assert.doesNotMatch(copy, /타인 자료|오래된 자료/); + assert.ok(response.template.quickReplies.length <= 5); +}); + +test("명시적인 자료 검색 결과가 없으면 파일 리스트를 안내한다", async () => { + const { store } = fixture(); + for (const query of ["생명과학 자료", "김종수T 학습지"]) { + const response = await ask(store, query); + assert.match(output(response).simpleText.text, /요청한 자료를 찾을 수 없습니다/); + assert.match(output(response).simpleText.text, /파일 리스트/); + } +}); + +test("일정과 파일 확인 후보가 겹치면 가장 최근 요청 하나만 확인한다", async () => { + const { store, member } = fixture(); + store.files = [ + { id: "english-a", alias: "영어 독해", filename: "reading.pdf", description: "영어 학습지", mime_type: "application/pdf", member_id: null, status: "active" }, + { id: "english-b", alias: "영어 문법", filename: "grammar.pdf", description: "영어 학습지", mime_type: "application/pdf", member_id: member.id, status: "active" }, + ]; + + await ask(store, "내일 영어 과제 추가"); + const fileCandidates = await ask(store, "영어 PDF"); + assert.match(output(fileCandidates).simpleText.text, /비슷한 후보/); + assert.equal(await store.getPendingKakaoAction(member.id), null); + + const opened = await ask(store, "맞아요"); + assert.ok(output(opened).textCard); + assert.equal(store.events.length, 0); + + await ask(store, "영어 PDF"); + const eventProposal = await ask(store, "내일 영어 과제 추가"); + assert.match(output(eventProposal).simpleText.text, /개인 일정으로 추가할까요/); + assert.equal(await store.getPendingFileSelection(member.id), null); + + const confirmed = await ask(store, "추가할게요"); + assert.match(output(confirmed).simpleText.text, /개인 일정을 추가했습니다/); + assert.equal(store.events.length, 1); +}); diff --git a/apps/classbot/server/services/commands.js b/apps/classbot/server/services/commands.js index 9758fb6..c19ef5b 100644 --- a/apps/classbot/server/services/commands.js +++ b/apps/classbot/server/services/commands.js @@ -16,6 +16,13 @@ import { getWeekTimetable, } from "./schedule.js"; import { dateForSeoulOffset, formatKoreanDate, getSeoulParts } from "../time.js"; +import { answerEventMutation, answerPendingEventMutation } from "./event-commands.js"; +import { + buildTodayBriefing, + isTodayBriefingCommand, + rankBrowseFileCandidates, + readFileBrowseSpec, +} from "./chatbot-read-features.js"; const TRAILING_REQUEST_WORDS = new Set([ "알려줘", "알려주세요", "보여줘", "보여주세요", "확인", "확인해줘", "확인해주세요", @@ -168,19 +175,15 @@ function helpText({ registered = false, displayName = "" } = {}) { "Quilo schedule 사용법", registration, "", - "일정·시간표", - "• 오늘 일정 / 내일 일정 / 다음 일정", - "• 이번 주 남은 일정 / 이번 달 일정", - "• 수행평가 과제 통합 요약 / 다음 주 시험", - "• 오늘 시간표 / 시간표 전체", - "", - "공지·자료·알림", - "• 공지 / 파일 리스트", - "• 파일 좌석표 / PDF 가정통신문 / 이미지 좌석표", - "• 파일명이나 별칭을 바로 입력해도 됩니다. 비슷한 파일이면 후보를 확인해 드려요.", - "• 알림 설정 / 알림 켜기 / 알림 끄기", + "자주 쓰는 기능", + "• 오늘 브리핑 / 오늘 일정 / 시간표 전체", + "• 7월 20일 수학 수행평가 추가", + "• 방금 일정 완료 / 수학 수행평가 22일로 변경", + "• 공지 / 파일 리스트 / 최근 올라온 파일", + "• 수학 자료 / 김종수T 학습지 / 영어 PDF", + "• 알림 설정", "", - "다른 구성원의 반 전체 일정은 질문 맨 뒤에 정확한 이름을 붙여 조회할 수 있어요.", + "일정 변경은 확인 후 적용됩니다. 다른 구성원의 반 전체 일정은 질문 맨 뒤에 정확한 이름을 붙여 조회할 수 있어요.", ].join("\n"); } @@ -331,6 +334,9 @@ function candidateQuickReplies(candidates) { async function rememberFileCandidates(store, requester, candidates) { if (typeof store.setPendingFileSelection !== "function") return; + if (typeof store.clearPendingKakaoAction === "function") { + await store.clearPendingKakaoAction(requester.id); + } await store.setPendingFileSelection({ memberId: requester.id, fileIds: candidates.map(({ file }) => file.id), @@ -343,7 +349,11 @@ async function answerFileQuery({ command, requester, store, makeFileUrl, quickRe if (typeof store.listFiles !== "function") { return simpleTextResponse("현재 자료 조회 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", replies); } - const spec = readFileCommand(command, { allowRaw }); + const browseSpec = readFileBrowseSpec(command, { allowRaw }); + const baseSpec = readFileCommand(command, { allowRaw }); + const spec = browseSpec + ? { kind: "open", requestedType: browseSpec.requestedType, alias: browseSpec.query } + : baseSpec; if (!spec) return allowRaw ? null : simpleTextResponse("‘파일 리스트’ 또는 ‘파일 별칭’처럼 입력해 주세요.", replies); const files = availableFiles(await store.listFiles({ targetMemberId: requester.id }), requester.id); @@ -357,9 +367,18 @@ async function answerFileQuery({ command, requester, store, makeFileUrl, quickRe if (typeof store.clearPendingFileSelection === "function") await store.clearPendingFileSelection(requester.id); return respondWithFile({ file: exactMatches[0], makeFileUrl, replies }); } - const candidates = rankFileCandidates(files, spec.alias, spec.requestedType); + const browseCandidates = browseSpec ? rankBrowseFileCandidates(files, browseSpec) : []; + if (browseCandidates.length === 1) { + if (typeof store.clearPendingFileSelection === "function") await store.clearPendingFileSelection(requester.id); + return respondWithFile({ file: browseCandidates[0].file, makeFileUrl, replies }); + } + const candidates = browseCandidates.length > 1 + ? browseCandidates + : rankFileCandidates(files, spec.alias, spec.requestedType); if (!candidates.length) { - return allowRaw ? null : simpleTextResponse("요청한 자료를 찾을 수 없습니다. ‘파일 리스트’에서 파일명이나 별칭을 확인해 주세요.", replies); + return allowRaw && !browseSpec?.explicit + ? null + : simpleTextResponse("요청한 자료를 찾을 수 없습니다. ‘파일 리스트’에서 파일명이나 별칭을 확인해 주세요.", replies); } await rememberFileCandidates(store, requester, candidates); const names = candidates.map(({ file }, index) => `${index + 1}. ${file.alias || file.filename}`).join("\n"); @@ -520,6 +539,12 @@ export async function handleKakaoCommand({ payload, store, now = new Date(), mak const confirmingRequester = await getRequester(); if (confirmingRequester?.status === "active") { + const eventConfirmation = await answerPendingEventMutation({ + command, + member: confirmingRequester, + store, + }); + if (eventConfirmation) return eventConfirmation; const confirmation = await answerPendingFileConfirmation({ command, requester: confirmingRequester, @@ -529,6 +554,24 @@ export async function handleKakaoCommand({ payload, store, now = new Date(), mak if (confirmation) return confirmation; } + if (isTodayBriefingCommand(command)) { + if (!confirmingRequester || confirmingRequester.status !== "active") { + return simpleTextResponse("오늘 브리핑은 2학년 4반 구성원만 볼 수 있습니다. 먼저 ‘이름 등록 구민준’처럼 명단의 이름으로 등록해 주세요.", helpQuickReplies()); + } + return simpleTextResponse( + await buildTodayBriefing({ store, member: confirmingRequester, now }), + registeredQuickReplies(), + ); + } + + const mutationResponse = await answerEventMutation({ + command, + member: confirmingRequester, + store, + now, + }); + if (mutationResponse) return mutationResponse; + if (looksLikeFileCommand(command)) { const requester = await getRequester(); const text = normalizedText(command); @@ -623,7 +666,7 @@ export async function handleKakaoCommand({ payload, store, now = new Date(), mak helpQuickReplies(member?.status === "active"), ); } catch (error) { - const friendly = /초대 코드|이미 다른 구성원|이미 가입|이미 등록|다른 카카오|학급 정원|찾을 수 없습니다|명단|정확|필요합니다|구성원 이름|등록된 구성원|동명이인|맨 뒤|비활성/.test(error.message) + const friendly = /초대 코드|이미 다른 구성원|이미 가입|이미 등록|다른 카카오|학급 정원|찾을 수 없습니다|명단|정확|필요합니다|구성원 이름|등록된 구성원|동명이인|맨 뒤|비활성|날짜|시간|일정 제목|본인 개인 일정|이미 삭제|이미 완료/.test(error.message) ? error.message : "잠시 후 다시 시도해 주세요."; const replies = targetQuickReplies || (targetDisplayName ? personalizedQuickReplies(targetDisplayName) : undefined); diff --git a/apps/classbot/server/services/commands.test.js b/apps/classbot/server/services/commands.test.js index 0fc8796..95847f2 100644 --- a/apps/classbot/server/services/commands.test.js +++ b/apps/classbot/server/services/commands.test.js @@ -84,9 +84,9 @@ test("가입된 본인은 파일 명령에서 이름 suffix를 생략하고 이 const list = await ask(store, "자료 목록", { userId: "joined-hong" }); assert.match(text(list), /좌석표|개인피드백/); assert.deepEqual(list.template.quickReplies.map((item) => item.messageText), [ + "오늘 브리핑", "오늘 일정", "다음 일정", - "수행평가 과제 통합 요약", "시간표 전체", "파일 리스트", ]); @@ -215,12 +215,13 @@ test("첫 인사와 도움말은 코드 없는 이름 등록, 파일 후보 확 const help = await ask(store, "도움말"); assert.match(text(help), /이름 등록 구민준/); assert.match(text(help), /초대 코드는 필요하지 않습니다/); - assert.match(text(help), /다음 일정/); - assert.match(text(help), /이번 달 일정/); - assert.match(text(help), /수행평가 과제 통합 요약/); + assert.match(text(help), /오늘 브리핑/); + assert.match(text(help), /수학 수행평가 추가/); + assert.match(text(help), /일정 완료/); assert.match(text(help), /시간표 전체/); assert.match(text(help), /파일 리스트/); - assert.match(text(help), /후보를 확인/); + assert.match(text(help), /김종수T 학습지/); + assert.match(text(help), /확인 후 적용/); assert.equal(help.template.quickReplies.some((item) => item.messageText === "파일 리스트"), true); const greeting = await ask(store, "안녕하세요"); @@ -241,7 +242,7 @@ test("이름 등록은 명단의 정확한 이름을 현재 Kakao key에 묶고 const registration = await ask(store, "이름 등록 홍길동", { userId: "new-hong-user" }); assert.match(text(registration), /홍길동님, 이름 등록이 완료/); assert.equal(store.members[0].kakao_user_key, "new-hong-user"); - assert.equal(registration.template.quickReplies[0].messageText, "오늘 일정"); + assert.equal(registration.template.quickReplies[0].messageText, "오늘 브리핑"); const response = await ask(store, "오늘 일정", { userId: "new-hong-user" }); assert.match(text(response), /홍길동님의.*오늘|홍길동님의 7월 15일/); @@ -312,9 +313,9 @@ test("active 요청자는 본인 개인 조회를 이름 없이 쓰고 다른 const response = await ask(store, utterance, { userId: "joined-hong" }); assert.match(text(response), /홍길동님의/); assert.deepEqual(response.template.quickReplies.map((item) => item.messageText), [ + "오늘 브리핑", "오늘 일정", "다음 일정", - "수행평가 과제 통합 요약", "시간표 전체", "파일 리스트", ]); @@ -331,9 +332,9 @@ test("등록된 요청자의 알림·공지 응답 Quick Reply에도 이름 suff const store = fileFixture(); const notifications = await ask(store, "알림 설정", { userId: "joined-hong" }); assert.deepEqual(notifications.template.quickReplies.map((item) => item.messageText), [ + "오늘 브리핑", "오늘 일정", "다음 일정", - "수행평가 과제 통합 요약", "시간표 전체", "파일 리스트", ]); diff --git a/apps/classbot/server/services/event-commands.js b/apps/classbot/server/services/event-commands.js new file mode 100644 index 0000000..2321ad5 --- /dev/null +++ b/apps/classbot/server/services/event-commands.js @@ -0,0 +1,249 @@ +import crypto from "node:crypto"; +import { formatKoreanDateTime, getSeoulParts } from "../time.js"; +import { registeredQuickReplies, simpleTextResponse } from "./kakao.js"; + +const ACTION_TTL_MS = 10 * 60 * 1000; +const YES_WORDS = new Set(["맞아", "맞아요", "네", "넵", "예", "응", "ㅇㅇ", "확인"]); +const NO_WORDS = new Set(["아니", "아니야", "아니요", "ㄴㄴ", "취소", "그만"]); +const ACTION_LABELS = { + create: "추가", + update: "변경", + complete: "완료", + delete: "삭제", +}; + +function normalizedText(value) { + return String(value || "").trim().replace(/[?!.,。]+$/u, "").replace(/\s+/g, " "); +} + +function compactText(value) { + return normalizedText(value).replace(/\s+/g, "").toLowerCase(); +} + +function seoulDate(year, month, day, hour = 23, minute = 59) { + const value = new Date(`${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00+09:00`); + const parts = getSeoulParts(value); + if (Number.isNaN(value.getTime()) || parts.year !== year || parts.month !== month || parts.day !== day) { + throw new Error("올바른 날짜를 입력해 주세요."); + } + return value; +} + +function readTime(text) { + const match = text.match(/(?:(오전|오후)\s*)?(\d{1,2})시(?!간)(?:\s*(\d{1,2})분)?/u); + if (!match) return { hour: 23, minute: 59, explicit: false, text }; + let hour = Number(match[2]); + const minute = Number(match[3] || 0); + if (match[1]) { + if (hour < 1 || hour > 12) throw new Error("시간을 정확히 입력해 주세요."); + if (match[1] === "오후" && hour < 12) hour += 12; + if (match[1] === "오전" && hour === 12) hour = 0; + } + if (hour > 23 || minute > 59) throw new Error("시간을 정확히 입력해 주세요."); + return { hour, minute, explicit: true, text: normalizedText(text.replace(match[0], "")) }; +} + +function dateFromText(text, now, { fallbackDate } = {}) { + const timed = readTime(text); + const base = getSeoulParts(now); + let match = timed.text.match(/(오늘|내일|모레)/u); + if (match) { + const offset = { 오늘: 0, 내일: 1, 모레: 2 }[match[1]]; + const noon = new Date(`${base.dateKey}T12:00:00+09:00`); + const target = getSeoulParts(new Date(noon.getTime() + offset * 86_400_000)); + return { + date: seoulDate(target.year, target.month, target.day, timed.hour, timed.minute), + text: normalizedText(timed.text.replace(match[0], "")), + }; + } + + match = timed.text.match(/(?:(\d{4})년\s*)?(\d{1,2})월\s*(\d{1,2})일/u); + if (match) { + const month = Number(match[2]); + const day = Number(match[3]); + let year = match[1] ? Number(match[1]) : base.year; + const fallback = fallbackDate && !timed.explicit ? getSeoulParts(fallbackDate) : timed; + let date = seoulDate(year, month, day, fallback.hour, fallback.minute); + if (!match[1] && date.getTime() < now.getTime() - 86_400_000) { + year += 1; + date = seoulDate(year, month, day, fallback.hour, fallback.minute); + } + return { date, text: normalizedText(timed.text.replace(match[0], "")) }; + } + + match = timed.text.match(/(\d{1,2})일/u); + if (match && fallbackDate) { + const fallback = getSeoulParts(fallbackDate); + const hour = timed.explicit ? timed.hour : fallback.hour; + const minute = timed.explicit ? timed.minute : fallback.minute; + return { + date: seoulDate(fallback.year, fallback.month, Number(match[1]), hour, minute), + text: normalizedText(timed.text.replace(match[0], "")), + }; + } + return null; +} + +function categoryForTitle(title) { + const compact = compactText(title); + if (["수행평가", "시험", "테스트"].some((word) => compact.includes(word))) return "assessment"; + if (["과제", "숙제", "제출"].some((word) => compact.includes(word))) return "assignment"; + return "class"; +} + +function subjectForTitle(title) { + const first = normalizedText(title).split(" ")[0] || ""; + return first.length <= 30 && !/^(수행평가|시험|테스트|과제|숙제|제출)$/u.test(first) ? first : ""; +} + +function looksLikeMutation(command) { + return /(추가|등록|변경|수정|삭제|완료)(?:해줘|해주세요)?$/u.test(normalizedText(command)); +} + +function confirmationReplies(action) { + const label = ACTION_LABELS[action]; + return [ + { label: `${label}할게요`, action: "message", messageText: `${label}할게요` }, + { label: "취소", action: "message", messageText: "취소" }, + { label: "오늘 일정", action: "message", messageText: "오늘 일정" }, + { label: "도움말", action: "message", messageText: "도움말" }, + ]; +} + +function mutationSummary(action, payload) { + const label = ACTION_LABELS[action]; + if (action === "create") { + return `개인 일정으로 ${label}할까요?\n\n${payload.title}\n${formatKoreanDateTime(new Date(payload.due_at))}`; + } + if (action === "update") { + return `이 개인 일정을 ${label}할까요?\n\n${payload.title}\n${formatKoreanDateTime(new Date(payload.previous_due_at))} → ${formatKoreanDateTime(new Date(payload.due_at))}`; + } + return `이 개인 일정을 ${label}할까요?\n\n${payload.title}\n${formatKoreanDateTime(new Date(payload.due_at))}`; +} + +function eventSearchKey(text) { + return compactText(text).replace(/일정$/u, ""); +} + +async function findOwnedEvent(store, member, query, { allowCompleted = false } = {}) { + const rows = (await store.listEvents({ targetMemberId: member.id })) + .filter((event) => event.member_id === member.id) + .filter((event) => allowCompleted ? event.status !== "cancelled" : event.status === "scheduled"); + const key = eventSearchKey(query); + if (key === "방금" || key === "최근" || key === "방금일정" || key === "최근일정") { + return rows.sort((a, b) => new Date(b.created_at || b.updated_at) - new Date(a.created_at || a.updated_at))[0] || null; + } + const exact = rows.filter((event) => eventSearchKey(event.title) === key); + if (exact.length === 1) return exact[0]; + const partial = rows.filter((event) => { + const title = eventSearchKey(event.title); + return title.includes(key) || key.includes(title); + }); + if (partial.length === 1) return partial[0]; + if (exact.length > 1 || partial.length > 1) throw new Error("같은 이름의 개인 일정이 여러 개입니다. 제목을 더 정확히 입력해 주세요."); + return null; +} + +async function parseMutation(command, member, store, now) { + const text = normalizedText(command); + let match = text.match(/^(.+?)\s*(?:추가|등록)(?:해줘|해주세요)?$/u); + if (match) { + const parsed = dateFromText(match[1], now); + if (!parsed) throw new Error("날짜를 함께 입력해 주세요. 예: ‘7월 20일 수학 수행평가 추가’"); + const title = normalizedText(parsed.text); + if (!title || title.length > 100) throw new Error("일정 제목을 100자 이내로 입력해 주세요."); + return { + action: "create", + payload: { + title, + due_at: parsed.date.toISOString(), + category: categoryForTitle(title), + subject: subjectForTitle(title), + request_key: `kakao-${member.id}-${crypto.randomUUID()}`, + }, + }; + } + + match = text.match(/^(.+?)\s+((?:(?:\d{4})년\s*)?\d{1,2}월\s*\d{1,2}일|\d{1,2}일)(?:로)?\s*(?:변경|수정)(?:해줘|해주세요)?$/u); + if (match) { + const event = await findOwnedEvent(store, member, match[1]); + if (!event) throw new Error("변경할 본인 개인 일정을 찾을 수 없습니다."); + const parsed = dateFromText(match[2], now, { fallbackDate: new Date(event.due_at) }); + if (!parsed) throw new Error("변경할 날짜를 정확히 입력해 주세요."); + return { + action: "update", + eventId: event.id, + payload: { title: event.title, previous_due_at: event.due_at, due_at: parsed.date.toISOString() }, + }; + } + + match = text.match(/^(.+?)(?:\s+일정)?\s*(삭제|완료)(?:해줘|해주세요)?$/u); + if (match) { + const action = match[2] === "완료" ? "complete" : "delete"; + const event = await findOwnedEvent(store, member, match[1], { allowCompleted: action === "delete" }); + if (!event) throw new Error(`${ACTION_LABELS[action]}할 본인 개인 일정을 찾을 수 없습니다.`); + return { action, eventId: event.id, payload: { title: event.title, due_at: event.due_at } }; + } + return null; +} + +function isConfirmCommand(command, pendingAction) { + const compact = compactText(command); + const actionLabel = ACTION_LABELS[pendingAction]; + return YES_WORDS.has(compact) || compact === `${actionLabel}할게요` || compact === `${actionLabel}해주세요` || compact === `${actionLabel}해줘`; +} + +async function executePending(store, member, pending) { + if (pending.action === "create") { + return store.createEvent({ ...pending.payload, member_id: member.id }, member.id); + } + const event = await store.getEvent(pending.event_id); + if (!event || event.member_id !== member.id) throw new Error("본인 개인 일정만 바꿀 수 있습니다."); + if (event.status === "cancelled") throw new Error("이미 삭제된 일정입니다."); + if (pending.action === "update") return store.updateEvent(event.id, { due_at: pending.payload.due_at }, member.id); + if (pending.action === "complete") { + if (event.status !== "scheduled") throw new Error("이미 완료된 일정입니다."); + return store.updateEvent(event.id, { status: "completed" }, member.id); + } + return store.cancelEvent(event.id, member.id); +} + +export async function answerPendingEventMutation({ command, member, store }) { + if (!member || typeof store.getPendingKakaoAction !== "function") return null; + const pending = await store.getPendingKakaoAction(member.id); + if (!pending) return null; + const compact = compactText(command); + if (NO_WORDS.has(compact)) { + await store.clearPendingKakaoAction(member.id); + return simpleTextResponse("일정 변경을 취소했습니다.", registeredQuickReplies()); + } + if (!isConfirmCommand(command, pending.action)) return null; + const event = await executePending(store, member, pending); + await store.clearPendingKakaoAction(member.id); + const label = ACTION_LABELS[pending.action]; + const suffix = pending.action === "delete" ? "삭제했습니다" : `${label}했습니다`; + return simpleTextResponse(`개인 일정을 ${suffix}.\n\n${event.title}\n${formatKoreanDateTime(new Date(event.due_at))}`, registeredQuickReplies()); +} + +export async function answerEventMutation({ command, member, store, now = new Date() }) { + if (!looksLikeMutation(command)) return null; + if (!member || member.status !== "active") { + return simpleTextResponse("개인 일정을 바꾸려면 먼저 ‘이름 등록 구민준’처럼 명단의 이름으로 등록해 주세요."); + } + const mutation = await parseMutation(command, member, store, now); + if (!mutation) return null; + if (typeof store.setPendingKakaoAction !== "function") { + return simpleTextResponse("현재 채팅 일정 변경 기능을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.", registeredQuickReplies()); + } + if (typeof store.clearPendingFileSelection === "function") { + await store.clearPendingFileSelection(member.id); + } + await store.setPendingKakaoAction({ + memberId: member.id, + action: mutation.action, + eventId: mutation.eventId || null, + payload: mutation.payload, + expiresAt: new Date(Date.now() + ACTION_TTL_MS).toISOString(), + }); + return simpleTextResponse(`${mutationSummary(mutation.action, mutation.payload)}\n\n10분 안에 확인해 주세요.`, confirmationReplies(mutation.action)); +} diff --git a/apps/classbot/server/services/event-commands.test.js b/apps/classbot/server/services/event-commands.test.js new file mode 100644 index 0000000..e905f0d --- /dev/null +++ b/apps/classbot/server/services/event-commands.test.js @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getSeoulParts } from "../time.js"; +import { MemoryStore } from "../store/memory-store.js"; +import { handleKakaoCommand } from "./commands.js"; + +const config = { classCode: "2-4", className: "2학년 4반", timezone: "Asia/Seoul" }; + +function fixture() { + const store = new MemoryStore(config); + store.events = []; + store.members[0].display_name = "홍길동"; + store.members[0].status = "active"; + store.members[0].kakao_user_key = "joined-hong"; + return store; +} + +async function ask(store, utterance, { userId = "joined-hong", now = new Date() } = {}) { + return handleKakaoCommand({ + store, + now, + payload: { userRequest: { utterance, user: { id: userId } } }, + }); +} + +function text(response) { + return response.template.outputs[0].simpleText.text; +} + +test("채팅 개인 일정 추가는 확인 전 저장하지 않고 10분 pending 확인 뒤 본인 일정만 만든다", async () => { + const store = fixture(); + const now = new Date(); + const proposal = await ask(store, "내일 영어 과제 추가", { now }); + + assert.equal(store.events.length, 0); + assert.match(text(proposal), /개인 일정으로 추가할까요/); + assert.match(text(proposal), /영어 과제/); + assert.equal(proposal.template.quickReplies.length <= 5, true); + assert.equal(proposal.template.quickReplies[0].messageText, "추가할게요"); + + const pending = await store.getPendingKakaoAction(store.members[0].id); + assert.equal(pending.action, "create"); + const remaining = new Date(pending.expires_at).getTime() - Date.now(); + assert.equal(remaining > 9 * 60 * 1000 && remaining <= 10 * 60 * 1000, true); + + const confirmed = await ask(store, "추가할게요", { now }); + assert.match(text(confirmed), /개인 일정을 추가했습니다/); + assert.equal(store.events.length, 1); + assert.equal(store.events[0].member_id, store.members[0].id); + assert.equal(store.events[0].category, "assignment"); + assert.equal(getSeoulParts(new Date(store.events[0].due_at)).hour, 23); + assert.equal(await store.getPendingKakaoAction(store.members[0].id), null); +}); + +test("미등록 사용자는 개인 일정 변경을 시작할 수 없다", async () => { + const store = fixture(); + const response = await ask(store, "내일 수학 시험 추가", { userId: "anonymous" }); + assert.match(text(response), /먼저.*이름 등록/); + assert.equal(store.events.length, 0); +}); + +test("공부 시간 분량의 '시간'을 시각으로 오인하지 않는다", async () => { + const store = fixture(); + const proposal = await ask(store, "7월 20일 2시간 영어 공부 추가", { + now: new Date("2026-07-16T03:00:00Z"), + }); + assert.match(text(proposal), /2시간 영어 공부/); + assert.match(text(proposal), /23:59/); + + await ask(store, "추가할게요"); + assert.equal(store.events[0].title, "2시간 영어 공부"); + assert.equal(getSeoulParts(new Date(store.events[0].due_at)).timeKey, "23:59"); +}); + +test("제목 일정의 날짜 변경도 확인 후 적용하고 취소하면 원본을 유지한다", async () => { + const store = fixture(); + const event = await store.createEvent({ + member_id: store.members[0].id, + category: "assessment", + title: "수학 수행평가", + due_at: "2026-07-20T18:30:00+09:00", + }, store.members[0].id); + + const proposal = await ask(store, "수학 수행평가 22일로 변경"); + assert.match(text(proposal), /7\. 20|7월 20일/); + assert.match(text(proposal), /7\. 22|7월 22일/); + assert.equal((await store.getEvent(event.id)).due_at, event.due_at); + + const cancelled = await ask(store, "취소"); + assert.match(text(cancelled), /취소했습니다/); + assert.equal((await store.getEvent(event.id)).due_at, event.due_at); + + await ask(store, "수학 수행평가 22일로 변경", { now: new Date() }); + const confirmed = await ask(store, "변경할게요"); + assert.match(text(confirmed), /개인 일정을 변경했습니다/); + const changed = getSeoulParts(new Date((await store.getEvent(event.id)).due_at)); + assert.equal(changed.day, 22); + assert.equal(changed.timeKey, "18:30"); +}); + +test("방금 일정 완료와 삭제는 본인 개인 일정만 대상으로 하고 각각 확인을 요구한다", async () => { + const store = fixture(); + const classEvent = await store.createEvent({ title: "반 전체 일정", due_at: "2026-07-30T23:59:00+09:00" }); + const personal = await store.createEvent({ member_id: store.members[0].id, title: "내 개인 일정", due_at: "2026-07-21T23:59:00+09:00" }, store.members[0].id); + + const completion = await ask(store, "방금 일정 완료"); + assert.match(text(completion), /내 개인 일정/); + assert.equal((await store.getEvent(personal.id)).status, "scheduled"); + await ask(store, "완료할게요"); + assert.equal((await store.getEvent(personal.id)).status, "completed"); + assert.equal((await store.getEvent(classEvent.id)).status, "scheduled"); + + await ask(store, "방금 일정 삭제"); + assert.equal((await store.getEvent(personal.id)).status, "completed"); + await ask(store, "삭제할게요"); + assert.equal((await store.getEvent(personal.id)).status, "cancelled"); + assert.equal((await store.getEvent(classEvent.id)).status, "scheduled"); +}); + +test("반 전체 일정은 이름이 같아도 카카오 개인 일정 명령으로 수정할 수 없다", async () => { + const store = fixture(); + const classEvent = await store.createEvent({ title: "수학 수행평가", due_at: "2026-07-20T23:59:00+09:00" }); + const response = await ask(store, "수학 수행평가 22일로 변경", { now: new Date("2026-07-16T03:00:00Z") }); + assert.match(text(response), /본인 개인 일정을 찾을 수 없습니다/); + assert.equal((await store.getEvent(classEvent.id)).due_at, classEvent.due_at); + assert.equal(await store.getPendingKakaoAction(store.members[0].id), null); +}); + +test("10분이 지난 일정 변경 확인 상태는 실행하지 않고 만료한다", async () => { + const store = fixture(); + await store.setPendingKakaoAction({ + memberId: store.members[0].id, + action: "create", + payload: { + title: "만료된 과제", + category: "assignment", + due_at: "2026-07-30T14:59:00.000Z", + request_key: "expired-request", + }, + expiresAt: new Date(Date.now() - 1_000).toISOString(), + }); + + const response = await ask(store, "추가할게요"); + assert.equal(store.events.length, 0); + assert.equal(await store.getPendingKakaoAction(store.members[0].id), null); + assert.doesNotMatch(text(response), /추가했습니다/); +}); diff --git a/apps/classbot/server/services/kakao.js b/apps/classbot/server/services/kakao.js index 3fb7b87..ce2d176 100644 --- a/apps/classbot/server/services/kakao.js +++ b/apps/classbot/server/services/kakao.js @@ -91,9 +91,9 @@ export function personalizedQuickReplies(displayName) { export function registeredQuickReplies() { return [ + "오늘 브리핑", "오늘 일정", "다음 일정", - "수행평가 과제 통합 요약", "시간표 전체", "파일 리스트", ].map((messageText) => ({ label: messageText, action: "message", messageText })); diff --git a/apps/classbot/server/services/kakao.test.js b/apps/classbot/server/services/kakao.test.js index 1efc3c8..687e716 100644 --- a/apps/classbot/server/services/kakao.test.js +++ b/apps/classbot/server/services/kakao.test.js @@ -64,9 +64,9 @@ test("스킬 응답 output은 최대 3개로 제한하고 개인화 Quick Reply test("이름등록이 끝난 요청자용 Quick Reply는 이름 suffix 없이 5개다", () => { const replies = registeredQuickReplies(); assert.deepEqual(replies.map((item) => item.messageText), [ + "오늘 브리핑", "오늘 일정", "다음 일정", - "수행평가 과제 통합 요약", "시간표 전체", "파일 리스트", ]); diff --git a/apps/classbot/server/store/memory-store.js b/apps/classbot/server/store/memory-store.js index 6926245..590ce47 100644 --- a/apps/classbot/server/store/memory-store.js +++ b/apps/classbot/server/store/memory-store.js @@ -266,6 +266,7 @@ export class MemoryStore { this.files = []; this.fileBodies = new Map(); this.kakaoStates = new Map(); + this.kakaoActionStates = new Map(); } async healthCheck() { @@ -481,6 +482,40 @@ export class MemoryStore { this.kakaoStates.delete(memberId); } + async setPendingKakaoAction({ memberId, action, eventId = null, payload, expiresAt }) { + const member = this.members.find((item) => item.id === memberId && item.status === "active"); + const expires = new Date(expiresAt); + if (!member) throw new Error("구성원을 찾을 수 없습니다."); + if (!["create", "update", "complete", "delete"].includes(action)) throw new Error("일정 변경 상태가 올바르지 않습니다."); + if (!payload || typeof payload !== "object" || Array.isArray(payload) || Number.isNaN(expires.getTime())) { + throw new Error("일정 변경 상태가 올바르지 않습니다."); + } + const state = { + class_id: this.classroom.id, + member_id: memberId, + action, + event_id: eventId || null, + payload: clone(payload), + expires_at: expires.toISOString(), + }; + this.kakaoActionStates.set(memberId, state); + return clone(state); + } + + async getPendingKakaoAction(memberId) { + const state = this.kakaoActionStates.get(memberId); + if (!state) return null; + if (new Date(state.expires_at).getTime() <= Date.now()) { + this.kakaoActionStates.delete(memberId); + return null; + } + return clone(state); + } + + async clearPendingKakaoAction(memberId) { + this.kakaoActionStates.delete(memberId); + } + async listTimetable({ weekday } = {}) { return clone(this.timetable) .filter((row) => weekday == null || row.weekday === Number(weekday)) diff --git a/apps/classbot/server/store/schema-security.test.js b/apps/classbot/server/store/schema-security.test.js index 6b4c995..6980e89 100644 --- a/apps/classbot/server/store/schema-security.test.js +++ b/apps/classbot/server/store/schema-security.test.js @@ -34,8 +34,8 @@ test("스키마 버전 health RPC와 전체 RLS가 운영 준비 상태를 검 assert.match(schema, /create table if not exists public\.classbot_schema_meta/); assert.match(schema, /create or replace function public\.classbot_health_check\(\)/); assert.match(schema, /grant execute on function public\.classbot_health_check\(\) to service_role/); - assert.match(schema, /values \(1, 5, now\(\)\)/); - for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "member_timetable", "events", "notices", "files", "kakao_states", "notifications", "audit_logs"]) { + assert.match(schema, /values \(1, 6, now\(\)\)/); + for (const table of ["schema_meta", "classes", "members", "invites", "timetable", "member_timetable", "events", "notices", "files", "kakao_states", "kakao_pending_actions", "notifications", "audit_logs"]) { assert.match(schema, new RegExp(`alter table public\\.classbot_${table} enable row level security`)); } assert.match(storeSource, /\.rpc\("classbot_health_check"\)/); @@ -51,7 +51,7 @@ test("개인별 시간표는 학급-구성원 복합 경계와 원자적 전체 assert.match(replaceFunction, /class_id = p_class_id[\s\S]*id = p_member_id[\s\S]*for update/); assert.match(replaceFunction, /delete from public\.classbot_member_timetable/); assert.match(storeSource, /\.rpc\("classbot_replace_member_timetable"/); - assert.match(storeSource, /Number\(version\) !== 5/); + assert.match(storeSource, /Number\(version\) !== 6/); }); test("이름 등록 RPC는 학급 lock과 정확 일치로 key 탈취·재바인딩을 막는다", () => { @@ -72,6 +72,15 @@ test("카카오 파일 후보 상태는 구성원 경계·최대 3개·만료 assert.match(storeSource, /from\("classbot_kakao_states"\)[\s\S]*pending_expires_at/); }); +test("카카오 일정 변경은 구성원별 10분 pending 상태와 작업 종류를 DB 경계에 둔다", () => { + assert.match(schema, /create table if not exists public\.classbot_kakao_pending_actions/); + assert.match(schema, /action text not null check \(action in \('create', 'update', 'complete', 'delete'\)\)/); + assert.match(schema, /payload jsonb not null check \(jsonb_typeof\(payload\) = 'object'\)/); + assert.match(schema, /foreign key \(class_id, member_id\)[\s\S]*references public\.classbot_members\(class_id, id\)/); + assert.match(schema, /check \(\(action = 'create' and event_id is null\) or \(action <> 'create' and event_id is not null\)\)/); + assert.match(storeSource, /from\("classbot_kakao_pending_actions"\)[\s\S]*expires_at/); +}); + test("카카오와 학생 포털은 같은 초대 코드의 일회성 사용 상태를 채널별로 분리한다", () => { assert.match(schema, /portal_used_at timestamptz/); assert.match(storeSource, /update\(\{ portal_used_at: usedAt \}\)/); diff --git a/apps/classbot/server/store/supabase-store.js b/apps/classbot/server/store/supabase-store.js index bed3acc..dc603d4 100644 --- a/apps/classbot/server/store/supabase-store.js +++ b/apps/classbot/server/store/supabase-store.js @@ -135,7 +135,7 @@ export class SupabaseStore { async healthCheck() { await this.ensureClassroom(); const version = unwrap(await this.client.rpc("classbot_health_check"), "학급 저장소 상태 확인 실패"); - if (Number(version) !== 5) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); + if (Number(version) !== 6) throw new Error("지원하지 않는 Classbot 데이터베이스 스키마입니다."); return { ok: true, storage: "supabase" }; } @@ -394,6 +394,59 @@ export class SupabaseStore { ); } + async setPendingKakaoAction({ memberId, action, eventId = null, payload, expiresAt }) { + const classroom = await this.ensureClassroom(); + const expires = new Date(expiresAt); + if (!String(memberId || "").trim() || !["create", "update", "complete", "delete"].includes(action)) { + throw new Error("일정 변경 상태가 올바르지 않습니다."); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload) || Number.isNaN(expires.getTime())) { + throw new Error("일정 변경 상태가 올바르지 않습니다."); + } + const state = unwrap( + await this.client + .from("classbot_kakao_pending_actions") + .upsert({ + class_id: classroom.id, + member_id: memberId, + action, + event_id: eventId || null, + payload, + expires_at: expires.toISOString(), + }, { onConflict: "member_id" }) + .select("class_id,member_id,action,event_id,payload,expires_at") + .single(), + "일정 변경 상태 저장 실패", + ); + return state; + } + + async getPendingKakaoAction(memberId) { + const classroom = await this.ensureClassroom(); + return unwrap( + await this.client + .from("classbot_kakao_pending_actions") + .select("class_id,member_id,action,event_id,payload,expires_at") + .eq("class_id", classroom.id) + .eq("member_id", memberId) + .gt("expires_at", new Date().toISOString()) + .maybeSingle(), + "일정 변경 상태 조회 실패", + ); + } + + async clearPendingKakaoAction(memberId) { + const classroom = await this.ensureClassroom(); + unwrap( + await this.client + .from("classbot_kakao_pending_actions") + .delete() + .eq("class_id", classroom.id) + .eq("member_id", memberId), + "일정 변경 상태 삭제 실패", + ); + } + async listTimetable({ weekday } = {}) { const classroom = await this.ensureClassroom(); let query = this.client.from("classbot_timetable").select("*").eq("class_id", classroom.id); diff --git a/apps/classbot/server/store/supabase-store.test.js b/apps/classbot/server/store/supabase-store.test.js index 386059d..6c29e1d 100644 --- a/apps/classbot/server/store/supabase-store.test.js +++ b/apps/classbot/server/store/supabase-store.test.js @@ -107,3 +107,47 @@ test("Kakao key 조회와 파일 후보 저장은 Supabase 결과를 store 계 expires_at: savedState.pending_expires_at, }); }); + +test("카카오 개인 일정 pending은 학급·구성원·만료 시각과 함께 저장한다", async () => { + const calls = []; + const savedState = { + class_id: "class-private", + member_id: "member-1", + action: "update", + event_id: "event-1", + payload: { title: "수학 수행평가", due_at: "2026-07-22T14:59:00.000Z" }, + expires_at: "2026-07-16T12:10:00.000Z", + }; + const store = Object.create(SupabaseStore.prototype); + store.classroom = { id: "class-private" }; + store.client = { + from(table) { + assert.equal(table, "classbot_kakao_pending_actions"); + const query = { + upsert(value, options) { calls.push({ value, options }); return query; }, + select() { return query; }, + async single() { return { data: savedState, error: null }; }, + }; + return query; + }, + }; + + assert.deepEqual(await store.setPendingKakaoAction({ + memberId: savedState.member_id, + action: savedState.action, + eventId: savedState.event_id, + payload: savedState.payload, + expiresAt: savedState.expires_at, + }), savedState); + assert.deepEqual(calls, [{ + value: { + class_id: "class-private", + member_id: "member-1", + action: "update", + event_id: "event-1", + payload: savedState.payload, + expires_at: savedState.expires_at, + }, + options: { onConflict: "member_id" }, + }]); +});