v2.2.268-291: /email 통합(브리핑·스캔·워처) + stocks 자동편입·매수알림 + 지식문서·메신저 중앙화

[/email — Gmail + 클래식 Outlook (신규 기능군)]
- 기본 명령: brief(PM 업무 허브 대시보드)·list·read·wikify·reply(Drafts만)·open(Outlook 창 열기)
- Gmail: 자체 OAuth gmail.readonly 스코프 추가 (재연결 1회 필요) / Outlook: COM(PowerShell), 전 계정(Store)·하위 폴더(120개 가드) 수집, To/CC 판별
- 브리핑: 상태 3분류(결정/회신/확인만)·P등급 영향·프로젝트 위험도·Today Focus·오늘 완료 예상·기간 명시 시 표시 범위 제한
- 영속 상태(email_state.json): 회신대기/공넘김 상태머신, 창 밖 미회신 보존, done old N 일괄정리
- 워처: 주기 자동 수집 + 미회신 임계 알림(기본 끔) + 데일리 브리핑 '답할 이메일' 섹션
- 전체 스캔(/email scan): 자동 위키화(회차 15건·레지스트리·파일삭제 감지 재생성)·진행 표시(streamProgress)
- 자동발신 필터 확장(Confluence·AWS·광고·[Notification]) + 라벨 오타 후처리(polishBriefLabels)

[/stocks]
- discover 자동 편입(stocks.json) + 매수권장가 224일선 자동 산출
- 매수사정권 진입 전이 감지 → 텔레그램 1회 알림 (직전신호 추적)
- 버그 수정: '미충족'이 .includes('충족')에 오탐되던 필터 판정

[지식 문서 파이프라인 통일 (wikiFormat/wikiSave)]
- PC 로컬 직접 저장 (NAS 볼륨 미아 문제 해소) — 설정 폴더 → 두뇌 → 워크스페이스
- 이메일 전용 Email_Wiki 분리(email.wikifySavePath) + 봉투(frontmatter) 자동 통일
- 정본 프롬프트 단일 빌더(buildKnowledgeWikiPrompt) — web/email 래퍼화, 템플릿 fetch 일원화
- 이메일 문서 시점 3중 방어: 기준시점 헤더·제목=메일 최신일·날짜 없는 현재형 금지
- docs/WIKI_DOC_PIPELINE.md 개발 규약

[텔레그램 메신저 중앙화 (messageFormat)]
- 단일 렌더러 renderTelegram — 5개 발신 빌더(주식보고·매수시그널·Top5·데일리·미회신) 통일
- 주식 보고 시각 하드코딩 제거(messenger.stocksReportTimes CSV) + 알림 토글 2종
- 설정 패널 '메신저' 탭 신설 · '이메일' 토글 섹션(Gmail/Outlook on/off)

테스트 803개 통과 (이메일 상태머신·CC판별·포맷·매수전이·봉투 등 60+ 신규)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 18:57:12 +09:00
parent 92d81e38ce
commit fa397d7fa1
44 changed files with 3635 additions and 174 deletions
+121
View File
@@ -0,0 +1,121 @@
/**
* v2.2.271 — 이메일 영속 상태 머신 (syncThreads / detectOverdue / setThreadState).
* "명령 안 쳐도 f/u 되는" 핵심 로직: 창 밖 미회신 보존, done 자동 재개, 알림 중복 차단.
*/
import { syncThreads, detectOverdue, setThreadState, waitingHours, type EmailStateFile } from '../src/features/email/emailState';
import type { ThreadGroup } from '../src/features/email/emailBrief';
const NOW = new Date('2026-07-03T12:00:00');
const group = (o: Partial<ThreadGroup>): ThreadGroup => ({
topic: '계약 검토', subject: '계약 검토', participants: ['김철수'],
inCount: 1, outCount: 0, lastDirection: 'in', lastDate: '2026-07-03 10:00',
snippet: '검토 부탁드립니다', hasUnread: true, automated: false, refs: ['O1'],
timeline: [],
ccOnly: false,
...o,
});
const empty = (): EmailStateFile => ({ updatedAt: '', threads: [] });
describe('syncThreads — 관측 병합', () => {
test('새 수신 스레드 → waiting + waitingSince 기록', () => {
const s = syncThreads(empty(), [group({})], NOW);
expect(s.threads[0].state).toBe('waiting');
expect(s.threads[0].waitingSince).toBe('2026-07-03 10:00');
});
test('관측 안 된 기존 스레드는 보존 — 수집 창 밖 미회신이 사라지지 않음', () => {
let s = syncThreads(empty(), [group({ topic: '옛건', subject: '옛건', lastDate: '2026-06-20 09:00' })], NOW);
s = syncThreads(s, [group({})], NOW); // 다음 사이클엔 '옛건' 미관측
expect(s.threads.map(t => t.topic).sort()).toEqual(['계약 검토', '옛건']);
expect(s.threads.find(t => t.topic === '옛건')!.state).toBe('waiting');
});
test('내가 답하면 answered + 대기·알림 리셋', () => {
let s = syncThreads(empty(), [group({})], NOW);
s.threads[0].alertedFor = s.threads[0].waitingSince;
s = syncThreads(s, [group({ lastDirection: 'out', outCount: 1, lastDate: '2026-07-03 11:00' })], NOW);
expect(s.threads[0].state).toBe('answered');
expect(s.threads[0].waitingSince).toBeUndefined();
expect(s.threads[0].alertedFor).toBeUndefined();
});
test('done 스레드에 새 수신 → waiting 자동 재개 (놓침 방지)', () => {
let s = syncThreads(empty(), [group({})], NOW);
setThreadState(s, '계약 검토', 'done');
s = syncThreads(s, [group({ lastDate: '2026-07-03 11:30', inCount: 2 })], NOW);
expect(s.threads[0].state).toBe('waiting');
expect(s.threads[0].waitingSince).toBe('2026-07-03 11:30');
});
test('muted 스레드는 새 수신에도 조용', () => {
let s = syncThreads(empty(), [group({})], NOW);
setThreadState(s, '계약 검토', 'muted');
s = syncThreads(s, [group({ lastDate: '2026-07-03 11:30' })], NOW);
expect(s.threads[0].state).toBe('muted');
});
test('60일 무활동 스레드는 정리', () => {
let s = syncThreads(empty(), [group({ topic: '고대', subject: '고대', lastDate: '2026-04-01 09:00' })], NOW);
s = syncThreads(s, [], NOW);
expect(s.threads).toHaveLength(0);
});
});
describe('detectOverdue — 임계 초과 1회 알림', () => {
test('24시간 초과 → 감지 + alertedFor 마킹, 재호출 시 중복 없음', () => {
const s = syncThreads(empty(), [group({ lastDate: '2026-07-02 10:00' })], NOW); // 26시간 전
const first = detectOverdue(s, 24, NOW);
expect(first).toHaveLength(1);
expect(detectOverdue(s, 24, NOW)).toHaveLength(0); // 같은 대기 기간엔 1회만
});
test('임계 미만·자동발신·answered 는 제외', () => {
const s = syncThreads(empty(), [
group({ topic: '방금', subject: '방금', lastDate: '2026-07-03 11:00' }),
group({ topic: '뉴스', subject: '뉴스', automated: true, lastDate: '2026-07-01 10:00' }),
group({ topic: '답함', subject: '답함', lastDirection: 'out', lastDate: '2026-07-01 10:00' }),
], NOW);
expect(detectOverdue(s, 24, NOW)).toHaveLength(0);
});
test('새 수신으로 대기 리셋되면 다시 알림 가능', () => {
let s = syncThreads(empty(), [group({ lastDate: '2026-07-01 10:00' })], NOW);
detectOverdue(s, 24, NOW); // 1차 알림
s = syncThreads(s, [group({ lastDate: '2026-07-02 09:00', inCount: 2 })], NOW); // 새 수신 (그래도 27시간 전)
expect(detectOverdue(s, 24, NOW)).toHaveLength(1); // 새 대기 기간 → 재알림
});
test('임계 0 = 끔', () => {
const s = syncThreads(empty(), [group({ lastDate: '2026-07-01 10:00' })], NOW);
expect(detectOverdue(s, 0, NOW)).toHaveLength(0);
});
});
describe('waitingHours', () => {
test('26시간 전 수신 → ~26', () => {
const s = syncThreads(empty(), [group({ lastDate: '2026-07-02 10:00' })], NOW);
expect(Math.round(waitingHours(s.threads[0], NOW))).toBe(26);
});
});
describe('wikifiedTopicSet — 파일 삭제 감지 (v2.2.280)', () => {
const { wikifiedTopicSet } = require('../src/features/email/emailState');
const topics = {
'구형항목': '2026-07-01T00:00:00Z', // 경로 없는 legacy
'살아있음': { at: '2026-07-03', path: 'E:/wiki/a.md' },
'삭제됨': { at: '2026-07-03', path: 'E:/wiki/deleted.md' },
};
const exists = (p: string) => p === 'E:/wiki/a.md';
test('verify=true → 파일 지워진 항목은 완료에서 제외 (재위키화 대상)', () => {
const s = wikifiedTopicSet(topics, exists, true);
expect(s.has('살아있음')).toBe(true);
expect(s.has('삭제됨')).toBe(false);
expect(s.has('구형항목')).toBe(true); // 경로 없어 검증 불가 — 완료 유지 (reset 으로 해소)
});
test('verify=false → 기록 그대로', () => {
expect(wikifiedTopicSet(topics, exists, false).size).toBe(3);
});
});