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:
@@ -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; }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user