diff --git a/docs/discord-chat.md b/docs/discord-chat.md index 4a00c1c1..861b010e 100644 --- a/docs/discord-chat.md +++ b/docs/discord-chat.md @@ -175,7 +175,8 @@ at: 2026-10-16T12:00:00.000Z 1. 봇에게 등록한 포럼 채널들의 권한을 준다(채널 보기, 메시지 기록 읽기, 스레드에 메시지 보내기) — kyle. 2. `kadan discord check`로 등록 채널이 모두 "받는 중"인지 본다 — kyle·슈퍼감독. 3. 다리를 다시 띄운다: `kadan stop 디코` → 사람이 친 `kadan up`(새 코드로 도는 다리가 필요하다). -4. 확인: 포럼의 아무 글에 kyle이 한 줄 쓰면 비서 우편으로 오고(옛 글은 오지 않는다), 열려 있는 결정 요청 글에 답장으로 `1`을 쓰면 `기록됨: …`이 달린다(운영 결정이 실제로 기록되므로 시험할 결정으로 한다). +4. 끝난 업무 글 보관: 슈퍼감독이 봇에 Manage Threads를 붙인 뒤 `kadan discord check`에서 `Manage Threads <레포>: 있음`을 본다. 실제 보관·'보관된 글에 웹훅이 쓰면 디코가 자동으로 다시 여는가'는 아직 실측 전이다 → 완료한 시험 업무 하나로 보관·재개를 확인하고 결과를 [사용자 알림](user-notify.md#끝난-업무-글-보관재개-2026-10-16)에 적는다. +5. 확인: 포럼의 아무 글에 kyle이 한 줄 쓰면 비서 우편으로 오고(옛 글은 오지 않는다), 열려 있는 결정 요청 글에 답장으로 `1`을 쓰면 `기록됨: …`이 달린다(운영 결정이 실제로 기록되므로 시험할 결정으로 한다). ## #비서 채널 ID를 얻는 방법 diff --git a/docs/user-notify.md b/docs/user-notify.md index 706808b0..291d9fab 100644 --- a/docs/user-notify.md +++ b/docs/user-notify.md @@ -84,7 +84,7 @@ kadan secrets unset discord 갱신: 2026-10-14 06:55 UTC ``` -- 상태 파일: `notify-threads.json`은 `업무 키 → {threadId, statusMessageId}`다. 1단계의 옛 형식(`업무 키 → 글 id` 문자열)도 읽고, 다음 저장 때 같은 잠금 안에서 새 형식으로 바꿔 쓴다. 주소·토큰·사용자 ID는 저장하지 않는다. +- 상태 파일: `notify-threads.json`은 `업무 키 → {threadId, statusMessageId, archived?}`다(`archived`는 봇이 보관한 글일 때만 `true`로 쓰고, 없으면 `false`로 읽는다). 1단계의 옛 형식(`업무 키 → 글 id` 문자열)도 읽고, 다음 저장 때 같은 잠금 안에서 새 형식으로 바꿔 쓴다. 주소·토큰·사용자 ID는 저장하지 않는다. - 맞추는 함수는 `refreshWorkStatus(home, work)` 하나다. 새 주기 작업은 없고 이미 있는 사건 지점에서만 부른다. | 사건 | 부르는 곳 | @@ -98,6 +98,30 @@ kadan secrets unset discord - 공통 12초 예산과 장애 직후 60초 건너뛰기를 알림과 같이 쓴다. 건너뛰는 동안은 수정 요청도 나가지 않는다. 결과는 `notify-log.jsonl`에 `kind:"status"`로 남는다(중복 키 없음: 경보·우편의 중복 막기와 섞이지 않는다). - 요청 0건인 경우: `KADAN_NOTIFY=off`, 디코 설정 없음, 그 레포의 포럼 웹훅 키 없음, 업무 글 없음. +### 끝난 업무 글 보관·재개 (2026-10-16) + +업무가 **완료·취소**되면 상태 메시지를 고친 뒤 그 포럼 글을 보관(archived)해, 진행 중인 글만 열려 있게 한다. 보관은 웹훅이 못 하고 **봇**만 한다(`PATCH /channels/<글 id> {archived}`). + +- **봇 자격 경로:** `discord-bot` 비밀 파일의 `DISCORD_BOT_TOKEN`(`kadan secrets`로 위치 지정)을 자식 프로세스 `discord-archive.mjs`가 읽는다(알림 경로가 동기라 웹훅 전송처럼 `spawnSync`, `thread-archive.mjs`). 요청 함수는 `discord-chat.mjs` `setThreadArchived(chat, id, archived)`이고 `editThread`의 `assertSecretaryThread`는 거치지 않는다(notify-threads에 기록된 업무 글 id에만 쓴다). 봇 토큰은 `scrubSecrets` 목록에 더해 notify-log·오류 문구에 나가지 않는다. +- **보관 조건(같은 잠금 안, 모두):** (a) 업무가 완료·취소 (b) 이번 상태 메시지 고치기가 `sent` (c) 키가 `*/__decisions__`가 아님 (d) 기록이 아직 `archived`가 아님 (e) 시간 예산이 `MIN_SEND_MS` 이상 남음. 순서는 **고치기 → 보관**이다(보관된 글에 쓰면 다시 열릴 수 있다). + +| 경우 | 요청 순서 | 기록 | +|---|---|---| +| 완료·취소 + 고치기 `sent` | 상태 PATCH → 보관 PATCH | `archived:true` | +| 결정 글 / 고치기 실패 / 예산 부족 | 보관 요청 없음(예산 부족은 notify-log에 사유) | 그대로(다음 사건 때 재시도) | +| 보관 403(Manage Threads 없음)·실패 | 보관 PATCH 1회 | 그대로, `notify-log` `kind:"archive"`에 사유, 60초 차단 없음, 알림 결과 불변 | +| `archived:true` 글에 새 알림·reopen(열린 업무) | 재개 PATCH → 쓰기 → 상태 PATCH | `archived` 해제 | +| 기록 없음인데 웹훅이 `50083` | 쓰기(거절) → 재개 PATCH → 쓰기 **한 번만** 다시 | `archived` 없음 | +| 재개 403 등 | 재개 PATCH까지만, 그 글 쓰기 건너뜀(`archived-skip`) | 그대로. 알림은 기존 대체 전달(시스템 알림)을 받고 #브리핑 같은 다른 디코 채널로는 보내지 않음. 상태 고치기는 원래 대체 전달이 없어 로그만 | +| 보관된 글의 결정 해결 고치기 | 재개 PATCH → 요청 글 고치기 → 상태 PATCH → (끝난 업무면) 보관 PATCH | `decisions..resolved`, `archived:true`. 손으로 보관했으면 50083 → 재개 → 한 번만 다시 | +| 끝난 업무 + 이미 보관된 글에 늦은 실행 done | 요청 0건 | 그대로 | + +- **대체 전달을 받는 알림:** `notifyUser`로 나가는 알림은 모두(결정 요청·답변·취소, 울림 경보, 막힘·겹알림, 우편·인계 알림) 업무 글에 못 쓰면 시스템 알림으로 내려간다. `systemFallback:false`인 조용한 알림(`revive`)과 `refreshWorkStatus`(상태 고치기)는 원래 대체 전달이 없어 건너뛰고 `notify-log`에만 남긴다. +- 봇 자식의 출력은 정수·고정 단어·가린 문자열만 받는다(반사된 `code`·`message`에 토큰이 실려도 나가지 않는다). `attempts`는 실제로 나간 요청 수다. +- 디코 자동 재개방 여부는 **실측 미확인**(실제 전송 금지)이다. 그래서 두 경우를 모두 처리한다. 열린 업무로 돌아온 글(reopen)은 재개 뒤 다시 보관하지 않는다. 끝난 업무에 새 알림이 오면 재개 → 쓰기 → 고치기 뒤 다시 보관한다. +- '보관된 스레드' 오류: 디스코드 JSON 오류 코드 `50083`("Tried to perform an operation on an archived thread, such as editing a message or adding a user to the thread."), 근거 [Discord Docs — JSON Error Codes](https://docs.discord.com/developers/topics/opcodes-and-status-codes). 웹훅 전송 결과에 `code`(숫자)만 담아 알아본다. +- 권한: 봇에 Manage Threads가 필요하다(서버 설정은 슈퍼감독). `kadan discord check`가 "Manage Threads <레포>: 있음/없음"을 읽기만으로 계산해 보여 준다(서버 기본 권한 + 포럼 덮어쓰기). + ### 경보를 업무 글로 (2단계) 감시기가 사용자에게 경보를 보낼 때(`@user` 전달과 RED·죽음 겹알림), 경보의 실행 키(`taskId`)를 `WorkStore`의 실행 목록에서 찾아 **업무가 하나로 정해지면** `work`로 넘긴다(`watch-runner.mjs` `workKeyOfAlert`). 그 업무의 레포 포럼 키가 있으면 업무 글로 가고, 업무를 못 찾거나 여러 업무에 걸치거나 역할 단위 경보(감독 멈춤·관계표 읽기 실패 등)면 지금처럼 #브리핑이다. 경보 원장 기록과 `status?super=` 응답은 바뀌지 않는다. diff --git a/src/discord-archive.mjs b/src/discord-archive.mjs new file mode 100644 index 00000000..bf15fa0f --- /dev/null +++ b/src/discord-archive.mjs @@ -0,0 +1,38 @@ +// 업무 글 보관·재개 자식 프로세스 — thread-archive.mjs가 spawnSync로 띄워 stdin으로 {home, env, threadId, archived, budgetMs}를 준다. +// 봇 토큰은 여기서만 읽는다. 출력(stdout 한 줄 JSON)에는 토큰·주소·오류 원문을 넣지 않고 상태 코드·디스코드 오류 코드만 남긴다. +import {fileURLToPath} from 'node:url'; +import {loadSecret} from './secrets.mjs'; +import {createApi, apiBase, DiscordError} from './discord-bot.mjs'; +import {sanitize} from './thread-archive.mjs'; +import {setThreadArchived} from './discord-chat.mjs'; + +export async function run({home, env, threadId, archived, budgetMs}) { + const bot = loadSecret(env, home, 'discord-bot'); + const token = bot.values?.DISCORD_BOT_TOKEN; + if (!token || /\s/.test(token)) return {outcome: 'failed', attempts: 0, skip: true, reason: '봇 설정 없음(discord-bot 비밀 파일의 DISCORD_BOT_TOKEN)'}; + const done = r => sanitize(r, [token]); + try { + const api = createApi({base: apiBase(env), token, secrets: [token]}); + await setThreadArchived({api: (method, path, body) => api(method, path, body, {budgetMs})}, threadId, archived); + return done({outcome: 'sent', attempts: 1}); + } catch (error) { + if (error instanceof DiscordError) { + const base = {attempts: error.attempts ?? 1, reason: error.message}; + if (error.kind === 'uncertain') return done({outcome: 'uncertain', ...base}); + return done({outcome: 'failed', ...base, status: error.status, code: error.code}); + } + return done({outcome: 'failed', attempts: 0, skip: true, reason: '보관 준비 오류'}); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', c => { input += c; }); + process.stdin.on('end', async () => { + let result; + try { result = await run(JSON.parse(input)); } catch { result = {outcome: 'failed', attempts: 0, reason: '보관 준비 오류'}; } + process.stdout.write(JSON.stringify(result) + '\n'); + process.exit(0); + }); +} diff --git a/src/discord-bot.mjs b/src/discord-bot.mjs index e9e32166..7ad2aea7 100644 --- a/src/discord-bot.mjs +++ b/src/discord-bot.mjs @@ -18,9 +18,10 @@ const MAX_RATE_WAIT_MS = 30000; export const CONNECT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH', 'UND_ERR_CONNECT_TIMEOUT']); export class DiscordError extends Error { - constructor(message, {kind, status = null, code = null, exhausted = false} = {}) { + constructor(message, {kind, status = null, code = null, exhausted = false, attempts = null} = {}) { super(message); this.kind = kind; this.status = status; this.code = code; this.exhausted = exhausted; + this.attempts = attempts; // 실제로 나간 요청 수(알 수 없으면 null) } } @@ -50,7 +51,7 @@ export async function discordRequest({base, token, method = 'GET', path, body, i const headers = {'user-agent': USER_AGENT, ...(token ? {authorization: `Bot ${token}`} : {}), ...(body === undefined ? {} : {'content-type': 'application/json'})}; for (let attempt = 1; ; attempt++) { - if (left() <= 0) throw new DiscordError('시간 상한 도달', {kind: 'budget'}); + if (left() <= 0) throw new DiscordError('시간 상한 도달', {kind: 'budget', attempts: attempt - 1}); let status, text, headerRetry; try { const res = await fetchFn(base + path, {method, headers, body: body === undefined ? undefined : JSON.stringify(body), @@ -61,8 +62,8 @@ export async function discordRequest({base, token, method = 'GET', path, body, i } catch (error) { const code = error?.cause?.code ?? (['TimeoutError', 'AbortError'].includes(error?.name) ? 'TIMEOUT' : 'ERR'); if (!CONNECT_CODES.has(code) && !idempotent) - throw new DiscordError(`연결 뒤 응답이 불확실하다(${code}) — 보냈는지 알 수 없어 재시도하지 않는다`, {kind: 'uncertain', code}); - if (attempt >= MAX_ATTEMPTS) throw new DiscordError(`네트워크 오류(${code}) ${attempt}회 — 멈춘다`, {kind: 'network', code, exhausted: true}); + throw new DiscordError(`연결 뒤 응답이 불확실하다(${code}) — 보냈는지 알 수 없어 재시도하지 않는다`, {kind: 'uncertain', code, attempts: attempt}); + if (attempt >= MAX_ATTEMPTS) throw new DiscordError(`네트워크 오류(${code}) ${attempt}회 — 멈춘다`, {kind: 'network', code, exhausted: true, attempts: attempt}); await sleep(Math.min(backoffMs(attempt), Math.max(0, left()))); continue; } @@ -72,18 +73,18 @@ export async function discordRequest({base, token, method = 'GET', path, body, i if (status === 429) { const wait = Number(json?.retry_after ?? headerRetry); const ms = Math.ceil((Number.isFinite(wait) && wait >= 0 ? wait : 1) * 1000); - if (ms > MAX_RATE_WAIT_MS || ms > left()) throw new DiscordError(`429 대기(${Math.ceil(ms / 1000)}초)가 상한을 넘는다`, {kind: 'rate', status, exhausted: true}); - if (attempt >= MAX_ATTEMPTS) throw new DiscordError(`429 ${attempt}회 — 멈춘다`, {kind: 'rate', status, exhausted: true}); + if (ms > MAX_RATE_WAIT_MS || ms > left()) throw new DiscordError(`429 대기(${Math.ceil(ms / 1000)}초)가 상한을 넘는다`, {kind: 'rate', status, exhausted: true, attempts: attempt}); + if (attempt >= MAX_ATTEMPTS) throw new DiscordError(`429 ${attempt}회 — 멈춘다`, {kind: 'rate', status, exhausted: true, attempts: attempt}); await sleep(ms); continue; } if (status >= 500) { - if (!idempotent) throw new DiscordError(`HTTP ${status} — 보냈는지 알 수 없어 재시도하지 않는다`, {kind: 'uncertain', status}); - if (attempt >= MAX_ATTEMPTS) throw new DiscordError(`HTTP ${status} ${attempt}회 — 멈춘다`, {kind: 'http', status, exhausted: true}); + if (!idempotent) throw new DiscordError(`HTTP ${status} — 보냈는지 알 수 없어 재시도하지 않는다`, {kind: 'uncertain', status, attempts: attempt}); + if (attempt >= MAX_ATTEMPTS) throw new DiscordError(`HTTP ${status} ${attempt}회 — 멈춘다`, {kind: 'http', status, exhausted: true, attempts: attempt}); await sleep(Math.min(backoffMs(attempt), Math.max(0, left()))); continue; } - throw new DiscordError(`HTTP ${status}${json?.code ? ` (디스코드 코드 ${json.code})` : ''}`, {kind: 'http', status, code: json?.code ?? null}); + throw new DiscordError(`HTTP ${status}${json?.code ? ` (디스코드 코드 ${json.code})` : ''}`, {kind: 'http', status, code: json?.code ?? null, attempts: attempt}); } } diff --git a/src/discord-chat.mjs b/src/discord-chat.mjs index c8e3afad..603d1f15 100644 --- a/src/discord-chat.mjs +++ b/src/discord-chat.mjs @@ -494,6 +494,36 @@ export async function editThread(chat, id, {rename, archive}) { return body; } +// 업무 글(notify-threads에 기록된 글 id)의 보관·재개. 웹훅은 보관을 못 하고 봇만 한다. +// editThread와 달리 assertSecretaryThread를 거치지 않는다 — 업무 포럼 글은 #비서 글이 아니다. 그래서 호출 쪽(user-notify)이 +// notify-threads 기록의 글 id만 넘긴다. 같은 값 PATCH라 discord-bot의 재시도 규칙(5xx·끊김 재시도)이 그대로 안전하다. +export async function setThreadArchived(chat, id, archived) { + if (!isId(id)) throw new Error('스레드 ID는 숫자여야 한다'); + await chat.api('PATCH', `/channels/${id}`, {archived: archived === true}); +} + +// 봇이 이 포럼에서 스레드를 보관할 수 있는지(Manage Threads) 읽기만으로 계산한다 — 실제로 보관해 보지 않는다. +// 서버 기본 권한(@everyone + 봇 역할) → 포럼 채널 덮어쓰기(@everyone → 역할 → 봇 본인) 순서로 디스코드 규칙대로 합친다. 요청 4개. +const ADMINISTRATOR = 1n << 3n, MANAGE_THREADS = 1n << 34n; +async function botCanManageThreads(chat, forum) { + const botId = (await chat.api('GET', '/users/@me')).json?.id; + const member = (await chat.api('GET', `/guilds/${forum.guildId}/members/${botId}`)).json; + const roles = (await chat.api('GET', `/guilds/${forum.guildId}/roles`)).json ?? []; + const info = (await chat.api('GET', `/channels/${forum.id}`)).json; + const mine = new Set(member?.roles ?? []); + let perms = roles.filter(r => r.id === forum.guildId || mine.has(r.id)).reduce((all, r) => all | BigInt(r.permissions ?? 0), 0n); + if (perms & ADMINISTRATOR) return true; + const overwrites = info?.permission_overwrites ?? []; + const apply = list => { + const deny = list.reduce((all, o) => all | BigInt(o.deny ?? 0), 0n), allow = list.reduce((all, o) => all | BigInt(o.allow ?? 0), 0n); + perms = (perms & ~deny) | allow; + }; + apply(overwrites.filter(o => o.type === 0 && o.id === forum.guildId)); + apply(overwrites.filter(o => o.type === 0 && mine.has(o.id))); + apply(overwrites.filter(o => o.type === 1 && o.id === botId)); + return (perms & MANAGE_THREADS) !== 0n; +} + // 점검: 토큰·채널·권한·Message Content Intent. 값(ID)은 출력하지 않고 결과만 준다. export async function checkChat(chat) { const lines = []; @@ -504,9 +534,13 @@ export async function checkChat(chat) { if (chat.cfg.repoWebhooks.length) { await chat.forums({force: true}); const ok = new Set((await chat.forums()).map(f => f.name)); + const forums = await chat.forums(); for (const w of chat.cfg.repoWebhooks) { const bad = chat.forumFailures().find(f => f.name === w.repo); lines.push(`레포 포럼 ${w.repo}: ${ok.has(w.repo) ? '받는 중(채널 읽기 정상)' : `받지 않음 — ${bad?.reason ?? '확인 못 함'}`}`); + const forum = forums.find(f => f.name === w.repo); + if (forum) await step(`Manage Threads ${w.repo}`, async () => (await botCanManageThreads(chat, forum)) + ? '있음(끝난 업무 글을 보관할 수 있다)' : '없음 — 끝난 업무 글을 보관하지 못한다(슈퍼감독이 봇에 Manage Threads를 붙인다)'); } } await step('Message Content Intent', async () => { diff --git a/src/discord-send.mjs b/src/discord-send.mjs index 9c7a5e09..e28ef33e 100644 --- a/src/discord-send.mjs +++ b/src/discord-send.mjs @@ -80,7 +80,9 @@ export async function sendWebhook(webhook, payload, {budgetMs = TOTAL_BUDGET_MS, continue; } if (r.status >= 500 && !serverRetried && left() > 2500) { serverRetried = true; await sleep(1000); continue; } - return {outcome: 'failed', attempts, status: r.status, reason: `HTTP ${r.status}`}; + // 디스코드 오류 코드(숫자)만 담는다 — '보관된 스레드'(50083)를 알아보는 데 쓴다. 오류 본문 원문은 싣지 않는다. + let code; try { const c = JSON.parse(r.text)?.code; if (Number.isInteger(c)) code = c; } catch { /* 본문이 JSON이 아님 */ } + return {outcome: 'failed', attempts, status: r.status, reason: `HTTP ${r.status}`, ...(code === undefined ? {} : {code})}; } } diff --git a/src/notify-threads.mjs b/src/notify-threads.mjs index 6e4c2ee6..8c4e7043 100644 --- a/src/notify-threads.mjs +++ b/src/notify-threads.mjs @@ -12,9 +12,11 @@ const LOCK_STALE_MS = 20000; // 알림 한 번의 최대 시간(약 14초)보다 const file = home => path.join(home, THREADS_FILE); const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -const entryOf = v => typeof v === 'string' && v ? {threadId: v, statusMessageId: null} +// archived: 끝난 업무라 봇이 보관한 글이면 true. 옛 기록(문자열·archived 없음)은 false로 읽고, 파일에는 true일 때만 쓴다. +const entryOf = v => typeof v === 'string' && v ? {threadId: v, statusMessageId: null, archived: false} : v && typeof v === 'object' && typeof v.threadId === 'string' && v.threadId - ? {threadId: v.threadId, statusMessageId: typeof v.statusMessageId === 'string' && v.statusMessageId ? v.statusMessageId : null} : null; + ? {threadId: v.threadId, statusMessageId: typeof v.statusMessageId === 'string' && v.statusMessageId ? v.statusMessageId : null, archived: v.archived === true} : null; +const storedEntry = ({archived, ...rest}) => (archived ? {...rest, archived: true} : rest); const MAX_DECISIONS = 500; // 오래된 결정 기록부터 버린다(해결된 결정은 다시 고칠 일이 거의 없다) @@ -40,7 +42,7 @@ export function readDecisions(home) { function writeState(home, threads, decisions) { fs.mkdirSync(home, {recursive: true, mode: 0o700}); const tmp = `${file(home)}.${process.pid}.tmp`; - fs.writeFileSync(tmp, JSON.stringify({version: 1, threads, ...(Object.keys(decisions).length ? {decisions} : {})}, null, 2) + '\n', {mode: 0o600}); + fs.writeFileSync(tmp, JSON.stringify({version: 1, threads: Object.fromEntries(Object.entries(threads).map(([k, e]) => [k, storedEntry(e)])), ...(Object.keys(decisions).length ? {decisions} : {})}, null, 2) + '\n', {mode: 0o600}); fs.renameSync(tmp, file(home)); } @@ -50,6 +52,12 @@ export function setThread(home, work, entry) { writeState(home, {...readThreads(home), [work]: entryOf(entry)}, readDecisions(home)); } +// 글의 보관 여부만 바꾼다(기록이 없으면 아무것도 안 한다). 잠금 안에서 부른다. +export function setArchived(home, work, archived) { + const all = readThreads(home); + if (all[work]) writeState(home, {...all, [work]: {...all[work], archived: archived === true}}, readDecisions(home)); +} + // 결정 요청 메시지의 위치를 남긴다. 잠금 안에서 부른다. export function setDecision(home, id, entry) { const all = readDecisions(home); diff --git a/src/thread-archive.mjs b/src/thread-archive.mjs new file mode 100644 index 00000000..187e4134 --- /dev/null +++ b/src/thread-archive.mjs @@ -0,0 +1,42 @@ +// 업무 포럼 글의 보관·재개(봇 전용) — 알림 경로(user-notify)가 동기라서 웹훅 전송처럼 자식 프로세스(discord-archive.mjs)를 +// spawnSync로 띄워 강제 시간 제한을 건다. 웹훅은 보관을 못 하므로 봇 토큰이 필요하고, 토큰은 자식만 읽는다. +// 반환(전송 함수와 같은 모양): {outcome:'sent'|'failed'|'uncertain', attempts, status?, code?, reason?, skip?} +// skip: 봇 설정이 없는 것처럼 '요청이 나가지 않았고 다시 해도 같은' 실패(장애로 세지 않는다). +import {spawnSync} from 'node:child_process'; +import {loadSecret} from './secrets.mjs'; + +const CHILD = new URL('./discord-archive.mjs', import.meta.url).pathname; +const PASS_ENV = ['KADAN_SECRET_DISCORD_BOT_FILE', 'KADAN_DISCORD_API_BASE', 'KADAN_NOTIFY']; + +export function setArchivedSync({home, env = process.env, threadId, archived, budgetMs = 12000}) { + const child = spawnSync(process.execPath, [CHILD], { + input: JSON.stringify({home, env: Object.fromEntries(PASS_ENV.filter(k => env[k] !== undefined).map(k => [k, env[k]])), threadId, archived, budgetMs}), + encoding: 'utf8', timeout: budgetMs + 2000, maxBuffer: 1 << 16, + }); + if (child.error || child.status !== 0) + return {outcome: 'uncertain', attempts: null, reason: child.error?.code === 'ETIMEDOUT' ? '자식 프로세스 시간 초과' : '자식 프로세스 오류'}; + // 자식 출력도 믿지 않고 한 번 더 걸러 받는다(허용 필드·정수만, 문자열은 봇 토큰 가림). + try { return sanitize(JSON.parse(child.stdout.trim().split('\n').at(-1)), botSecrets(home, env)); } + catch { return {outcome: 'uncertain', attempts: null, reason: '보관 결과 해석 실패'}; } +} + +// 자식 출력은 허용한 필드만, 모양을 검사해서 받는다(정수만·고정 단어만·문자열은 비밀값 가림을 거친다). +// 디스코드 응답의 code·message는 반사될 수 있어 믿지 않는다 — 웹훅 경로(discord-send.mjs)처럼 code는 정수일 때만 낸다. +const OUTCOMES = new Set(['sent', 'failed', 'uncertain']); +const hide = (text, secrets) => secrets.reduce((out, s) => (s ? out.split(s).join('[가림]') : out), String(text)); +export function sanitize(result, secrets = []) { + const int = v => (Number.isInteger(v) ? v : undefined); + const out = {outcome: OUTCOMES.has(result?.outcome) ? result.outcome : 'failed', attempts: result?.attempts === null ? null : int(result?.attempts) ?? 0}; + for (const key of ['status', 'code']) if (int(result?.[key]) !== undefined) out[key] = result[key]; + if (result?.skip === true) out.skip = true; + if (typeof result?.reason === 'string') out.reason = hide(result.reason, secrets).slice(0, 300); + return JSON.parse(hide(JSON.stringify(out), secrets)); +} + +// 오류·기록 문구에서 가릴 봇 토큰(없거나 못 읽으면 빈 목록). 토큰이 notify-log·오류 출력에 나가지 않게 scrubSecrets 목록에 더한다. +export function botSecrets(home, env = process.env) { + try { + const token = loadSecret(env, home, 'discord-bot').values?.DISCORD_BOT_TOKEN; + return token ? [token] : []; + } catch { return []; } +} diff --git a/src/user-notify.mjs b/src/user-notify.mjs index a56e1154..8e6cc22e 100644 --- a/src/user-notify.mjs +++ b/src/user-notify.mjs @@ -8,7 +8,8 @@ import path from 'node:path'; import {spawnSync} from 'node:child_process'; import {ledgerHome} from './ledger.mjs'; import {effectiveNotifyEnvFile, readDiscordSecrets} from './notify-setting.mjs'; -import {setThread, setDecision, readDecisions, withThreadLock} from './notify-threads.mjs'; +import {setThread, setArchived, setDecision, readDecisions, readThreads, withThreadLock} from './notify-threads.mjs'; +import {setArchivedSync, botSecrets} from './thread-archive.mjs'; import {WorkStore} from './work-store.mjs'; import {buildWorkStatus} from './work-status-message.mjs'; @@ -113,15 +114,88 @@ const is4xx = r => r.outcome === 'failed' && r.status >= 400 && r.status < 500 & const outageOf = r => r.outcome === 'uncertain' || (r.outcome === 'failed' && r.attempts !== 0 && (r.status === undefined || r.status >= 500 || r.status === 429)); const noteOutage = r => { if (outageOf(r)) blockedUntil = Date.now() + DISCORD_BLOCK_MS; }; -// 업무 글의 상태 메시지를 올리거나 고친다. 잠금 안에서 부른다. 예산이 모자라면 버린다(상태는 다음 사건 때 다시 계산된다). +// ---------- 끝난 업무 글 보관·재개 ---------- +// 웹훅은 보관을 못 해서 봇(thread-archive.mjs)이 한다. 보관된 글에 쓰면 디코가 다시 열어 주는지는 실측하지 못했다(실제 전송 금지) — +// 그래서 두 경우를 모두 처리한다: (1) 기록이 archived인 글에는 쓰기 전에 먼저 재개한다. (2) 기록이 없는데 웹훅이 '보관된 스레드' 오류를 +// 내면(손으로 보관한 글) 재개한 뒤 한 번만 다시 시도한다. 재개가 막히면(403 등) 그 쓰기를 건너뛰고 기록에 사유만 남긴다. +export const ARCHIVED_THREAD_CODE = 50083; // 디스코드 JSON 오류 코드: 보관된 스레드에 쓰려 함 +const isArchivedError = r => r.outcome === 'failed' && r.status === 400 && r.code === ARCHIVED_THREAD_CODE; + +// 한 번의 잠금 안에서 쓰는 묶음. resumed: 이번 사건에서 이미 재개를 시도했는가(재개는 한 번만). +function workCtx({home, env, webhook, work, deadline, secrets}) { + return {home, env, webhook, work, deadline, secrets, resumed: false, left: () => deadline - Date.now()}; +} + +// 글을 봇으로 다시 연다. 반환 {ok:true} 또는 {ok:false, result}. +// 404(글 없음)는 result를 그대로 돌려줘 호출 쪽의 '지워진 글' 경로가 이어받는다. +// 그 밖의 4xx·봇 설정 없음은 'archived-skip'(그 글 쓰기는 건너뜀, blockedUntil 없음 — 사용자가 알아야 할 알림은 notifyUser가 시스템 알림 대체 전달로 내린다. 대체 전달이 없던 상태 고치기는 로그만), 5xx·불확실은 실패 그대로. +function resumeThread(ctx, threadId) { + ctx.resumed = true; + const r = ctx.left() < MIN_SEND_MS ? budgetOut() : setArchivedSync({home: ctx.home, env: ctx.env, threadId, archived: false, budgetMs: ctx.left()}); + if (r.outcome === 'sent') { + // 이 글 id를 가진 기록(보통 ctx.work, 결정 요청이 다른 업무의 글에 있으면 그 업무)의 보관 표시를 푼다. + for (const [work, e] of Object.entries(readThreads(ctx.home))) if (e.threadId === threadId && e.archived) setArchived(ctx.home, work, false); + return {ok: true}; + } + if (r.outcome === 'failed' && r.status === 404) return {ok: false, result: r}; + if (r.skip === true || is4xx(r)) + return {ok: false, result: {outcome: 'archived-skip', attempts: r.attempts ?? 0, status: r.status, reason: `보관된 글을 다시 열지 못해 쓰기를 건너뜀 — ${r.reason ?? '사유 없음'}`}}; + return {ok: false, result: r}; +} +const resumeGone = o => !o.ok && o.result.outcome === 'failed' && o.result.status === 404; + +// 웹훅 쓰기 하나를 하되, '보관된 스레드' 오류면 재개 → 한 번만 다시 시도한다. +function withResume(ctx, threadId, attempt) { + const r = attempt(); + if (!isArchivedError(r) || ctx.resumed) return r; + const o = resumeThread(ctx, threadId); + return o.ok ? attempt() : o.result; +} + +function logArchive(ctx, a) { + appendLog(ctx.home, {at: new Date().toISOString(), key: null, kind: 'archive', work: ctx.work, via: 'discord', outcome: a.outcome, + reason: scrubSecrets(a.reason ?? '', ctx.secrets) || undefined, attempts: a.attempts ?? undefined, status: a.status ?? undefined}); +} + +// 끝난(완료·취소) 업무의 글을 보관한다. 조건(모두): 상태 메시지 고치기가 sent, 결정 글 아님, 아직 보관 안 됨, 시간 예산 있음. +// 실패(403 등)는 사유만 기록하고 글은 보관 안 됨으로 둔다. 알림 결과는 바꾸지 않는다. 장애성 실패만 60초 차단에 센다. +function archiveIfEnded(ctx, status, r) { + if (!status.ended || r.outcome !== 'sent' || ctx.work.endsWith('/__decisions__')) return null; + const entry = readThreads(ctx.home)[ctx.work]; + if (!entry || entry.archived) return null; + const a = ctx.left() < MIN_SEND_MS ? {outcome: 'skipped', attempts: 0, reason: '시간 예산이 모자라 보관을 건너뜀(다음 사건 때 다시 시도)'} + : setArchivedSync({home: ctx.home, env: ctx.env, threadId: entry.threadId, archived: true, budgetMs: ctx.left()}); + if (a.outcome === 'sent') setArchived(ctx.home, ctx.work, true); + noteOutage(a); + logArchive(ctx, a); + return a; +} + +// 업무 글의 상태 메시지를 올리거나 고친 뒤(끝난 업무면) 글을 보관한다. 잠금 안에서 부른다. +// entry: 글 기록 {threadId, statusMessageId, archived}. +// 끝난 업무인데 이미 보관된 글이면 아무것도 안 한다(늦게 온 실행 done이 끝난 글을 다시 열지 않는다). +// 업무가 열려 있는데 보관된 글이면(reopen) 먼저 재개한다. +function syncWork(ctx, entry) { + const status = buildWorkStatus(ctx.home, ctx.work); + if (!status) return {outcome: 'none', attempts: 0}; + if (entry.archived) { + if (status.ended) return {outcome: 'none', attempts: 0}; + const o = resumeThread(ctx, entry.threadId); + if (!o.ok && !resumeGone(o)) return o.result; + } + const r = syncStatus(ctx, status, entry); + const archive = archiveIfEnded(ctx, status, r); + return archive ? {...r, archive} : r; +} + +// 상태 메시지를 올리거나 고친다. 예산이 모자라면 버린다(상태는 다음 사건 때 다시 계산된다). // 기록된 메시지가 있으면 고친다 → 404(지워짐)면 같은 글에 새 상태 메시지를 올리고 id를 바꾼다 // → 글까지 404면 상태 메시지를 첫 글로 새 글을 만든다. 404 아닌 실패(400·403·5xx·불확실)는 더 시도하지 않고 버린다. -function syncStatus({home, webhook, work, threadId, statusMessageId, deadline}) { - const left = () => deadline - Date.now(); - const status = buildWorkStatus(home, work); - if (!status) return {outcome: 'none', attempts: 0}; - const call = extra => left() < MIN_SEND_MS ? budgetOut() - : postDiscordWebhook({webhook, content: status.content, allowedMentions: {parse: []}, budgetMs: left(), ...extra}); +function syncStatus(ctx, status, {threadId, statusMessageId}) { + const {home, webhook, work} = ctx; + const raw = extra => ctx.left() < MIN_SEND_MS ? budgetOut() + : postDiscordWebhook({webhook, content: status.content, allowedMentions: {parse: []}, budgetMs: ctx.left(), ...extra}); + const call = extra => (extra.threadId ? withResume(ctx, extra.threadId, () => raw(extra)) : raw(extra)); const gone = r => r.outcome === 'failed' && r.status === 404; let r; if (statusMessageId) { @@ -142,33 +216,47 @@ function syncStatus({home, webhook, work, threadId, statusMessageId, deadline}) // 기본 #브리핑에 섞지 않고, 상태 파일을 잠금 없이 건드리지 않으려고 시스템 알림으로 내려간다(잘못된 채널로 새지 않는 쪽). // 알림이 나간 뒤에는 같은 잠금 안에서 상태 메시지도 맞춘다(결과는 statusSync에 따로 담고 알림 결과를 바꾸지 않는다). // decision: {id, edit?}. 요청이면 메시지 id를 남기고(wait), 해결(edit 본문 있음)이면 남긴 메시지를 고친다 — 못 찾을 때(기록 없음·404)만 새 메시지. -function sendForum({home, webhook, work, threadName, content, allowedMentions, deadline, decision}) { +function sendForum({home, env, webhook, work, threadName, content, allowedMentions, deadline, decision, secrets}) { const left = () => deadline - Date.now(); + const ctx = workCtx({home, env, webhook, work, deadline, secrets}); const lock = withThreadLock(home, deadline - MIN_SEND_MS, threads => { const plan = planDecision(home, decision, content, allowedMentions); if (plan.skip) return plan.skip; const post = extra => left() < MIN_SEND_MS ? budgetOut() : postDiscordWebhook({webhook, content: plan.content, allowedMentions: plan.allowedMentions, budgetMs: left(), ...extra}); if (decision?.edit) { - const done = editDecision({home, decision, post, forum: true}); + // 결정 요청이 보관된 글에 있으면 고치기 전에 먼저 다시 열고(재개가 막히면 고치기를 건너뛴다), 50083이면 재개 뒤 한 번만 다시 시도한다. + const at = readDecisions(home)[decision.id], cur = threads[work]; + if (at?.forum && at.threadId && cur?.archived && cur.threadId === at.threadId) { + const o = resumeThread(ctx, at.threadId); + if (!o.ok && !resumeGone(o)) return o.result; + } + const done = editDecision({home, decision, post, forum: true, resumeWith: (threadId, attempt) => withResume(ctx, threadId, attempt)}); if (done) { // 메시지를 고쳤어도 그 업무 글의 상태 메시지는 기준대로 맞춘다(결정 전용 글은 업무가 아니라 'none'). - const known = threads[work]; - return done.outcome === 'sent' && known ? {...done, statusSync: syncStatus({home, webhook, work, ...known, deadline})} : done; + // 재개했으면 기록이 바뀌었으니 최신 기록으로 맞춘다(끝난 업무면 고치기 뒤 다시 보관된다). + const known = readThreads(home)[work]; + return done.outcome === 'sent' && known ? {...done, statusSync: syncWork(ctx, known)} : done; } } - const known = threads[work]; + let known = threads[work]; const wait = Boolean(decision); let r, entry = null; + if (known?.archived) { // 보관된 글에는 쓰기 전에 먼저 다시 연다(재개가 막히면 이 쓰기를 건너뛴다) + const o = resumeThread(ctx, known.threadId); + if (o.ok) known = {...known, archived: false}; + else if (resumeGone(o)) known = null; + else return o.result; + } if (known) { - r = post({threadId: known.threadId, wait}); + r = withResume(ctx, known.threadId, () => post({threadId: known.threadId, wait})); if (r.outcome === 'sent') entry = known; } - if (!entry && !(r && !is4xx(r))) { + if (!entry && !(r && (!is4xx(r) || isArchivedError(r)))) { r = post({threadName, wait: true}); if (r.outcome === 'sent' && r.channelId) { entry = {threadId: r.channelId, statusMessageId: null}; setThread(home, work, entry); } } if (entry && wait && r.messageId) setDecision(home, decision.id, {threadId: entry.threadId, messageId: r.messageId, forum: true, resolved: plan.resolved}); - if (entry) r = {...r, statusSync: syncStatus({home, webhook, work, ...entry, deadline})}; + if (entry) r = {...r, statusSync: syncWork(ctx, entry)}; return noteRead(r, plan); }); return lock.locked ? lock.value : {outcome: 'lock-timeout', attempts: 0, reason: '포럼 상태 잠금을 시간 안에 못 얻음'}; @@ -213,10 +301,10 @@ const noteRead = (r, plan) => (plan.readFailed ? {...r, readFailed: true} : r); // 남긴 결정 요청 메시지를 고친다. 돌려주는 값: 결과(고침 성공, 또는 404 아닌 실패라 더 시도하지 않음) 또는 null(못 찾음 → 호출 쪽이 새 메시지). // 기록이 없거나, 기록한 길(포럼/기본)이 지금 길과 다르거나, 수정이 404(지워짐·다른 웹훅)면 못 찾은 것이다. -function editDecision({home, decision, post, forum}) { +function editDecision({home, decision, post, forum, resumeWith = (threadId, attempt) => attempt()}) { const known = readDecisions(home)[decision.id]; if (!known || known.forum !== forum) return null; - const r = post({threadId: known.threadId ?? undefined, messageId: known.messageId, content: decision.edit, allowedMentions: {parse: []}}); + const r = resumeWith(known.threadId, () => post({threadId: known.threadId ?? undefined, messageId: known.messageId, content: decision.edit, allowedMentions: {parse: []}})); if (r.outcome === 'failed' && r.status === 404) return null; if (r.outcome === 'sent') setDecision(home, decision.id, {...known, resolved: true}); return r; @@ -234,11 +322,12 @@ export function refreshWorkStatus(home, work, opts = {}) { if (cfg.error || !cfg.envFile) return {outcome: 'none'}; const sec = readDiscordSecrets(cfg.envFile, {repo: work.split('/')[0]}); if (sec.error || !sec.repoWebhook) return {outcome: 'none'}; - secrets = [sec.webhook, sec.repoWebhook, sec.userId]; + secrets = [sec.webhook, sec.repoWebhook, sec.userId, ...botSecrets(home, env)]; if (Date.now() < blockedUntil) return {outcome: 'skipped', reason: '디스코드 장애 직후라 건너뜀(60초)'}; const deadline = Date.now() + (opts.totalMs ?? TOTAL_BUDGET_MS); + const ctx = workCtx({home, env, webhook: sec.repoWebhook, work, deadline, secrets}); const lock = withThreadLock(home, deadline - MIN_SEND_MS, threads => - threads[work] ? syncStatus({home, webhook: sec.repoWebhook, work, ...threads[work], deadline}) : {outcome: 'none', attempts: 0}); + threads[work] ? syncWork(ctx, threads[work]) : {outcome: 'none', attempts: 0}); const r = lock.locked ? lock.value : {outcome: 'lock-timeout', attempts: 0, reason: '포럼 상태 잠금을 시간 안에 못 얻음'}; noteOutage(r); if (r.outcome !== 'none') logStatus(home, work, r, secrets); @@ -300,7 +389,7 @@ export function notifyUser(message, spawn, opts = {}) { const sec = readDiscordSecrets(cfg.envFile, {repo, checkDir: cfg.source === 'default'}); if (sec.error) result.discord = {outcome: 'config-error', reason: scrubSecrets(sec.error)}; else { - secrets = [sec.webhook, sec.repoWebhook, sec.userId]; + secrets = [sec.webhook, sec.repoWebhook, sec.userId, ...botSecrets(home, env)]; warnings = sec.warnings ?? []; const now = (opts.now ?? Date.now)(); if (opts.key && alreadySent(home, opts.key, opts.dedupWindowMs, now)) @@ -317,7 +406,7 @@ export function notifyUser(message, spawn, opts = {}) { const sent = Date.now() < blockedUntil ? {outcome: 'skipped', attempts: 0, reason: '디스코드 장애 직후라 건너뜀(60초)'} : to.forum - ? sendForum({home, webhook: to.webhook, work: forumKey, threadName, content: d.content, allowedMentions, deadline, decision}) + ? sendForum({home, env, webhook: to.webhook, work: forumKey, threadName, content: d.content, allowedMentions, deadline, decision, secrets}) : decision ? sendDefaultDecision({home, webhook: to.webhook, content: d.content, allowedMentions, deadline, decision}) : postDiscordWebhook({webhook: to.webhook, content: d.content, allowedMentions, budgetMs: Math.max(MIN_SEND_MS, deadline - Date.now())}); @@ -340,6 +429,7 @@ export function notifyUser(message, spawn, opts = {}) { } } if (result.via === 'discord') return result; + // 보관된 글을 다시 열지 못해 쓰기를 건너뛴 경우(archived-skip)도 여기로 내려와 다른 디코 채널 없이 기존 대체 전달(시스템 알림)을 받는다. // opts.systemFallback === false: 디코로 못 보내면 맥 시스템 알림으로 대신하지 않는다(조용한 알림 전용 — 놓쳐도 되는 알림이 맥을 울리지 않게). if (opts.systemFallback === false) return {...result, via: 'none', error: null}; // 시스템 알림도 같은 판단이다: 이미 해결 메시지를 보낸 결정의 요청은 다시 띄우지 않고, 해결됐다고 읽히면 해결 문구로 바꾼다. diff --git a/src/work-status-message.mjs b/src/work-status-message.mjs index 6b0c6a0b..1686475d 100644 --- a/src/work-status-message.mjs +++ b/src/work-status-message.mjs @@ -20,7 +20,7 @@ const stateText = card => { const cut = (text, max) => { const t = String(text ?? '').replace(/\s+/g, ' ').trim(); return t.length > max ? `${t.slice(0, max - 1)}…` : t; }; const minute = iso => (Number.isFinite(Date.parse(iso)) ? new Date(iso).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : '모름'); -// 반환: {content, title} 또는 업무를 못 읽으면 null(상태 메시지를 만들지 않는다). +// 반환: {content, title, ended(업무가 완료·취소면 true — 포럼 글 보관 판단)} 또는 업무를 못 읽으면 null(상태 메시지를 만들지 않는다). // summary: 나중에 설명 AI 요약 한 줄이 들어갈 자리. 지금은 비워 둔다. export function buildWorkStatus(home, workKey, {summary = ''} = {}) { let work, running = [], hidden = 0, failed = 0; @@ -47,5 +47,5 @@ export function buildWorkStatus(home, workKey, {summary = ''} = {}) { `책임 감독: ${cut(work.owner, 100)}`, `갱신: ${minute(work.at)}`, ]; - return {content: lines.join('\n').slice(0, LIMIT), title: work.title ?? ''}; + return {content: lines.join('\n').slice(0, LIMIT), title: work.title ?? '', ended: work.status === 'done' || work.status === 'cancelled'}; } diff --git a/tests/helpers/fake-discord-api.mjs b/tests/helpers/fake-discord-api.mjs index a30f55ef..60e122f8 100644 --- a/tests/helpers/fake-discord-api.mjs +++ b/tests/helpers/fake-discord-api.mjs @@ -22,6 +22,7 @@ export async function startFakeDiscord({intent = true} = {}) { [FAKE.otherChannelId, {id: FAKE.otherChannelId, type: 0, guild_id: FAKE.guildId}], ]); messages.set(FAKE.otherChannelId, []); + const guild = {roles: [{id: FAKE.guildId, permissions: '0'}], memberRoles: []}; // 봇 권한 계산용(checkChat의 Manage Threads 줄) const webhooks = new Map([[`${FAKE.hookId}/${FAKE.hookToken}`, FAKE.channelId]]); // 웹훅 id/token → 채널 id const send = (res, status, json) => { res.writeHead(status, {'content-type': 'application/json'}); res.end(json === undefined ? '' : JSON.stringify(json)); }; @@ -43,6 +44,8 @@ export async function startFakeDiscord({intent = true} = {}) { const p = url.pathname.split('/').filter(Boolean); const m = req.method; if (m === 'GET' && p[0] === 'webhooks' && webhooks.has(`${p[1]}/${p[2]}`)) return send(res, 200, {id: p[1], channel_id: webhooks.get(`${p[1]}/${p[2]}`)}); + if (m === 'GET' && p[0] === 'guilds' && p[2] === 'members') return send(res, 200, {user: {id: p[3]}, roles: guild.memberRoles}); + if (m === 'GET' && p[0] === 'guilds' && p[2] === 'roles') return send(res, 200, guild.roles); if (m === 'GET' && p[0] === 'users' && p[1] === '@me') return send(res, 200, {id: FAKE.botId, bot: true}); if (m === 'GET' && p[0] === 'applications' && p[1] === '@me') return send(res, 200, {id: FAKE.appId, flags: intent ? 1 << 18 : 0}); if (m === 'GET' && p[0] === 'guilds' && p[2] === 'threads') return send(res, 200, {threads: [...threads.values()].filter(t => !t.archived && t.guild_id === p[1]).map(t => ({...t, last_message_id: lastOf(t.id)}))}); @@ -103,7 +106,7 @@ export async function startFakeDiscord({intent = true} = {}) { server.on('connection', s => { sockets.add(s); s.on('close', () => sockets.delete(s)); }); return { base: `http://127.0.0.1:${server.address().port}`, - requests, threads, messages, + requests, threads, messages, guild, inject: rule => rules.push({times: 1, status: 500, ...rule}), // 채널(또는 스레드)에 메시지를 넣는다. 반환: 메시지 add(channelId, extra = {}) { @@ -118,9 +121,9 @@ export async function startFakeDiscord({intent = true} = {}) { return t; }, // 레포 포럼 채널(type 15)과 그 웹훅을 만든다. 반환: {id, hookId, hookToken, name}. 비밀 파일에는 DISCORD_WEBHOOK_URL_<이름>으로 넣는다. - addForum(name, {guildId = FAKE.guildId, type = 15} = {}) { + addForum(name, {guildId = FAKE.guildId, type = 15, overwrites = []} = {}) { const id = nextId(), hookId = nextId(), hookToken = `FAKE-HOOK-${name}-${hookId.slice(-4)}`; - channels.set(id, {id, type, guild_id: guildId, name}); messages.set(id, []); + channels.set(id, {id, type, guild_id: guildId, name, permission_overwrites: overwrites}); messages.set(id, []); webhooks.set(`${hookId}/${hookToken}`, id); return {id, hookId, hookToken, name, url: `https://discord.com/api/webhooks/${hookId}/${hookToken}`}; }, diff --git a/tests/helpers/fake-forum-bot.mjs b/tests/helpers/fake-forum-bot.mjs new file mode 100644 index 00000000..0adaf50b --- /dev/null +++ b/tests/helpers/fake-forum-bot.mjs @@ -0,0 +1,64 @@ +// 가짜 디스코드 포럼 웹훅 + 봇 보관 API 서버 — 시험 프로세스와 따로 띄운다(알림 전송이 spawnSync로 부모를 막는다). +// 사용: node fake-forum-bot.mjs <요청 기록 파일> '<옵션 JSON>'. 첫 줄에 "PORT <번호>"를 출력한다. 모든 값은 가짜다. +// 웹훅(/api/webhooks//): thread_name이면 새 글, thread_id면 그 글에 쓰기, PATCH .../messages/는 메시지 수정. +// 보관된 글에 쓰면 400 {code: 50083}(디스코드 '보관된 스레드' 오류). 옵션 autoReopen이면 대신 글을 다시 열고 받아 준다. +// 봇(PATCH /channels/<글 id> {archived}): 헤더 x-fake-manual이 있으면 상태 주입을 건너뛴다(사람이 손으로 보관한 것을 흉내). +// Authorization이 "Bot "이어야 한다. 글 id는 숫자여야 한다(진짜 스노플레이크 모양). +// 옵션: {token, archiveStatus(보관 요청에 줄 상태, 기본 200), resumeStatus(재개 요청), failEditStatus(메시지 수정에 줄 상태), +// editDelayMs(메시지 수정 응답 지연), autoReopen, reflectToken(봇 오류 응답의 code·message에 토큰을 반사 — 비정상 응답 흉내)}. 기록 줄: {method, url, auth(토큰 앞 일부만), body}. +import http from 'node:http'; +import fs from 'node:fs'; + +const [, , logFile, optionsJson = '{}'] = process.argv; +const options = JSON.parse(optionsJson), token = options.token ?? 'FAKE-BOT-TOKEN-zz99'; +const base = 9000000000000000000n; +let seq = 0n; +const nextId = () => String(base + (seq += 1n)); +const threads = new Set(), archived = new Set(), messages = new Map(); // 메시지 id → 글 id +const sockets = new Set(); + +const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'), url = new URL(req.url, 'http://x'), p = url.pathname.split('/').filter(Boolean); + const body = text ? JSON.parse(text) : {}; + fs.appendFileSync(logFile, JSON.stringify({method: req.method, url: req.url, auth: req.headers.authorization ? 'Bot ***' : null, + authOk: req.headers.authorization === `Bot ${token}`, body: text}) + '\n'); + const reply = (status, json, delay = 0) => setTimeout(() => { res.writeHead(status, {'content-type': 'application/json'}); res.end(json === undefined ? '' : JSON.stringify(json)); }, delay); + if (p[0] === 'channels' && p.length === 2 && req.method === 'PATCH') { + if (req.headers.authorization !== `Bot ${token}`) return reply(401, {code: 0}); + if (!threads.has(p[1])) return reply(404, {code: 10003}); + const wanted = body.archived === true, status = wanted ? options.archiveStatus : options.resumeStatus; + if (status && status !== 200 && !req.headers['x-fake-manual']) return reply(status, options.reflectToken ? {code: token, message: `bad ${token}`} : {code: status === 403 ? 50013 : 0, message: `fake ${status}`}); + if (wanted) archived.add(p[1]); else archived.delete(p[1]); + return reply(200, {id: p[1], thread_metadata: {archived: wanted}}); + } + if (p[0] === 'api' && p[1] === 'webhooks') { + const thread = url.searchParams.get('thread_id'); + if (thread && threads.has(thread) && archived.has(thread)) { + if (!options.autoReopen) return reply(400, {code: 50083, message: 'Thread is archived'}); + archived.delete(thread); + } + if (req.method === 'PATCH') { + const mid = p[5]; + if (!thread || !threads.has(thread) || messages.get(mid) !== thread) return reply(404, {code: 10008}); + if (options.failEditStatus) return reply(options.failEditStatus, {code: 0}, options.editDelayMs ?? 0); + return reply(200, {id: mid, channel_id: thread}, options.editDelayMs ?? 0); + } + const mid = nextId(); + if (thread) { + if (!threads.has(thread)) return reply(404, {code: 10003}); + messages.set(mid, thread); + return reply(200, {id: mid, channel_id: thread}); + } + if (!body.thread_name) return reply(400, {message: 'thread_name or thread_id required'}); + const id = nextId(); threads.add(id); messages.set(mid, id); + return reply(200, {id: mid, channel_id: id}); + } + reply(404, {code: 0}); + }); +}); +server.on('connection', s => { sockets.add(s); s.on('close', () => sockets.delete(s)); }); +server.listen(0, '127.0.0.1', () => console.log(`PORT ${server.address().port}`)); +process.on('SIGTERM', () => { for (const s of sockets) s.destroy(); server.close(() => process.exit(0)); }); diff --git a/tests/work-archive.test.mjs b/tests/work-archive.test.mjs new file mode 100644 index 00000000..625c6c80 --- /dev/null +++ b/tests/work-archive.test.mjs @@ -0,0 +1,373 @@ +import './helpers/isolated-home.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {spawn} from 'node:child_process'; +import {sanitize, setArchivedSync} from '../src/thread-archive.mjs'; +import {readDecisions} from '../src/notify-threads.mjs'; +import {spawnSync} from 'node:child_process'; +import {notifyUser, refreshWorkStatus, resetDiscordBlock, scrubSecrets, NOTIFY_LOG_FILE} from '../src/user-notify.mjs'; +import {readThreads} from '../src/notify-threads.mjs'; +import {botSecrets} from '../src/thread-archive.mjs'; +import {WorkStore} from '../src/work-store.mjs'; +import {workCommand} from '../src/work-command.mjs'; +import {createChat, checkChat} from '../src/discord-chat.mjs'; +import {startFakeDiscord, FAKE} from './helpers/fake-discord-api.mjs'; + +// 끝난 업무의 디코 포럼 글 보관·재개. 가짜 서버(웹훅+봇)와 가짜 비밀 파일만 쓴다 — 실제 디코로는 나가지 않는다. +const helper = new URL('./helpers/fake-forum-bot.mjs', import.meta.url).pathname; +const TOKEN = 'FAKE-BOT-TOKEN-zz99', USER_ID = '424242424242', WORK = 'kadan/work-x'; +const live = []; +process.on('exit', () => { for (const c of live) c.kill('SIGKILL'); }); + +async function world(options = {}) { + resetDiscordBlock(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kadan-archive-test-')), home = path.join(dir, 'home'), logFile = path.join(dir, 'requests.jsonl'); + fs.mkdirSync(home); + const child = spawn(process.execPath, [helper, logFile, JSON.stringify({token: TOKEN, ...options})], {stdio: ['ignore', 'pipe', 'inherit']}); + live.push(child); + const port = await new Promise(resolve => child.stdout.once('data', d => resolve(Number(String(d).match(/PORT (\d+)/)[1])))); + const envFile = path.join(dir, 'secret.env'), botFile = path.join(dir, 'bot.env'); + fs.writeFileSync(envFile, `DISCORD_WEBHOOK_URL=http://127.0.0.1:${port}/api/webhooks/111/HOOKTOKEN\nDISCORD_USER_ID=${USER_ID}\n` + + `DISCORD_WEBHOOK_URL_KADAN=http://127.0.0.1:${port}/api/webhooks/333/HOOKTOKEN\n`, {mode: 0o600}); + fs.writeFileSync(botFile, `DISCORD_BOT_TOKEN=${TOKEN}\n`, {mode: 0o600}); + const env = {KADAN_NOTIFY: '', KADAN_NOTIFY_ENV_FILE: envFile, KADAN_SECRET_DISCORD_BOT_FILE: botFile, KADAN_DISCORD_API_BASE: `http://127.0.0.1:${port}`}; + const requests = () => (fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []); + const channelPatches = () => requests().filter(r => r.method === 'PATCH' && r.url.startsWith('/channels/')); + const log = () => (fs.existsSync(path.join(home, NOTIFY_LOG_FILE)) ? fs.readFileSync(path.join(home, NOTIFY_LOG_FILE), 'utf8') : ''); + const logLines = () => log().split('\n').filter(Boolean).map(l => JSON.parse(l)); + const record = (work = WORK) => readThreads(home)[work]; + const store = new WorkStore(home); + const make = (key = WORK) => store.create({key, title: '보관 시험', goal: '목표', scope: '범위', acceptance: '조건', owner: 'kadan-감독', repoPath: os.tmpdir()}); + const finish = (action = 'complete', key = WORK) => store.change(key, action, {result: '끝남'}, {revision: store.get(key).revision, note: '시험'}); + const reopen = (key = WORK) => store.change(key, 'reopen', {}, {revision: store.get(key).revision, note: '다시 엶'}); + const refresh = (opts = {}, key = WORK) => refreshWorkStatus(home, key, {env, ...opts}); + const alert = (key = WORK) => notifyUser('경보', () => ({status: 0}), {home, env, work: key, workTitle: '보관 시험', kind: 'mail', key: `mail:${Math.random()}`}); + // 글 + 상태 메시지를 먼저 만든다(알림 한 번). + const open = (key = WORK) => { make(key); assert.equal(alert(key).via, 'discord'); }; + // 봇이 아닌 사람이 손으로 보관한 것처럼 가짜 서버의 글을 보관한다(시험 → 가짜 서버로만 나간다). + const manualArchive = async threadId => { + const r = await fetch(`http://127.0.0.1:${port}/channels/${threadId}`, {method: 'PATCH', headers: {authorization: `Bot ${TOKEN}`, 'x-fake-manual': '1', 'content-type': 'application/json'}, body: '{"archived":true}'}); + assert.equal(r.status, 200); + }; + return {dir, home, env, envFile, botFile, port, requests, channelPatches, log, logLines, record, store, make, finish, reopen, refresh, alert, open, manualArchive, stop: () => child.kill('SIGTERM')}; +} + +test('1 완료·취소 각각: 상태 고치기 sent 뒤에 보관된다(고치기 → 보관 순서)', async () => { + for (const action of ['complete', 'cancel']) { + const w = await world(); + try { + w.open(); w.finish(action); + const before = w.requests().length; + assert.equal(w.refresh().outcome, 'sent'); + assert.equal(w.requests().length - before, 2, '상태 고치기 1 + 보관 1'); + const [edit, archive] = w.requests().slice(before); + assert.equal(edit.method, 'PATCH'); assert.match(edit.url, /\/api\/webhooks\/.*\/messages\//); + assert.match(archive.url, /^\/channels\//); + assert.match(JSON.parse(edit.body).content, action === 'complete' ? /상태: 완료/ : /상태: 취소/); + assert.deepEqual(JSON.parse(archive.body), {archived: true}); + assert.equal(archive.authOk, true); + assert.equal(w.record().archived, true); + assert.equal(JSON.parse(fs.readFileSync(path.join(w.home, 'notify-threads.json'), 'utf8')).threads[WORK].archived, true); + } finally { w.stop(); } + } +}); + +test('2 __decisions__ 결정 글은 보관하지 않는다', async () => { + const w = await world(); + try { + const key = 'kadan/__decisions__'; + w.open(key); w.finish('complete', key); // 업무로 읽혀 끝나 있어도 결정 글 키라서 건너뛴다 + const before = w.requests().length; + w.refresh({}, key); + assert.equal(w.channelPatches().length, 0); + assert.equal(w.requests().length - before, 1, '상태 고치기만'); + assert.equal(w.record(key).archived, false); + } finally { w.stop(); } +}); + +test('3 보관 403: 사유가 notify-log에 남고, 기록은 보관 안 됨, 알림 결과는 그대로, 60초 차단 없음', async () => { + const w = await world({archiveStatus: 403}); + try { + w.open(); w.finish(); + assert.equal(w.refresh().outcome, 'sent'); // 알림(상태 고치기) 결과는 그대로 sent + assert.equal(w.channelPatches().length, 1); + assert.equal(w.record().archived, false); + const entry = w.logLines().find(e => e.kind === 'archive'); + assert.equal(entry.outcome, 'failed'); assert.equal(entry.status, 403); assert.match(entry.reason, /403/); + assert.equal(w.refresh().outcome, 'sent'); // blockedUntil이 없으니 바로 다음 사건도 요청이 나간다 + assert.equal(w.channelPatches().length, 2, '보관은 다음 사건 때 다시 시도'); + const r = w.alert(); // 알림 호출의 결과도 바뀌지 않는다 + assert.equal(r.via, 'discord'); assert.equal(r.discord.outcome, 'sent'); + } finally { w.stop(); } +}); + +test('4 보관된 기록 + 새 알림(열린 업무): 재개 → 올리기 → 기록이 false가 된다', async () => { + const w = await world(); + try { + w.open(); w.finish(); w.refresh(); + assert.equal(w.record().archived, true); + w.reopen(); + const before = w.requests().length; + const r = w.alert(); + assert.equal(r.via, 'discord'); + const seq = w.requests().slice(before); + assert.deepEqual(JSON.parse(seq[0].body), {archived: false}); // 재개가 먼저 + assert.match(seq[0].url, /^\/channels\//); + assert.match(seq[1].url, /thread_id=/); assert.equal(seq[1].method, 'POST'); // 알림 올리기 + assert.equal(seq[2].method, 'PATCH'); // 상태 고치기 + assert.equal(w.record().archived, false); + assert.equal(w.channelPatches().length, 2, '보관 1 + 재개 1'); + } finally { w.stop(); } +}); + +test('5 기록 없음 + 웹훅이 보관 오류(50083): 재개 → 한 번만 다시 시도', async () => { + const w = await world(); + try { + w.open(); + await w.manualArchive(w.record().threadId); // 손으로 보관: 기록은 여전히 false + assert.equal(w.record().archived, false); + const before = w.requests().length; + const r = w.alert(); + assert.equal(r.via, 'discord'); + const seq = w.requests().slice(before).map(x => `${x.method} ${x.url.startsWith('/channels/') ? 'channel' : 'webhook'}`); + assert.deepEqual(seq.slice(0, 3), ['POST webhook', 'PATCH channel', 'POST webhook'], '쓰기 → 재개 → 한 번만 다시'); + assert.equal(w.requests().slice(before).filter(x => x.method === 'POST').length, 2, '재시도는 한 번'); + assert.equal(w.channelPatches().filter(x => JSON.parse(x.body).archived === false).length, 1); + // 재개가 403이면 다시 시도하지 않고 그 글 쓰기를 건너뛴다(#브리핑 같은 다른 디코 채널로는 보내지 않고, 기존 대체 전달만 받는다) + const w2 = await world({resumeStatus: 403}); + try { + w2.open(); await w2.manualArchive(w2.record().threadId); + const n = w2.requests().length; + let osCalls = 0; + const skipped = notifyUser('경보', () => { osCalls++; return {status: 0}; }, {home: w2.home, env: w2.env, work: WORK, workTitle: '보관 시험', kind: 'mail', key: 'mail:s'}); + assert.equal(skipped.via, 'system'); assert.equal(skipped.discord.outcome, 'archived-skip'); assert.equal(osCalls, 1, '쓰기는 건너뛰되 알림은 기존 대체 전달(시스템 알림)을 받는다'); + assert.equal(w2.requests().slice(n).filter(x => x.method === 'POST').length, 1, '다시 시도 없음'); + assert.match(w2.log(), /archived-skip/); + } finally { w2.stop(); } + } finally { w.stop(); } +}); + +test('6 work reopen: 보관된 글을 다시 연다', async () => { + const w = await world(); + const saved = {...process.env}; + try { + w.open(); w.finish(); w.refresh(); + assert.equal(w.record().archived, true); + Object.assign(process.env, w.env); // workCommand → refreshWorkStatus는 process.env를 쓴다 + const n = w.requests().length; + workCommand(['reopen', WORK], {revision: w.store.get(WORK).revision, note: '다시 엶'}, {home: w.home, by: '사람'}); + const seq = w.requests().slice(n); + assert.deepEqual(JSON.parse(seq[0].body), {archived: false}); + assert.equal(seq[1].method, 'PATCH'); + assert.match(JSON.parse(seq[1].body).content, /상태: 열림/); + assert.equal(w.record().archived, false); + assert.equal(seq.length, 2, '열린 업무라 다시 보관하지 않는다'); + } finally { for (const k of Object.keys(w.env)) { if (k in saved) process.env[k] = saved[k]; else delete process.env[k]; } w.stop(); } +}); + +test('7 완료 뒤 늦은 실행 done: 고치기·재개 요청이 0건이다', async () => { + const w = await world(); + try { + w.open(); w.finish(); w.refresh(); + const n = w.requests().length; + assert.equal(w.refresh().outcome, 'none'); // done.mjs가 부르는 것과 같은 함수 + assert.equal(w.requests().length, n); + assert.equal(w.record().archived, true); + } finally { w.stop(); } +}); + +test('8 시간 예산 부족: 상태 고치기는 하고 보관은 건너뛴다', async () => { + const w = await world({editDelayMs: 2000}); + try { + w.open(); w.finish(); + assert.equal(w.refresh({totalMs: 3300}).outcome, 'sent'); // 고치기에 2초 → 남은 1.3초 < 최소 1.5초 + assert.equal(w.channelPatches().length, 0); + assert.equal(w.record().archived, false); + assert.match(w.log(), /시간 예산이 모자라 보관을 건너뜀/); + } finally { w.stop(); } +}); + +test('9 상태 고치기 실패: 보관 0건', async () => { + const w = await world({failEditStatus: 403}); + try { + w.open(); w.finish(); + assert.equal(w.refresh().outcome, 'failed'); + assert.equal(w.channelPatches().length, 0); + assert.equal(w.record().archived, false); + } finally { w.stop(); } +}); + +test('10 봇 토큰이 notify-log·글 기록·반환값 어디에도 없고, 가림 목록에 들어 있다', async () => { + const w = await world({archiveStatus: 403, resumeStatus: 403}); + try { + w.open(); w.finish(); + const outs = [w.refresh(), w.refresh()]; + await w.manualArchive(w.record().threadId); + outs.push(w.alert()); // 재개 403 → archived-skip 경로까지 지난다 + const everything = JSON.stringify(outs) + w.log() + fs.readFileSync(path.join(w.home, 'notify-threads.json'), 'utf8'); + assert.ok(w.requests().some(r => r.authOk), '봇 요청에는 토큰이 실제로 쓰였다'); + assert.ok(!everything.includes(TOKEN), '토큰 문자열이 새지 않는다'); + assert.deepEqual(botSecrets(w.home, w.env), [TOKEN]); + assert.equal(scrubSecrets(`오류 ${TOKEN}`, botSecrets(w.home, w.env)), '오류 [가림]'); + } finally { w.stop(); } +}); + +test('11 옛 notify-threads 형식(문자열·archived 없음)을 archived:false로 읽고 이어 쓴다', async () => { + const w = await world(); + try { + w.open(); + const threadId = w.record().threadId; + fs.writeFileSync(path.join(w.home, 'notify-threads.json'), JSON.stringify({version: 1, threads: {[WORK]: threadId, 'kadan/other': {threadId: '7', statusMessageId: 'M7'}}})); + assert.deepEqual(readThreads(w.home), { + [WORK]: {threadId, statusMessageId: null, archived: false}, + 'kadan/other': {threadId: '7', statusMessageId: 'M7', archived: false}, + }); + w.finish(); + assert.equal(w.refresh().outcome, 'sent'); // 옛 기록 그대로도 상태 → 보관이 돈다 + assert.equal(readThreads(w.home)[WORK].archived, true); + assert.equal(readThreads(w.home)['kadan/other'].archived, false); + const stored = JSON.parse(fs.readFileSync(path.join(w.home, 'notify-threads.json'), 'utf8')).threads; + assert.ok(!('archived' in stored['kadan/other']), '보관 아닌 글은 archived를 파일에 쓰지 않는다'); + } finally { w.stop(); } +}); + +test('12 checkChat: 봇의 Manage Threads 권한을 읽기만으로 보여 준다(서버 권한·포럼 덮어쓰기)', async () => { + const fake = await startFakeDiscord(); + try { + const forum = fake.addForum('kadan'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kadan-archive-check-')), home = path.join(dir, 'home'); + fs.mkdirSync(home); + const botFile = path.join(dir, 'bot.env'), hookFile = path.join(dir, 'hook.env'); + fs.writeFileSync(botFile, `DISCORD_BOT_TOKEN=${FAKE.token}\nDISCORD_APPLICATION_ID=${FAKE.appId}\n`, {mode: 0o600}); + fs.writeFileSync(hookFile, `DISCORD_USER_ID=${FAKE.userId}\nDISCORD_WEBHOOK_URL_SECRETARY=https://discord.com/api/webhooks/${FAKE.hookId}/${FAKE.hookToken}\nDISCORD_WEBHOOK_URL_KADAN=${forum.url}\n`, {mode: 0o600}); + const env = {KADAN_SECRET_DISCORD_BOT_FILE: botFile, KADAN_SECRET_DISCORD_FILE: hookFile, KADAN_DISCORD_API_BASE: fake.base, KADAN_NOTIFY: 'off'}; + const line = async () => (await checkChat(createChat({home, env, sleep: async () => {}}))).find(l => l.startsWith('Manage Threads kadan')); + const MANAGE = String(1n << 34n); + assert.match(await line(), /없음/); // 기본: 권한 없음 + fake.guild.memberRoles = ['R1']; fake.guild.roles.push({id: 'R1', permissions: MANAGE}); + assert.match(await line(), /있음/); // 봇 역할에 권한 + fake.guild.roles.find(r => r.id === 'R1').permissions = '0'; + fake.guild.roles[0].permissions = MANAGE; // @everyone에 권한이 있어도 + assert.match(await line(), /있음/); + fake.guild.roles[0].permissions = String(1n << 3n); // 관리자는 덮어쓰기와 무관하게 통과 + assert.match(await line(), /있음/); + assert.ok(!fake.requests.some(r => r.method === 'PATCH' && /^\/channels\//.test(r.path)), '실제 보관 요청은 나가지 않는다'); + } finally { await fake.close(); } +}); + +// ---- PR #179 수정 1라운드 ---- +const decisionOpts = (w, id, extra = {}) => ({home: w.home, env: w.env, work: WORK, workTitle: '보관 시험', kind: 'decision-request', decision: {id}, ...extra}); +const archivedAndBlocked = async (options = {resumeStatus: 403}) => { // 완료·보관된 글 + 열린 업무로 돌아온 상태(재개 403) + const w = await world(options); + w.open(); w.finish(); w.refresh(); + assert.equal(w.record().archived, true); + w.reopen(); + return w; +}; + +test('13 재개 403 + 결정 요청 → 대체 전달 1건(시스템 알림), 다른 디코 채널로는 보내지 않는다', async () => { + const w = await archivedAndBlocked(); + try { + let os = 0; + const n = w.requests().length; + const r = notifyUser('결정 요청', () => { os++; return {status: 0}; }, {...decisionOpts(w, 'D1'), discord: {content: '결정 요청 본문', mention: true}}); + assert.equal(r.via, 'system'); assert.equal(r.discord.outcome, 'archived-skip'); assert.equal(os, 1); + assert.equal(w.requests().slice(n).filter(x => x.method === 'POST').length, 0, '웹훅 쓰기 0건'); + assert.ok(w.requests().slice(n).every(x => x.url.startsWith('/channels/')), '재개 요청만 나갔다'); + } finally { w.stop(); } +}); + +test('14 재개 403 + 울림 경보 → 대체 전달 1건, 조용한 알림(systemFallback:false)은 0건', async () => { + const w = await archivedAndBlocked(); + try { + let os = 0; + const spawn = () => { os++; return {status: 0}; }; + const ring = notifyUser('막힘 경보', spawn, {home: w.home, env: w.env, work: WORK, kind: 'alert', ring: true}); + assert.equal(ring.via, 'system'); assert.equal(os, 1); + const quiet = notifyUser('조용한 알림', spawn, {home: w.home, env: w.env, work: WORK, kind: 'alert', systemFallback: false}); + assert.equal(quiet.via, 'none'); assert.equal(os, 1, '조용한 알림은 대체 전달을 받지 않던 그대로'); + } finally { w.stop(); } +}); + +test('15 재개 403 + 상태 고치기 → 대체 0건, 로그 있음', async () => { + const w = await archivedAndBlocked(); + try { + const n = w.requests().length; + const r = w.refresh(); // refreshWorkStatus에는 원래 대체 전달이 없다 + assert.equal(r.outcome, 'archived-skip'); + assert.equal(w.requests().slice(n).filter(x => x.method !== 'PATCH' || !x.url.startsWith('/channels/')).length, 0); + assert.match(w.log(), /archived-skip/); + assert.match(w.logLines().at(-1).reason, /403/); + } finally { w.stop(); } +}); + +test('16 보관된 글의 결정 해결 고치기 → 재개 → 고치기 → 상태 → 다시 보관, resolved 기록', async () => { + const w = await world(); + try { + w.open(); + assert.equal(notifyUser('요청', () => ({status: 0}), {...decisionOpts(w, 'D1'), discord: {content: '요청 본문', mention: true}}).via, 'discord'); + w.finish(); w.refresh(); + assert.equal(w.record().archived, true); + const messageId = readDecisions(w.home).D1.messageId; + const n = w.requests().length; + const r = notifyUser('해결', () => ({status: 0}), {...decisionOpts(w, 'D1', {kind: 'decision-answer'}), decision: {id: 'D1', edit: '✅ 해결됨 — 답변: 가'}}); + assert.equal(r.via, 'discord'); + const seq = w.requests().slice(n); + assert.deepEqual(JSON.parse(seq[0].body), {archived: false}); // 재개 + assert.ok(seq[1].url.includes(`/messages/${messageId}`) && seq[1].method === 'PATCH'); // 요청 글 고치기 + assert.match(JSON.parse(seq[1].body).content, /해결됨/); + assert.equal(seq[2].method, 'PATCH'); // 상태 메시지 + assert.deepEqual(JSON.parse(seq[3].body), {archived: true}); // 끝난 업무라 다시 보관 + assert.equal(seq.length, 4); + assert.equal(readDecisions(w.home).D1.resolved, true); + assert.equal(w.record().archived, true); + // 손으로 보관(기록 false)한 결정 글: 50083 → 재개 → 한 번만 다시 시도 + const w2 = await world(); + try { + w2.open(); + notifyUser('요청', () => ({status: 0}), {...decisionOpts(w2, 'D2'), discord: {content: '요청 본문', mention: true}}); + await w2.manualArchive(w2.record().threadId); + const m = w2.requests().length; + assert.equal(notifyUser('해결', () => ({status: 0}), {...decisionOpts(w2, 'D2', {kind: 'decision-answer'}), decision: {id: 'D2', edit: '✅ 해결됨'}}).via, 'discord'); + const kinds = w2.requests().slice(m).map(x => (x.url.startsWith('/channels/') ? 'channel' : x.method)); + assert.deepEqual(kinds.slice(0, 3), ['PATCH', 'channel', 'PATCH'], '고치기(거절) → 재개 → 한 번만 다시'); + assert.equal(readDecisions(w2.home).D2.resolved, true); + } finally { w2.stop(); } + } finally { w.stop(); } +}); + +test('17 토큰 반사: 비정상 code·message에 실린 토큰이 stdout·stderr·notify-log·반환값 어디에도 없다', async () => { + const w = await world({archiveStatus: 403, resumeStatus: 403, reflectToken: true}); + try { + w.open(); w.finish(); + const threadId = w.record().threadId; + const child = spawnSync(process.execPath, [new URL('../src/discord-archive.mjs', import.meta.url).pathname], + {input: JSON.stringify({home: w.home, env: w.env, threadId, archived: true, budgetMs: 5000}), encoding: 'utf8'}); + const out = JSON.parse(child.stdout.trim()); + assert.ok(!child.stdout.includes(TOKEN) && !child.stderr.includes(TOKEN), '자식 stdout·stderr'); + assert.ok(!('code' in out), '정수가 아닌 code는 버린다'); + assert.equal(out.status, 403); + const direct = setArchivedSync({home: w.home, env: w.env, threadId, archived: true, budgetMs: 5000}); + const outs = [direct, w.refresh(), w.refresh()]; + w.reopen(); + outs.push(w.alert()); // 반사 응답으로 재개가 막히는 경로 + assert.ok(!JSON.stringify(outs).includes(TOKEN) && !w.log().includes(TOKEN)); + // 부모의 걸러 받기: 자식이 어떤 값을 내도 정수·고정 단어·가린 문자열만 남는다 + assert.deepEqual(sanitize({outcome: 'weird', attempts: '3', code: TOKEN, status: 403.5, reason: `x ${TOKEN}`, extra: TOKEN}, [TOKEN]), + {outcome: 'failed', attempts: 0, reason: 'x [가림]'}); + } finally { w.stop(); } +}); + +test('18 attempts가 실제 재시도 수와 같다(503 → 요청 3건 → attempts 3)', async () => { + const w = await world({archiveStatus: 503}); + try { + w.open(); w.finish(); + const before = w.channelPatches().length; + const r = setArchivedSync({home: w.home, env: w.env, threadId: w.record().threadId, archived: true, budgetMs: 12000}); + assert.equal(w.channelPatches().length - before, 3); + assert.equal(r.outcome, 'failed'); assert.equal(r.status, 503); assert.equal(r.attempts, 3); + } finally { w.stop(); } +});