diff --git a/.env.example b/.env.example index a0fff60..5251f2b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 3cdf416..2610310 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ livid-bot .env .mise.toml .jj + +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6404cd2 --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index d0241e6..8415031 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ DISCORD_BOT_TOKEN= DISCORD_APPLICATION_ID= DISCORD_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) 참고. @@ -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) @@ -62,6 +71,7 @@ docker compose up --build - branch 형식 검증: `YY-Q` (`Q`는 `1~4`) ### `/recruit` +- 관리자 전용 - 옵션 - `channel` (channel, required) - `branch` (string, required, autocomplete) @@ -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) - 동작 @@ -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 저장이 실패해도 사용자 명령은 계속 실행됩니다. + ## 참고 - 봇 토큰/애플리케이션 정보가 필요한 경우 서버 관리자에게 문의하세요. diff --git a/bot/archive_category.go b/bot/archive_category.go index ad48967..534c264 100644 --- a/bot/archive_category.go +++ b/bot/archive_category.go @@ -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) { diff --git a/bot/audit.go b/bot/audit.go new file mode 100644 index 0000000..6b8438b --- /dev/null +++ b/bot/audit.go @@ -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 +} diff --git a/bot/audit_test.go b/bot/audit_test.go new file mode 100644 index 0000000..bcdd63d --- /dev/null +++ b/bot/audit_test.go @@ -0,0 +1,158 @@ +package bot + +import ( + "context" + "strings" + "testing" + + "github.com/bwmarrin/discordgo" +) + +type triggeredCall struct { + interactionID string + commandName string + actorUserID string + guildID string + channelID string + optionsJSON string +} + +type fakeAuditStore struct { + triggered []triggeredCall + successes []string + errors []struct { + interactionID string + message string + } +} + +func (f *fakeAuditStore) RecordTriggered( + _ context.Context, + interactionID, commandName, actorUserID, guildID, channelID, optionsJSON string, +) error { + f.triggered = append(f.triggered, triggeredCall{ + interactionID: interactionID, + commandName: commandName, + actorUserID: actorUserID, + guildID: guildID, + channelID: channelID, + optionsJSON: optionsJSON, + }) + return nil +} + +func (f *fakeAuditStore) RecordSuccess(_ context.Context, interactionID string) error { + f.successes = append(f.successes, interactionID) + return nil +} + +func (f *fakeAuditStore) RecordError(_ context.Context, interactionID, errorMessage string) error { + f.errors = append(f.errors, struct { + interactionID string + message string + }{ + interactionID: interactionID, + message: errorMessage, + }) + return nil +} + +func TestRecordCommandTriggered(t *testing.T) { + store := &fakeAuditStore{} + setCommandAuditStore(store) + defer setCommandAuditStore(nil) + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + ID: "12345", + Type: discordgo.InteractionApplicationCommand, + GuildID: "guild-1", + ChannelID: "channel-1", + Member: &discordgo.Member{ + User: &discordgo.User{ID: "user-1"}, + }, + Data: discordgo.ApplicationCommandInteractionData{ + Name: "members", + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "channel", + Type: discordgo.ApplicationCommandOptionString, + Value: "channel-1", + }, + }, + }, + }, + } + + recordCommandTriggered(i) + + if len(store.triggered) != 1 { + t.Fatalf("expected one trigger record, got %d", len(store.triggered)) + } + call := store.triggered[0] + if call.interactionID != "12345" { + t.Fatalf("expected interaction id 12345, got %q", call.interactionID) + } + if call.commandName != "members" { + t.Fatalf("expected command members, got %q", call.commandName) + } + if call.actorUserID != "user-1" { + t.Fatalf("expected actor user-1, got %q", call.actorUserID) + } + if !strings.Contains(call.optionsJSON, `"channel"`) { + t.Fatalf("expected options json to include option name, got %s", call.optionsJSON) + } +} + +func TestRecordCommandResult(t *testing.T) { + store := &fakeAuditStore{} + setCommandAuditStore(store) + defer setCommandAuditStore(nil) + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + ID: "abc", + Type: discordgo.InteractionApplicationCommand, + GuildID: "guild-1", + Data: discordgo.ApplicationCommandInteractionData{ + Name: "help", + }, + }, + } + + recordCommandResult(i, "start", "started") + recordCommandResult(i, "success", "done") + recordCommandResult(i, "error", "failed") + + if len(store.successes) != 1 || store.successes[0] != "abc" { + t.Fatalf("expected one success for interaction abc, got %+v", store.successes) + } + if len(store.errors) != 1 || store.errors[0].interactionID != "abc" { + t.Fatalf("expected one error for interaction abc, got %+v", store.errors) + } +} + +func TestAutocompleteNotAudited(t *testing.T) { + store := &fakeAuditStore{} + setCommandAuditStore(store) + defer setCommandAuditStore(nil) + + i := &discordgo.InteractionCreate{ + Interaction: &discordgo.Interaction{ + ID: "auto-1", + Type: discordgo.InteractionApplicationCommandAutocomplete, + GuildID: "guild-1", + Data: discordgo.ApplicationCommandInteractionData{ + Name: "members", + }, + }, + } + + recordCommandTriggered(i) + recordCommandResult(i, "success", "done") + recordCommandResult(i, "error", "failed") + + if len(store.triggered) != 0 || len(store.successes) != 0 || len(store.errors) != 0 { + t.Fatalf("expected autocomplete to skip audit, got triggered=%d success=%d error=%d", len(store.triggered), len(store.successes), len(store.errors)) + } +} diff --git a/bot/bot.go b/bot/bot.go index 608172c..51b12a3 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -2,7 +2,7 @@ package bot import ( "fmt" - "log" + "log/slog" "os" "os/signal" @@ -17,11 +17,16 @@ type Config struct { StudyRepo *db.StudyRepository MemberRepo *db.MemberRepository RecruitRepo *db.RecruitRepository + AuditRepo CommandAuditStore } -func Run(cfg Config) { +func Run(cfg Config) error { + setCommandAuditStore(cfg.AuditRepo) + discord, err := discordgo.New("Bot " + cfg.BotToken) - checkNilErr(err) + if err != nil { + return fmt.Errorf("create discord session: %w", err) + } discord.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | @@ -30,29 +35,37 @@ func Run(cfg Config) { // Initialize reaction handler and load existing mappings from DB reactionHandler := NewReactionHandler(cfg.MemberRepo) if err := reactionHandler.LoadFromDB(cfg.RecruitRepo); err != nil { - log.Printf("Warning: failed to load reaction mappings: %v", err) + slog.Warn("failed to load reaction mappings", "error", err) } commandHandlers := map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "hello": handleHello, - "submit": handleSubmit, + "help": handleHelp, "create-study": newCreateStudyHandler(cfg.StudyRepo), "recruit": newRecruitHandler(cfg.StudyRepo, cfg.RecruitRepo, reactionHandler), "archive-study": newArchiveStudyHandler(cfg.StudyRepo), + "studies": newStudiesHandler(cfg.StudyRepo), + "members": newMembersHandler(cfg.StudyRepo, cfg.MemberRepo), "archive-all": newArchiveAllHandler(cfg.StudyRepo), + "study-start": newStudyStartHandler(cfg.StudyRepo, cfg.MemberRepo, cfg.RecruitRepo, reactionHandler), } autocompleteHandlers := map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "help": handleHelpAutocomplete, "archive-study": newArchiveStudyAutocompleteHandler(cfg.StudyRepo), + "members": newMembersAutocompleteHandler(cfg.StudyRepo), "recruit": newRecruitBranchAutocompleteHandler(cfg.StudyRepo), + "study-start": newStudyStartAutocompleteHandler(cfg.StudyRepo), } discord.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { switch i.Type { case discordgo.InteractionApplicationCommand: commandName := i.ApplicationCommandData().Name + recordCommandTriggered(i) logCommand(i, "dispatch", "received application command") if h, ok := commandHandlers[commandName]; ok { h(s, i) + } else { + logCommand(i, "error", "no handler registered for command=%s", commandName) } case discordgo.InteractionApplicationCommandAutocomplete: commandName := i.ApplicationCommandData().Name @@ -66,22 +79,23 @@ func Run(cfg Config) { discord.AddHandler(reactionHandler.OnReactionAdd) discord.AddHandler(reactionHandler.OnReactionRemove) - registeredCommands := make([]*discordgo.ApplicationCommand, len(commands)) - for i, command := range commands { - cmd, err := discord.ApplicationCommandCreate(cfg.ApplicationID, cfg.GuildID, command) - checkNilErr(err) - registeredCommands[i] = cmd + if err := discord.Open(); err != nil { + return fmt.Errorf("open discord session: %w", err) } + defer func() { + if err := discord.Close(); err != nil { + slog.Warn("failed to close discord session", "error", err) + } + }() - err = discord.Open() - if err != nil { - fmt.Println(err.Error()) - return + if err := syncCommands(discord, cfg.ApplicationID, cfg.GuildID); err != nil { + return fmt.Errorf("sync commands: %w", err) } - defer discord.Close() - fmt.Println("Bot running.... Press CTRL + C to exit") + slog.Info("bot running; press CTRL + C to exit") c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) <-c + + return nil } diff --git a/bot/commands.go b/bot/commands.go index 64fd486..4492a59 100644 --- a/bot/commands.go +++ b/bot/commands.go @@ -4,24 +4,15 @@ import "github.com/bwmarrin/discordgo" var commands = []*discordgo.ApplicationCommand{ { - Name: "hello", - Description: "Say Hello", - }, - { - Name: "submit", - Description: "Submit a link", + Name: "help", + Description: "사용 가능한 명령어 안내", Options: []*discordgo.ApplicationCommandOption{ { - Type: discordgo.ApplicationCommandOptionAttachment, - Name: "screenshot", - Description: "Screenshot of problem solution", - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "link", - Description: "Link to submit", - Required: true, + Type: discordgo.ApplicationCommandOptionString, + Name: "command", + Description: "상세 도움말을 볼 명령어 (자동완성)", + Required: false, + Autocomplete: true, }, }, }, @@ -96,6 +87,62 @@ var commands = []*discordgo.ApplicationCommand{ }, }, }, + { + Name: "studies", + Description: "List studies by branch/status", + DefaultMemberPermissions: int64Ptr(discordgo.PermissionAdministrator), + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "branch", + Description: "Branch filter (YY-Q)", + Required: false, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "status", + Description: "Study status", + Required: false, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "active", + Value: "active", + }, + { + Name: "archived", + Value: "archived", + }, + }, + }, + }, + }, + { + Name: "members", + Description: "List active members of a study", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "channel", + Description: "Study channel (autocomplete)", + Required: true, + Autocomplete: true, + }, + }, + }, + { + Name: "study-start", + Description: "Close recruitment and start studies for a branch", + DefaultMemberPermissions: int64Ptr(discordgo.PermissionAdministrator), + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "branch", + Description: "Target branch (YY-Q)", + Required: true, + Autocomplete: true, + }, + }, + }, { Name: "archive-all", Description: "Archive all active studies", diff --git a/bot/doc.go b/bot/doc.go new file mode 100644 index 0000000..315f4c0 --- /dev/null +++ b/bot/doc.go @@ -0,0 +1,2 @@ +// Package bot provides Discord interaction handlers and runtime wiring. +package bot diff --git a/bot/handler_archive.go b/bot/handler_archive.go index ba50215..9e468b9 100644 --- a/bot/handler_archive.go +++ b/bot/handler_archive.go @@ -3,7 +3,7 @@ package bot import ( "context" "fmt" - "log" + "log/slog" "sort" "strings" "unicode/utf8" @@ -22,6 +22,53 @@ type archiveFailure struct { reason string } +type archiveResult struct { + CategoryName string + Warning string +} + +func archiveStudy(s *discordgo.Session, studyRepo *db.StudyRepository, guildID string, st study.Study) (archiveResult, error) { + ctx := context.Background() + + channel, err := s.Channel(st.ChannelID) + if err != nil { + return archiveResult{}, fmt.Errorf("load channel %s for study %q: %w", st.ChannelID, st.Name, err) + } + originalParentID := channel.ParentID + + allocator, err := newArchiveCategoryAllocator(s, guildID) + if err != nil { + return archiveResult{}, fmt.Errorf("prepare archive category allocator: %w", err) + } + + targetCategoryID, targetCategoryName, reservation, err := allocator.Reserve() + if err != nil { + return archiveResult{}, fmt.Errorf("reserve archive category: %w", err) + } + + if _, err := s.ChannelEdit(st.ChannelID, &discordgo.ChannelEdit{ParentID: targetCategoryID}); err != nil { + return archiveResult{}, fmt.Errorf("move channel %s to %s: %w", st.ChannelID, targetCategoryName, err) + } + reservation.Commit() + + if err := studyRepo.ArchiveByID(ctx, st.ID); err != nil { + if rollbackErr := rollbackChannelParent(s, st.ChannelID, originalParentID); rollbackErr != nil { + slog.Error("failed to rollback channel after DB failure", "channel_id", st.ChannelID, "study_name", st.Name, "error", rollbackErr) + return archiveResult{}, fmt.Errorf("archive study %q in DB (rollback also failed): %w", st.Name, err) + } + reservation.Release() + return archiveResult{}, fmt.Errorf("archive study %q in DB (channel rolled back): %w", st.Name, err) + } + + warning := "" + if err := s.GuildRoleDelete(guildID, st.RoleID); err != nil { + slog.Warn("failed to delete role for archived study", "role_id", st.RoleID, "study_name", st.Name, "error", err) + warning = "role deletion failed" + } + + return archiveResult{CategoryName: targetCategoryName, Warning: warning}, nil +} + func newArchiveStudyHandler(studyRepo *db.StudyRepository) func(s *discordgo.Session, i *discordgo.InteractionCreate) { return func(s *discordgo.Session, i *discordgo.InteractionCreate) { options := i.ApplicationCommandData().Options @@ -36,7 +83,7 @@ func newArchiveStudyHandler(studyRepo *db.StudyRepository) func(s *discordgo.Ses st, err := studyRepo.FindByChannelID(ctx, channelID) if err != nil { - log.Printf("Failed to find study by channel %q: %v", channelID, err) + slog.Error("failed to find study by channel", "channel_id", channelID, "error", err) respondError(s, i, "No study found for the selected channel.") return } @@ -46,63 +93,28 @@ func newArchiveStudyHandler(studyRepo *db.StudyRepository) func(s *discordgo.Ses return } - channel, err := s.Channel(st.ChannelID) - if err != nil { - log.Printf("Failed to load channel %s for study %q: %v", st.ChannelID, st.Name, err) - respondError(s, i, "Failed to load study channel. Archive aborted.") - return - } - originalParentID := channel.ParentID - - allocator, err := newArchiveCategoryAllocator(s, i.GuildID) - if err != nil { - log.Printf("Failed to prepare archive category allocator: %v", err) - respondError(s, i, "Failed to prepare archive category.") - return - } - - targetCategoryID, targetCategoryName, reservation, err := allocator.Reserve() + result, err := archiveStudy(s, studyRepo, i.GuildID, st) if err != nil { - log.Printf("Failed to reserve archive category: %v", err) - respondError(s, i, "Failed to prepare archive category.") - return - } - - if _, err := s.ChannelEdit(st.ChannelID, &discordgo.ChannelEdit{ParentID: targetCategoryID}); err != nil { - log.Printf("Failed to move channel %s for study %q to %s: %v", st.ChannelID, st.Name, targetCategoryName, err) - respondError(s, i, "Failed to move study channel to archive category.") - return - } - reservation.Commit() - - if err := studyRepo.ArchiveByID(ctx, st.ID); err != nil { - log.Printf("Failed to archive study %q in DB after move: %v", st.Name, err) - if rollbackErr := rollbackChannelParent(s, st.ChannelID, originalParentID); rollbackErr != nil { - log.Printf("Failed to rollback channel %s after DB failure for study %q: %v", st.ChannelID, st.Name, rollbackErr) - respondError(s, i, "Failed to archive study and rollback failed. Please check channel/category state manually.") - return - } - reservation.Release() - respondError(s, i, "Failed to archive study. Channel move was rolled back.") + slog.Error("failed to archive study", "study_id", st.ID, "study_name", st.Name, "error", err) + respondError(s, i, fmt.Sprintf("Failed to archive study: %v", err)) return } warning := "" - if err := s.GuildRoleDelete(i.GuildID, st.RoleID); err != nil { - log.Printf("Failed to delete role %s for study %q: %v", st.RoleID, st.Name, err) - warning = "\nWarning: Role deletion failed. Please remove it manually if needed." + if result.Warning != "" { + warning = fmt.Sprintf("\nWarning: %s. Please remove it manually if needed.", result.Warning) } if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf("Study **%s** has been archived and moved to **%s**.%s", st.Name, targetCategoryName, warning), + Content: fmt.Sprintf("Study **%s** has been archived and moved to **%s**.%s", st.Name, result.CategoryName, warning), }, }); err != nil { logCommand(i, "error", "failed to respond archive-study success: %v", err) return } - logCommand(i, "success", "archived study id=%d name=%s channel=%s category=%s", st.ID, st.Name, st.ChannelID, targetCategoryName) + logCommand(i, "success", "archived study id=%d name=%s channel=%s category=%s", st.ID, st.Name, st.ChannelID, result.CategoryName) } } @@ -115,13 +127,13 @@ func newArchiveStudyAutocompleteHandler(studyRepo *db.StudyRepository) func(s *d studies, err := studyRepo.FindAllActive(ctx) if err != nil { - log.Printf("Failed to load active studies for autocomplete: %v", err) - respondArchiveAutocomplete(s, i, nil) + slog.Error("failed to load active studies for archive autocomplete", "error", err) + respondAutocomplete(s, i, nil) return } choices := buildArchiveStudyAutocompleteChoices(studies, query, archiveAutocompleteMaxChoices) - respondArchiveAutocomplete(s, i, choices) + respondAutocomplete(s, i, choices) logCommand(i, "success", "archive-study autocomplete choices=%d", len(choices)) } } @@ -142,7 +154,7 @@ func newArchiveAllHandler(studyRepo *db.StudyRepository) func(s *discordgo.Sessi studies, err := studyRepo.FindAllActive(ctx) if err != nil { - log.Printf("Failed to find active studies: %v", err) + slog.Error("failed to find active studies", "error", err) respondError(s, i, "Failed to load active studies.") return } @@ -152,14 +164,13 @@ func newArchiveAllHandler(studyRepo *db.StudyRepository) func(s *discordgo.Sessi return } - allocator, err := newArchiveCategoryAllocator(s, i.GuildID) - if err != nil { - log.Printf("Failed to prepare archive category allocator: %v", err) - respondError(s, i, "Failed to prepare archive category.") - return - } - if dryRun { + allocator, err := newArchiveCategoryAllocator(s, i.GuildID) + if err != nil { + slog.Error("failed to prepare archive category allocator", "error", err) + respondError(s, i, "Failed to prepare archive category.") + return + } studyNames := make([]string, len(studies)) for idx, st := range studies { studyNames[idx] = st.Name @@ -184,43 +195,15 @@ func newArchiveAllHandler(studyRepo *db.StudyRepository) func(s *discordgo.Sessi warnings := make([]string, 0) for _, st := range studies { - channel, err := s.Channel(st.ChannelID) + result, err := archiveStudy(s, studyRepo, i.GuildID, st) if err != nil { - log.Printf("Failed to load channel %s for study %q: %v", st.ChannelID, st.Name, err) - failures = append(failures, archiveFailure{studyName: st.Name, reason: "channel lookup failed"}) - continue - } - originalParentID := channel.ParentID - - targetCategoryID, targetCategoryName, reservation, err := allocator.Reserve() - if err != nil { - log.Printf("Failed to reserve archive category for study %q: %v", st.Name, err) - failures = append(failures, archiveFailure{studyName: st.Name, reason: "archive category unavailable"}) - continue - } - - if _, err := s.ChannelEdit(st.ChannelID, &discordgo.ChannelEdit{ParentID: targetCategoryID}); err != nil { - log.Printf("Failed to move channel %s for study %q to %s: %v", st.ChannelID, st.Name, targetCategoryName, err) - failures = append(failures, archiveFailure{studyName: st.Name, reason: "channel move failed"}) - continue - } - reservation.Commit() - - if err := studyRepo.ArchiveByID(ctx, st.ID); err != nil { - log.Printf("Failed to archive study %q in DB after move: %v", st.Name, err) - if rollbackErr := rollbackChannelParent(s, st.ChannelID, originalParentID); rollbackErr != nil { - log.Printf("Failed to rollback channel %s after DB failure for study %q: %v", st.ChannelID, st.Name, rollbackErr) - warnings = append(warnings, fmt.Sprintf("%s: rollback failed", st.Name)) - } else { - reservation.Release() - } - failures = append(failures, archiveFailure{studyName: st.Name, reason: "db archive failed"}) + slog.Error("failed to archive study", "study_id", st.ID, "study_name", st.Name, "error", err) + failures = append(failures, archiveFailure{studyName: st.Name, reason: err.Error()}) continue } - if err := s.GuildRoleDelete(i.GuildID, st.RoleID); err != nil { - log.Printf("Failed to delete role %s for study %q: %v", st.RoleID, st.Name, err) - warnings = append(warnings, fmt.Sprintf("%s: role deletion failed", st.Name)) + if result.Warning != "" { + warnings = append(warnings, fmt.Sprintf("%s: %s", st.Name, result.Warning)) } successCount++ @@ -249,7 +232,7 @@ func rollbackChannelParent(s *discordgo.Session, channelID, parentID string) err func buildArchiveAllSummary(total, success int, failures []archiveFailure, warnings []string) string { var b strings.Builder - b.WriteString(fmt.Sprintf("Archived **%d/%d** studies.", success, total)) + fmt.Fprintf(&b, "Archived **%d/%d** studies.", success, total) if len(failures) > 0 { parts := make([]string, 0, len(failures)) @@ -270,7 +253,7 @@ func buildArchiveAllSummary(total, success int, failures []archiveFailure, warni func buildArchiveAllDryRunSummary(studyNames []string, plan archiveDryRunPlan) string { var b strings.Builder - b.WriteString(fmt.Sprintf("Dry run: **%d** active studies would be archived. No changes were made.", len(studyNames))) + fmt.Fprintf(&b, "Dry run: **%d** active studies would be archived. No changes were made.", len(studyNames)) if len(plan.CategoryUseCounts) > 0 { categoryNames := make([]string, 0, len(plan.CategoryUseCounts)) @@ -306,10 +289,10 @@ func buildArchiveAllDryRunSummary(studyNames []string, plan archiveDryRunPlan) s if previewLimit > 0 && len(plan.Assignments) >= previewLimit { b.WriteString("\nPreview:\n") for idx := 0; idx < previewLimit; idx++ { - b.WriteString(fmt.Sprintf("%d. %s -> %s\n", idx+1, studyNames[idx], plan.Assignments[idx])) + fmt.Fprintf(&b, "%d. %s -> %s\n", idx+1, studyNames[idx], plan.Assignments[idx]) } if len(studyNames) > previewLimit { - b.WriteString(fmt.Sprintf("...and %d more", len(studyNames)-previewLimit)) + fmt.Fprintf(&b, "...and %d more", len(studyNames)-previewLimit) } } @@ -350,17 +333,6 @@ func buildArchiveStudyAutocompleteChoices(studies []study.Study, query string, l return choices } -func respondArchiveAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate, choices []*discordgo.ApplicationCommandOptionChoice) { - if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionApplicationCommandAutocompleteResult, - Data: &discordgo.InteractionResponseData{ - Choices: choices, - }, - }); err != nil { - log.Printf("Failed to respond archive-study autocomplete: %v", err) - } -} - func buildArchiveAutocompleteChoiceName(studyName, channelID string) string { suffix := fmt.Sprintf(" (<#%s>)", channelID) maxStudyNameLength := archiveAutocompleteChoiceNameLimit - utf8.RuneCountInString(suffix) diff --git a/bot/handler_create_study.go b/bot/handler_create_study.go index c039be6..0bb284e 100644 --- a/bot/handler_create_study.go +++ b/bot/handler_create_study.go @@ -3,7 +3,7 @@ package bot import ( "context" "fmt" - "log" + "log/slog" "strings" "github.com/bwmarrin/discordgo" @@ -40,7 +40,7 @@ func newCreateStudyHandler(studyRepo *db.StudyRepository) func(s *discordgo.Sess categoryID, err := ensureCategoryID(s, guildID, "active") if err != nil { - log.Printf("Failed to ensure active category: %v", err) + slog.Error("failed to ensure active category", "guild_id", guildID, "error", err) respondError(s, i, "Failed to prepare active category.") return } @@ -120,6 +120,16 @@ func ensureCategoryID(s *discordgo.Session, guildID, categoryName string) (strin Type: discordgo.ChannelTypeGuildCategory, }) if err != nil { + // Re-fetch in case another goroutine created it concurrently + channels, refetchErr := s.GuildChannels(guildID) + if refetchErr != nil { + return "", err + } + for _, ch := range channels { + if ch.Type == discordgo.ChannelTypeGuildCategory && strings.EqualFold(ch.Name, categoryName) { + return ch.ID, nil + } + } return "", err } diff --git a/bot/handler_hello.go b/bot/handler_hello.go deleted file mode 100644 index d8ce0bd..0000000 --- a/bot/handler_hello.go +++ /dev/null @@ -1,17 +0,0 @@ -package bot - -import "github.com/bwmarrin/discordgo" - -func handleHello(s *discordgo.Session, i *discordgo.InteractionCreate) { - logCommand(i, "start", "hello command invoked") - if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "Hello Command! 😃", - }, - }); err != nil { - logCommand(i, "error", "failed to respond hello command: %v", err) - return - } - logCommand(i, "success", "hello response sent") -} diff --git a/bot/handler_help.go b/bot/handler_help.go new file mode 100644 index 0000000..0356707 --- /dev/null +++ b/bot/handler_help.go @@ -0,0 +1,306 @@ +package bot + +import ( + "fmt" + "sort" + "strings" + + "github.com/bwmarrin/discordgo" +) + +const ( + helpAutocompleteMaxChoices = 25 + helpAutocompleteChoiceNameLimit = 100 + helpEmbedDescriptionLimit = 4000 + helpEmbedFieldLimit = 1000 + helpEmbedColor = 0x5865F2 +) + +func handleHelp(s *discordgo.Session, i *discordgo.InteractionCreate) { + logCommand(i, "start", "help command invoked") + + memberPermissions, hasMember := helpMemberPermissions(i) + visibleCommands := visibleCommandsForMember(commands, memberPermissions, hasMember) + selectedCommand := selectedHelpCommandName(i.ApplicationCommandData().Options) + + var embed *discordgo.MessageEmbed + if selectedCommand == "" { + embed = buildHelpOverviewEmbed(visibleCommands) + } else { + cmd := findVisibleCommandByName(visibleCommands, selectedCommand) + if cmd == nil { + respondError(s, i, fmt.Sprintf("`/%s` 명령어를 찾을 수 없거나 사용할 권한이 없습니다.", selectedCommand)) + return + } + embed = buildHelpCommandDetailEmbed(cmd) + } + + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: newHelpResponseData(embed), + }); err != nil { + logCommand(i, "error", "failed to respond help command: %v", err) + return + } + + logCommand(i, "success", "help response sent visible_commands=%d selected=%q", len(visibleCommands), selectedCommand) +} + +func handleHelpAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) { + memberPermissions, hasMember := helpMemberPermissions(i) + query := focusedStringOptionValue(i.ApplicationCommandData().Options, "command") + visibleCommands := visibleCommandsForMember(commands, memberPermissions, hasMember) + choices := buildHelpCommandAutocompleteChoices(visibleCommands, query, helpAutocompleteMaxChoices) + logCommand(i, "start", "help autocomplete query=%q", query) + respondAutocomplete(s, i, choices) + logCommand(i, "success", "help autocomplete choices=%d", len(choices)) +} + +func newHelpResponseData(embed *discordgo.MessageEmbed) *discordgo.InteractionResponseData { + embeds := []*discordgo.MessageEmbed{} + if embed != nil { + embeds = append(embeds, embed) + } + + return &discordgo.InteractionResponseData{ + Embeds: embeds, + Flags: discordgo.MessageFlagsEphemeral, + } +} + +func helpMemberPermissions(i *discordgo.InteractionCreate) (int64, bool) { + if i == nil || i.Member == nil { + return 0, false + } + return i.Member.Permissions, true +} + +func visibleCommandsForMember(cmds []*discordgo.ApplicationCommand, memberPermissions int64, hasMember bool) []*discordgo.ApplicationCommand { + visible := make([]*discordgo.ApplicationCommand, 0, len(cmds)) + for _, cmd := range cmds { + if commandVisibleToMember(cmd, memberPermissions, hasMember) { + visible = append(visible, cmd) + } + } + return visible +} + +func selectedHelpCommandName(options []*discordgo.ApplicationCommandInteractionDataOption) string { + for _, opt := range options { + if opt.Name == "command" { + return normalizeHelpCommandName(opt.StringValue()) + } + } + return "" +} + +func normalizeHelpCommandName(raw string) string { + name := strings.TrimSpace(strings.ToLower(raw)) + return strings.TrimPrefix(name, "/") +} + +func findVisibleCommandByName(cmds []*discordgo.ApplicationCommand, name string) *discordgo.ApplicationCommand { + normalized := normalizeHelpCommandName(name) + for _, cmd := range cmds { + if normalizeHelpCommandName(cmd.Name) == normalized { + return cmd + } + } + return nil +} + +func buildHelpOverviewEmbed(cmds []*discordgo.ApplicationCommand) *discordgo.MessageEmbed { + var b strings.Builder + b.WriteString("`/help command:<명령어>` 를 입력하면 상세 카드를 볼 수 있습니다.\n\n") + + for _, cmd := range cmds { + fmt.Fprintf(&b, "- `%s`\n", cmd.Name) + } + + desc := strings.TrimSpace(b.String()) + if len(cmds) == 0 { + desc = "현재 권한으로 사용할 수 있는 명령어가 없습니다." + } + + return &discordgo.MessageEmbed{ + Title: "도움말", + Description: truncateForDiscord(desc, helpEmbedDescriptionLimit), + Color: helpEmbedColor, + } +} + +func buildHelpCommandDetailEmbed(cmd *discordgo.ApplicationCommand) *discordgo.MessageEmbed { + optionsText := buildHelpOptionLines(cmd.Options) + fields := []*discordgo.MessageEmbedField{ + { + Name: "설명", + Value: truncateForDiscord(localizedCommandDescription(cmd), helpEmbedFieldLimit), + Inline: false, + }, + { + Name: "권한", + Value: helpPermissionLabel(cmd), + Inline: true, + }, + { + Name: "옵션", + Value: truncateForDiscord(optionsText, helpEmbedFieldLimit), + Inline: false, + }, + } + + return &discordgo.MessageEmbed{ + Title: cmd.Name, + Color: helpEmbedColor, + Fields: fields, + } +} + +func buildHelpOptionLines(options []*discordgo.ApplicationCommandOption) string { + if len(options) == 0 { + return "없음" + } + + var b strings.Builder + for idx, opt := range options { + if idx > 0 { + b.WriteString("\n") + } + fmt.Fprintf( + &b, + "- `%s` (%s, %s%s)", + opt.Name, + optionTypeLabel(opt.Type), + requiredLabel(opt.Required), + autocompleteSuffix(opt.Autocomplete), + ) + } + return b.String() +} + +func helpPermissionLabel(cmd *discordgo.ApplicationCommand) string { + if cmd == nil || cmd.DefaultMemberPermissions == nil { + return "모든 멤버" + } + return "관리자 전용" +} + +func buildHelpCommandAutocompleteChoices( + cmds []*discordgo.ApplicationCommand, + query string, + limit int, +) []*discordgo.ApplicationCommandOptionChoice { + if limit <= 0 { + return nil + } + + filter := normalizeHelpCommandName(query) + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, min(limit, len(cmds))) + for _, cmd := range cmds { + target := normalizeHelpCommandName(cmd.Name + " " + localizedCommandDescription(cmd)) + if filter != "" && !strings.Contains(target, filter) { + continue + } + + label := truncateForDiscord( + cmd.Name, + helpAutocompleteChoiceNameLimit, + ) + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: label, + Value: cmd.Name, + }) + if len(choices) >= limit { + break + } + } + + sort.SliceStable(choices, func(i, j int) bool { + return choices[i].Name < choices[j].Name + }) + return choices +} + +func commandVisibleToMember(cmd *discordgo.ApplicationCommand, memberPermissions int64, hasMember bool) bool { + if cmd == nil || cmd.DefaultMemberPermissions == nil { + return true + } + if !hasMember { + return false + } + required := *cmd.DefaultMemberPermissions + if required == 0 { + return true + } + return memberPermissions&required == required +} + +func optionTypeLabel(optionType discordgo.ApplicationCommandOptionType) string { + switch optionType { + case discordgo.ApplicationCommandOptionSubCommand: + return "서브커맨드" + case discordgo.ApplicationCommandOptionSubCommandGroup: + return "서브커맨드 그룹" + case discordgo.ApplicationCommandOptionString: + return "문자열" + case discordgo.ApplicationCommandOptionInteger: + return "정수" + case discordgo.ApplicationCommandOptionBoolean: + return "불리언" + case discordgo.ApplicationCommandOptionUser: + return "사용자" + case discordgo.ApplicationCommandOptionChannel: + return "채널" + case discordgo.ApplicationCommandOptionRole: + return "역할" + case discordgo.ApplicationCommandOptionMentionable: + return "멘션 가능 대상" + case discordgo.ApplicationCommandOptionNumber: + return "숫자" + case discordgo.ApplicationCommandOptionAttachment: + return "첨부파일" + default: + return "알 수 없음" + } +} + +func requiredLabel(required bool) string { + if required { + return "필수" + } + return "선택" +} + +func autocompleteSuffix(autocomplete bool) string { + if autocomplete { + return ", 자동완성" + } + return "" +} + +func localizedCommandDescription(cmd *discordgo.ApplicationCommand) string { + if cmd == nil { + return "" + } + + switch cmd.Name { + case "help": + return "사용 가능한 명령어 안내" + case "create-study": + return "새 스터디 채널과 역할(role) 생성" + case "recruit": + return "스터디 모집 메시지 게시(active study only)" + case "archive-study": + return "스터디 고유 역할(role)을 제거하고 채널을 아카이브(archive) 상태로 전환" + case "studies": + return "분기/상태 기준 스터디 목록 조회" + case "members": + return "role 을 사용하여 스터디에 속한 멤버 목록을 조회" + case "study-start": + return "분기 모집 종료 및 스터디 시작 공지" + case "archive-all": + return "활성(active) 스터디 전체 아카이브" + default: + return cmd.Description + } +} diff --git a/bot/handler_help_test.go b/bot/handler_help_test.go new file mode 100644 index 0000000..8534d5a --- /dev/null +++ b/bot/handler_help_test.go @@ -0,0 +1,162 @@ +package bot + +import ( + "strings" + "testing" + + "github.com/bwmarrin/discordgo" +) + +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 visible[0].Name != "help" || visible[1].Name != "members" { + t.Fatalf("unexpected visible commands: %s, %s", visible[0].Name, visible[1].Name) + } +} + +func TestVisibleCommandsForMemberAdmin(t *testing.T) { + visible := visibleCommandsForMember(commands, discordgo.PermissionAdministrator, true) + if len(visible) != len(commands) { + t.Fatalf("expected all commands for admin, got %d/%d", len(visible), len(commands)) + } +} + +func TestBuildHelpOverviewEmbed(t *testing.T) { + embed := buildHelpOverviewEmbed(visibleCommandsForMember(commands, 0, true)) + if embed == nil { + t.Fatal("expected embed") + } + if embed.Title != "도움말" { + t.Fatalf("expected title 도움말, got %q", embed.Title) + } + if !strings.Contains(embed.Description, "`help`") { + t.Fatalf("expected help in description, got: %s", embed.Description) + } + if !strings.Contains(embed.Description, "`members`") { + t.Fatalf("expected members in description, got: %s", embed.Description) + } +} + +func TestBuildHelpOverviewEmbedTruncation(t *testing.T) { + longDesc := strings.Repeat("긴설명", 1200) + cmds := []*discordgo.ApplicationCommand{ + {Name: "a", Description: longDesc}, + {Name: "b", Description: longDesc}, + } + + embed := buildHelpOverviewEmbed(cmds) + if len([]rune(embed.Description)) > helpEmbedDescriptionLimit { + t.Fatalf("description exceeds limit: %d", len([]rune(embed.Description))) + } +} + +func TestBuildHelpCommandDetailEmbed(t *testing.T) { + cmd := &discordgo.ApplicationCommand{ + Name: "sample", + Description: "샘플 명령어", + DefaultMemberPermissions: int64Ptr(discordgo.PermissionAdministrator), + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "query", + Required: true, + Autocomplete: true, + }, + { + Type: discordgo.ApplicationCommandOptionBoolean, + Name: "dry-run", + Required: false, + }, + }, + } + embed := buildHelpCommandDetailEmbed(cmd) + + if embed.Title != "sample" { + t.Fatalf("expected title sample, got %q", embed.Title) + } + optionsField := findEmbedField(embed, "옵션") + if optionsField == nil { + t.Fatalf("expected options field") + } + if !strings.Contains(optionsField.Value, "`query` (문자열, 필수, 자동완성)") { + t.Fatalf("expected query option in field, got: %s", optionsField.Value) + } + if !strings.Contains(optionsField.Value, "`dry-run` (불리언, 선택)") { + t.Fatalf("expected dry-run option in field, got: %s", optionsField.Value) + } + permissionField := findEmbedField(embed, "권한") + if permissionField == nil || permissionField.Value != "관리자 전용" { + t.Fatalf("expected admin permission field, got: %+v", permissionField) + } +} + +func TestBuildHelpCommandAutocompleteChoices(t *testing.T) { + cmds := []*discordgo.ApplicationCommand{ + { + Name: "help", + Description: "사용 가능한 명령어 안내", + }, + { + Name: "members", + Description: "List active members of a study", + }, + { + Name: "study-start", + Description: "Close recruitment and start studies for a branch", + }, + } + + choices := buildHelpCommandAutocompleteChoices(cmds, "study", 25) + if len(choices) != 1 { + t.Fatalf("expected one choice, got %d", len(choices)) + } + if choices[0].Value != "study-start" { + t.Fatalf("expected study-start value, got %v", choices[0].Value) + } + if choices[0].Name != "study-start" { + t.Fatalf("expected choice label with command name, got %q", choices[0].Name) + } +} + +func TestFindVisibleCommandByName(t *testing.T) { + cmds := []*discordgo.ApplicationCommand{ + {Name: "members"}, + {Name: "create-study"}, + } + + found := findVisibleCommandByName(cmds, "/create-study") + if found == nil || found.Name != "create-study" { + t.Fatalf("expected /create-study to be found, got %+v", found) + } + + missing := findVisibleCommandByName(cmds, "unknown") + if missing != nil { + t.Fatalf("expected missing command to return nil, got %+v", missing) + } +} + +func TestNewHelpResponseDataEphemeral(t *testing.T) { + embed := &discordgo.MessageEmbed{Title: "도움말"} + data := newHelpResponseData(embed) + if data.Flags != discordgo.MessageFlagsEphemeral { + t.Fatalf("expected ephemeral flag, got %d", data.Flags) + } + if len(data.Embeds) != 1 || data.Embeds[0].Title != "도움말" { + t.Fatalf("expected one embed with title 도움말, got %+v", data.Embeds) + } +} + +func findEmbedField(embed *discordgo.MessageEmbed, name string) *discordgo.MessageEmbedField { + if embed == nil { + return nil + } + for _, field := range embed.Fields { + if field.Name == name { + return field + } + } + return nil +} diff --git a/bot/handler_members.go b/bot/handler_members.go new file mode 100644 index 0000000..dda0855 --- /dev/null +++ b/bot/handler_members.go @@ -0,0 +1,94 @@ +package bot + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/bwmarrin/discordgo" + "livid-bot/db" + "livid-bot/study" +) + +func newMembersHandler(studyRepo *db.StudyRepository, memberRepo *db.MemberRepository) func(s *discordgo.Session, i *discordgo.InteractionCreate) { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + options := i.ApplicationCommandData().Options + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + opt, ok := optionMap["channel"] + if !ok { + respondError(s, i, "Missing required option: channel.") + return + } + channelID := opt.StringValue() + logCommand(i, "start", "members requested channel=%s", channelID) + ctx := context.Background() + + st, err := studyRepo.FindByChannelID(ctx, channelID) + if err != nil { + slog.Error("failed to find study by channel", "channel_id", channelID, "error", err) + respondError(s, i, "No study found for the selected channel.") + return + } + + members, err := memberRepo.FindActiveByStudyID(ctx, st.ID) + if err != nil { + slog.Error("failed to find members for study", "study_id", st.ID, "study_name", st.Name, "error", err) + respondError(s, i, "Failed to load study members.") + return + } + + content := buildMembersResponse(st.Name, members) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + logCommand(i, "error", "failed to respond members command: %v", err) + return + } + logCommand(i, "success", "members returned count=%d study=%s", len(members), st.Name) + } +} + +func newMembersAutocompleteHandler(studyRepo *db.StudyRepository) func(s *discordgo.Session, i *discordgo.InteractionCreate) { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + ctx := context.Background() + data := i.ApplicationCommandData() + query := focusedStringOptionValue(data.Options, "channel") + logCommand(i, "start", "members autocomplete query=%q", query) + + studies, err := studyRepo.FindAllActive(ctx) + if err != nil { + slog.Error("failed to load active studies for members autocomplete", "error", err) + respondAutocomplete(s, i, nil) + return + } + + choices := buildArchiveStudyAutocompleteChoices(studies, query, archiveAutocompleteMaxChoices) + respondAutocomplete(s, i, choices) + logCommand(i, "success", "members autocomplete choices=%d", len(choices)) + } +} + +func buildMembersResponse(studyName string, members []study.StudyMember) string { + if len(members) == 0 { + return fmt.Sprintf("No members found for study **%s**.", studyName) + } + + var b strings.Builder + fmt.Fprintf(&b, "📚 **%s** members (%d)\n", studyName, len(members)) + + for _, m := range members { + joinedDate := m.JoinedAt.Format("2006-01-02") + fmt.Fprintf(&b, "- <@%s> (joined: %s)\n", m.UserID, joinedDate) + } + + return truncateForDiscord(strings.TrimSuffix(b.String(), "\n"), discordMessageLimit) +} diff --git a/bot/handler_members_test.go b/bot/handler_members_test.go new file mode 100644 index 0000000..7987b5c --- /dev/null +++ b/bot/handler_members_test.go @@ -0,0 +1,84 @@ +package bot + +import ( + "strings" + "testing" + "time" + + "livid-bot/study" +) + +func TestBuildMembersResponse(t *testing.T) { + joinedAt := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + + longName := strings.Repeat("스터디", 50) + manyMembers := make([]study.StudyMember, 100) + for i := range manyMembers { + manyMembers[i] = study.StudyMember{ + StudyID: 1, + UserID: "111222333444555666", + Username: "user", + JoinedAt: joinedAt, + } + } + + cases := []struct { + name string + studyName string + members []study.StudyMember + contains []string + exact string + maxRunes int + hasSuffix string + }{ + { + name: "two members", + studyName: "알고리즘", + members: []study.StudyMember{ + {StudyID: 1, UserID: "111", Username: "alice", JoinedAt: joinedAt}, + {StudyID: 1, UserID: "222", Username: "bob", JoinedAt: joinedAt.AddDate(0, 0, 5)}, + }, + contains: []string{ + "📚 **알고리즘** members (2)", + "<@111>", + "(joined: 2026-03-01)", + "<@222>", + "(joined: 2026-03-06)", + }, + }, + { + name: "empty", + studyName: "알고리즘", + members: nil, + exact: "No members found for study **알고리즘**.", + }, + { + name: "truncation", + studyName: longName, + members: manyMembers, + maxRunes: discordMessageLimit, + hasSuffix: "...", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := buildMembersResponse(tc.studyName, tc.members) + + if tc.exact != "" && result != tc.exact { + t.Fatalf("expected %q, got %q", tc.exact, result) + } + for _, s := range tc.contains { + if !strings.Contains(result, s) { + t.Fatalf("expected result to contain %q, got: %s", s, result) + } + } + if tc.maxRunes > 0 && len([]rune(result)) > tc.maxRunes { + t.Fatalf("response exceeds limit: %d runes", len([]rune(result))) + } + if tc.hasSuffix != "" && !strings.HasSuffix(result, tc.hasSuffix) { + t.Fatalf("expected suffix %q, got: %s", tc.hasSuffix, result[len(result)-20:]) + } + }) + } +} diff --git a/bot/handler_recruit.go b/bot/handler_recruit.go index 70c2396..ea3684f 100644 --- a/bot/handler_recruit.go +++ b/bot/handler_recruit.go @@ -96,6 +96,11 @@ func newRecruitHandler(studyRepo *db.StudyRepository, recruitRepo *db.RecruitRep if err := recruitRepo.SaveRecruitMessage(ctx, msg.ID, channelID, mappings); err != nil { logCommand(i, "error", "failed to save recruit message mapping message=%s err=%v", msg.ID, err) + if delErr := s.ChannelMessageDelete(channelID, msg.ID); delErr != nil { + logCommand(i, "error", "failed to delete recruit message after DB failure message=%s err=%v", msg.ID, delErr) + } + respondError(s, i, "Failed to save recruitment data. Message has been removed. Please try again.") + return } // Update in-memory mapping @@ -125,7 +130,7 @@ func newRecruitHandler(studyRepo *db.StudyRepository, recruitRepo *db.RecruitRep func buildRecruitMessage(branch string, studies []study.Study, from, to time.Time) string { var b strings.Builder b.WriteString("@everyone 스터디 모집이 시작되었습니다!\n") - b.WriteString(fmt.Sprintf("대상 분기: **%s**\n", branch)) + fmt.Fprintf(&b, "대상 분기: **%s**\n", branch) b.WriteString("참여를 원하시면 이모지로 참여 의사를 표현해주세요!\n\n") for idx, st := range studies { @@ -133,12 +138,12 @@ func buildRecruitMessage(branch string, studies []study.Study, from, to time.Tim if st.Description != "" { desc = fmt.Sprintf(" — %s", st.Description) } - b.WriteString(fmt.Sprintf("%s **%s**%s\n", numberEmojis[idx], st.Name, desc)) + fmt.Fprintf(&b, "%s **%s**%s\n", numberEmojis[idx], st.Name, desc) } - b.WriteString(fmt.Sprintf("\n📅 모집 기간: %s ~ %s\n", + fmt.Fprintf(&b, "\n📅 모집 기간: %s ~ %s\n", from.Format("2006-01-02"), - to.Format("2006-01-02"))) + to.Format("2006-01-02")) b.WriteString("\n이모지 반응으로 스터디 역할이 자동 부여됩니다.") return b.String() diff --git a/bot/handler_recruit_autocomplete.go b/bot/handler_recruit_autocomplete.go index 9c622b2..35f4532 100644 --- a/bot/handler_recruit_autocomplete.go +++ b/bot/handler_recruit_autocomplete.go @@ -2,7 +2,7 @@ package bot import ( "context" - "log" + "log/slog" "strings" "github.com/bwmarrin/discordgo" @@ -19,7 +19,7 @@ func newRecruitBranchAutocompleteHandler(studyRepo *db.StudyRepository) func(s * branches, err := studyRepo.FindDistinctActiveBranches(ctx) if err != nil { - log.Printf("Failed to load active branches for recruit autocomplete: %v", err) + slog.Error("failed to load active branches for recruit autocomplete", "error", err) respondRecruitBranchAutocomplete(s, i, nil) return } @@ -59,6 +59,6 @@ func respondRecruitBranchAutocomplete(s *discordgo.Session, i *discordgo.Interac Choices: choices, }, }); err != nil { - log.Printf("Failed to respond recruit branch autocomplete: %v", err) + slog.Error("failed to respond recruit branch autocomplete", "error", err) } } diff --git a/bot/handler_studies.go b/bot/handler_studies.go new file mode 100644 index 0000000..39f80f5 --- /dev/null +++ b/bot/handler_studies.go @@ -0,0 +1,84 @@ +package bot + +import ( + "context" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "livid-bot/db" + "livid-bot/study" +) + +func newStudiesHandler(studyRepo *db.StudyRepository) func(s *discordgo.Session, i *discordgo.InteractionCreate) { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + branch, status := parseStudiesFilters(i.ApplicationCommandData().Options) + logCommand(i, "start", "studies requested branch=%q status=%q", branch, status) + + if branch != "" && !isValidBranch(branch) { + respondError(s, i, "Invalid branch format. Use YY-Q with Q in 1~4 (e.g. 26-2).") + return + } + + if status != "active" && status != "archived" { + respondError(s, i, "Invalid status. Use one of: active, archived.") + return + } + + ctx := context.Background() + studies, err := studyRepo.FindByFilters(ctx, branch, status) + if err != nil { + logCommand(i, "error", "failed to load studies branch=%q status=%q err=%v", branch, status, err) + respondError(s, i, "Failed to load studies.") + return + } + + content := buildStudiesResponse(branch, status, studies) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + logCommand(i, "error", "failed to respond studies command: %v", err) + return + } + logCommand(i, "success", "studies returned count=%d branch=%q status=%q", len(studies), branch, status) + } +} + +func parseStudiesFilters(options []*discordgo.ApplicationCommandInteractionDataOption) (branch, status string) { + status = "active" + for _, opt := range options { + switch opt.Name { + case "branch": + branch = strings.TrimSpace(opt.StringValue()) + case "status": + status = strings.TrimSpace(opt.StringValue()) + } + } + if status == "" { + status = "active" + } + return branch, status +} + +func buildStudiesResponse(branch, status string, studies []study.Study) string { + if len(studies) == 0 { + return "No studies found for the provided filters." + } + + var b strings.Builder + fmt.Fprintf(&b, "Studies (status=%s", status) + if branch != "" { + fmt.Fprintf(&b, ", branch=%s", branch) + } + b.WriteString("):\n") + + for _, st := range studies { + fmt.Fprintf(&b, "- [%s] %s (%s) <#%s>\n", st.Branch, st.Name, st.Status, st.ChannelID) + } + + return truncateForDiscord(strings.TrimSuffix(b.String(), "\n"), discordMessageLimit) +} diff --git a/bot/handler_studies_test.go b/bot/handler_studies_test.go new file mode 100644 index 0000000..3bd7163 --- /dev/null +++ b/bot/handler_studies_test.go @@ -0,0 +1,43 @@ +package bot + +import ( + "strings" + "testing" + + "github.com/bwmarrin/discordgo" + "livid-bot/study" +) + +func TestParseStudiesFilters(t *testing.T) { + options := []*discordgo.ApplicationCommandInteractionDataOption{ + { + Name: "branch", + Type: discordgo.ApplicationCommandOptionString, + Value: "26-2", + }, + } + + branch, status := parseStudiesFilters(options) + if branch != "26-2" { + t.Fatalf("expected branch 26-2, got %q", branch) + } + if status != "active" { + t.Fatalf("expected default status active, got %q", status) + } +} + +func TestBuildStudiesResponse(t *testing.T) { + studies := []study.Study{ + {Branch: "26-2", Name: "algo", Status: "active", ChannelID: "111"}, + {Branch: "26-2", Name: "backend", Status: "active", ChannelID: "222"}, + } + + message := buildStudiesResponse("26-2", "active", studies) + + if !strings.Contains(message, "- [26-2] algo (active) <#111>") { + t.Fatalf("expected formatted first row, got: %s", message) + } + if !strings.Contains(message, "- [26-2] backend (active) <#222>") { + t.Fatalf("expected formatted second row, got: %s", message) + } +} diff --git a/bot/handler_study_start.go b/bot/handler_study_start.go new file mode 100644 index 0000000..6f18766 --- /dev/null +++ b/bot/handler_study_start.go @@ -0,0 +1,181 @@ +package bot + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "livid-bot/db" + "livid-bot/study" + + "github.com/bwmarrin/discordgo" +) + +const minMembersToStart = 3 + +func newStudyStartHandler( + studyRepo *db.StudyRepository, + memberRepo *db.MemberRepository, + recruitRepo *db.RecruitRepository, + reactionHandler *ReactionHandler, +) func(s *discordgo.Session, i *discordgo.InteractionCreate) { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + options := i.ApplicationCommandData().Options + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + opt, ok := optionMap["branch"] + if !ok { + respondError(s, i, "Missing required option: branch.") + return + } + branch := opt.StringValue() + logCommand(i, "start", "study-start requested branch=%s", branch) + + if !isValidBranch(branch) { + respondError(s, i, fmt.Sprintf("Invalid branch format: %q. Use YY-Q (e.g. 26-2).", branch)) + return + } + + ctx := context.Background() + + messageIDs, studyInfos, err := recruitRepo.FindOpenMappingsByBranch(ctx, branch) + if err != nil { + slog.Error("failed to find open mappings by branch", "branch", branch, "error", err) + respondError(s, i, "Failed to load recruitment data.") + return + } + + if len(studyInfos) == 0 { + respondError(s, i, fmt.Sprintf("No open recruitments found for branch %q.", branch)) + return + } + + reactionHandler.Untrack(messageIDs) + + if _, err := recruitRepo.CloseByBranch(ctx, branch); err != nil { + slog.Error("failed to close recruit messages by branch", "branch", branch, "error", err) + respondError(s, i, "Failed to close recruitment.") + return + } + + var started []string + var archived []string + var errors []string + + for _, info := range studyInfos { + members, err := memberRepo.FindActiveByStudyID(ctx, info.StudyID) + if err != nil { + slog.Error("failed to load members for study", "study_id", info.StudyID, "study_name", info.StudyName, "error", err) + errors = append(errors, fmt.Sprintf("%s: failed to load members", info.StudyName)) + continue + } + + if len(members) < minMembersToStart { + archiveResult, archiveErr := archiveStudy(s, studyRepo, i.GuildID, studyToModel(info)) + if archiveErr != nil { + slog.Error("failed to auto-archive study", "study_id", info.StudyID, "study_name", info.StudyName, "error", archiveErr) + errors = append(errors, fmt.Sprintf("%s: archive failed", info.StudyName)) + continue + } + + if _, msgErr := s.ChannelMessageSend(info.ChannelID, + fmt.Sprintf("모집 인원이 %d명 미만이어서 스터디가 자동 아카이브되었습니다.", minMembersToStart), + ); msgErr != nil { + slog.Warn("failed to send archive notice to channel", "channel_id", info.ChannelID, "error", msgErr) + } + + archiveEntry := info.StudyName + if archiveResult.Warning != "" { + archiveEntry += " (" + archiveResult.Warning + ")" + } + archived = append(archived, archiveEntry) + } else { + if _, msgErr := s.ChannelMessageSend(info.ChannelID, + fmt.Sprintf("<@&%s> 스터디에 오신 것을 환영합니다! 스터디를 진행해주세요.", info.RoleID), + ); msgErr != nil { + slog.Warn("failed to send start notice to channel", "channel_id", info.ChannelID, "error", msgErr) + } + + started = append(started, info.StudyName) + } + } + + summary := buildStudyStartSummary(started, archived, errors) + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: summary, + Flags: discordgo.MessageFlagsEphemeral, + }, + }); err != nil { + logCommand(i, "error", "failed to respond study-start summary: %v", err) + return + } + logCommand(i, "success", "study-start completed branch=%s started=%d archived=%d errors=%d", + branch, len(started), len(archived), len(errors)) + } +} + +// studyToModel converts RecruitStudyInfo to Study for archiveStudy. +// Only ID, Name, ChannelID, RoleID, and Status are required by archiveStudy. +func studyToModel(info db.RecruitStudyInfo) study.Study { + return study.Study{ + ID: info.StudyID, + Name: info.StudyName, + ChannelID: info.ChannelID, + RoleID: info.RoleID, + Status: "active", + } +} + +func newStudyStartAutocompleteHandler(studyRepo *db.StudyRepository) func(s *discordgo.Session, i *discordgo.InteractionCreate) { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + ctx := context.Background() + query := focusedStringOptionValue(i.ApplicationCommandData().Options, "branch") + logCommand(i, "start", "study-start branch autocomplete query=%q", query) + + branches, err := studyRepo.FindDistinctActiveBranches(ctx) + if err != nil { + slog.Error("failed to load active branches for study-start autocomplete", "error", err) + respondAutocomplete(s, i, nil) + return + } + + choices := buildRecruitBranchAutocompleteChoices(branches, query, recruitBranchAutocompleteMaxChoices) + respondAutocomplete(s, i, choices) + logCommand(i, "success", "study-start branch autocomplete choices=%d", len(choices)) + } +} + +func buildStudyStartSummary(started, archived, errors []string) string { + var b strings.Builder + + if len(started) > 0 { + fmt.Fprintf(&b, "Started **%d** studies: %s", len(started), strings.Join(started, ", ")) + } + + if len(archived) > 0 { + if b.Len() > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "Archived **%d** studies (< %d members): %s", + len(archived), minMembersToStart, strings.Join(archived, ", ")) + } + + if len(errors) > 0 { + if b.Len() > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "Errors: %s", strings.Join(errors, ", ")) + } + + if b.Len() == 0 { + b.WriteString("No studies were processed.") + } + + return truncateForDiscord(b.String(), discordMessageLimit) +} diff --git a/bot/handler_study_start_test.go b/bot/handler_study_start_test.go new file mode 100644 index 0000000..40e28d6 --- /dev/null +++ b/bot/handler_study_start_test.go @@ -0,0 +1,71 @@ +package bot + +import ( + "strings" + "testing" +) + +func TestBuildStudyStartSummary(t *testing.T) { + tests := []struct { + name string + started []string + archived []string + errors []string + wantContains []string + }{ + { + name: "only started", + started: []string{"algo", "backend"}, + wantContains: []string{"Started **2** studies: algo, backend"}, + }, + { + name: "only archived", + archived: []string{"frontend"}, + wantContains: []string{"Archived **1** studies (< 3 members): frontend"}, + }, + { + name: "started and archived", + started: []string{"algo"}, + archived: []string{"frontend"}, + wantContains: []string{"Started **1** studies: algo", "Archived **1** studies"}, + }, + { + name: "with errors", + started: []string{"algo"}, + errors: []string{"backend: failed to load members"}, + wantContains: []string{"Started **1** studies: algo", "Errors: backend: failed to load members"}, + }, + { + name: "all empty", + wantContains: []string{"No studies were processed."}, + }, + { + name: "archive with warning", + archived: []string{"frontend (role deletion failed)"}, + wantContains: []string{"Archived **1** studies (< 3 members): frontend (role deletion failed)"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildStudyStartSummary(tt.started, tt.archived, tt.errors) + for _, want := range tt.wantContains { + if !strings.Contains(result, want) { + t.Errorf("expected summary to contain %q, got: %s", want, result) + } + } + }) + } +} + +func TestBuildStudyStartSummaryTruncation(t *testing.T) { + started := make([]string, 100) + for i := range started { + started[i] = "very-long-study-name-that-takes-up-space-" + string(rune('a'+i%26)) + } + + result := buildStudyStartSummary(started, nil, nil) + if len([]rune(result)) > discordMessageLimit { + t.Errorf("expected summary to be truncated to %d chars, got %d", discordMessageLimit, len([]rune(result))) + } +} diff --git a/bot/handler_submit.go b/bot/handler_submit.go deleted file mode 100644 index 78cb350..0000000 --- a/bot/handler_submit.go +++ /dev/null @@ -1,68 +0,0 @@ -package bot - -import ( - "net/http" - - "github.com/bwmarrin/discordgo" -) - -func handleSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) { - options := i.ApplicationCommandData().Options - - optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) - for _, option := range options { - optionMap[option.Name] = option - } - link := optionMap["link"].StringValue() - logCommand(i, "start", "submit command invoked link=%s", link) - - // Markdown link conversion - markdown, err := ConvertLinkToMarkdown(link) - if err != nil { - respondError(s, i, "Error converting link to markdown") - return - } - - // attachment - attachmentID := optionMap["screenshot"].Value.(string) - attachmentUrl := i.ApplicationCommandData().Resolved.Attachments[attachmentID].URL - - res, resError := http.DefaultClient.Get(attachmentUrl) - if resError != nil { - logCommand(i, "error", "failed to fetch attachment url=%s err=%v", attachmentUrl, resError) - respondError(s, i, "Could not get response") - return - } - defer res.Body.Close() - - if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{ - { - Title: "New Submission! 🚀", - URL: link, - Fields: []*discordgo.MessageEmbedField{ - { - Name: "Challenge", - Value: markdown, - }, - }, - Image: &discordgo.MessageEmbedImage{ - URL: attachmentUrl, - }, - Author: &discordgo.MessageEmbedAuthor{ - Name: i.Member.User.Username, - URL: "https://discord.com/users/" + i.Member.User.ID, - IconURL: i.Member.User.AvatarURL(""), - }, - Color: 0x9400D3, - }, - }, - }, - }); err != nil { - logCommand(i, "error", "failed to respond submit command: %v", err) - return - } - logCommand(i, "success", "submit processed link=%s attachment=%s", link, attachmentID) -} diff --git a/bot/helpers.go b/bot/helpers.go index 4cfdf3d..0b5ce14 100644 --- a/bot/helpers.go +++ b/bot/helpers.go @@ -2,17 +2,11 @@ package bot import ( "fmt" - "log" + "log/slog" "github.com/bwmarrin/discordgo" ) -func checkNilErr(e error) { - if e != nil { - log.Fatal(e.Error()) - } -} - func respondError(s *discordgo.Session, i *discordgo.InteractionCreate, message string) { logCommand(i, "error", "%s", message) if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ @@ -26,6 +20,17 @@ func respondError(s *discordgo.Session, i *discordgo.InteractionCreate, message } } +func respondAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate, choices []*discordgo.ApplicationCommandOptionChoice) { + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: choices, + }, + }); err != nil { + logCommand(i, "error", "failed to respond autocomplete: %v", err) + } +} + func boolPtr(v bool) *bool { return &v } @@ -45,12 +50,26 @@ func logCommand(i *discordgo.InteractionCreate, stage, format string, args ...in userID = interactionUserID(i) } - prefix := fmt.Sprintf("[cmd=%s stage=%s guild=%s user=%s]", commandName, stage, guildID, userID) + message := "command event" if format == "" { - log.Printf("%s", prefix) + format = message + } + message = fmt.Sprintf(format, args...) + + attrs := []any{ + "cmd", commandName, + "stage", stage, + "guild", guildID, + "user", userID, + } + + if stage == "error" { + slog.Error(message, attrs...) + recordCommandResult(i, stage, message) return } - log.Printf("%s %s", prefix, fmt.Sprintf(format, args...)) + slog.Info(message, attrs...) + recordCommandResult(i, stage, message) } func interactionCommandName(i *discordgo.InteractionCreate) string { diff --git a/bot/helpers_logging_test.go b/bot/helpers_logging_test.go new file mode 100644 index 0000000..d8bf808 --- /dev/null +++ b/bot/helpers_logging_test.go @@ -0,0 +1,66 @@ +package bot + +import ( + "context" + "log/slog" + "sync" + "testing" +) + +type recordingHandler struct { + mu sync.Mutex + records []slog.Record +} + +func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { + return true +} + +func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + h.records = append(h.records, r.Clone()) + return nil +} + +func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { + return h +} + +func (h *recordingHandler) WithGroup(string) slog.Handler { + return h +} + +func TestLogCommandStructuredFieldsAndLevels(t *testing.T) { + orig := slog.Default() + handler := &recordingHandler{} + slog.SetDefault(slog.New(handler)) + defer slog.SetDefault(orig) + + logCommand(nil, "error", "failed operation: %s", "boom") + logCommand(nil, "success", "done") + + if len(handler.records) != 2 { + t.Fatalf("expected 2 log records, got %d", len(handler.records)) + } + + first := handler.records[0] + if first.Level != slog.LevelError { + t.Fatalf("expected first record level error, got %v", first.Level) + } + attrs := map[string]any{} + first.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.Any() + return true + }) + for _, key := range []string{"cmd", "stage", "guild", "user"} { + if _, ok := attrs[key]; !ok { + t.Fatalf("missing structured field %q", key) + } + } + + second := handler.records[1] + if second.Level != slog.LevelInfo { + t.Fatalf("expected second record level info, got %v", second.Level) + } +} diff --git a/bot/reaction.go b/bot/reaction.go index f1a555d..e0c7820 100644 --- a/bot/reaction.go +++ b/bot/reaction.go @@ -2,7 +2,7 @@ package bot import ( "context" - "log" + "log/slog" "sync" "github.com/bwmarrin/discordgo" @@ -49,7 +49,7 @@ func (h *ReactionHandler) LoadFromDB(recruitRepo *db.RecruitRepository) error { h.mappings = newMap h.mu.Unlock() - log.Printf("Loaded %d reaction mappings from DB", len(dbMappings)) + slog.Info("loaded reaction mappings from DB", "count", len(dbMappings)) return nil } @@ -66,6 +66,28 @@ func (h *ReactionHandler) Track(messageID string, emojiMap map[string]emojiStudy h.mappings = newMappings } +// Untrack removes message mappings for the given IDs (copy-on-write). +func (h *ReactionHandler) Untrack(messageIDs []string) { + if len(messageIDs) == 0 { + return + } + + h.mu.Lock() + defer h.mu.Unlock() + + newMappings := make(map[string]map[string]emojiStudyInfo, len(h.mappings)) + removeSet := make(map[string]struct{}, len(messageIDs)) + for _, id := range messageIDs { + removeSet[id] = struct{}{} + } + for k, v := range h.mappings { + if _, skip := removeSet[k]; !skip { + newMappings[k] = v + } + } + h.mappings = newMappings +} + func (h *ReactionHandler) OnReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd) { // Ignore bot's own reactions if r.UserID == s.State.User.ID { @@ -78,14 +100,14 @@ func (h *ReactionHandler) OnReactionAdd(s *discordgo.Session, r *discordgo.Messa } if err := s.GuildMemberRoleAdd(r.GuildID, r.UserID, info.RoleID); err != nil { - log.Printf("Failed to add role %s to user %s: %v", info.RoleID, r.UserID, err) + slog.Error("failed to add role to user", "guild_id", r.GuildID, "role_id", info.RoleID, "user_id", r.UserID, "error", err) return } // Get username for DB record member, err := s.GuildMember(r.GuildID, r.UserID) if err != nil { - log.Printf("Failed to get member info for %s: %v", r.UserID, err) + slog.Error("failed to get member info", "guild_id", r.GuildID, "user_id", r.UserID, "error", err) return } @@ -96,29 +118,33 @@ func (h *ReactionHandler) OnReactionAdd(s *discordgo.Session, r *discordgo.Messa ctx := context.Background() if err := h.memberRepo.AddMember(ctx, info.StudyID, r.UserID, username); err != nil { - log.Printf("Failed to record member join: %v", err) + slog.Error("failed to record member join", "study_id", info.StudyID, "user_id", r.UserID, "error", err) } - log.Printf("User %s (%s) joined study %d", username, r.UserID, info.StudyID) + slog.Info("user joined study", "username", username, "user_id", r.UserID, "study_id", info.StudyID) } func (h *ReactionHandler) OnReactionRemove(s *discordgo.Session, r *discordgo.MessageReactionRemove) { + if r.UserID == s.State.User.ID { + return + } + info, ok := h.lookup(r.MessageID, r.Emoji.Name) if !ok { return } if err := s.GuildMemberRoleRemove(r.GuildID, r.UserID, info.RoleID); err != nil { - log.Printf("Failed to remove role %s from user %s: %v", info.RoleID, r.UserID, err) + slog.Error("failed to remove role from user", "guild_id", r.GuildID, "role_id", info.RoleID, "user_id", r.UserID, "error", err) return } ctx := context.Background() if err := h.memberRepo.RemoveMember(ctx, info.StudyID, r.UserID); err != nil { - log.Printf("Failed to record member leave: %v", err) + slog.Error("failed to record member leave", "study_id", info.StudyID, "user_id", r.UserID, "error", err) } - log.Printf("User %s left study %d", r.UserID, info.StudyID) + slog.Info("user left study", "user_id", r.UserID, "study_id", info.StudyID) } func (h *ReactionHandler) lookup(messageID, emoji string) (emojiStudyInfo, bool) { diff --git a/bot/reaction_test.go b/bot/reaction_test.go index df524b2..f7a6204 100644 --- a/bot/reaction_test.go +++ b/bot/reaction_test.go @@ -25,6 +25,47 @@ func TestReactionHandlerTrackAndLookup(t *testing.T) { } } +func TestReactionHandlerUntrack(t *testing.T) { + h := NewReactionHandler(nil) + + h.Track("msg-1", map[string]emojiStudyInfo{ + "1️⃣": {RoleID: "role-1", StudyID: 101}, + }) + h.Track("msg-2", map[string]emojiStudyInfo{ + "2️⃣": {RoleID: "role-2", StudyID: 202}, + }) + h.Track("msg-3", map[string]emojiStudyInfo{ + "3️⃣": {RoleID: "role-3", StudyID: 303}, + }) + + h.Untrack([]string{"msg-1", "msg-3"}) + + if _, ok := h.lookup("msg-1", "1️⃣"); ok { + t.Fatal("expected msg-1 to be untracked") + } + if _, ok := h.lookup("msg-3", "3️⃣"); ok { + t.Fatal("expected msg-3 to be untracked") + } + if _, ok := h.lookup("msg-2", "2️⃣"); !ok { + t.Fatal("expected msg-2 to still be tracked") + } +} + +func TestReactionHandlerUntrackEmpty(t *testing.T) { + h := NewReactionHandler(nil) + + h.Track("msg-1", map[string]emojiStudyInfo{ + "1️⃣": {RoleID: "role-1", StudyID: 101}, + }) + + h.Untrack(nil) + h.Untrack([]string{}) + + if _, ok := h.lookup("msg-1", "1️⃣"); !ok { + t.Fatal("expected msg-1 to remain after empty untrack") + } +} + func TestReactionHandlerTrackReplacesMessageMapping(t *testing.T) { h := NewReactionHandler(nil) diff --git a/bot/study_branch.go b/bot/study_branch.go index daff4c6..23623b6 100644 --- a/bot/study_branch.go +++ b/bot/study_branch.go @@ -21,5 +21,23 @@ func normalizeStudyName(name string) string { } func buildStudyChannelName(branch, name string) string { - return branch + "-" + name + return sanitizeChannelName(branch + "-" + name) +} + +func sanitizeChannelName(name string) string { + name = strings.ToLower(name) + name = strings.ReplaceAll(name, " ", "-") + + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } + } + + result := b.String() + if len(result) > 100 { + result = result[:100] + } + return result } diff --git a/bot/study_branch_test.go b/bot/study_branch_test.go index 1168f45..b0e8687 100644 --- a/bot/study_branch_test.go +++ b/bot/study_branch_test.go @@ -43,8 +43,29 @@ func TestNormalizeStudyName(t *testing.T) { } func TestBuildStudyChannelName(t *testing.T) { - actual := buildStudyChannelName("26-2", "algo") - if actual != "26-2-algo" { - t.Fatalf("expected 26-2-algo, got %s", actual) + testCases := []struct { + branch string + name string + expected string + }{ + {branch: "26-2", name: "algo", expected: "26-2-algo"}, + {branch: "26-1", name: "System Design", expected: "26-1-system-design"}, + {branch: "26-3", name: "C++", expected: "26-3-c"}, + {branch: "26-2", name: "네트워크", expected: "26-2-"}, + } + + for _, tc := range testCases { + actual := buildStudyChannelName(tc.branch, tc.name) + if actual != tc.expected { + t.Fatalf("branch=%q name=%q expected=%q got=%q", tc.branch, tc.name, tc.expected, actual) + } + } +} + +func TestSanitizeChannelName_TruncatesAt100(t *testing.T) { + long := "26-2-" + string(make([]byte, 200)) + result := sanitizeChannelName(long) + if len(result) > 100 { + t.Fatalf("expected max 100 chars, got %d", len(result)) } } diff --git a/bot/sync_commands.go b/bot/sync_commands.go new file mode 100644 index 0000000..0d77205 --- /dev/null +++ b/bot/sync_commands.go @@ -0,0 +1,37 @@ +package bot + +import ( + "fmt" + "log/slog" + + "github.com/bwmarrin/discordgo" +) + +func syncCommands(s *discordgo.Session, appID, guildID string) error { + desired := make(map[string]*discordgo.ApplicationCommand, len(commands)) + for _, cmd := range commands { + desired[cmd.Name] = cmd + } + + registered, err := s.ApplicationCommands(appID, guildID) + if err != nil { + return fmt.Errorf("fetch registered commands: %w", err) + } + + for _, cmd := range registered { + if _, ok := desired[cmd.Name]; !ok { + slog.Info("deleting stale command", "command", cmd.Name) + if err := s.ApplicationCommandDelete(appID, guildID, cmd.ID); err != nil { + return fmt.Errorf("delete stale command %q: %w", cmd.Name, err) + } + } + } + + for _, cmd := range commands { + if _, err := s.ApplicationCommandCreate(appID, guildID, cmd); err != nil { + return fmt.Errorf("register command %q: %w", cmd.Name, err) + } + } + + return nil +} diff --git a/db/command_audit_repository.go b/db/command_audit_repository.go new file mode 100644 index 0000000..25e1d69 --- /dev/null +++ b/db/command_audit_repository.go @@ -0,0 +1,62 @@ +package db + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type CommandAuditRepository struct { + pool *pgxpool.Pool +} + +func NewCommandAuditRepository(pool *pgxpool.Pool) *CommandAuditRepository { + return &CommandAuditRepository{pool: pool} +} + +func (r *CommandAuditRepository) RecordTriggered( + ctx context.Context, + interactionID, commandName, actorUserID, guildID, channelID, optionsJSON string, +) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO command_audit_logs ( + interaction_id, command_name, actor_user_id, guild_id, channel_id, options_json, status + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'triggered') + ON CONFLICT (interaction_id) DO NOTHING`, + interactionID, commandName, actorUserID, guildID, channelID, optionsJSON, + ) + if err != nil { + return fmt.Errorf("record command audit trigger: %w", err) + } + return nil +} + +func (r *CommandAuditRepository) RecordSuccess(ctx context.Context, interactionID string) error { + _, err := r.pool.Exec(ctx, + `UPDATE command_audit_logs + SET status = 'success', completed_at = NOW() + WHERE interaction_id = $1 AND status = 'triggered'`, + interactionID, + ) + if err != nil { + return fmt.Errorf("record command audit success: %w", err) + } + return nil +} + +func (r *CommandAuditRepository) RecordError(ctx context.Context, interactionID, errorMessage string) error { + _, err := r.pool.Exec(ctx, + `UPDATE command_audit_logs + SET status = 'error', + completed_at = COALESCE(completed_at, NOW()), + error_message = COALESCE(error_message, $2) + WHERE interaction_id = $1 AND status = 'triggered'`, + interactionID, errorMessage, + ) + if err != nil { + return fmt.Errorf("record command audit error: %w", err) + } + return nil +} diff --git a/db/doc.go b/db/doc.go new file mode 100644 index 0000000..d490058 --- /dev/null +++ b/db/doc.go @@ -0,0 +1,2 @@ +// Package db provides database setup and repository implementations. +package db diff --git a/db/member_repository.go b/db/member_repository.go index 2b163e1..3257c46 100644 --- a/db/member_repository.go +++ b/db/member_repository.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/jackc/pgx/v5/pgxpool" + "livid-bot/study" ) type MemberRepository struct { @@ -29,6 +30,28 @@ func (r *MemberRepository) AddMember(ctx context.Context, studyID int64, userID, return nil } +func (r *MemberRepository) FindActiveByStudyID(ctx context.Context, studyID int64) ([]study.StudyMember, error) { + rows, err := r.pool.Query(ctx, + `SELECT study_id, user_id, username, joined_at + FROM study_members + WHERE study_id = $1 AND left_at IS NULL + ORDER BY joined_at`, studyID) + if err != nil { + return nil, fmt.Errorf("find active members by study: %w", err) + } + defer rows.Close() + + var members []study.StudyMember + for rows.Next() { + var m study.StudyMember + if err := rows.Scan(&m.StudyID, &m.UserID, &m.Username, &m.JoinedAt); err != nil { + return nil, fmt.Errorf("scan member: %w", err) + } + members = append(members, m) + } + return members, rows.Err() +} + func (r *MemberRepository) RemoveMember(ctx context.Context, studyID int64, userID string) error { _, err := r.pool.Exec(ctx, `UPDATE study_members SET left_at = NOW() diff --git a/db/migrate.go b/db/migrate.go index ef4e6a7..43713fe 100644 --- a/db/migrate.go +++ b/db/migrate.go @@ -4,7 +4,7 @@ import ( "context" "embed" "fmt" - "log" + "log/slog" "github.com/jackc/pgx/v5/pgxpool" ) @@ -13,6 +13,14 @@ import ( var migrationFS embed.FS func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + if _, err := pool.Exec(ctx, + `CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); err != nil { + return fmt.Errorf("create schema_migrations table: %w", err) + } + entries, err := migrationFS.ReadDir("migrations") if err != nil { return fmt.Errorf("read migration directory: %w", err) @@ -23,15 +31,46 @@ func Migrate(ctx context.Context, pool *pgxpool.Pool) error { continue } + var count int + if err := pool.QueryRow(ctx, + `SELECT COUNT(*) FROM schema_migrations WHERE filename = $1`, + entry.Name()).Scan(&count); err != nil { + return fmt.Errorf("check migration %s: %w", entry.Name(), err) + } + if count > 0 { + slog.Info("skipping migration because already applied", "filename", entry.Name()) + continue + } + sql, err := migrationFS.ReadFile("migrations/" + entry.Name()) if err != nil { return fmt.Errorf("read migration %s: %w", entry.Name(), err) } - log.Printf("Running migration: %s", entry.Name()) - if _, err := pool.Exec(ctx, string(sql)); err != nil { + slog.Info("running migration", "filename", entry.Name()) + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin transaction for migration %s: %w", entry.Name(), err) + } + + if _, err := tx.Exec(ctx, string(sql)); err != nil { + if rbErr := tx.Rollback(ctx); rbErr != nil { + return fmt.Errorf("rollback migration %s: %w", entry.Name(), rbErr) + } return fmt.Errorf("execute migration %s: %w", entry.Name(), err) } + + if _, err := tx.Exec(ctx, + `INSERT INTO schema_migrations (filename) VALUES ($1)`, entry.Name()); err != nil { + if rbErr := tx.Rollback(ctx); rbErr != nil { + return fmt.Errorf("rollback migration %s: %w", entry.Name(), rbErr) + } + return fmt.Errorf("record migration %s: %w", entry.Name(), err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit migration %s: %w", entry.Name(), err) + } } return nil diff --git a/db/migrations/003_add_closed_at_to_recruit_messages.sql b/db/migrations/003_add_closed_at_to_recruit_messages.sql new file mode 100644 index 0000000..6c30b1f --- /dev/null +++ b/db/migrations/003_add_closed_at_to_recruit_messages.sql @@ -0,0 +1,2 @@ +ALTER TABLE recruit_messages ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_recruit_messages_open ON recruit_messages (closed_at) WHERE closed_at IS NULL; diff --git a/db/migrations/004_create_command_audit_logs.sql b/db/migrations/004_create_command_audit_logs.sql new file mode 100644 index 0000000..4e70537 --- /dev/null +++ b/db/migrations/004_create_command_audit_logs.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS command_audit_logs ( + interaction_id TEXT PRIMARY KEY, + command_name TEXT NOT NULL, + actor_user_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + options_json JSONB NOT NULL DEFAULT '[]'::jsonb, + status TEXT NOT NULL CHECK (status IN ('triggered', 'success', 'error')), + error_message TEXT, + triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_command_audit_logs_triggered_at + ON command_audit_logs (triggered_at DESC); + +CREATE INDEX IF NOT EXISTS idx_command_audit_logs_command_name_triggered_at + ON command_audit_logs (command_name, triggered_at DESC); + +CREATE INDEX IF NOT EXISTS idx_command_audit_logs_actor_user_id_triggered_at + ON command_audit_logs (actor_user_id, triggered_at DESC); diff --git a/db/recruit_repository.go b/db/recruit_repository.go index 4fda13c..6aeb850 100644 --- a/db/recruit_repository.go +++ b/db/recruit_repository.go @@ -21,7 +21,9 @@ func (r *RecruitRepository) SaveRecruitMessage(ctx context.Context, messageID, c if err != nil { return fmt.Errorf("begin transaction: %w", err) } - defer tx.Rollback(ctx) + defer func() { + _ = tx.Rollback(ctx) + }() var recruitID int64 err = tx.QueryRow(ctx, @@ -61,7 +63,7 @@ func (r *RecruitRepository) LoadAllMappings(ctx context.Context) ([]EmojiRoleMap FROM recruit_message_mappings rmm JOIN recruit_messages rm ON rm.id = rmm.recruit_message_id JOIN studies s ON s.id = rmm.study_id - WHERE s.status = 'active'`) + WHERE s.status = 'active' AND rm.closed_at IS NULL`) if err != nil { return nil, fmt.Errorf("load mappings: %w", err) } @@ -77,3 +79,62 @@ func (r *RecruitRepository) LoadAllMappings(ctx context.Context) ([]EmojiRoleMap } return mappings, rows.Err() } + +type RecruitStudyInfo struct { + StudyID int64 + StudyName string + ChannelID string + RoleID string +} + +func (r *RecruitRepository) FindOpenMappingsByBranch(ctx context.Context, branch string) ([]string, []RecruitStudyInfo, error) { + rows, err := r.pool.Query(ctx, + `SELECT DISTINCT rm.message_id, s.id, s.name, s.channel_id, s.role_id + FROM recruit_messages rm + JOIN recruit_message_mappings rmm ON rm.id = rmm.recruit_message_id + JOIN studies s ON s.id = rmm.study_id + WHERE s.branch = $1 AND s.status = 'active' AND rm.closed_at IS NULL + ORDER BY s.id`, branch) + if err != nil { + return nil, nil, fmt.Errorf("find open mappings by branch: %w", err) + } + defer rows.Close() + + messageIDSet := make(map[string]struct{}) + var messageIDs []string + studyIDSet := make(map[int64]struct{}) + var studies []RecruitStudyInfo + + for rows.Next() { + var msgID string + var info RecruitStudyInfo + if err := rows.Scan(&msgID, &info.StudyID, &info.StudyName, &info.ChannelID, &info.RoleID); err != nil { + return nil, nil, fmt.Errorf("scan open mapping: %w", err) + } + if _, exists := messageIDSet[msgID]; !exists { + messageIDSet[msgID] = struct{}{} + messageIDs = append(messageIDs, msgID) + } + if _, exists := studyIDSet[info.StudyID]; !exists { + studyIDSet[info.StudyID] = struct{}{} + studies = append(studies, info) + } + } + return messageIDs, studies, rows.Err() +} + +func (r *RecruitRepository) CloseByBranch(ctx context.Context, branch string) (int64, error) { + tag, err := r.pool.Exec(ctx, + `UPDATE recruit_messages SET closed_at = NOW() + WHERE closed_at IS NULL AND id IN ( + SELECT DISTINCT rm.id + FROM recruit_messages rm + JOIN recruit_message_mappings rmm ON rm.id = rmm.recruit_message_id + JOIN studies s ON s.id = rmm.study_id + WHERE s.branch = $1 AND s.status = 'active' + )`, branch) + if err != nil { + return 0, fmt.Errorf("close recruit messages by branch: %w", err) + } + return tag.RowsAffected(), nil +} diff --git a/db/study_repository.go b/db/study_repository.go index 22a206e..96ef558 100644 --- a/db/study_repository.go +++ b/db/study_repository.go @@ -70,6 +70,32 @@ func (r *StudyRepository) FindAllActiveByBranch(ctx context.Context, branch stri return studies, rows.Err() } +func (r *StudyRepository) FindByFilters(ctx context.Context, branch, status string) ([]study.Study, error) { + if status == "" { + return nil, fmt.Errorf("find studies by filters: status is required") + } + + rows, err := r.pool.Query(ctx, + `SELECT id, branch, name, description, channel_id, role_id, created_at, status + FROM studies + WHERE status = $1 AND ($2 = '' OR branch = $2) + ORDER BY id`, status, branch) + if err != nil { + return nil, fmt.Errorf("find studies by filters: %w", err) + } + defer rows.Close() + + var studies []study.Study + for rows.Next() { + var s study.Study + if err := rows.Scan(&s.ID, &s.Branch, &s.Name, &s.Description, &s.ChannelID, &s.RoleID, &s.CreatedAt, &s.Status); err != nil { + return nil, fmt.Errorf("scan study: %w", err) + } + studies = append(studies, s) + } + return studies, rows.Err() +} + func (r *StudyRepository) FindDistinctActiveBranches(ctx context.Context) ([]string, error) { rows, err := r.pool.Query(ctx, `SELECT DISTINCT branch diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..f3f51dc --- /dev/null +++ b/doc.go @@ -0,0 +1,2 @@ +// Package main runs the livid-bot Discord application. +package main diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..c3a4bba --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,65 @@ +package logging + +import ( + "io" + "log/slog" + "os" + "strings" +) + +const ( + formatText = "text" + formatJSON = "json" +) + +type Config struct { + Format string + Level slog.Level +} + +func Configure() *slog.Logger { + cfg := configFromEnv(os.Getenv) + logger := New(cfg, os.Stdout) + slog.SetDefault(logger) + return logger +} + +func New(cfg Config, output io.Writer) *slog.Logger { + opts := &slog.HandlerOptions{Level: cfg.Level} + + switch cfg.Format { + case formatJSON: + return slog.New(slog.NewJSONHandler(output, opts)) + default: + return slog.New(slog.NewTextHandler(output, opts)) + } +} + +func configFromEnv(getEnv func(string) string) Config { + return Config{ + Format: parseFormat(getEnv("LOG_FORMAT")), + Level: parseLevel(getEnv("LOG_LEVEL")), + } +} + +func parseFormat(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case formatJSON: + return formatJSON + default: + return formatText + } +} + +func parseLevel(value string) slog.Level { + switch strings.ToLower(strings.TrimSpace(value)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go new file mode 100644 index 0000000..084b90d --- /dev/null +++ b/internal/logging/logger_test.go @@ -0,0 +1,74 @@ +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" + "testing" +) + +func TestConfigFromEnvDefaults(t *testing.T) { + cfg := configFromEnv(func(string) string { return "" }) + + if cfg.Format != formatText { + t.Fatalf("expected default format %q, got %q", formatText, cfg.Format) + } + if cfg.Level != slog.LevelInfo { + t.Fatalf("expected default level info, got %v", cfg.Level) + } +} + +func TestConfigFromEnvCustom(t *testing.T) { + cfg := configFromEnv(func(key string) string { + switch key { + case "LOG_FORMAT": + return "json" + case "LOG_LEVEL": + return "debug" + default: + return "" + } + }) + + if cfg.Format != formatJSON { + t.Fatalf("expected format %q, got %q", formatJSON, cfg.Format) + } + if cfg.Level != slog.LevelDebug { + t.Fatalf("expected level debug, got %v", cfg.Level) + } +} + +func TestConfigFromEnvInvalidFallback(t *testing.T) { + cfg := configFromEnv(func(key string) string { + switch key { + case "LOG_FORMAT": + return "xml" + case "LOG_LEVEL": + return "trace" + default: + return "" + } + }) + + if cfg.Format != formatText { + t.Fatalf("expected invalid format fallback to %q, got %q", formatText, cfg.Format) + } + if cfg.Level != slog.LevelInfo { + t.Fatalf("expected invalid level fallback to info, got %v", cfg.Level) + } +} + +func TestNewLoggerFormatAndLevel(t *testing.T) { + logger := New(Config{Format: formatJSON, Level: slog.LevelWarn}, io.Discard) + + if got := fmt.Sprintf("%T", logger.Handler()); got != "*slog.JSONHandler" { + t.Fatalf("expected JSON handler, got %s", got) + } + if logger.Enabled(context.Background(), slog.LevelInfo) { + t.Fatalf("expected info level to be disabled for warn logger") + } + if !logger.Enabled(context.Background(), slog.LevelWarn) { + t.Fatalf("expected warn level to be enabled") + } +} diff --git a/main.go b/main.go index e0fc9cf..d3e3da3 100644 --- a/main.go +++ b/main.go @@ -2,29 +2,34 @@ package main import ( "context" - "log" + "log/slog" "os" "livid-bot/bot" "livid-bot/db" + "livid-bot/internal/logging" ) func main() { - token := os.Getenv("DISCORD_BOT_TOKEN") - appID := os.Getenv("DISCORD_APPLICATION_ID") - guildID := os.Getenv("DISCORD_GUILD_ID") - databaseURL := os.Getenv("DATABASE_URL") + logging.Configure() + + token := requireEnv("DISCORD_BOT_TOKEN") + appID := requireEnv("DISCORD_APPLICATION_ID") + guildID := requireEnv("DISCORD_GUILD_ID") + databaseURL := requireEnv("DATABASE_URL") ctx := context.Background() pool, err := db.NewPool(ctx, databaseURL) if err != nil { - log.Fatalf("Failed to connect to database: %v", err) + slog.Error("failed to connect to database", "error", err) + os.Exit(1) } defer pool.Close() if err := db.Migrate(ctx, pool); err != nil { - log.Fatalf("Failed to run migrations: %v", err) + slog.Error("failed to run migrations", "error", err) + os.Exit(1) } cfg := bot.Config{ @@ -34,7 +39,20 @@ func main() { StudyRepo: db.NewStudyRepository(pool), MemberRepo: db.NewMemberRepository(pool), RecruitRepo: db.NewRecruitRepository(pool), + AuditRepo: db.NewCommandAuditRepository(pool), } - bot.Run(cfg) + if err := bot.Run(cfg); err != nil { + slog.Error("bot exited with error", "error", err) + os.Exit(1) + } +} + +func requireEnv(key string) string { + value := os.Getenv(key) + if value == "" { + slog.Error("required environment variable is not set", "key", key) + os.Exit(1) + } + return value } diff --git a/mise.toml b/mise.toml index ac113ab..7676d45 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,6 @@ [tools] go = "1.26" +golangci-lint = "latest" [env] _.file = ".env" diff --git a/study/doc.go b/study/doc.go new file mode 100644 index 0000000..3115e05 --- /dev/null +++ b/study/doc.go @@ -0,0 +1,2 @@ +// Package study defines domain types used by study workflows. +package study