콘텐츠로 이동

Bot Installation

사용자가 Umbra Bot 을 Discord 길드에 처음 설치하는 흐름. Guild 등록, Free License 자동 생성, AntiNuke workflow 시작(Pro+), 환영 DM 까지의 end-to-end.

Scenario

길드 관리자가 Discord 의 "앱 추가" UI 또는 Umbra 웹사이트의 "Add to Discord" 버튼으로 봇을 길드에 초대한다. Discord 가 GUILD_CREATE 이벤트를 Gateway 로 전송하고, Umbra 가 이를 감지해 길드 등록과 초기 설정을 수행한다.

결과: Guild row 생성, Free License 부여, Guild 의 관리자가 Umbra 웹 대시보드에서 확인 가능한 상태.

Actors

  • Guild Admin — 봇을 초대하는 사용자 (Discord 의 MANAGE_GUILD 권한 보유)
  • Discord GatewayGUILD_CREATE 이벤트 전송
  • umbra-bot — Gateway 이벤트 수신 및 1차 처리
  • Guild domain — 길드 등록
  • Licensing domain — Free License 자동 생성
  • Recovery (AntiNuke) — Plan 에 따라 workflow 시작
  • Notification domain — 환영 메시지
  • Audit domain — 이벤트 기록

Preconditions

  • Umbra Bot 이 Discord Developer Portal 에 등록되어 있음
  • Bot 이 초대 링크로 필요한 scope 와 permissions 를 요청함 (bot, applications.commands)
  • Guild Admin 이 MANAGE_GUILD 권한 보유
  • 봇이 이 길드에 이전에 있었다면 관련 Guild row 가 bot_removed_at IS NOT NULL 상태로 존재

Postconditions

  • guild.guilds 에 row 존재 (신규 또는 재설치 복원)
  • guild.guild_configs 에 기본 설정 row 존재
  • licensing.licenses 에 Free Plan active License 존재
  • events.outboxBotInstalled 이벤트 기록
  • audit.events 에 감사 로그 기록
  • Guild Admin 에게 환영 DM 발송 (거부 가능)
  • recovery.antinuke_incidents 는 비어 있음 (Free 는 AntiNuke 없음)

Sequence

sequenceDiagram
    participant Admin as Guild Admin
    participant Discord
    participant Bot as umbra-bot
    participant Guild as Guild Service
    participant Licensing
    participant Outbox as events.outbox
    participant Poller as Outbox Poller
    participant Notification
    participant Audit

    Admin->>Discord: Click invite link
(authorize bot) Discord->>Bot: Gateway: GUILD_CREATE Bot->>Bot: Parse event
extract installer (Audit Log fallback) Bot->>Guild: RegisterGuild(discordGuildID, installerDiscordID) Guild->>Guild: BEGIN TX Guild->>Guild: UPSERT guild.guilds
(bot_removed_at = NULL) Guild->>Guild: UPSERT guild.guild_configs
(기본값) Guild->>Outbox: INSERT BotInstalled event Guild->>Guild: COMMIT Guild-->>Bot: Guild{id, isNew} Bot-->>Discord: (no immediate response; event processed) Note over Poller: ~2 seconds later Poller->>Outbox: SELECT unpublished Outbox-->>Poller: BotInstalled event Poller->>Licensing: OnBotInstalled(event) Licensing->>Licensing: BEGIN TX Licensing->>Licensing: INSERT license (plan=FREE, status=active, expires_at=NULL) Licensing->>Outbox: INSERT LicenseGranted event Licensing->>Licensing: COMMIT Poller->>Notification: OnBotInstalled(event) Notification->>Notification: INSERT welcome notification request Notification->>Discord: DM to installer (via asynq worker later) Poller->>Audit: OnBotInstalled(event) Audit->>Audit: INSERT audit.events (BotInstalled) Poller->>Audit: OnLicenseGranted(event) Audit->>Audit: INSERT audit.events (LicenseGranted)

Step-by-step

1. Invite and authorize

Guild Admin 이 umbra.ink 의 "Add to Discord" 버튼 또는 OAuth2 URL 로 봇을 초대한다.

https://discord.com/api/oauth2/authorize
  ?client_id={UMBRA_CLIENT_ID}
  &permissions={REQUIRED_PERMISSIONS}
  &scope=bot+applications.commands
  &guild_id={pre-filled}

Discord 가 권한 확인 화면을 띄우고 Admin 승인 시 봇을 길드에 추가.

2. GUILD_CREATE event received

umbra-bot 의 Gateway 핸들러가 GUILD_CREATE 이벤트를 수신한다. 주의: Gateway 는 재연결 시에도 이미 봇이 있는 길드에 대해 GUILD_CREATE 를 전송한다. Umbra 는 이미 설치된 길드신규 설치 를 구분해야 한다.

// apps/bot/internal/handler/gateway.go
func (h *Handler) OnGuildCreate(ev *events.GuildCreate) {
    // isNew 는 Discord 의 GUILD_CREATE 구분 플래그 (unavailable=false 이면서 처음 보는 길드)
    if isAlreadyTracked(ev.Guild.ID) { return }

    installer := h.resolveInstaller(ev.Guild.ID)  // Audit Log 조회 fallback
    h.guildService.RegisterGuild(ctx, ev.Guild.ID, installer.ID)
}

3. RegisterGuild (Guild domain)

Guild Service 가 트랜잭션 내에서:

  • guild.guilds UPSERT — 이전 bot_removed_at 있으면 NULL 로 복원, 신규면 INSERT
  • guild.guild_configs UPSERT — 기본값 (notification_channel_id NULL, antinuke_enabled false)
  • BotInstalled 이벤트 Outbox INSERT
  • 트랜잭션 커밋

이 트랜잭션은 반드시 한 번에 COMMIT. 중간 실패 시 전체 롤백.

4. Outbox poller dispatch

약 2초 후 Worker 의 Outbox poller 가 BotInstalled 이벤트를 pickup. 구독자:

  • Licensing — Free License 생성
  • Notification — 환영 DM 요청
  • Audit — 감사 로그

각 핸들러는 idempotent. 이벤트가 중복 전달되어도 결과 동일.

5. Free License 생성

Licensing handler:

func (h *LicensingHandler) OnBotInstalled(ctx, event) error {
    // 이미 active license 있으면 no-op (재설치 시)
    existing, _ := h.repo.GetActiveByGuildID(ctx, event.GuildID)
    if existing != nil {
        return nil
    }

    freePlan := h.plans.GetByCode(ctx, "FREE")
    license := License{
        ID:         uuid.NewV7(),
        GuildID:    event.GuildID,
        PlanID:     freePlan.ID,
        Status:     "active",
        GrantedAt:  time.Now(),
        ExpiresAt:  nil,  // Free 는 무기한
    }

    // 같은 트랜잭션에서 LicenseGranted 이벤트 발행
    return h.licenses.InsertWithEvent(ctx, license)
}

6. Welcome DM (Notification)

Notification handler 가 Discord DM 요청을 큐에 등록. asynq 워커가 Discord API 로 DM 발송:

안녕하세요, Umbra 가 설치되었습니다.

Free 플랜이 자동으로 활성화되었으며, 웹 조인(join.umbra.ink/{slug}) 기능을
바로 사용할 수 있습니다.

Pro 플랜으로 업그레이드하면 실시간 백업, 복구, Anti-Nuke 보호가 활성화됩니다.
대시보드: app.umbra.ink

7. Audit recording

Audit consumer 가 BotInstalledLicenseGranted 두 이벤트를 각각 audit.events 에 INSERT. outbox_event_id UNIQUE 로 중복 방지.

Failure cases

Installer identification fails

  • WhenGUILD_CREATE payload 에 설치자 ID 없음 (드물지만 가능), Audit Log 접근 권한 없음
  • Detection — 봇 권한 부족
  • Responseowner_user_id = NULL 로 기록, owner_discord_id 만 저장 (길드 owner 로 fallback)
  • User experience — 환영 DM 은 owner 에게 발송

Bot doesn't have VIEW_AUDIT_LOG

  • When — 봇 초대 시 권한을 축소해서 초대
  • Detection — AntiNuke 를 위한 audit log 접근 불가
  • Response — 봇 환영 DM 에 "Anti-Nuke 기능을 위해 VIEW_AUDIT_LOG 권한을 허용해주세요" 안내. Free 에서는 기능 영향 없음
  • User experience — 사용자가 권한 추가 후 Pro 업그레이드 시 안내 재표시

Licensing INSERT race (재설치 직후 중복 요청)

  • When — 짧은 시간에 bot kicked → reinstall 반복
  • DetectionUNIQUE (guild_id) WHERE status IN ('active', 'suspended') partial index 위반
  • Response — Licensing handler 의 idempotency check 에서 조용히 no-op
  • User experience — 투명 (영향 없음)

Outbox poller 지연

  • When — Worker 프로세스 과부하
  • DetectionBotInstalled 이벤트가 수 분 동안 unpublished 상태
  • Response — Worker 스케일 자동 확장 (Fly.io auto-scale). 대시보드에서 "License 생성 중" 일시 표시.
  • User experience — 최대 수 초~수십 초 지연 후 정상

Welcome DM 거부

  • When — 사용자의 Discord DM 설정이 "서버 멤버만 DM 허용 안 함"
  • Detection — Discord API 403 응답
  • Response — 길드의 notification_channel_id 가 설정되어 있으면 해당 채널로 fallback. 없으면 기본 시스템 채널 시도.
  • User experience — DM 대신 길드 채널 메시지 표시

Edge cases

Guild 가 이전에 삭제 상태

  • deleted_at IS NOT NULL 인 Guild 에 봇 재설치
  • 정책: deleted_at NULL 로 복원하고 bot_removed_at NULL. License 도 active 로 재활성 (Free 로 리셋)
  • 기존 스냅샷은 retention 정책에 따라 이미 삭제되었을 가능성 높음

Bot 강퇴 → 재설치 (Pro 구독 유지 중)

  • BotKicked 로 License + Subscription 은 suspended 상태였음
  • 재설치 시 BotInstalled → Licensing 이 "suspended 인 License 있는지" 확인 → resume
  • Billing 도 OnBotInstalled 구독하여 suspended subscription 을 active 로 복귀
  • 다음 결제 시점은 이전 구독 기준 유지

매우 큰 길드 (10,000+ 멤버)

  • GUILD_CREATE payload 가 큼 (Discord 가 chunk 로 전달)
  • Bot 은 guild_id 와 기본 메타데이터만 추출해 RegisterGuild 호출, 멤버 목록은 Live Sync 로 처리
  • 초기 멤버 전체 sync 는 Live Sync 의 별도 bootstrap 작업 (Phase 2)

웹 조인 slug 충돌

  • 기본 slug 는 길드 이름 기반 (my-cool-guild)
  • 이미 같은 slug 가 있으면 -{random4} 접미사
  • 충돌이 여러 번이면 fully random slug (abc-def-ghi 형태)

Involved domains

Domain Role in this flow
Guild 길드 등록 / 메타데이터 관리 (writer)
Licensing Free License 자동 생성
Notification 환영 DM
Audit 감사 기록
Recovery (AntiNuke) Pro+ 일 때 workflow 시작 (이 flow 에서는 Free 라 skip)

See also

  • domain/guild.md
  • domain/licensing.md
  • flows/first-subscription.md — Free → Pro 전환의 시작점
  • flows/web-join.md — 설치 후 바로 사용 가능한 기능
  • adr/0011-hybrid-license-model.md