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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ DISCORD_BOT_TOKEN=your_bot_token_here
DISCORD_APPLICATION_ID=your_application_id_here
DISCORD_GUILD_ID=your_guild_id_here
DATABASE_URL=postgres://livid:livid@localhost:15432/livid?sslmode=disable
LOG_FORMAT=text
LOG_LEVEL=info
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ livid-bot
.env
.mise.toml
.jj

.claude/settings.local.json
52 changes: 52 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build & Test Commands

```bash
go build ./... # Build all packages
go test ./... # Run all tests
go test ./... -cover # Tests with coverage
go test ./bot -run TestName # Run a single test
go vet ./... # Static analysis
```

## Local Development

```bash
docker compose up -d db # Start PostgreSQL (port 15432)
go run main.go # Run bot (requires env vars below)
```

Required environment variables (see `.env.example`):
- `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID`, `DATABASE_URL`

## Architecture

Go module `livid-bot` — a Discord bot for study group lifecycle management (create → recruit → start → archive).

### Package Structure

- **`main.go`** — Entry point. Loads env vars, connects DB, runs migrations, initializes repositories, starts bot.
- **`bot/`** — Discord interaction layer. Slash command definitions (`commands.go`), handler closures (`handler_*.go`), reaction tracking (`reaction.go`), archive category allocation (`archive_category.go`), helpers (`helpers.go`).
- **`db/`** — PostgreSQL data access via `pgx/v5`. Repository pattern (`StudyRepository`, `MemberRepository`, `RecruitRepository`). Embedded SQL migrations auto-applied on startup.
- **`study/`** — Domain types (`Study`, `StudyMember`, `RecruitMessage`, `RecruitMapping`).

### Key Patterns

**Handler pattern**: Each command handler is a closure returned by `newXxxHandler(repos...)`, capturing dependencies. Registered in `bot.go` as `map[string]func`.

**Copy-on-write concurrency**: `ReactionHandler` uses `sync.RWMutex` + copy-on-write for its in-memory `messageID → emoji → roleInfo` map. `Track`/`Untrack` create new maps rather than mutating.

**Archive category allocator**: Manages `archiveN` categories with a 50-channel limit, reservation/commit/release pattern, and read-only permission enforcement.

**Migrations**: Embedded SQL files in `db/migrations/` with `NNN_description.sql` naming. Tracked in `schema_migrations` table. Applied transactionally on startup.

### Conventions

- Branch format: `YY-Q` (e.g., `26-2` for 2026 Q2). Validated by `isValidBranch()`.
- Structured logging: `[cmd=X stage=Y guild=Z user=W] message`.
- Error responses to users are ephemeral. Errors are wrapped with `fmt.Errorf("context: %w", err)`.
- Tests use table-driven style.
- Autocomplete handlers are registered separately from command handlers in `bot.go`.
56 changes: 47 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ DISCORD_BOT_TOKEN=<YOUR_BOT_TOKEN>
DISCORD_APPLICATION_ID=<YOUR_APPLICATION_ID>
DISCORD_GUILD_ID=<YOUR_GUILD_ID>
DATABASE_URL=postgres://livid:livid@localhost:15432/livid?sslmode=disable
LOG_FORMAT=text # text | json (default: text)
LOG_LEVEL=info # debug | info | warn | error (default: info)
```

예시는 [.env.example](.env.example) 참고.
Expand All @@ -41,16 +43,23 @@ docker compose up --build

## 슬래시 명령어

### `/hello`
- 간단한 응답 확인용 명령어
### `/help`
- 호출자 권한 기준으로 사용 가능한 명령어를 안내
- 옵션
- `command` (string, optional, autocomplete)
- 동작
- 옵션 미입력: 사용 가능한 명령 목록을 카드(Embed)로 표시
- 옵션 입력: 선택한 명령의 상세 정보(설명/권한/옵션)를 카드(Embed)로 표시
- 응답은 ephemeral(호출자에게만 표시)

### `/submit`
### `/members`
- 옵션
- `screenshot` (attachment, required)
- `link` (string, required)
- 문제 링크를 마크다운으로 변환해 임베드 메시지로 게시
- `channel` (string, required, autocomplete)
- 동작
- 선택한 스터디의 active 멤버 목록을 표시 (ephemeral)

### `/create-study`
- 관리자 전용
- 옵션
- `branch` (string, required) 예: `26-2`
- `name` (string, required)
Expand All @@ -62,6 +71,7 @@ docker compose up --build
- branch 형식 검증: `YY-Q` (`Q`는 `1~4`)

### `/recruit`
- 관리자 전용
- 옵션
- `channel` (channel, required)
- `branch` (string, required, autocomplete)
Expand All @@ -71,14 +81,31 @@ docker compose up --build
- 선택한 branch의 active 스터디만 모집
- 모집 메시지에 branch 정보 표시

### `/studies`
- 관리자 전용
- 옵션
- `branch` (string, optional)
- `status` (string, optional: `active` | `archived`)
- 동작
- 조건에 맞는 스터디 목록을 표시 (ephemeral)

### `/archive-study`
- 관리자 전용
- 옵션
- `channel` (string, required, autocomplete)
- 동작
- 선택한 스터디 채널을 `archiveN` 카테고리로 이동
- DB 상태를 archived로 변경, 역할 삭제 시도

### `/study-start`
- 관리자 전용
- 옵션
- `branch` (string, required, autocomplete)
- 동작
- 분기의 모집을 마감하고 멤버 수 기준으로 스터디 시작/아카이브 처리 (ephemeral)

### `/archive-all`
- 관리자 전용
- 옵션
- `dry-run` (boolean, optional)
- 동작
Expand All @@ -101,13 +128,24 @@ go test ./... -cover
```

## 로그
커맨드 라이프사이클 로그를 출력합니다.
`slog` 기반 구조화 로그를 출력합니다.

- `LOG_FORMAT=text`(기본): 사람이 읽기 쉬운 key=value 형식
- `LOG_FORMAT=json`: JSON 단일 라인 형식
- `LOG_LEVEL`로 최소 출력 레벨 제어

예시:
```text
[cmd=create-study stage=start guild=... user=...] create-study requested branch=26-2 name=algo
[cmd=create-study stage=success guild=... user=...] created study branch=26-2 name=algo channel=... role=...
time=2026-03-02T10:00:00Z level=INFO msg="create-study requested branch=26-2 name=algo" cmd=create-study stage=start guild=... user=...
time=2026-03-02T10:00:01Z level=INFO msg="created study branch=26-2 name=algo channel=... role=..." cmd=create-study stage=success guild=... user=...
```

## Command Audit
- 모든 슬래시 명령(`InteractionApplicationCommand`) 호출은 `command_audit_logs` 테이블에 기록됩니다.
- key는 Discord interaction ID (`i.Interaction.ID`)를 사용합니다.
- 기록 단계: `triggered` -> `success` 또는 `error`
- autocomplete 인터랙션은 audit 대상에서 제외됩니다.
- audit 저장이 실패해도 사용자 명령은 계속 실행됩니다.

## 참고
- 봇 토큰/애플리케이션 정보가 필요한 경우 서버 관리자에게 문의하세요.
8 changes: 4 additions & 4 deletions bot/archive_category.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,12 @@ func (a *archiveCategoryAllocator) getOrCreateWritableSlot() (*archiveCategorySl
return a.createSlot(1)
}

last := &a.slots[len(a.slots)-1]
if last.ChannelCount < archiveCategoryMaxChannels {
return last, nil
lastIdx := len(a.slots) - 1
if a.slots[lastIdx].ChannelCount < archiveCategoryMaxChannels {
return &a.slots[lastIdx], nil
}

return a.createSlot(last.Number + 1)
return a.createSlot(a.slots[lastIdx].Number + 1)
}

func (a *archiveCategoryAllocator) createSlot(number int) (*archiveCategorySlot, error) {
Expand Down
150 changes: 150 additions & 0 deletions bot/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package bot

import (
"context"
"encoding/json"
"log/slog"
"sync"

"github.com/bwmarrin/discordgo"
)

const unknownAuditValue = "unknown"

type CommandAuditStore interface {
RecordTriggered(ctx context.Context, interactionID, commandName, actorUserID, guildID, channelID, optionsJSON string) error
RecordSuccess(ctx context.Context, interactionID string) error
RecordError(ctx context.Context, interactionID, errorMessage string) error
}

type noopAuditStore struct{}

func (noopAuditStore) RecordTriggered(context.Context, string, string, string, string, string, string) error {
return nil
}

func (noopAuditStore) RecordSuccess(context.Context, string) error {
return nil
}

func (noopAuditStore) RecordError(context.Context, string, string) error {
return nil
}

var (
commandAuditStoreMu sync.RWMutex
commandAuditStore CommandAuditStore = noopAuditStore{}
)

func setCommandAuditStore(store CommandAuditStore) {
if store == nil {
store = noopAuditStore{}
}

commandAuditStoreMu.Lock()
commandAuditStore = store
commandAuditStoreMu.Unlock()
}

func getCommandAuditStore() CommandAuditStore {
commandAuditStoreMu.RLock()
defer commandAuditStoreMu.RUnlock()
return commandAuditStore
}

func recordCommandTriggered(i *discordgo.InteractionCreate) {
if !isApplicationCommandInteraction(i) {
return
}

interactionID := interactionID(i)
if interactionID == "" {
return
}

optionsJSON, err := marshalCommandOptions(i)
if err != nil {
slog.Warn("failed to marshal command options for audit", "interaction_id", interactionID, "error", err)
optionsJSON = "[]"
}

err = getCommandAuditStore().RecordTriggered(
context.Background(),
interactionID,
interactionCommandName(i),
interactionUserID(i),
interactionGuildID(i),
interactionChannelID(i),
optionsJSON,
)
if err != nil {
slog.Warn("failed to write command audit trigger", "interaction_id", interactionID, "error", err)
}
}

func recordCommandResult(i *discordgo.InteractionCreate, stage, message string) {
if !isApplicationCommandInteraction(i) {
return
}

interactionID := interactionID(i)
if interactionID == "" {
return
}

var err error
switch stage {
case "success":
err = getCommandAuditStore().RecordSuccess(context.Background(), interactionID)
case "error":
err = getCommandAuditStore().RecordError(context.Background(), interactionID, message)
default:
return
}

if err != nil {
slog.Warn("failed to write command audit result", "interaction_id", interactionID, "stage", stage, "error", err)
}
}

func isApplicationCommandInteraction(i *discordgo.InteractionCreate) bool {
return i != nil && i.Type == discordgo.InteractionApplicationCommand
}

func interactionID(i *discordgo.InteractionCreate) string {
if i == nil || i.Interaction == nil || i.ID == "" {
return ""
}
return i.ID
}

func interactionGuildID(i *discordgo.InteractionCreate) string {
if i == nil || i.GuildID == "" {
return unknownAuditValue
}
return i.GuildID
}

func interactionChannelID(i *discordgo.InteractionCreate) string {
if i == nil || i.ChannelID == "" {
return unknownAuditValue
}
return i.ChannelID
}

func marshalCommandOptions(i *discordgo.InteractionCreate) (string, error) {
if i == nil {
return "[]", nil
}

options := i.ApplicationCommandData().Options
if len(options) == 0 {
return "[]", nil
}

raw, err := json.Marshal(options)
if err != nil {
return "", err
}
return string(raw), nil
}
Loading