Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/discord-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 얻는 방법

Expand Down
26 changes: 25 additions & 1 deletion docs/user-notify.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` 하나다. 새 주기 작업은 없고 이미 있는 사건 지점에서만 부른다.

| 사건 | 부르는 곳 |
Expand All @@ -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.<id>.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=` 응답은 바뀌지 않는다.
Expand Down
38 changes: 38 additions & 0 deletions src/discord-archive.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
}
19 changes: 10 additions & 9 deletions src/discord-bot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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),
Expand All @@ -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;
}
Expand All @@ -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});
}
}

Expand Down
34 changes: 34 additions & 0 deletions src/discord-chat.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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 () => {
Expand Down
4 changes: 3 additions & 1 deletion src/discord-send.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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})};
}
}

Expand Down
Loading
Loading