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: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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_FORMAT=json
LOG_LEVEL=info
LOG_FILE_ENABLED=false
LOG_FILE_PATH=./logs/bot.log
Expand Down
111 changes: 107 additions & 4 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,56 @@ concurrency:
cancel-in-progress: true

jobs:
ci:
lint:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set Up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Download Modules
run: go mod download

- name: Check Formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "The following files are not formatted:"
echo "$unformatted"
exit 1
fi

- name: GolangCI-Lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.11.1
args: --timeout=5m ./...

test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: livid
POSTGRES_PASSWORD: livid
POSTGRES_DB: livid
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U livid -d livid"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
TEST_DATABASE_URL: postgres://livid:livid@localhost:5432/livid?sslmode=disable

steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -34,8 +81,64 @@ jobs:
- name: Build
run: go build -v ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test -v ./...

- name: Race Test DB
run: go test -race ./db

coverage:
runs-on: ubuntu-latest
needs: test
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: livid
POSTGRES_PASSWORD: livid
POSTGRES_DB: livid
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U livid -d livid"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
TEST_DATABASE_URL: postgres://livid:livid@localhost:5432/livid?sslmode=disable

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set Up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Download Modules
run: go mod download

- name: Generate Coverage Profile
run: go test -covermode=atomic -coverpkg=./... -coverprofile=coverage.out ./...

- name: Generate Coverage Reports
run: |
go tool cover -func=coverage.out | tee coverage.txt
go tool cover -html=coverage.out -o coverage.html
total=$(awk '/^total:/ {print $3}' coverage.txt)
{
echo "## Coverage"
echo
echo "Total coverage: \`$total\`"
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload Coverage Artifacts
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
coverage.out
coverage.txt
coverage.html
12 changes: 12 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: "2"
run:
tests: true

linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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_FORMAT=json # json | text (default: json)
LOG_LEVEL=info # debug | info | warn | error (default: info)
LOG_FILE_ENABLED=false
LOG_FILE_PATH=/var/log/livid-bot/bot.log
Expand Down Expand Up @@ -137,11 +137,16 @@ go test ./...
go test ./... -cover
```

GitHub Actions CI:
- `lint`: `gofmt -l .`, `golangci-lint`
- `test`: `go build ./...`, `go test -v ./...`, `go test -race ./db`
- `coverage`: 전체 패키지 coverage 프로파일, 요약, HTML artifact 생성

## 로그
`slog` 기반 구조화 로그를 출력합니다.

- `LOG_FORMAT=text`(기본): 사람이 읽기 쉬운 key=value 형식
- `LOG_FORMAT=json`: JSON 단일 라인 형식
- `LOG_FORMAT=json`(기본): JSON 단일 라인 형식
- `LOG_FORMAT=text`: 사람이 읽기 쉬운 key=value 형식
- `LOG_LEVEL`로 최소 출력 레벨 제어
- `LOG_FILE_ENABLED=true`면 로그를 파일에도 동시 저장합니다.
- 파일 저장은 `lumberjack`(size 기반 rotation)으로 처리합니다.
Expand All @@ -161,9 +166,9 @@ Docker Compose 기본 설정(`docker-compose.yml`):
- `docker compose down`으로 컨테이너를 삭제해도 `./logs`는 유지됩니다.

예시:
```text
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=...
```json
{"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
Expand Down
30 changes: 23 additions & 7 deletions bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import (
)

type Config struct {
BotToken string
ApplicationID string
GuildID string
StudyRepo *db.StudyRepository
MemberRepo *db.MemberRepository
RecruitRepo *db.RecruitRepository
AuditRepo CommandAuditStore
BotToken string
ApplicationID string
GuildID string
StudyRepo *db.StudyRepository
MemberRepo *db.MemberRepository
RecruitRepo *db.RecruitRepository
AuditRepo CommandAuditStore
SuggestionRepo *db.SuggestionRepository
}

func Run(cfg Config) error {
Expand Down Expand Up @@ -47,6 +48,9 @@ func Run(cfg Config) error {
"members": newMembersHandler(cfg.StudyRepo, cfg.MemberRepo),
"archive-all": newArchiveAllHandler(cfg.StudyRepo),
"study-start": newStudyStartHandler(cfg.StudyRepo, cfg.MemberRepo, cfg.RecruitRepo, reactionHandler),
"suggest-start": newSuggestStartHandler(cfg.SuggestionRepo),
"suggest": newSuggestHandler(cfg.SuggestionRepo),
"vote": newVoteHandler(cfg.SuggestionRepo),
}
autocompleteHandlers := map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
"help": handleHelpAutocomplete,
Expand All @@ -73,6 +77,18 @@ func Run(cfg Config) error {
if h, ok := autocompleteHandlers[commandName]; ok {
h(s, i)
}
case discordgo.InteractionModalSubmit:
customID := i.ModalSubmitData().CustomID
switch customID {
case "suggest_modal":
newSuggestModalHandler(cfg.SuggestionRepo)(s, i)
}
case discordgo.InteractionMessageComponent:
customID := i.MessageComponentData().CustomID
switch customID {
case "vote_select":
newVoteSelectHandler(cfg.SuggestionRepo)(s, i)
}
}
})

Expand Down
20 changes: 20 additions & 0 deletions bot/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,24 @@ var commands = []*discordgo.ApplicationCommand{
},
},
},
{
Name: "suggest-start",
Description: "스터디 제안 기간을 시작합니다 (운영진 전용)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "deadline",
Description: "제안 마감일 (YYYY-MM-DD)",
Required: true,
},
},
},
{
Name: "suggest",
Description: "익명으로 스터디를 제안합니다",
},
{
Name: "vote",
Description: "스터디 제안에 투표합니다",
},
}
7 changes: 5 additions & 2 deletions bot/handler_help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import (

func TestVisibleCommandsForMemberNonAdmin(t *testing.T) {
visible := visibleCommandsForMember(commands, 0, true)
if len(visible) != 2 {
t.Fatalf("expected 2 visible commands (/help, /members), got %d", len(visible))
if len(visible) != 5 {
t.Fatalf("expected 5 visible commands (/help, /members, /suggest-start, /suggest, /vote), got %d", len(visible))
}
if visible[0].Name != "help" || visible[1].Name != "members" {
t.Fatalf("unexpected visible commands: %s, %s", visible[0].Name, visible[1].Name)
}
if visible[2].Name != "suggest-start" || visible[3].Name != "suggest" || visible[4].Name != "vote" {
t.Fatalf("unexpected visible commands: %s, %s, %s", visible[2].Name, visible[3].Name, visible[4].Name)
}
}

func TestVisibleCommandsForMemberAdmin(t *testing.T) {
Expand Down
Loading
Loading