seogitan 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +63 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +10 -0
- package/dist/commands/meetings.d.ts +2 -0
- package/dist/commands/meetings.js +160 -0
- package/dist/commands/record.d.ts +2 -0
- package/dist/commands/record.js +212 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +50 -0
- package/dist/commands/summarize.d.ts +2 -0
- package/dist/commands/summarize.js +69 -0
- package/dist/commands/upload.d.ts +2 -0
- package/dist/commands/upload.js +186 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +11 -0
- package/dist/exit-codes.d.ts +8 -0
- package/dist/exit-codes.js +8 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +32 -0
- package/dist/lib/audio.d.ts +18 -0
- package/dist/lib/audio.js +185 -0
- package/dist/lib/auth.d.ts +21 -0
- package/dist/lib/auth.js +76 -0
- package/dist/lib/calendar.d.ts +6 -0
- package/dist/lib/calendar.js +37 -0
- package/dist/lib/n8n.d.ts +10 -0
- package/dist/lib/n8n.js +24 -0
- package/dist/lib/output.d.ts +4 -0
- package/dist/lib/output.js +94 -0
- package/dist/lib/skills.d.ts +1 -0
- package/dist/lib/skills.js +11 -0
- package/dist/lib/supabase.d.ts +2 -0
- package/dist/lib/supabase.js +7 -0
- package/package.json +28 -0
- package/skills/meetings/SKILL.md +32 -0
- package/skills/record/SKILL.md +37 -0
- package/skills/summary/SKILL.md +35 -0
- package/skills/upload/SKILL.md +43 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import { saveTokens } from "../lib/auth.js";
|
|
3
|
+
// Supabase URL에서 프로젝트 ref 추출 → Vercel 배포 URL 추론
|
|
4
|
+
// 또는 환경변수로 직접 지정 가능
|
|
5
|
+
const WEB_URL = process.env.SEOGITAN_WEB_URL || "https://seogitan.vercel.app";
|
|
6
|
+
export function registerLoginCommand(program) {
|
|
7
|
+
program
|
|
8
|
+
.command("login")
|
|
9
|
+
.description("Google 계정으로 로그인합니다 (웹 브라우저 사용)")
|
|
10
|
+
.action(async () => {
|
|
11
|
+
const server = http.createServer();
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
server.listen(0, "127.0.0.1", () => {
|
|
14
|
+
const addr = server.address();
|
|
15
|
+
if (!addr || typeof addr === "string") {
|
|
16
|
+
reject(new Error("서버 주소를 가져올 수 없습니다."));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const port = addr.port;
|
|
20
|
+
const cliCallback = `http://127.0.0.1:${port}/callback`;
|
|
21
|
+
server.on("request", async (req, res) => {
|
|
22
|
+
if (!req.url?.startsWith("/callback")) {
|
|
23
|
+
res.writeHead(404);
|
|
24
|
+
res.end();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
28
|
+
const accessToken = url.searchParams.get("access_token");
|
|
29
|
+
const refreshToken = url.searchParams.get("refresh_token");
|
|
30
|
+
const expiresAt = url.searchParams.get("expires_at");
|
|
31
|
+
if (!accessToken || !refreshToken) {
|
|
32
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
33
|
+
res.end("<h1>로그인 실패</h1><p>토큰을 받지 못했습니다.</p>");
|
|
34
|
+
reject(new Error("토큰을 받지 못했습니다."));
|
|
35
|
+
server.close();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
saveTokens({
|
|
39
|
+
access_token: accessToken,
|
|
40
|
+
refresh_token: refreshToken,
|
|
41
|
+
expires_at: expiresAt ? parseInt(expiresAt, 10) : Math.floor(Date.now() / 1000) + 3600,
|
|
42
|
+
});
|
|
43
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
44
|
+
res.end("<h1>CLI 로그인 성공!</h1><p>이 창을 닫고 터미널로 돌아가세요.</p>");
|
|
45
|
+
console.log("로그인 성공!");
|
|
46
|
+
server.close();
|
|
47
|
+
resolve();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
});
|
|
50
|
+
// 웹 로그인 페이지로 리다이렉트 (cli_callback 파라미터 포함)
|
|
51
|
+
const loginUrl = `${WEB_URL}/auth/login?cli_callback=${encodeURIComponent(cliCallback)}`;
|
|
52
|
+
console.log("브라우저에서 Google 로그인을 진행하세요...");
|
|
53
|
+
import("open").then(({ default: open }) => open(loginUrl)).catch(() => {
|
|
54
|
+
console.log(`브라우저가 자동으로 열리지 않으면 이 URL을 직접 열어주세요:\n${loginUrl}`);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
reject(new Error("로그인 시간 초과 (120초)"));
|
|
59
|
+
server.close();
|
|
60
|
+
}, 120_000);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { listMeetings, getMeeting, getMeetingSummary, getMeetingTranscripts, searchMeetings, } from "seogitan-core";
|
|
2
|
+
import { getSupabaseClient } from "../lib/supabase.js";
|
|
3
|
+
import { formatMeetingsList, formatMeetingDetail, formatSearchResults, } from "../lib/output.js";
|
|
4
|
+
import { EXIT_CODES } from "../exit-codes.js";
|
|
5
|
+
export function registerMeetingsCommand(program) {
|
|
6
|
+
const meetings = program
|
|
7
|
+
.command("meetings")
|
|
8
|
+
.description("회의 관리 명령어");
|
|
9
|
+
// list
|
|
10
|
+
meetings
|
|
11
|
+
.command("list")
|
|
12
|
+
.description("회의 목록을 조회합니다")
|
|
13
|
+
.option("--status <status>", "필터할 상태")
|
|
14
|
+
.option("--from <date>", "조회 시작 날짜 (ISO 8601)")
|
|
15
|
+
.option("--to <date>", "조회 종료 날짜 (ISO 8601)")
|
|
16
|
+
.option("--limit <n>", "조회할 최대 개수", "10")
|
|
17
|
+
.option("--json", "JSON 형식으로 출력", false)
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
try {
|
|
20
|
+
const supabase = await getSupabaseClient();
|
|
21
|
+
const data = await listMeetings(supabase, {
|
|
22
|
+
limit: parseInt(opts.limit, 10),
|
|
23
|
+
status: opts.status,
|
|
24
|
+
date_from: opts.from,
|
|
25
|
+
date_to: opts.to,
|
|
26
|
+
});
|
|
27
|
+
console.log(formatMeetingsList(data, opts.json));
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
31
|
+
process.exit(EXIT_CODES.ERROR);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
// show
|
|
35
|
+
meetings
|
|
36
|
+
.command("show <id>")
|
|
37
|
+
.description("회의 상세 정보를 조회합니다")
|
|
38
|
+
.option("--json", "JSON 형식으로 출력", false)
|
|
39
|
+
.option("--full", "전사록 포함", false)
|
|
40
|
+
.action(async (id, opts) => {
|
|
41
|
+
try {
|
|
42
|
+
const supabase = await getSupabaseClient();
|
|
43
|
+
const meeting = await getMeeting(supabase, id);
|
|
44
|
+
if (!meeting) {
|
|
45
|
+
console.error(`회의를 찾을 수 없습니다: ${id}`);
|
|
46
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
47
|
+
}
|
|
48
|
+
const summary = await getMeetingSummary(supabase, id);
|
|
49
|
+
const transcripts = opts.full
|
|
50
|
+
? await getMeetingTranscripts(supabase, id)
|
|
51
|
+
: await getMeetingTranscripts(supabase, id);
|
|
52
|
+
console.log(formatMeetingDetail(meeting, summary, transcripts, opts.json, opts.full));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
process.exit(EXIT_CODES.ERROR);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
// search
|
|
60
|
+
meetings
|
|
61
|
+
.command("search <query>")
|
|
62
|
+
.description("키워드로 회의를 검색합니다")
|
|
63
|
+
.option("--json", "JSON 형식으로 출력", false)
|
|
64
|
+
.option("--limit <n>", "조회할 최대 개수", "5")
|
|
65
|
+
.action(async (query, opts) => {
|
|
66
|
+
try {
|
|
67
|
+
const supabase = await getSupabaseClient();
|
|
68
|
+
const results = await searchMeetings(supabase, query, parseInt(opts.limit, 10));
|
|
69
|
+
console.log(formatSearchResults(results, query, opts.json));
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
73
|
+
process.exit(EXIT_CODES.ERROR);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// wait
|
|
77
|
+
meetings
|
|
78
|
+
.command("wait <id>")
|
|
79
|
+
.description("회의 처리가 완료될 때까지 대기합니다")
|
|
80
|
+
.option("--timeout <seconds>", "최대 대기 시간(초)", "120")
|
|
81
|
+
.action(async (id, opts) => {
|
|
82
|
+
try {
|
|
83
|
+
const supabase = await getSupabaseClient();
|
|
84
|
+
const timeout = parseInt(opts.timeout, 10) * 1000;
|
|
85
|
+
const start = Date.now();
|
|
86
|
+
while (Date.now() - start < timeout) {
|
|
87
|
+
const meeting = await getMeeting(supabase, id);
|
|
88
|
+
if (!meeting) {
|
|
89
|
+
console.error(`회의를 찾을 수 없습니다: ${id}`);
|
|
90
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
91
|
+
}
|
|
92
|
+
if (meeting.status === "completed") {
|
|
93
|
+
console.log(`회의 처리 완료: ${meeting.title}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (meeting.status === "error") {
|
|
97
|
+
console.error(`회의 처리 오류: ${meeting.error_message || "알 수 없는 오류"}`);
|
|
98
|
+
process.exit(EXIT_CODES.ERROR);
|
|
99
|
+
}
|
|
100
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
101
|
+
process.stderr.write(`\r대기 중... [${meeting.status}] (${elapsed}초)`);
|
|
102
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
103
|
+
}
|
|
104
|
+
console.error("\n대기 시간 초과");
|
|
105
|
+
process.exit(EXIT_CODES.ERROR);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
109
|
+
process.exit(EXIT_CODES.ERROR);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
// link
|
|
113
|
+
meetings
|
|
114
|
+
.command("link <id> <calendar_event_id>")
|
|
115
|
+
.description("회의에 캘린더 이벤트를 연결합니다")
|
|
116
|
+
.action(async (id, calendarEventId) => {
|
|
117
|
+
try {
|
|
118
|
+
const supabase = await getSupabaseClient();
|
|
119
|
+
const { error } = await supabase
|
|
120
|
+
.from("meetings")
|
|
121
|
+
.update({ calendar_event_id: calendarEventId })
|
|
122
|
+
.eq("id", id);
|
|
123
|
+
if (error) {
|
|
124
|
+
console.error(`업데이트 실패: ${error.message}`);
|
|
125
|
+
process.exit(EXIT_CODES.ERROR);
|
|
126
|
+
}
|
|
127
|
+
console.log("캘린더 이벤트가 연결되었습니다.");
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
131
|
+
process.exit(EXIT_CODES.ERROR);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
// add-participant
|
|
135
|
+
meetings
|
|
136
|
+
.command("add-participant <id> <email>")
|
|
137
|
+
.description("회의에 참석자를 추가합니다")
|
|
138
|
+
.option("--name <name>", "참석자 이름")
|
|
139
|
+
.action(async (id, email, opts) => {
|
|
140
|
+
try {
|
|
141
|
+
const supabase = await getSupabaseClient();
|
|
142
|
+
const { error } = await supabase
|
|
143
|
+
.from("meeting_participants")
|
|
144
|
+
.insert({
|
|
145
|
+
meeting_id: id,
|
|
146
|
+
email,
|
|
147
|
+
name: opts.name || null,
|
|
148
|
+
});
|
|
149
|
+
if (error) {
|
|
150
|
+
console.error(`참석자 추가 실패: ${error.message}`);
|
|
151
|
+
process.exit(EXIT_CODES.ERROR);
|
|
152
|
+
}
|
|
153
|
+
console.log(`참석자가 추가되었습니다: ${email}`);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
157
|
+
process.exit(EXIT_CODES.ERROR);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { getSupabaseClient } from "../lib/supabase.js";
|
|
3
|
+
import { getValidAccessToken } from "../lib/auth.js";
|
|
4
|
+
import { getValidGoogleToken } from "../lib/calendar.js";
|
|
5
|
+
import { checkFfmpegInstalled, listAudioDevices, createAudioRecorder, createTempOutputDir, } from "../lib/audio.js";
|
|
6
|
+
import { triggerChunkTranscription } from "../lib/n8n.js";
|
|
7
|
+
import { EXIT_CODES } from "../exit-codes.js";
|
|
8
|
+
import { fetchTodayEvents, linkCalendarEvent } from "seogitan-core";
|
|
9
|
+
export function registerRecordCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command("record [title]")
|
|
12
|
+
.description("터미널에서 회의를 녹음합니다 (5분 단위 자동 업로드)")
|
|
13
|
+
.option("--device <index>", "오디오 장치 인덱스", "0")
|
|
14
|
+
.option("--chunk-duration <seconds>", "청크 단위 (초)", "300")
|
|
15
|
+
.option("--list-devices", "사용 가능한 오디오 장치 목록 출력")
|
|
16
|
+
.option("--no-calendar", "캘린더 자동 연결을 건너뜁니다")
|
|
17
|
+
.action(async (title, opts) => {
|
|
18
|
+
try {
|
|
19
|
+
// 1. Check ffmpeg
|
|
20
|
+
checkFfmpegInstalled();
|
|
21
|
+
if (opts.listDevices) {
|
|
22
|
+
const devices = listAudioDevices();
|
|
23
|
+
if (devices.length === 0) {
|
|
24
|
+
console.log("사용 가능한 오디오 장치를 찾을 수 없습니다.");
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.log("사용 가능한 오디오 장치:");
|
|
28
|
+
for (const d of devices) {
|
|
29
|
+
console.log(` [${d.index}] ${d.name}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// 2. Auth
|
|
35
|
+
await getValidAccessToken();
|
|
36
|
+
const supabase = await getSupabaseClient();
|
|
37
|
+
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
|
38
|
+
if (userError || !user) {
|
|
39
|
+
console.error("사용자 정보를 가져올 수 없습니다. 다시 로그인해주세요.");
|
|
40
|
+
process.exit(EXIT_CODES.AUTH_FAILURE);
|
|
41
|
+
}
|
|
42
|
+
const userId = user.id;
|
|
43
|
+
const now = new Date();
|
|
44
|
+
// 3. 임시 제목으로 회의 즉시 생성
|
|
45
|
+
const tempTitle = title ||
|
|
46
|
+
`회의 ${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
47
|
+
const { data: meeting, error: insertError } = await supabase
|
|
48
|
+
.from("meetings")
|
|
49
|
+
.insert({
|
|
50
|
+
title: tempTitle,
|
|
51
|
+
status: "recording",
|
|
52
|
+
created_by: userId,
|
|
53
|
+
date: now.toISOString(),
|
|
54
|
+
})
|
|
55
|
+
.select("id")
|
|
56
|
+
.single();
|
|
57
|
+
if (insertError || !meeting) {
|
|
58
|
+
console.error(`회의 생성 실패: ${insertError?.message || "알 수 없는 오류"}`);
|
|
59
|
+
process.exit(EXIT_CODES.ERROR);
|
|
60
|
+
}
|
|
61
|
+
const meetingId = meeting.id;
|
|
62
|
+
// 4. 녹음 즉시 시작
|
|
63
|
+
const outputDir = createTempOutputDir(meetingId);
|
|
64
|
+
const deviceIndex = parseInt(opts.device, 10);
|
|
65
|
+
const chunkDurationSeconds = parseInt(opts.chunkDuration, 10);
|
|
66
|
+
const recorder = createAudioRecorder({
|
|
67
|
+
outputDir,
|
|
68
|
+
chunkDurationSeconds,
|
|
69
|
+
deviceIndex,
|
|
70
|
+
});
|
|
71
|
+
// 5. 캘린더 병렬 조회 (녹음과 동시 진행, 블로킹 없음)
|
|
72
|
+
let calendarEvent = null;
|
|
73
|
+
if (!title && opts.calendar !== false) {
|
|
74
|
+
// fire-and-forget: 녹음을 블로킹하지 않음
|
|
75
|
+
(async () => {
|
|
76
|
+
try {
|
|
77
|
+
const googleToken = await getValidGoogleToken(supabase, userId);
|
|
78
|
+
if (!googleToken)
|
|
79
|
+
return;
|
|
80
|
+
const events = await fetchTodayEvents(googleToken);
|
|
81
|
+
const ongoing = events.filter((e) => {
|
|
82
|
+
if (e.start.length <= 10)
|
|
83
|
+
return false;
|
|
84
|
+
const start = new Date(e.start);
|
|
85
|
+
const end = new Date(e.end);
|
|
86
|
+
return start <= now && now <= end;
|
|
87
|
+
});
|
|
88
|
+
// 진행 중인 회의가 있으면 자동 매칭 (첫 번째 = 가장 먼저 시작한 회의)
|
|
89
|
+
if (ongoing.length > 0) {
|
|
90
|
+
calendarEvent = ongoing[0];
|
|
91
|
+
// 회의 제목 + calendar_event_id 업데이트
|
|
92
|
+
await supabase
|
|
93
|
+
.from("meetings")
|
|
94
|
+
.update({
|
|
95
|
+
title: calendarEvent.title,
|
|
96
|
+
calendar_event_id: calendarEvent.id,
|
|
97
|
+
})
|
|
98
|
+
.eq("id", meetingId);
|
|
99
|
+
console.log(`캘린더 연결됨: ${calendarEvent.title} (${ongoing.length > 1 ? `외 ${ongoing.length - 1}건` : ""})`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// 캘린더 조회 실패는 무시 — 녹음에 영향 없음
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
}
|
|
107
|
+
// 6. 청크 업로드 콜백
|
|
108
|
+
recorder.onNewChunk(async (chunkPath, chunkIndex) => {
|
|
109
|
+
try {
|
|
110
|
+
const fileData = readFileSync(chunkPath);
|
|
111
|
+
const storagePath = `${userId}/${meetingId}/chunk_${String(chunkIndex).padStart(3, "0")}.wav`;
|
|
112
|
+
const { error: uploadError } = await supabase.storage
|
|
113
|
+
.from("meeting-audio")
|
|
114
|
+
.upload(storagePath, fileData, { contentType: "audio/wav", upsert: false });
|
|
115
|
+
if (uploadError) {
|
|
116
|
+
console.error(`\n청크 ${chunkIndex} 업로드 실패: ${uploadError.message}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await supabase.from("meeting_audio_chunks").insert({
|
|
120
|
+
meeting_id: meetingId,
|
|
121
|
+
chunk_index: chunkIndex,
|
|
122
|
+
audio_storage_path: storagePath,
|
|
123
|
+
is_final: false,
|
|
124
|
+
});
|
|
125
|
+
const { data: signedUrlData } = await supabase.storage
|
|
126
|
+
.from("meeting-audio")
|
|
127
|
+
.createSignedUrl(storagePath, 3600);
|
|
128
|
+
if (signedUrlData?.signedUrl) {
|
|
129
|
+
await triggerChunkTranscription({
|
|
130
|
+
meeting_id: meetingId,
|
|
131
|
+
chunk_index: chunkIndex,
|
|
132
|
+
audio_signed_url: signedUrlData.signedUrl,
|
|
133
|
+
is_final: false,
|
|
134
|
+
created_by: userId,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await getValidAccessToken();
|
|
139
|
+
}
|
|
140
|
+
catch { }
|
|
141
|
+
const recordedMinutes = Math.floor(((chunkIndex + 1) * chunkDurationSeconds) / 60);
|
|
142
|
+
console.log(`청크 ${chunkIndex} 업로드 완료 (${recordedMinutes}분 녹음됨)`);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
console.error(`\n청크 ${chunkIndex} 처리 오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// 7. SIGINT 핸들러
|
|
149
|
+
let cleaningUp = false;
|
|
150
|
+
process.once("SIGINT", async () => {
|
|
151
|
+
if (cleaningUp)
|
|
152
|
+
return;
|
|
153
|
+
cleaningUp = true;
|
|
154
|
+
console.log("\n녹음 종료 중...");
|
|
155
|
+
try {
|
|
156
|
+
const chunkFiles = await recorder.stop();
|
|
157
|
+
if (chunkFiles.length > 0) {
|
|
158
|
+
const lastChunkPath = chunkFiles[chunkFiles.length - 1];
|
|
159
|
+
const lastChunkIndex = chunkFiles.length - 1;
|
|
160
|
+
const storagePath = `${userId}/${meetingId}/chunk_${String(lastChunkIndex).padStart(3, "0")}.wav`;
|
|
161
|
+
const fileData = readFileSync(lastChunkPath);
|
|
162
|
+
const { error: uploadError } = await supabase.storage
|
|
163
|
+
.from("meeting-audio")
|
|
164
|
+
.upload(storagePath, fileData, { contentType: "audio/wav", upsert: true });
|
|
165
|
+
if (!uploadError) {
|
|
166
|
+
await supabase.from("meeting_audio_chunks").upsert({
|
|
167
|
+
meeting_id: meetingId,
|
|
168
|
+
chunk_index: lastChunkIndex,
|
|
169
|
+
audio_storage_path: storagePath,
|
|
170
|
+
is_final: true,
|
|
171
|
+
}, { onConflict: "meeting_id,chunk_index" });
|
|
172
|
+
const { data: signedUrlData } = await supabase.storage
|
|
173
|
+
.from("meeting-audio")
|
|
174
|
+
.createSignedUrl(storagePath, 3600);
|
|
175
|
+
if (signedUrlData?.signedUrl) {
|
|
176
|
+
await triggerChunkTranscription({
|
|
177
|
+
meeting_id: meetingId,
|
|
178
|
+
chunk_index: lastChunkIndex,
|
|
179
|
+
audio_signed_url: signedUrlData.signedUrl,
|
|
180
|
+
is_final: true,
|
|
181
|
+
created_by: userId,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
await supabase.from("meetings").update({ status: "processing_chunks" }).eq("id", meetingId);
|
|
187
|
+
// 캘린더 이벤트 매칭 시 참석자 등록
|
|
188
|
+
if (calendarEvent && user.email) {
|
|
189
|
+
try {
|
|
190
|
+
await linkCalendarEvent(supabase, meetingId, calendarEvent, user.email);
|
|
191
|
+
}
|
|
192
|
+
catch { }
|
|
193
|
+
}
|
|
194
|
+
console.log(`녹음 완료. Meeting ID: ${meetingId}`);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
console.error(`정리 중 오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
198
|
+
}
|
|
199
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
200
|
+
});
|
|
201
|
+
// 8. 녹음 시작!
|
|
202
|
+
recorder.start();
|
|
203
|
+
const chunkMinutes = Math.floor(chunkDurationSeconds / 60);
|
|
204
|
+
console.log(`녹음 시작: ${tempTitle} (${chunkMinutes}분마다 자동 전사, Ctrl+C로 종료)`);
|
|
205
|
+
console.log(`Meeting ID: ${meetingId}`);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
209
|
+
process.exit(EXIT_CODES.ERROR);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, cpSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
// npm 패키지 내 번들된 스킬 경로 (dist/commands/ → ../../skills)
|
|
7
|
+
const BUNDLED_SKILLS_DIR = join(__dirname, "..", "..", "skills");
|
|
8
|
+
// 설치 대상: ~/.claude/skills/
|
|
9
|
+
const TARGET_SKILLS_DIR = join(homedir(), ".claude", "skills");
|
|
10
|
+
const SEOGITAN_SKILLS = ["record", "meetings", "summary", "upload"];
|
|
11
|
+
export function registerSetupCommand(program) {
|
|
12
|
+
program
|
|
13
|
+
.command("setup")
|
|
14
|
+
.description("Claude Code 스킬을 설치합니다")
|
|
15
|
+
.option("--force", "기존 스킬을 덮어씁니다")
|
|
16
|
+
.action(async (opts) => {
|
|
17
|
+
console.log("🔧 Seogitan 설치를 시작합니다...\n");
|
|
18
|
+
// 1. 스킬 설치
|
|
19
|
+
if (!existsSync(BUNDLED_SKILLS_DIR)) {
|
|
20
|
+
console.error("❌ 번들된 스킬을 찾을 수 없습니다. 패키지가 손상되었을 수 있습니다.");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
mkdirSync(TARGET_SKILLS_DIR, { recursive: true });
|
|
24
|
+
let installed = 0;
|
|
25
|
+
let skipped = 0;
|
|
26
|
+
for (const skill of SEOGITAN_SKILLS) {
|
|
27
|
+
const src = join(BUNDLED_SKILLS_DIR, skill);
|
|
28
|
+
const dest = join(TARGET_SKILLS_DIR, `seogitan-${skill}`);
|
|
29
|
+
if (!existsSync(src)) {
|
|
30
|
+
console.log(` ⚠ ${skill} 스킬을 찾을 수 없습니다 (건너뜀)`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (existsSync(dest) && !opts.force) {
|
|
34
|
+
console.log(` ✓ seogitan-${skill} (이미 설치됨)`);
|
|
35
|
+
skipped++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
cpSync(src, dest, { recursive: true });
|
|
39
|
+
console.log(` ✓ seogitan-${skill} 설치 완료`);
|
|
40
|
+
installed++;
|
|
41
|
+
}
|
|
42
|
+
console.log(`\n📦 스킬 설치: ${installed}개 설치, ${skipped}개 기존 유지`);
|
|
43
|
+
// 2. 로그인 안내
|
|
44
|
+
console.log("\n📋 다음 단계:");
|
|
45
|
+
console.log(" 1. seogitan login — Google 계정으로 로그인");
|
|
46
|
+
console.log(" 2. seogitan record — 회의 녹음 시작");
|
|
47
|
+
console.log(" 3. Claude Code에서 /seogitan-record, /seogitan-meetings, /seogitan-summary 사용 가능");
|
|
48
|
+
console.log("");
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getMeeting, getMeetingSummary, getMeetingTranscripts, } from "seogitan-core";
|
|
2
|
+
import { getSupabaseClient } from "../lib/supabase.js";
|
|
3
|
+
import { formatMeetingDetail } from "../lib/output.js";
|
|
4
|
+
import { EXIT_CODES } from "../exit-codes.js";
|
|
5
|
+
import { triggerSummarize } from "../lib/n8n.js";
|
|
6
|
+
export function registerSummarizeCommand(program) {
|
|
7
|
+
program
|
|
8
|
+
.command("summarize <meeting-id>")
|
|
9
|
+
.description("회의 요약을 생성합니다")
|
|
10
|
+
.option("--wait", "요약 완료까지 대기합니다", false)
|
|
11
|
+
.option("--timeout <seconds>", "대기 최대 시간(초)", "120")
|
|
12
|
+
.action(async (meetingId, opts) => {
|
|
13
|
+
try {
|
|
14
|
+
const supabase = await getSupabaseClient();
|
|
15
|
+
// 1. Check meeting exists
|
|
16
|
+
const meeting = await getMeeting(supabase, meetingId);
|
|
17
|
+
if (!meeting) {
|
|
18
|
+
console.error(`회의를 찾을 수 없습니다: ${meetingId}`);
|
|
19
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
20
|
+
}
|
|
21
|
+
// 2. Check transcripts exist
|
|
22
|
+
const transcripts = await getMeetingTranscripts(supabase, meetingId);
|
|
23
|
+
if (!transcripts || transcripts.length === 0) {
|
|
24
|
+
console.error("전사 내용이 없습니다. 먼저 전사를 완료해주세요.");
|
|
25
|
+
process.exit(EXIT_CODES.ERROR);
|
|
26
|
+
}
|
|
27
|
+
// 3. Check if summary already exists
|
|
28
|
+
const existingSummary = await getMeetingSummary(supabase, meetingId);
|
|
29
|
+
if (existingSummary) {
|
|
30
|
+
console.log("이미 요약이 존재합니다.");
|
|
31
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
32
|
+
}
|
|
33
|
+
// 4. Trigger n8n summarize webhook
|
|
34
|
+
await triggerSummarize(meetingId);
|
|
35
|
+
console.log("요약 생성을 요청했습니다.");
|
|
36
|
+
// 5. If --wait, poll until complete
|
|
37
|
+
if (opts.wait) {
|
|
38
|
+
const timeout = parseInt(opts.timeout, 10) * 1000;
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
while (Date.now() - start < timeout) {
|
|
41
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
42
|
+
const current = await getMeeting(supabase, meetingId);
|
|
43
|
+
if (!current) {
|
|
44
|
+
console.error(`회의를 찾을 수 없습니다: ${meetingId}`);
|
|
45
|
+
process.exit(EXIT_CODES.NOT_FOUND);
|
|
46
|
+
}
|
|
47
|
+
if (current.status === "completed") {
|
|
48
|
+
const summary = await getMeetingSummary(supabase, meetingId);
|
|
49
|
+
const txs = await getMeetingTranscripts(supabase, meetingId);
|
|
50
|
+
console.log(formatMeetingDetail(current, summary, txs, false, false));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (current.status === "error") {
|
|
54
|
+
console.error(`요약 생성 오류: ${current.error_message || "알 수 없는 오류"}`);
|
|
55
|
+
process.exit(EXIT_CODES.ERROR);
|
|
56
|
+
}
|
|
57
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
58
|
+
process.stderr.write(`\r요약 대기 중... [${current.status}] (${elapsed}초)`);
|
|
59
|
+
}
|
|
60
|
+
console.error("\n대기 시간 초과");
|
|
61
|
+
process.exit(EXIT_CODES.ERROR);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
process.exit(EXIT_CODES.ERROR);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|