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.
@@ -0,0 +1,186 @@
1
+ import { readFileSync, existsSync, statSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { basename, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { mkdirSync } from "node:fs";
7
+ import { getSupabaseClient } from "../lib/supabase.js";
8
+ import { getValidAccessToken } from "../lib/auth.js";
9
+ import { getValidGoogleToken } from "../lib/calendar.js";
10
+ import { triggerChunkTranscription } from "../lib/n8n.js";
11
+ import { EXIT_CODES } from "../exit-codes.js";
12
+ import { linkCalendarEvent } from "seogitan-core";
13
+ export function registerUploadCommand(program) {
14
+ program
15
+ .command("upload <file>")
16
+ .description("녹음된 오디오 파일을 업로드하여 전사/요약합니다")
17
+ .option("-t, --title <title>", "회의 제목")
18
+ .option("--calendar", "업로드 후 오늘 캘린더에서 선택 (인터랙티브)")
19
+ .option("--calendar-date <YYYY-MM-DD>", "특정 날짜의 캘린더에서 선택")
20
+ .option("--calendar-search <query>", "캘린더 일정을 제목으로 검색 (최근 30일)")
21
+ .option("--calendar-index <n>", "검색/날짜 결과에서 N번째 일정을 자동 선택 (1부터)")
22
+ .option("--chunk-duration <seconds>", "청크 단위 (초)", "300")
23
+ .action(async (file, opts) => {
24
+ try {
25
+ const filePath = resolve(file);
26
+ if (!existsSync(filePath)) {
27
+ console.error(`파일을 찾을 수 없습니다: ${filePath}`);
28
+ process.exit(EXIT_CODES.ERROR);
29
+ }
30
+ const fileSize = statSync(filePath).size;
31
+ console.log(`파일: ${basename(filePath)} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
32
+ // Auth
33
+ await getValidAccessToken();
34
+ const supabase = await getSupabaseClient();
35
+ const { data: { user }, error: userError } = await supabase.auth.getUser();
36
+ if (userError || !user) {
37
+ console.error("로그인이 필요합니다.");
38
+ process.exit(EXIT_CODES.AUTH_FAILURE);
39
+ }
40
+ const userId = user.id;
41
+ const title = opts.title || `업로드 ${basename(filePath)}`;
42
+ const chunkDuration = parseInt(opts.chunkDuration, 10);
43
+ // 1. 회의 생성
44
+ const { data: meeting, error: insertError } = await supabase
45
+ .from("meetings")
46
+ .insert({
47
+ title,
48
+ status: "recording",
49
+ created_by: userId,
50
+ date: new Date().toISOString(),
51
+ })
52
+ .select("id")
53
+ .single();
54
+ if (insertError || !meeting) {
55
+ console.error(`회의 생성 실패: ${insertError?.message}`);
56
+ process.exit(EXIT_CODES.ERROR);
57
+ }
58
+ const meetingId = meeting.id;
59
+ console.log(`Meeting ID: ${meetingId}`);
60
+ // 2. ffmpeg로 WAV 청크 분할
61
+ const chunkDir = `${tmpdir()}/seogitan-upload-${meetingId}`;
62
+ mkdirSync(chunkDir, { recursive: true });
63
+ console.log(`청크 분할 중 (${chunkDuration}초 단위)...`);
64
+ execSync(`ffmpeg -i "${filePath}" -ar 16000 -ac 1 -f segment -segment_time ${chunkDuration} -segment_format wav "${chunkDir}/chunk_%03d.wav" -y`, { stdio: "pipe" });
65
+ // 3. 청크 파일 목록
66
+ const { readdirSync } = await import("node:fs");
67
+ const chunkFiles = readdirSync(chunkDir)
68
+ .filter((f) => f.endsWith(".wav"))
69
+ .sort();
70
+ console.log(`${chunkFiles.length}개 청크 생성됨`);
71
+ // 4. 각 청크 업로드 + n8n 트리거
72
+ for (let i = 0; i < chunkFiles.length; i++) {
73
+ const chunkPath = `${chunkDir}/${chunkFiles[i]}`;
74
+ const chunkData = readFileSync(chunkPath);
75
+ const storagePath = `${userId}/${meetingId}/chunk_${String(i).padStart(3, "0")}.wav`;
76
+ const isFinal = i === chunkFiles.length - 1;
77
+ // Upload to Storage
78
+ const { error: uploadError } = await supabase.storage
79
+ .from("meeting-audio")
80
+ .upload(storagePath, chunkData, { contentType: "audio/wav" });
81
+ if (uploadError) {
82
+ console.error(`청크 ${i} 업로드 실패: ${uploadError.message}`);
83
+ continue;
84
+ }
85
+ // Insert chunk record
86
+ await supabase.from("meeting_audio_chunks").insert({
87
+ meeting_id: meetingId,
88
+ chunk_index: i,
89
+ audio_storage_path: storagePath,
90
+ is_final: isFinal,
91
+ });
92
+ // Signed URL
93
+ const { data: signedData } = await supabase.storage
94
+ .from("meeting-audio")
95
+ .createSignedUrl(storagePath, 3600);
96
+ if (signedData?.signedUrl) {
97
+ await triggerChunkTranscription({
98
+ meeting_id: meetingId,
99
+ chunk_index: i,
100
+ audio_signed_url: signedData.signedUrl,
101
+ is_final: isFinal,
102
+ created_by: userId,
103
+ });
104
+ }
105
+ console.log(`청크 ${i}/${chunkFiles.length - 1} 업로드 완료${isFinal ? " (최종)" : ""}`);
106
+ }
107
+ // 5. 상태 업데이트
108
+ await supabase
109
+ .from("meetings")
110
+ .update({ status: "processing_chunks" })
111
+ .eq("id", meetingId);
112
+ // 6. 캘린더 연결
113
+ const wantCalendar = opts.calendar || opts.calendarDate || opts.calendarSearch;
114
+ if (wantCalendar) {
115
+ try {
116
+ const googleToken = await getValidGoogleToken(supabase, userId);
117
+ if (!googleToken) {
118
+ console.log("Google 캘린더에 접근할 수 없습니다. seogitan login으로 재로그인하세요.");
119
+ }
120
+ else {
121
+ const { fetchTodayEvents: fetchToday, fetchEventsByDate, searchEvents } = await import("seogitan-core");
122
+ let events = [];
123
+ if (opts.calendarSearch) {
124
+ // 제목 검색 (최근 30일) — Claude Skill에서 사용
125
+ events = await searchEvents(googleToken, opts.calendarSearch);
126
+ if (events.length > 0)
127
+ console.log(`\n"${opts.calendarSearch}" 검색 결과 (${events.length}건):`);
128
+ }
129
+ else if (opts.calendarDate) {
130
+ // 특정 날짜 — Claude Skill에서 사용
131
+ events = await fetchEventsByDate(googleToken, new Date(opts.calendarDate));
132
+ if (events.length > 0)
133
+ console.log(`\n${opts.calendarDate} 일정 (${events.length}건):`);
134
+ }
135
+ else {
136
+ // 오늘 (인터랙티브)
137
+ events = await fetchToday(googleToken);
138
+ if (events.length > 0)
139
+ console.log(`\n오늘의 일정 (${events.length}건):`);
140
+ }
141
+ if (events.length === 0) {
142
+ console.log("해당하는 일정이 없습니다.");
143
+ }
144
+ else {
145
+ events.forEach((e, i) => {
146
+ const date = new Date(e.start).toLocaleDateString("ko-KR", { month: "short", day: "numeric" });
147
+ const isAllDay = e.start.length <= 10;
148
+ const time = isAllDay ? "종일" :
149
+ new Date(e.start).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", hour12: false });
150
+ console.log(` [${i + 1}] ${e.title} (${date} ${time}, ${e.attendees.length}명)`);
151
+ });
152
+ let selectedIndex = -1;
153
+ if (opts.calendarIndex) {
154
+ // 비인터랙티브: --calendar-index로 자동 선택 (Claude Skill용)
155
+ selectedIndex = parseInt(opts.calendarIndex, 10) - 1;
156
+ }
157
+ else {
158
+ // 인터랙티브: 사용자 선택
159
+ console.log(" [0] 건너뛰기\n");
160
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
161
+ const answer = await new Promise((resolve) => {
162
+ rl.question("연결할 일정 선택: ", (ans) => { rl.close(); resolve(ans.trim()); });
163
+ });
164
+ selectedIndex = parseInt(answer, 10) - 1;
165
+ }
166
+ if (selectedIndex >= 0 && selectedIndex < events.length) {
167
+ const selected = events[selectedIndex];
168
+ await linkCalendarEvent(supabase, meetingId, selected, user.email);
169
+ console.log(`캘린더 연결됨: ${selected.title}`);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ catch {
175
+ // 캘린더 연결 실패는 업로드에 영향 없음
176
+ }
177
+ }
178
+ console.log(`\n업로드 완료! 전사/요약이 진행됩니다.`);
179
+ console.log(`확인: seogitan meetings show ${meetingId}`);
180
+ }
181
+ catch (err) {
182
+ console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
183
+ process.exit(EXIT_CODES.ERROR);
184
+ }
185
+ });
186
+ }
@@ -0,0 +1,3 @@
1
+ export declare const SUPABASE_URL: string;
2
+ export declare const SUPABASE_ANON_KEY: string;
3
+ export declare const SUPABASE_FUNCTIONS_URL: string;
package/dist/config.js ADDED
@@ -0,0 +1,11 @@
1
+ // Supabase 공개 설정 (anon key는 브라우저에도 노출되는 공개 키)
2
+ // 환경변수로 오버라이드 가능 (개발/테스트용)
3
+ export const SUPABASE_URL = process.env.SUPABASE_URL ||
4
+ process.env.NEXT_PUBLIC_SUPABASE_URL ||
5
+ "https://pnedwqbbuqviykngpdbi.supabase.co";
6
+ export const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ||
7
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
8
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBuZWR3cWJidXF2aXlrbmdwZGJpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQ1NTc2NDYsImV4cCI6MjA5MDEzMzY0Nn0.cbLANAeWNy6XxL-dhOIdnGRV_C0c68lLM94H1-nk-fg";
9
+ // Supabase Edge Functions URL
10
+ export const SUPABASE_FUNCTIONS_URL = process.env.SUPABASE_FUNCTIONS_URL ||
11
+ "https://pnedwqbbuqviykngpdbi.supabase.co/functions/v1";
@@ -0,0 +1,8 @@
1
+ export declare const EXIT_CODES: {
2
+ readonly SUCCESS: 0;
3
+ readonly ERROR: 1;
4
+ readonly MISUSE: 2;
5
+ readonly AUTH_FAILURE: 3;
6
+ readonly NOT_FOUND: 4;
7
+ readonly NETWORK_ERROR: 5;
8
+ };
@@ -0,0 +1,8 @@
1
+ export const EXIT_CODES = {
2
+ SUCCESS: 0,
3
+ ERROR: 1,
4
+ MISUSE: 2,
5
+ AUTH_FAILURE: 3,
6
+ NOT_FOUND: 4,
7
+ NETWORK_ERROR: 5,
8
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { registerLoginCommand } from "./commands/login.js";
4
+ import { registerLogoutCommand } from "./commands/logout.js";
5
+ import { registerMeetingsCommand } from "./commands/meetings.js";
6
+ import { registerRecordCommand } from "./commands/record.js";
7
+ import { registerSummarizeCommand } from "./commands/summarize.js";
8
+ import { registerUploadCommand } from "./commands/upload.js";
9
+ import { registerSetupCommand } from "./commands/setup.js";
10
+ import { checkSkillsInstalled } from "./lib/skills.js";
11
+ import { EXIT_CODES } from "./exit-codes.js";
12
+ const program = new Command();
13
+ program
14
+ .name("seogitan")
15
+ .description("서기탄이 CLI - 회의 녹음 & AI 요약 서비스")
16
+ .version("1.0.0");
17
+ registerLoginCommand(program);
18
+ registerLogoutCommand(program);
19
+ registerMeetingsCommand(program);
20
+ registerRecordCommand(program);
21
+ registerSummarizeCommand(program);
22
+ registerUploadCommand(program);
23
+ registerSetupCommand(program);
24
+ // setup, login, logout 이외의 명령어 실행 시 스킬 미설치 안내
25
+ const subcommand = process.argv[2];
26
+ if (subcommand && !["setup", "login", "logout", "--help", "-h", "--version", "-V"].includes(subcommand)) {
27
+ checkSkillsInstalled();
28
+ }
29
+ program.parseAsync(process.argv).catch((err) => {
30
+ console.error(`오류: ${err instanceof Error ? err.message : String(err)}`);
31
+ process.exit(EXIT_CODES.ERROR);
32
+ });
@@ -0,0 +1,18 @@
1
+ export interface AudioRecorderOptions {
2
+ outputDir: string;
3
+ chunkDurationSeconds?: number;
4
+ deviceIndex?: number;
5
+ }
6
+ export interface AudioRecorder {
7
+ start(): void;
8
+ stop(): Promise<string[]>;
9
+ onNewChunk(callback: (chunkPath: string, chunkIndex: number) => void): void;
10
+ }
11
+ export declare function checkFfmpegInstalled(): void;
12
+ export interface AudioDevice {
13
+ index: number;
14
+ name: string;
15
+ }
16
+ export declare function listAudioDevices(): AudioDevice[];
17
+ export declare function createAudioRecorder(options: AudioRecorderOptions): AudioRecorder;
18
+ export declare function createTempOutputDir(meetingId: string): string;
@@ -0,0 +1,185 @@
1
+ import { spawn, execSync } from "node:child_process";
2
+ import { watch, existsSync, mkdirSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ export function checkFfmpegInstalled() {
6
+ try {
7
+ execSync("ffmpeg -version", { stdio: "pipe" });
8
+ }
9
+ catch {
10
+ throw new Error("ffmpeg가 설치되어 있지 않습니다.\n" +
11
+ "macOS: brew install ffmpeg\n" +
12
+ "Ubuntu/Debian: sudo apt install ffmpeg\n" +
13
+ "자세한 정보: https://ffmpeg.org/download.html");
14
+ }
15
+ }
16
+ export function listAudioDevices() {
17
+ try {
18
+ // ffmpeg exits with error code when listing devices, so we must catch
19
+ execSync("ffmpeg -f avfoundation -list_devices true -i dummy", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
20
+ return [];
21
+ }
22
+ catch (err) {
23
+ // ffmpeg writes device list to stderr even on "error" exit
24
+ if (err && typeof err === "object" && "stderr" in err) {
25
+ return parseAudioDevices(err.stderr);
26
+ }
27
+ return [];
28
+ }
29
+ }
30
+ function parseAudioDevices(output) {
31
+ const devices = [];
32
+ const lines = output.split("\n");
33
+ let inAudioSection = false;
34
+ for (const line of lines) {
35
+ if (line.includes("AVFoundation audio devices:")) {
36
+ inAudioSection = true;
37
+ continue;
38
+ }
39
+ if (inAudioSection && line.includes("AVFoundation video devices:")) {
40
+ break;
41
+ }
42
+ if (inAudioSection) {
43
+ // Match lines like: [AVFoundation indev @ 0x...] [0] Built-in Microphone
44
+ const match = line.match(/\[(\d+)]\s+(.+)$/);
45
+ if (match) {
46
+ devices.push({
47
+ index: parseInt(match[1], 10),
48
+ name: match[2].trim(),
49
+ });
50
+ }
51
+ }
52
+ }
53
+ return devices;
54
+ }
55
+ export function createAudioRecorder(options) {
56
+ const { outputDir, chunkDurationSeconds = 300, deviceIndex = 0, } = options;
57
+ let ffmpegProcess = null;
58
+ let chunkCallback = null;
59
+ let lastSeenIndex = -1;
60
+ let watcher = null;
61
+ // Build ffmpeg args based on platform
62
+ function buildFfmpegArgs() {
63
+ const inputArgs = process.platform === "darwin"
64
+ ? ["-f", "avfoundation", "-i", `:${deviceIndex}`]
65
+ : ["-f", "pulse", "-i", "default"];
66
+ return [
67
+ ...inputArgs,
68
+ "-ar",
69
+ "16000",
70
+ "-ac",
71
+ "1",
72
+ "-f",
73
+ "segment",
74
+ "-segment_time",
75
+ String(chunkDurationSeconds),
76
+ "-segment_format",
77
+ "wav",
78
+ join(outputDir, "chunk_%03d.wav"),
79
+ ];
80
+ }
81
+ function getChunkFiles() {
82
+ if (!existsSync(outputDir))
83
+ return [];
84
+ return readdirSync(outputDir)
85
+ .filter((f) => f.startsWith("chunk_") && f.endsWith(".wav"))
86
+ .sort();
87
+ }
88
+ function getChunkIndex(filename) {
89
+ const match = filename.match(/chunk_(\d+)\.wav/);
90
+ return match ? parseInt(match[1], 10) : -1;
91
+ }
92
+ function handleNewFile() {
93
+ const files = getChunkFiles();
94
+ if (files.length === 0)
95
+ return;
96
+ const highestIndex = Math.max(...files.map(getChunkIndex));
97
+ // When a new chunk file appears, the PREVIOUS chunk is complete
98
+ if (highestIndex > lastSeenIndex && lastSeenIndex >= 0 && chunkCallback) {
99
+ // All chunks from lastSeenIndex up to highestIndex-1 are complete
100
+ // (normally just one at a time, but handle edge cases)
101
+ for (let i = lastSeenIndex; i < highestIndex; i++) {
102
+ const completedFile = `chunk_${String(i).padStart(3, "0")}.wav`;
103
+ const completedPath = join(outputDir, completedFile);
104
+ if (existsSync(completedPath)) {
105
+ chunkCallback(completedPath, i);
106
+ }
107
+ }
108
+ }
109
+ lastSeenIndex = highestIndex;
110
+ }
111
+ const recorder = {
112
+ start() {
113
+ if (!existsSync(outputDir)) {
114
+ mkdirSync(outputDir, { recursive: true });
115
+ }
116
+ const args = buildFfmpegArgs();
117
+ ffmpegProcess = spawn("ffmpeg", args, {
118
+ stdio: ["pipe", "pipe", "pipe"],
119
+ });
120
+ ffmpegProcess.on("error", (err) => {
121
+ throw new Error(`ffmpeg 실행 실패: ${err.message}`);
122
+ });
123
+ ffmpegProcess.stderr?.on("data", (data) => {
124
+ const text = data.toString();
125
+ // Check for device errors
126
+ if (text.includes("No such device") || text.includes("Device not found")) {
127
+ throw new Error(`오디오 장치를 찾을 수 없습니다 (index: ${deviceIndex}). ` +
128
+ "`seogitan record --list-devices`로 사용 가능한 장치를 확인하세요.");
129
+ }
130
+ });
131
+ // Watch for new chunk files
132
+ watcher = watch(outputDir, (eventType, filename) => {
133
+ if (filename &&
134
+ filename.startsWith("chunk_") &&
135
+ filename.endsWith(".wav")) {
136
+ // Small delay to let ffmpeg finish writing headers of new file
137
+ setTimeout(handleNewFile, 500);
138
+ }
139
+ });
140
+ },
141
+ async stop() {
142
+ return new Promise((resolve, reject) => {
143
+ if (!ffmpegProcess) {
144
+ resolve(getChunkFiles().map((f) => join(outputDir, f)));
145
+ return;
146
+ }
147
+ const timeout = setTimeout(() => {
148
+ ffmpegProcess?.kill("SIGTERM");
149
+ }, 5000);
150
+ ffmpegProcess.on("close", () => {
151
+ clearTimeout(timeout);
152
+ if (watcher) {
153
+ watcher.close();
154
+ watcher = null;
155
+ }
156
+ resolve(getChunkFiles().map((f) => join(outputDir, f)));
157
+ });
158
+ ffmpegProcess.on("error", (err) => {
159
+ clearTimeout(timeout);
160
+ if (watcher) {
161
+ watcher.close();
162
+ watcher = null;
163
+ }
164
+ reject(new Error(`ffmpeg 종료 실패: ${err.message}`));
165
+ });
166
+ // Send 'q' to gracefully stop ffmpeg
167
+ if (ffmpegProcess.stdin?.writable) {
168
+ ffmpegProcess.stdin.write("q\n");
169
+ }
170
+ else {
171
+ ffmpegProcess.kill("SIGTERM");
172
+ }
173
+ });
174
+ },
175
+ onNewChunk(callback) {
176
+ chunkCallback = callback;
177
+ },
178
+ };
179
+ return recorder;
180
+ }
181
+ export function createTempOutputDir(meetingId) {
182
+ const dir = join(tmpdir(), `seogitan-${meetingId}`);
183
+ mkdirSync(dir, { recursive: true });
184
+ return dir;
185
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ declare const TokensSchema: z.ZodObject<{
3
+ access_token: z.ZodString;
4
+ refresh_token: z.ZodString;
5
+ expires_at: z.ZodNumber;
6
+ }, "strip", z.ZodTypeAny, {
7
+ access_token: string;
8
+ refresh_token: string;
9
+ expires_at: number;
10
+ }, {
11
+ access_token: string;
12
+ refresh_token: string;
13
+ expires_at: number;
14
+ }>;
15
+ export type Tokens = z.infer<typeof TokensSchema>;
16
+ export declare function loadTokens(): Tokens | null;
17
+ export declare function saveTokens(tokens: Tokens): void;
18
+ export declare function clearTokens(): void;
19
+ export declare function refreshAccessToken(refreshToken: string): Promise<Tokens>;
20
+ export declare function getValidAccessToken(): Promise<string>;
21
+ export {};
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import * as crypto from "node:crypto";
6
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "seogitan");
7
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
8
+ const TokensSchema = z.object({
9
+ access_token: z.string(),
10
+ refresh_token: z.string(),
11
+ expires_at: z.number(),
12
+ });
13
+ export function loadTokens() {
14
+ try {
15
+ const raw = fs.readFileSync(CREDENTIALS_FILE, "utf-8");
16
+ const parsed = JSON.parse(raw);
17
+ return TokensSchema.parse(parsed);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function saveTokens(tokens) {
24
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
25
+ const validated = TokensSchema.parse(tokens);
26
+ const content = JSON.stringify(validated, null, 2);
27
+ // Atomic write: write to temp file then rename
28
+ const tmpFile = path.join(CONFIG_DIR, `.credentials.${crypto.randomUUID()}.tmp`);
29
+ fs.writeFileSync(tmpFile, content, { mode: 0o600 });
30
+ fs.renameSync(tmpFile, CREDENTIALS_FILE);
31
+ }
32
+ export function clearTokens() {
33
+ try {
34
+ fs.unlinkSync(CREDENTIALS_FILE);
35
+ }
36
+ catch {
37
+ // File may not exist, that's fine
38
+ }
39
+ }
40
+ import { SUPABASE_URL, SUPABASE_ANON_KEY } from "../config.js";
41
+ export async function refreshAccessToken(refreshToken) {
42
+ const supabaseUrl = SUPABASE_URL;
43
+ const supabaseAnonKey = SUPABASE_ANON_KEY;
44
+ const response = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
45
+ method: "POST",
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ apikey: supabaseAnonKey,
49
+ },
50
+ body: JSON.stringify({ refresh_token: refreshToken }),
51
+ });
52
+ if (!response.ok) {
53
+ throw new Error(`토큰 갱신 실패: ${response.status} ${response.statusText}`);
54
+ }
55
+ const data = await response.json();
56
+ const tokens = {
57
+ access_token: data.access_token,
58
+ refresh_token: data.refresh_token,
59
+ expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
60
+ };
61
+ saveTokens(tokens);
62
+ return tokens;
63
+ }
64
+ export async function getValidAccessToken() {
65
+ const tokens = loadTokens();
66
+ if (!tokens) {
67
+ throw new Error("로그인이 필요합니다. `seogitan login` 명령어를 실행하세요.");
68
+ }
69
+ const now = Math.floor(Date.now() / 1000);
70
+ // Refresh if less than 300 seconds remaining
71
+ if (tokens.expires_at - now < 300) {
72
+ const refreshed = await refreshAccessToken(tokens.refresh_token);
73
+ return refreshed.access_token;
74
+ }
75
+ return tokens.access_token;
76
+ }
@@ -0,0 +1,6 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ /**
3
+ * profiles에서 Google access token을 가져오고, 만료 시 Edge Function으로 갱신합니다.
4
+ * Supabase JWT와는 별개의 Google OAuth 토큰입니다.
5
+ */
6
+ export declare function getValidGoogleToken(supabase: SupabaseClient, userId: string): Promise<string | null>;
@@ -0,0 +1,37 @@
1
+ import { SUPABASE_FUNCTIONS_URL } from "../config.js";
2
+ import { getValidAccessToken } from "./auth.js";
3
+ /**
4
+ * profiles에서 Google access token을 가져오고, 만료 시 Edge Function으로 갱신합니다.
5
+ * Supabase JWT와는 별개의 Google OAuth 토큰입니다.
6
+ */
7
+ export async function getValidGoogleToken(supabase, userId) {
8
+ const { data: profile } = await supabase
9
+ .from("profiles")
10
+ .select("google_access_token")
11
+ .eq("id", userId)
12
+ .single();
13
+ if (!profile?.google_access_token)
14
+ return null;
15
+ // Google Calendar API 테스트로 유효성 검사
16
+ const testRes = await fetch("https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1", { headers: { Authorization: `Bearer ${profile.google_access_token}` } });
17
+ if (testRes.ok)
18
+ return profile.google_access_token;
19
+ // 만료 → Supabase Edge Function으로 refresh
20
+ try {
21
+ const supabaseToken = await getValidAccessToken();
22
+ const refreshRes = await fetch(`${SUPABASE_FUNCTIONS_URL}/refresh-google-token`, {
23
+ method: "POST",
24
+ headers: {
25
+ Authorization: `Bearer ${supabaseToken}`,
26
+ "Content-Type": "application/json",
27
+ },
28
+ });
29
+ if (!refreshRes.ok)
30
+ return null;
31
+ const { access_token } = await refreshRes.json();
32
+ return access_token || null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
@@ -0,0 +1,10 @@
1
+ export interface ChunkUploadedPayload {
2
+ meeting_id: string;
3
+ chunk_index: number;
4
+ audio_signed_url: string;
5
+ is_final: boolean;
6
+ created_by: string;
7
+ attendees?: string[];
8
+ }
9
+ export declare function triggerChunkTranscription(payload: ChunkUploadedPayload): Promise<void>;
10
+ export declare function triggerSummarize(meetingId: string): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { SUPABASE_FUNCTIONS_URL } from "../config.js";
2
+ import { getValidAccessToken } from "./auth.js";
3
+ const TRIGGER_WEBHOOK_URL = `${SUPABASE_FUNCTIONS_URL}/trigger-n8n-webhook`;
4
+ async function callEdgeFunction(action, payload) {
5
+ const accessToken = await getValidAccessToken();
6
+ const res = await fetch(TRIGGER_WEBHOOK_URL, {
7
+ method: "POST",
8
+ headers: {
9
+ "Content-Type": "application/json",
10
+ Authorization: `Bearer ${accessToken}`,
11
+ },
12
+ body: JSON.stringify({ action, ...payload }),
13
+ });
14
+ if (!res.ok) {
15
+ const body = await res.text();
16
+ throw new Error(`Edge Function 호출 실패 (${res.status}): ${body}`);
17
+ }
18
+ }
19
+ export async function triggerChunkTranscription(payload) {
20
+ await callEdgeFunction("chunk-uploaded", payload);
21
+ }
22
+ export async function triggerSummarize(meetingId) {
23
+ await callEdgeFunction("summarize", { meeting_id: meetingId });
24
+ }
@@ -0,0 +1,4 @@
1
+ import type { Meeting, Summary, Transcript, SearchResult } from "seogitan-core";
2
+ export declare function formatMeetingsList(meetings: Meeting[], json: boolean): string;
3
+ export declare function formatMeetingDetail(meeting: Meeting, summary: Summary | null, transcripts: Transcript[], json: boolean, full?: boolean): string;
4
+ export declare function formatSearchResults(results: SearchResult[], query: string, json: boolean): string;