Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/classbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`를 반환하는지 확인한다.
Expand All @@ -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)를 참고한다.
Expand Down
29 changes: 29 additions & 0 deletions apps/classbot/db/migrations/006_kakao_personal_event_actions.sql
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 20 additions & 1 deletion apps/classbot/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -739,6 +756,7 @@ begin
'classbot_notices',
'classbot_files',
'classbot_kakao_states',
'classbot_kakao_pending_actions',
'classbot_notifications'
]
loop
Expand All @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion apps/classbot/docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 서비스 설정

Expand Down
223 changes: 223 additions & 0 deletions apps/classbot/server/services/chatbot-read-features.js
Original file line number Diff line number Diff line change
@@ -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");
}
Loading
Loading