Discord /stats 슬래시커맨드로 대시보드 지표 대화형 조회 - #671
Conversation
- DiscordAccessController를 공통 게이트(서명·PING·채널·allowlist) + data.name 라우팅으로 분해 - piki-admin 로직을 AdminGrantCommandHandler로 이동, StatsCommandHandler 신규 추가 - /stats period·metric 옵션 → MetricsService.snapshot 재사용 → ephemeral embed (LLM 없음, 개발진 제외 기본) - 순수 단위 TDD: StatsEmbed·StatsMetric·StatsPeriod·DiscordInteractions
- workflow_dispatch 로 Actions 버튼 실행, secrets.DISCORD_BOT_TOKEN 사용(로컬 curl 불필요) - 길드 스코프 POST upsert, command 입력으로 all/piki-admin/stats 선택 - HTTP 200/201 확인 + 실패 시 응답 본문과 함께 에러
|
Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다. |
|
Warning Review limit reached
Next review available in: 45 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughDiscord 슬래시커맨드 처리를 위한 공통 인터랙션 헬퍼(DiscordInteractions)와 DiscordCommandHandler 인터페이스가 추가되고, DiscordAccessController가 핸들러 라우팅 구조로 재구성됐다. /piki-admin(AdminGrantCommandHandler)과 /stats(StatsCommandHandler, StatsPeriod, StatsMetric, StatsEmbed) 커맨드가 신설되고, 커맨드 등록용 GitHub Actions 워크플로와 단위 테스트가 함께 추가됐다. ChangesDiscord 슬래시커맨드 인프라 및 핸들러
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Discord
participant DiscordAccessController
participant DiscordInteractions
participant DiscordCommandHandler
participant MetricsService
Discord->>DiscordAccessController: POST /admin-access/discord (인터랙션)
DiscordAccessController->>DiscordAccessController: Ed25519 서명 검증
DiscordAccessController->>DiscordAccessController: admin 채널 검증
DiscordAccessController->>DiscordInteractions: userId/userName 추출
DiscordAccessController->>DiscordAccessController: allowlist 검사
DiscordAccessController->>DiscordCommandHandler: handle(DiscordInteraction)
alt /stats 커맨드
DiscordCommandHandler->>MetricsService: resolveRange(), snapshot()
MetricsService-->>DiscordCommandHandler: MetricsSnapshot
end
DiscordCommandHandler-->>DiscordAccessController: embed 응답 Map
DiscordAccessController-->>Discord: embed 응답
Assessment against linked issues
Estimated code review effort: 3 (Moderate) | ~25 minutes 한 마디 짧고 위트있게: PING엔 즉답, allowlist는 철벽, 예외처리로 404까지 깔끔! 다만 Gemini는 다음 라운드를 위해 남겨두신 센스, 좋습니다 🎯 🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (6)
src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt (1)
8-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winallowlist 게이트 핵심 입력값인
userId/userName의 "없음" 폴백 케이스 테스트가 빠져있습니다.
DiscordAccessController는DiscordInteractions.userId(root)결과로discordAdminUserIds포함 여부를 체크합니다(allowlist 게이트).member.user.id가 없는 페이로드(예: DM 인터랙션 등 예상 밖 payload)가 들어오면userId()가 빈 문자열을 반환하는데, 이 fail-safe 동작이 테스트로 고정되어 있지 않습니다. 또한userName()의 최종 폴백값"unknown",pong()/embed()의 응답 구조(type, flags, embeds)도 검증 대상에서 빠져 있습니다.보안 게이트에 직결되는 순수 함수인 만큼 아래와 같은 케이스를 추가하면 회귀를 더 확실히 잡을 수 있습니다.
`@Test` fun `member user 정보가 없으면 userId 는 빈 문자열이다`() { val root = mapper.readTree("""{"data":{"name":"stats"}}""") assertEquals("", DiscordInteractions.userId(root)) assertEquals("unknown", DiscordInteractions.userName(root)) } `@Test` fun `embed 응답은 ephemeral flag 를 포함한다`() { val res = DiscordInteractions.embed(DiscordInteractions.COLOR_RED, "t", "d") assertEquals(DiscordInteractions.FLAG_EPHEMERAL, (res["data"] as Map<*, *>)["flags"]) }참고로 관련 경로 지침에 따르면 "핵심 비즈니스 규칙, 예외 케이스, 경계값 검증이 충분한지" 확인이 필요합니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt` around lines 8 - 37, `DiscordInteractionsTest` is missing coverage for fail-safe fallbacks that the allowlist gate depends on. Add tests around `DiscordInteractions.userId()` and `DiscordInteractions.userName()` to verify missing `member.user.id` returns an empty string and missing name fields fall back to `"unknown"`, and also add response-shape assertions for `pong()` and `embed()` (including `type`, `flags`, and `embeds`) so the behavior stays fixed.Source: Path instructions
src/main/kotlin/com/depromeet/piki/admin/access/DiscordInteractions.kt (1)
18-31: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value옵션 값이 문자열이 아니면 조용히 빈 문자열로 처리됩니다.
optionValue는value가 문자열(isString)일 때만 값을 반환하고, 그 외 타입(숫자/불리언 등)이면 빈 문자열을 돌려줍니다. 지금 스택의/statsperiod/metric은 드롭다운 STRING 옵션이라 문제없지만, 이후 다른 커맨드에서 INTEGER/BOOLEAN 옵션을 추가하면 이 헬퍼를 그대로 재사용하다가 값이 계속 빈 문자열로 나와서 원인 파악에 시간이 걸릴 수 있어요. 주석(17번 줄)에 "문자열 전용"이라는 점을 명시해두면 다음에 재사용할 팀원이 헷갈리지 않을 것 같습니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/kotlin/com/depromeet/piki/admin/access/DiscordInteractions.kt` around lines 18 - 31, `DiscordInteractions.optionValue` currently only returns a value when the option `value` is a string, which can silently hide non-string option types. Update the helper or its documentation to explicitly indicate it is string-only, and add a clear note near `optionValue` (and its `root`/`name` parameters) that INTEGER/BOOLEAN options will be returned as empty string unless a typed extractor is introduced. This will make reuse of the helper in future commands less error-prone.src/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.kt (2)
28-28: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win만료 안내 문구가
grantTokenTtl설정값과 분리되어 있어요.메시지에 "3분 내"가 하드코딩돼 있는데, 실제 만료 시간은
AdminProperties.grantTokenTtl(기본 3분이지만 설정으로 조정 가능)에서 옵니다. 운영 중 이 값을 5분으로 늘리면 안내 문구는 여전히 "3분"을 보여줘서 사용자가 실제보다 일찍 링크가 만료됐다고 오해하거나, 반대로 만료된 링크를 계속 유효하다고 믿고 재시도하는 혼선이 생길 수 있습니다.
adminProperties.grantTokenTtl을 분 단위로 변환해서 메시지에 넣어주면 설정 변경에도 항상 일치하는 안내를 줄 수 있어요.🔧 제안 diff
+ val ttlMinutes = adminProperties.grantTokenTtl.toMinutes() return DiscordInteractions.embed( DiscordInteractions.COLOR_GREEN, "✅ 관리자 인증됨 — ${interaction.userName}", - "**$env** 접속: 이 기기에서 3분 내 아래 링크를 여세요 (그 기기 IP 가 등록됩니다).\n$link", + "**$env** 접속: 이 기기에서 ${ttlMinutes}분 내 아래 링크를 여세요 (그 기기 IP 가 등록됩니다).\n$link", )As per path instructions, "운영 리스크 — 민감 로그, 외부 API 타임아웃/재시도, 설정 영향, null"을 우선 검토해야 합니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.kt` at line 28, The expiration 안내 in AdminGrantCommandHandler is hardcoded to “3분” and should be tied to the actual grant token TTL. Update the message building logic in AdminGrantCommandHandler to use adminProperties.grantTokenTtl converted to minutes, so the displayed text always matches the configured expiration time. Keep the existing link/message structure intact and make the TTL value the single source of truth for the 안내 문구.Source: Path instructions
18-24: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win토큰 "발급" 시점의 감사 로그가 빠져 있어요.
DiscordAccessController.grant()(같은 코호트, 이미 병합된 코드)는 토큰 소비 성공/실패를AdminAuditAction.ACCESS_GRANTED/ACCESS_DENIED로 남기는데, 정작 이 핸들러에서 토큰을 발급하는 순간에는 아무 기록이 없습니다. 누가, 언제, 어떤 env로 grant 링크를 요청했는지는 추적할 수 없고, 실제로 클릭해서 소비할 때만 흔적이 남는 구조예요.허용된 관리자만 도달 가능한 경로라 당장 보안 사고로 이어지진 않지만, "발급됐지만 아직 안 쓴 링크가 몇 개인지", "특정 계정이 비정상적으로 자주 링크를 요청하는지" 같은 사후 추적이 불가능해집니다.
AdminAuditService를 주입받아 발급 이벤트도 남겨두면 좋겠습니다.class AdminGrantCommandHandler( private val allowlistService: AdminAllowlistService, private val adminProperties: AdminProperties, private val auditService: AdminAuditService, ) : DiscordCommandHandler { ... val token = allowlistService.issueGrantToken(interaction.userId, interaction.userName, env) auditService.record(interaction.userName, AdminAuditAction.GRANT_TOKEN_ISSUED, "env=$env grant 토큰 발급", interaction.clientIp)(
AdminAuditAction에GRANT_TOKEN_ISSUED같은 값 추가가 필요할 수 있습니다.)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.kt` around lines 18 - 24, The token issuance path in AdminGrantCommandHandler.handle() is missing an audit record, so add logging for the moment a grant link is created. Inject AdminAuditService into AdminGrantCommandHandler, and after allowlistService.issueGrantToken(...) record an audit event with the requester identity, env, and client IP using a new AdminAuditAction such as GRANT_TOKEN_ISSUED. Keep the existing grant link generation flow unchanged and ensure the audit message clearly indicates token issuance rather than token consumption..github/workflows/discord-register-commands.yml (2)
36-50: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick wincurl 타임아웃 미설정 — 네트워크 이슈 시 워크플로가 무기한 대기
curl에--max-time/--connect-timeout이 없어 Discord API가 응답을 지연하거나 네트워크 파티션이 발생하면 job이 기본 타임아웃(6시간)까지 붙잡혀 있게 됩니다. 수동 실행 워크플로라 자주 발생하진 않겠지만, 외부 API 호출에는 타임아웃을 거는 게 안전합니다.🔧 제안 수정
code=$(curl -s -o /tmp/resp.json -w '%{http_code}' \ + --connect-timeout 5 --max-time 15 \ -X POST "https://discord.com/api/v10/applications/$APP_ID/guilds/$GUILD_ID/commands" \🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/discord-register-commands.yml around lines 36 - 50, The curl call inside register() has no timeout, so the workflow can hang on Discord API/network issues; update the curl invocation to include both a connection timeout and a total max time. Keep the existing success/error handling in register(), but make sure the request fails fast instead of waiting indefinitely.Source: Path instructions
24-27: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win
permissions블록 없음 — 최소 권한 원칙 미준수이 워크플로는 GITHUB_TOKEN을 전혀 사용하지 않는데도(Discord API 호출만 함) 명시적
permissions가 없어 리포지토리 기본 설정(경우에 따라 read-write)이 그대로 적용됩니다. 불필요한 권한을 명시적으로 차단해주세요.🔧 제안 수정
+permissions: + contents: read + jobs: register: runs-on: ubuntu-latestAs per path instructions,
.github/workflows/**파일은 "permissions 가 최소 권한 원칙을 지키는지" 검토가 필요합니다.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/discord-register-commands.yml around lines 24 - 27, 워크플로의 register job에는 GITHUB_TOKEN을 쓰지 않으므로 기본 권한을 그대로 두지 말고 최소 권한을 명시적으로 차단하세요. .github/workflows/discord-register-commands.yml의 register job 정의에 permissions 블록을 추가하고, 해당 job이 필요한 권한만 남기거나 모두 비활성화되도록 설정하세요. jobs.register 아래에 두면 됩니다.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt`:
- Around line 86-91: The Discord route in DiscordAccessController.discord()
calls handler.handle(...) without any exception handling, so failures from
handlers like StatsCommandHandler can escape and break the interaction response.
Wrap the handler invocation in a try-catch around the existing handler lookup
and execution, log the exception with the command name using
DiscordInteractions.commandName(root), and return a red ephemeral error embed
from DiscordInteractions.embed so users get a failure response instead of a
silent 500.
- Around line 38-40: `DiscordAccessController`에서 `handlers.associateBy {
it.commandName }`가 중복 `commandName`을 조용히 덮어쓰는 문제가 있으니, 생성자 초기화 시점에 `handlers`를
검사해 중복을 발견하면 즉시 실패하도록 수정하세요. `DiscordAccessController`의 `init` 블록(또는
`handlersByName` 생성 전)에서 `commandName`을 기준으로 중복을 찾아 `require`로 예외를 던지면, 배포 후 잘못된
매핑을 방지할 수 있습니다.
In `@src/main/kotlin/com/depromeet/piki/admin/access/StatsCommandHandler.kt`:
- Around line 17-25: The StatsCommandHandler.handle flow currently calls
metricsService.snapshot without any exception handling, so failures can escape
to DiscordAccessController.discord and bypass the normal ephemeral error
response path. Wrap the snapshot/relevant range resolution logic in try-catch
inside StatsCommandHandler.handle, and on failure return the same style of
ephemeral error embed used for other access/command failures so the user sees a
consistent “❌” response instead of a propagated exception.
---
Nitpick comments:
In @.github/workflows/discord-register-commands.yml:
- Around line 36-50: The curl call inside register() has no timeout, so the
workflow can hang on Discord API/network issues; update the curl invocation to
include both a connection timeout and a total max time. Keep the existing
success/error handling in register(), but make sure the request fails fast
instead of waiting indefinitely.
- Around line 24-27: 워크플로의 register job에는 GITHUB_TOKEN을 쓰지 않으므로 기본 권한을 그대로 두지 말고
최소 권한을 명시적으로 차단하세요. .github/workflows/discord-register-commands.yml의 register
job 정의에 permissions 블록을 추가하고, 해당 job이 필요한 권한만 남기거나 모두 비활성화되도록 설정하세요.
jobs.register 아래에 두면 됩니다.
In `@src/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.kt`:
- Line 28: The expiration 안내 in AdminGrantCommandHandler is hardcoded to “3분”
and should be tied to the actual grant token TTL. Update the message building
logic in AdminGrantCommandHandler to use adminProperties.grantTokenTtl converted
to minutes, so the displayed text always matches the configured expiration time.
Keep the existing link/message structure intact and make the TTL value the
single source of truth for the 안내 문구.
- Around line 18-24: The token issuance path in
AdminGrantCommandHandler.handle() is missing an audit record, so add logging for
the moment a grant link is created. Inject AdminAuditService into
AdminGrantCommandHandler, and after allowlistService.issueGrantToken(...) record
an audit event with the requester identity, env, and client IP using a new
AdminAuditAction such as GRANT_TOKEN_ISSUED. Keep the existing grant link
generation flow unchanged and ensure the audit message clearly indicates token
issuance rather than token consumption.
In `@src/main/kotlin/com/depromeet/piki/admin/access/DiscordInteractions.kt`:
- Around line 18-31: `DiscordInteractions.optionValue` currently only returns a
value when the option `value` is a string, which can silently hide non-string
option types. Update the helper or its documentation to explicitly indicate it
is string-only, and add a clear note near `optionValue` (and its `root`/`name`
parameters) that INTEGER/BOOLEAN options will be returned as empty string unless
a typed extractor is introduced. This will make reuse of the helper in future
commands less error-prone.
In `@src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt`:
- Around line 8-37: `DiscordInteractionsTest` is missing coverage for fail-safe
fallbacks that the allowlist gate depends on. Add tests around
`DiscordInteractions.userId()` and `DiscordInteractions.userName()` to verify
missing `member.user.id` returns an empty string and missing name fields fall
back to `"unknown"`, and also add response-shape assertions for `pong()` and
`embed()` (including `type`, `flags`, and `embeds`) so the behavior stays fixed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: 8076a20a-2db6-4481-a0c5-0cce2c2be17b
📒 Files selected for processing (10)
.github/workflows/discord-register-commands.ymlsrc/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.ktsrc/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.ktsrc/main/kotlin/com/depromeet/piki/admin/access/DiscordInteractions.ktsrc/main/kotlin/com/depromeet/piki/admin/access/StatsCommandHandler.ktsrc/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.ktsrc/main/kotlin/com/depromeet/piki/admin/access/StatsPeriod.ktsrc/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.ktsrc/test/kotlin/com/depromeet/piki/admin/access/StatsEmbedTest.ktsrc/test/kotlin/com/depromeet/piki/admin/access/StatsOptionTest.kt
- handler.handle 예외를 runCatching 으로 감싸 에러 embed 로 응답 (인터랙션 3초 타임아웃·상호작용 실패 방지) - 커맨드명 중복 시 init require 로 부팅 실패 (associateBy 의 조용한 덮어쓰기 차단) - DiscordInteractions userId/userName 폴백·embed·pong 응답 구조 단위테스트 추가
CodeRabbit 리뷰 대응 정리Actionable (3, 전부 accept + resolve)
Nitpick (6)
|
Situation
Task
Action
설계 결정
period(오늘/어제/최근 7일/최근 30일)·metric(요약/가입/위시/토너먼트/푸시) 드롭다운 choices 로 조회한다. period 는MetricsService의 preset(today/yesterday/7d/30d)에 1:1 로 맞춰 집계 코드를 그대로 재사용한다./admin-access/discord컨트롤러를 공통 게이트(서명 검증, PING, 채널, allowlist) +data.name라우팅으로 분해했다. 기존piki-admin로직은 별도 핸들러로 옮겨 회귀 없이 보존한다.piki-adminstatsMetricsService.snapshot()조회 후 ephemeral embed구현
DiscordInteractions(옵션·유저·응답 조립),DiscordCommandHandler·DiscordInteraction(라우팅 계약).AdminGrantCommandHandler(piki-admin),StatsCommandHandler(stats,excludeInternal=true개발진 제외 기본).StatsEmbed(metric 섹션별 embed),StatsMetric·StatsPeriod(옵션 파싱, 누락·미지원은 안전 기본값).등록 방식
discord-register-commands.yml워크플로 추가 —workflow_dispatch로secrets.DISCORD_BOT_TOKEN을 러너 안에서만 써서 커맨드를 등록한다. 로컬에서 봇 토큰을 다룰 필요가 없다.테스트
StatsEmbedTest)·옵션 파싱(StatsOptionTest)·인터랙션 파싱(DiscordInteractionsTest). 집계 자체는 기존MetricsDashboardIntegrationTest가 통합으로 커버해 중복하지 않는다.Result
/stats period:.. metric:..로 admin 채널에서 지표를 ephemeral 카드로 즉시 조회할 수 있다./stats는 인터랙션을 받는 prod 서버의 DB 만 조회한다(Interactions URL 이 prod 하나). dev/staging 통계까지 필요하면 env 옵션과 env별 조회 경로 설계가 필요해 후속으로 남긴다.admin.enabled=false인 test 컨텍스트라/admin-access/discord엔드포인트 통합테스트는 이 범위 밖이다(전역 컨텍스트 영향). admin 통합테스트 인프라는 별도 이슈 후보.연관 이슈
Summary by CodeRabbit
/piki-admin명령으로 환경별 관리자 인증 링크를 발급할 수 있게 되었습니다./stats명령으로 기간과 지표를 선택해 통계 요약을 바로 확인할 수 있습니다.