feat(meet): 확신 게이트 등록 + /meet confirm + 데일리 브리핑 (v2.2.216)

캘린더 등록 정책을 "확신 없으면 등록 대신 질문"으로 전환:
- 액션 표에 상태 컬럼(확정/진행미정/기한미정/조건부:선행/반복:주기) — LLM 분류.
- 확정+기한만 자동 등록. 진행미정·기한미정·조건부는 보류 목록으로 질문,
  `/meet confirm 1=6/20 2=ok 3=skip` 답변으로 등록 완결 (/meet pending 재확인).
- 조건부 규칙: ok=날짜 없는 Tasks 로 [조건부] 등록(선행조건 노트 명시),
  날짜=그날을 '조건 확인일'로 등록 — 의존 대상이 제목/노트에서 즉시 인지됨.
- 반복 업무: 반복 등록 없이 첫 1회만(다음 해당 요일) — 까먹음 방지.
- 기한 해석 불가 확정건: 구버전의 +5일 추측 등록 제거 → 보류 질문.
- 과거 날짜(옛 녹취): 과거 날짜 그대로 등록 + "과거자료·완료확인 필요" 표기.
- 중복 방지: 녹취 sha256 해시 레지스트리(.astra/meet_registered.json)로
  같은 녹취 재실행 시 이중 등록 차단.
- tasksApi: due 옵션화(날짜 없는 task 지원).

데일리 브리핑 (신규):
- 평일 KST 09:30(설정 가능) 오늘의 캘린더 일정 + Tasks(오늘 마감/기한 경과/
  조건부 대기)를 텔레그램 발송. 텔레그램·캘린더 미연결 시 조용히 skip.
- g1nation.dailyBriefing.enabled(기본 true) / .time("09:30").

테스트: meetRegistration 15건 (분류 게이트·confirm 파싱·날짜 정규화·중복 키).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:22:19 +09:00
parent 4eb8bf03f7
commit 70ea421827
10 changed files with 777 additions and 60 deletions
+169
View File
@@ -0,0 +1,169 @@
/**
* 데일리 브리핑 워처 — 평일(월~금) KST 09:30 에 "오늘의 할 일"을 텔레그램으로 발송.
*
* 소스:
* 1. Google Calendar — iCal 캐시 새로고침 후 오늘 일정 (readCalendarEventsCache)
* 2. Google Tasks — 오늘 마감 + 기한 지난 미완료 + 날짜 없는 [조건부] task (listTasks)
*
* 발송 조건 (모두 충족 시에만):
* - g1nation.dailyBriefing.enabled (기본 true)
* - 텔레그램 봇 토큰 등록 + allowedChatIds 설정 — 미설정이면 조용히 skip (로그만)
*
* 스케줄링은 stocksWatcher 와 동일한 단일 setTimeout 체인 패턴 (KST 고정).
*/
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
import { refreshCalendarCache, readCalendarEventsCache, readCalendarConfig } from '../calendar/calendarCache';
import { listTasks } from '../calendar/tasksApi';
let _timer: NodeJS.Timeout | undefined;
let _disposed = false;
let _lastFiredYmd = ''; // 같은 날 중복 발송 방지 (타이머 드리프트 대비)
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
const now = new Date();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(now);
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
// 'YYYY-MM-DD' → UTC midnight Date — getUTCDay 가 그 날짜의 요일 (0=일).
const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay();
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday };
}
function briefingTime(): { hour: number; minute: number } {
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30') || '09:30').trim();
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return { hour: 9, minute: 30 };
return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) };
}
/** 다음 발송까지 ms — 평일 스킵 포함. */
function msUntilNextFire(): number {
const { hour, minute, ymd, weekday } = nowInKst();
const target = briefingTime();
const nowMin = hour * 60 + minute;
const targetMin = target.hour * 60 + target.minute;
// 오늘이 평일이고 아직 시간 전이면 오늘.
if (weekday >= 1 && weekday <= 5 && targetMin > nowMin && _lastFiredYmd !== ymd) {
return (targetMin - nowMin) * 60_000;
}
// 다음 평일 찾기 (내일부터 최대 7일).
let daysAhead = 1;
let wd = weekday;
for (; daysAhead <= 7; daysAhead++) {
wd = (weekday + daysAhead) % 7;
if (wd >= 1 && wd <= 5) break;
}
return ((24 * 60 - nowMin) + (daysAhead - 1) * 24 * 60 + targetMin) * 60_000;
}
function pickChatId(): number | null {
const allowed = vscode.workspace.getConfiguration('g1nation').get<number[]>('telegram.allowedChatIds', []) || [];
return allowed.length > 0 ? allowed[0] : null;
}
/** 오늘의 브리핑 텍스트 구성. 일정·할일이 모두 없으면 null (발송 skip). */
export async function buildBriefingText(context: vscode.ExtensionContext, ymd: string): Promise<string | null> {
const lines: string[] = [`📋 *오늘의 할 일* — ${ymd}`];
// (1) 캘린더 일정 — 캐시 새로고침은 best-effort (실패해도 기존 캐시 사용).
try { await refreshCalendarCache(context); } catch { /* 기존 캐시로 진행 */ }
const events = readCalendarEventsCache(context)
.filter(e => (e.startIso || '').slice(0, 10) === ymd)
.sort((a, b) => a.startIso.localeCompare(b.startIso));
if (events.length) {
lines.push('', `🗓 *일정* (${events.length})`);
for (const e of events.slice(0, 15)) {
const time = e.allDay ? '종일' : e.startIso.slice(11, 16);
lines.push(`· ${time}${e.summary}${e.location ? ` @${e.location}` : ''}`);
}
}
// (2) Google Tasks — 미완료 중: 오늘 마감 / 기한 경과 / 날짜 없는 조건부.
const tr = await listTasks(context, { showCompleted: false, maxResults: 100 });
if (tr.ok) {
const open = tr.tasks.filter(t => t.status === 'needsAction');
const dueToday = open.filter(t => t.due === ymd);
const overdue = open.filter(t => t.due && t.due < ymd);
const conditional = open.filter(t => !t.due && /^\[조건부\]/.test(t.title));
if (dueToday.length) {
lines.push('', `✅ *오늘 마감 할 일* (${dueToday.length})`);
for (const t of dueToday.slice(0, 15)) lines.push(`· ${t.title}`);
}
if (overdue.length) {
lines.push('', `⏰ *기한 지난 미완료* (${overdue.length})`);
for (const t of overdue.slice(0, 10)) lines.push(`· ${t.due}${t.title}`);
}
if (conditional.length) {
lines.push('', `🔗 *조건부 대기* (${conditional.length})`);
for (const t of conditional.slice(0, 10)) lines.push(`· ${t.title}`);
}
if (!dueToday.length && !overdue.length && !events.length && !conditional.length) {
return null; // 알릴 것이 전혀 없음 — 발송 skip
}
} else if (!events.length) {
// Tasks 실패 + 일정도 없으면 보낼 내용 없음.
logInfo('Daily briefing: Tasks 조회 실패 + 일정 없음 — skip.', { error: tr.error });
return null;
} else {
lines.push('', `⚠️ Tasks 조회 실패: ${tr.error}`);
}
lines.push('', '— Astra 데일리 브리핑');
return lines.join('\n');
}
async function fireOnce(context: vscode.ExtensionContext): Promise<void> {
const { ymd } = nowInKst();
if (_lastFiredYmd === ymd) return;
_lastFiredYmd = ymd;
const chatId = pickChatId();
if (chatId === null) { logInfo('Daily briefing skip: telegram.allowedChatIds 미설정.'); return; }
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token.trim()) { logInfo('Daily briefing skip: 텔레그램 봇 토큰 없음.'); return; }
const calCfg = readCalendarConfig(context);
if (!calCfg.icalUrl && !calCfg.refreshToken) { logInfo('Daily briefing skip: 캘린더 미연결.'); return; }
try {
const text = await buildBriefingText(context, ymd);
if (!text) { logInfo('Daily briefing: 오늘 일정·할일 없음 — 발송 skip.'); return; }
const client = new TelegramHttpClient({ getToken: () => token });
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
logInfo('Daily briefing 발송 완료.', { chatId, chars: text.length });
} catch (e: any) {
logError('Daily briefing 발송 실패.', { error: e?.message ?? String(e) });
}
}
function scheduleNext(context: vscode.ExtensionContext): void {
if (_disposed) return;
const ms = msUntilNextFire();
logInfo('Daily briefing 다음 발송 예약.', { inMinutes: Math.round(ms / 60_000) });
_timer = setTimeout(async () => {
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('dailyBriefing.enabled', true);
if (enabled) {
try { await fireOnce(context); } catch (e: any) {
logError('Daily briefing fire 실패.', { error: e?.message ?? String(e) });
}
}
scheduleNext(context);
}, ms);
}
/** VS Code 시작 시 호출 — disposable 반환 (subscriptions 에 push). */
export function startDailyBriefingWatcher(context: vscode.ExtensionContext): vscode.Disposable {
_disposed = false;
scheduleNext(context);
return new vscode.Disposable(() => {
_disposed = true;
if (_timer) { clearTimeout(_timer); _timer = undefined; }
});
}